Skip to content
Merged
1 change: 1 addition & 0 deletions docs/.vitepress/redirects/reference-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"void-db-execute": "/reference/cli/database#void-db-execute",
"void-db-migrate": "/reference/cli/database#void-db-migrate",
"void-db-studio": "/reference/cli/database#void-db-studio",
"void-db-restore": "/reference/cli/database#void-db-restore",
"void-db-rename-migrations": "/reference/cli/database#void-db-rename-migrations",
"void-db-connect": "/reference/cli/database#void-db-connect",
"void-db-set-url": "/reference/cli/database#void-db-set-url",
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ void db generate

Review and commit the SQL, then deploy again. Rollback does not reverse database migrations. See [Database](./database.md) for the local workflow.

On your own Cloudflare account, deploy saves a D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) restore point before it applies pending migrations and prints the [`void db restore`](../reference/cli/database.md#void-db-restore) command for it. If no restore point can be saved, deploy stops before it migrates. If deploy fails after it applied the migrations, for example when the upload fails, it prints the command again. Usually you fix the error and deploy again; restore only if the live version cannot run against the new schema. To recover from a bad migration, roll back the code with `void project rollback` if the new version is live, restore the database, then fix or remove the migration before you deploy again. After a one-off `void deploy --platform cloudflare`, add `--platform cloudflare` to each of these commands. Restoring discards every write made after the restore point. Restore points expire after 7 days on Workers Free and 30 days on Workers Paid; after that, undo a migration with a new one.

### Environment variables

Declare values in `env.ts` and set production server secrets with `void secret put`. Deploy never uploads your local `.env`. Supply public `VITE_*` values through the build environment. See [Environment Variables](./env-vars.md).
Expand Down
2 changes: 1 addition & 1 deletion docs/integrations/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ void project status
void project rollback [version]
```

Void restores recorded triggers along with the code. Versions without a complete trigger snapshot keep current routes and schedules. Rollback does not reverse database migrations.
Void restores recorded triggers along with the code. Versions without a complete trigger snapshot keep current routes and schedules. Rollback does not reverse database migrations. To undo D1 migrations, run [`void db restore`](../reference/cli/database.md#void-db-restore) with the restore point that deploy printed before it applied them, within 7 days on Workers Free or 30 days on Workers Paid.

#### Scope and limitations

Expand Down
1 change: 1 addition & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Use this page as a command reference. If you are setting up a project for the fi
| `void db seed` | Reset + seed local database |
| `void db execute <sql>` | Run SQL against the database (--remote for deployed) |
| `void db studio` | Open Drizzle Studio (--remote for a deployed external database) |
| `void db restore --bookmark <b>` | Restore the deployed D1 database to a restore point (direct Cloudflare) |
| `void secret put <name=value>` | Set a production secret |
| `void secret list` | List production secrets |
| `void secret sync <file>` | Bulk upload secrets from dotenv file |
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/cli/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ By default, targets the local database. Pass `--remote` to open Studio against t
- **Direct Cloudflare PostgreSQL and MySQL projects**: use `DATABASE_URL` from the current shell.
- **D1 projects**: remote Studio is not available. Use `void db execute --remote` to query your deployed D1 database.

## `void db restore` {#void-db-restore}

```
void db restore --bookmark <bookmark> [--platform cloudflare] [--yes]
void db restore --timestamp <iso-time> [--platform cloudflare] [--yes]
```

Restore the deployed D1 database of a direct Cloudflare deploy with D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/). Before `void deploy --platform cloudflare` applies migrations, it prints the `--bookmark` that undoes them. `--timestamp` restores the state at an ISO 8601 time with `Z` or an offset instead, such as `2026-10-16T09:30:00Z`, with at most millisecond precision. Time Travel keeps 7 days of history on Workers Free and 30 days on Workers Paid. An older bookmark or time can no longer be restored: Cloudflare rejects it and the database stays unchanged. To undo an older migration, write a new migration that reverses it.

If the project's saved destination is not Cloudflare and you deployed once with `void deploy --platform cloudflare`, pass `--platform cloudflare`; deploy prints the command with it.

Restoring replaces the whole database and discards every write made after that point. Void names the database and asks before it restores. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. Afterwards Void prints the `void db restore` command that undoes the restore.

A restore also rewinds the record of applied migrations. Void lists the migration files the database no longer has applied; the next deploy applies them, so fix or remove the one that caused the problem first. Restoring does not change the deployed code: use [`void project rollback`](./project.md#void-project-rollback-deployid) if the live version needs the newer schema. After a one-off Cloudflare deploy, pass `--platform cloudflare` to `void project rollback` and `void deploy` as well.

## `void db rename-migrations` {#void-db-rename-migrations}

Rename existing migrations from the old numeric prefix format (`0001_name.sql`) to timestamp-based format (`20260410161500_name.sql`). Updates local tracking table and remote records if logged in with a linked project. A project that deploys to Void must be linked first with `void project link`; otherwise the command stops without changes.
Expand Down
6 changes: 4 additions & 2 deletions docs/reference/cli/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ void project requests --range 24h
## `void project rollback [deployId]` {#void-project-rollback-deployid}

```
void project rollback [deployId]
void project rollback [deployId] [--platform cloudflare]
```

Roll back to a previous deployment or Worker Version.
Expand All @@ -179,7 +179,9 @@ Roll back to a previous deployment or Worker Version.

On a Void platform, you can select a retained deployment. On Cloudflare, use a complete Worker Version ID or an unambiguous prefix. Void activates that version at 100%. When both versions have complete trigger snapshots, it also restores the selected version's schedules, queues, workflows, routes, and custom domains. Otherwise, rollback keeps the current triggers and restores the code, including versions originally deployed outside Void.

Rollback doesn't reverse database migrations. If older code may run against a newer schema, or migration metadata is missing, Void explains the risk and asks for confirmation.
If the project's saved destination is not Cloudflare and you deployed once with `void deploy --platform cloudflare`, pass `--platform cloudflare` to roll back that Worker.

Rollback doesn't reverse database migrations. If older code may run against a newer schema, or migration metadata is missing, Void explains the risk and asks for confirmation. On Cloudflare, undo D1 migrations with [`void db restore`](./database.md#void-db-restore) and the restore point that deploy printed before it applied them, within 7 days on Workers Free or 30 days on Workers Paid.

## `void project cancel [deployId]` {#void-project-cancel-deployid}

Expand Down
7 changes: 7 additions & 0 deletions packages/deploy-cloudflare/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ export type CloudflareOperation =
| { type: 'd1.create'; name: string; binding: string; configPath: string }
| { type: 'd1.execute'; database: string; sql: string; configPath: string }
| { type: 'd1.migrations.list' | 'd1.migrations.apply'; database: string; configPath: string }
| { type: 'd1.time-travel.bookmark'; database: string; configPath: string }
| {
type: 'd1.time-travel.restore';
database: string;
target: { bookmark: string } | { timestamp: string };
configPath: string;
}
| { type: 'kv.namespace.create'; name: string; binding: string; configPath: string }
| ({ type: 'r2.bucket.info'; name: string } & ConfigTarget)
| { type: 'r2.bucket.create'; name: string; binding: string; configPath: string }
Expand Down
5 changes: 5 additions & 0 deletions packages/deploy-cloudflare/test/operation-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ export function operationFixtureArgs(operation: CloudflareOperation): Array<stri
...config,
'--env=',
];
case 'd1.time-travel.bookmark':
return ['d1', 'time-travel', 'info', operation.database, '--json', ...config, '--env='];
case 'containers.list':
return ['containers', 'list', '--json', ...config, '--env='];
case 'secret.list':
Expand Down Expand Up @@ -331,6 +333,9 @@ export function operationFixtureRequest(args: Array<string>): CloudflareOperatio
configPath: value('-c')!,
};
}
if (args[0] === 'd1' && args[1] === 'time-travel') {
return { type: 'd1.time-travel.bookmark', database: args[3]!, configPath: value('-c')! };
}
if (args[0] === 'queues' && args[1] === 'info') {
return { type: 'queues.info', name: args[2]!, configPath: value('-c') };
}
Expand Down
5 changes: 5 additions & 0 deletions packages/void/src/cli/cf-command-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export function safePositional(value: unknown): value is string {
);
}

/** Accept only Cloudflare's hex-segment D1 Time Travel bookmarks; Void prints them as commands to copy. */
export function isD1Bookmark(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f]+(?:-[0-9a-f]+)+$/i.test(value);
}

/** Read the version ID and URLs that cf prints after a prebuilt upload. */
export function readCfUploadOutput(
stdout: string,
Expand Down
42 changes: 41 additions & 1 deletion packages/void/src/cli/cloudflare-operation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
} from '@void/deploy-cloudflare';
import { parse } from 'jsonc-parser';
import { dirname, join, resolve } from 'pathslash';
import { readCfUploadOutput, safePositional } from './cf-command-values.ts';
import { isD1Bookmark, readCfUploadOutput, safePositional } from './cf-command-values.ts';
import { persistCfKeyringPreference, readCfSessionCredential } from './cf-session.ts';
import { getCfCliVersion, getVoidUserAgent } from './cf-user-agent.ts';
import {
Expand Down Expand Up @@ -308,6 +308,15 @@ function d1Database(token: string): { id: string; dir: string; table: string; pa
};
}

function previousD1Bookmark(stdout: string): string | undefined {
try {
const previous = object(parsedJson(stdout)).previous_bookmark;
return isD1Bookmark(previous) ? previous : undefined;
} catch {
return undefined;
}
}

function cleanJson(command: Array<string>, transform?: (value: unknown) => unknown): Result {
const called = runCf(command);
if (called.status !== 0 || !transform) {
Expand Down Expand Up @@ -676,6 +685,37 @@ async function resources(): Promise<Result> {
}
return result(0, '', called.stderr, { pending: pending.map((item) => object(item).Name) });
}
case 'd1.time-travel.bookmark': {
const database = d1Database(operation.database);
const called = runCf(['d1', 'time-travel', 'get-bookmark', database.id]);
if (called.status !== 0) {
return called;
}
const bookmark = object(parsedJson(called.stdout)).bookmark;
if (!isD1Bookmark(bookmark)) {
throw new Error('D1 Time Travel bookmark was malformed.');
}
return result(0, '', called.stderr, { bookmark });
}
case 'd1.time-travel.restore': {
const database = d1Database(operation.database);
if ('bookmark' in operation.target && !isD1Bookmark(operation.target.bookmark)) {
throw new Error('D1 Time Travel bookmark was malformed.');
}
// One argv item each, so cf cannot read the value as a flag.
const target =
'bookmark' in operation.target
? `--bookmark=${operation.target.bookmark}`
: `--timestamp=${operation.target.timestamp}`;
// Void asks before it restores. cf cannot ask in this non-interactive child, so it would
// refuse without --force.
const called = runCf(['d1', 'time-travel', 'restore', database.id, target, '--force']);
if (called.status !== 0) {
return called;
}
// The database is restored now, so a response without the previous bookmark is not a failure.
return result(0, '', called.stderr, { previousBookmark: previousD1Bookmark(called.stdout) });
}
case 'containers.list':
return runCf(['containers', 'applications', 'list']);
case 'd1.list':
Expand Down
15 changes: 15 additions & 0 deletions packages/void/src/cli/cloudflare-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,21 @@ export async function applyCloudflareD1Migrations(context: CloudflareD1Context):
);
}

/** Restore the D1 database with Time Travel. Returns the bookmark that undoes the restore. */
export async function restoreCloudflareD1Database(
context: CloudflareD1Context,
target: { bookmark: string } | { timestamp: string },
): Promise<string | undefined> {
const result = await run(context.operation, {
type: 'd1.time-travel.restore',
database: context.migrateToken,
target,
configPath: context.operation.configPath,
});
const previous = (result.data as { previousBookmark?: unknown } | undefined)?.previousBookmark;
return typeof previous === 'string' ? previous : undefined;
}

export async function readCloudflareWorkerStatus(
context: CloudflareOperationContext,
): Promise<CloudflareWorkerStatus> {
Expand Down
95 changes: 94 additions & 1 deletion packages/void/src/cli/db-args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs } from 'node:util';
import { isD1Bookmark } from './cf-command-values.ts';
import type { SubcommandDef } from './subcommand-prompt.ts';

export const dbSubcommands: Array<SubcommandDef> = [
Expand Down Expand Up @@ -40,6 +41,28 @@ export const dbSubcommands: Array<SubcommandDef> = [
{ name: '--local-only', description: 'Do not sync a linked Void project' },
],
},
{
name: 'restore',
description: 'Restore the deployed D1 database to an earlier point (direct Cloudflare)',
flags: [
{
name: '--bookmark',
arg: '<bookmark>',
description: 'Restore point printed by deploy (or use --timestamp)',
},
{
name: '--timestamp',
arg: '<iso-time>',
description: 'Time to restore to, with Z or an offset (or use --bookmark)',
},
{
name: '--platform',
arg: 'cloudflare',
description: 'Restore after a one-off deploy with --platform cloudflare',
},
{ name: '--yes', description: 'Skip the confirmation prompt' },
],
},
{ name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' },
{
name: 'export',
Expand Down Expand Up @@ -78,6 +101,12 @@ export type DbArgs =
region?: string;
localOnly?: boolean;
}
| {
action: 'restore';
target: { bookmark: string } | { timestamp: string };
platform?: 'cloudflare';
yes?: boolean;
}
| { action: 'rename-migrations' };

// Positional arguments each action accepts after its name.
Expand All @@ -92,6 +121,7 @@ const MAX_POSITIONALS: Record<DbArgs['action'], number> = {
studio: 0,
'set-url': 0,
connect: 1,
restore: 0,
'rename-migrations': 0,
export: 0,
};
Expand All @@ -109,10 +139,42 @@ const ACTION_FLAGS: Record<DbArgs['action'], Array<string>> = {
studio: ['remote'],
'set-url': [],
connect: ['provider', 'name', 'region', 'local-only'],
restore: ['bookmark', 'timestamp', 'platform', 'yes'],
'rename-migrations': [],
export: ['output', 'no-data', 'no-schema', 'table'],
};

/**
* Read a restore time as a UTC ISO string. A time without a zone would depend on where the
* command runs, `new Date` rolls impossible dates over (Feb 30 becomes Mar 2), and it keeps only
* milliseconds, so all three are rejected instead of restoring to a moment the user did not write.
*/
function parseRestoreTime(value: string): string | undefined {
const match =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|([+-])(\d{2}):(\d{2}))$/i.exec(
value,
);
const time = new Date(value);
if (!match || Number.isNaN(time.getTime())) {
return undefined;
}
const offsetMinutes = match[7]
? (match[7] === '-' ? -1 : 1) * (Number(match[8]) * 60 + Number(match[9]))
: 0;
const written = new Date(time.getTime() + offsetMinutes * 60_000);
const fields = [
written.getUTCFullYear(),
written.getUTCMonth() + 1,
written.getUTCDate(),
written.getUTCHours(),
written.getUTCMinutes(),
written.getUTCSeconds(),
];
return fields.every((field, index) => field === Number(match[index + 1] ?? 0))
? time.toISOString()
Comment thread
Brooooooklyn marked this conversation as resolved.
: undefined;
}

function isDbAction(value: string | undefined): value is DbArgs['action'] {
return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value);
}
Expand All @@ -132,6 +194,9 @@ export function parseDbArgs(args: Array<string>): DbArgs {
region: { type: 'string' },
'local-only': { type: 'boolean' },
yes: { type: 'boolean' },
bookmark: { type: 'string' },
timestamp: { type: 'string' },
platform: { type: 'string' },
},
allowPositionals: true,
strict: true,
Expand All @@ -140,7 +205,7 @@ export function parseDbArgs(args: Array<string>): DbArgs {
const [action, ...rest] = positionals;
if (!isDbAction(action)) {
throw new Error(
'db: Usage: void db <push|generate|status|reset|seed|execute|migrate|studio|export|connect|set-url|rename-migrations>.',
'db: Usage: void db <push|generate|status|reset|seed|execute|migrate|studio|export|connect|set-url|restore|rename-migrations>.',
);
}
if (action === 'export' && values.remote) {
Expand Down Expand Up @@ -189,6 +254,34 @@ export function parseDbArgs(args: Array<string>): DbArgs {
localOnly: values['local-only'],
};
}
case 'restore': {
if (values.platform !== undefined && values.platform !== 'cloudflare') {
throw new Error("db: `void db restore --platform` accepts only 'cloudflare'.");
}
const platform = values.platform;
const bookmark = values.bookmark?.trim();
const timestamp = values.timestamp?.trim();
if (bookmark && !timestamp) {
if (!isD1Bookmark(bookmark)) {
throw new Error(
'db: --bookmark must be a D1 restore point printed by `void deploy`, for example 00000085-0000024c-00004ef2.',
);
}
return { action, target: { bookmark }, platform, yes: values.yes };
}
if (timestamp && !bookmark) {
const time = parseRestoreTime(timestamp);
if (!time) {
throw new Error(
'db: --timestamp must be a valid ISO 8601 time with Z or an offset and at most millisecond precision, for example 2026-10-16T09:30:00Z.',
);
}
return { action, target: { timestamp: time }, platform, yes: values.yes };
}
throw new Error(
'db: Usage: void db restore --bookmark <bookmark> or void db restore --timestamp <iso-time>.',
);
}
case 'export':
return {
action,
Expand Down
Loading
Loading