From e012d71cbc28f2a6c93d18a9a4a4a628b9f08a1c Mon Sep 17 00:00:00 2001 From: Bayram Ali Basgul Date: Thu, 15 Oct 2026 19:40:08 +0200 Subject: [PATCH 1/8] feat(cloudflare): take a D1 Time Travel bookmark before applying remote migrations A Worker rollback does not undo a migration. Before applying pending D1 migrations, deploy now takes a Time Travel bookmark through cf, prints the cf d1 time-travel restore command for it, and stops if the bookmark cannot be taken. --- docs/guide/deployment.md | 2 + packages/deploy-cloudflare/src/operations.ts | 1 + .../test/operation-fixture.ts | 5 + .../src/cli/cloudflare-operation-process.ts | 12 +++ packages/void/src/cli/deploy-migrations.ts | 47 ++++++++++ .../void/test/unit/cloudflare-deploy.test.ts | 15 ++- .../cloudflare-operation-process-d1.test.ts | 60 ++++++++++++ .../deploy-migrations-restore-point.test.ts | 94 +++++++++++++++++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 packages/void/test/unit/deploy-migrations-restore-point.test.ts diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 67394387c..09ec0e985 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -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 takes a D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) bookmark before it applies pending migrations, and prints the `cf d1 time-travel restore` command that returns the database to that point. Restoring overwrites the database and every write since the bookmark. If the bookmark cannot be taken, deploy stops before it migrates. + ### 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). diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index b0fd230d2..bbd85f5d0 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -9,6 +9,7 @@ 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: '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 } diff --git a/packages/deploy-cloudflare/test/operation-fixture.ts b/packages/deploy-cloudflare/test/operation-fixture.ts index e4a1b057c..b502a5913 100644 --- a/packages/deploy-cloudflare/test/operation-fixture.ts +++ b/packages/deploy-cloudflare/test/operation-fixture.ts @@ -266,6 +266,8 @@ export function operationFixtureArgs(operation: CloudflareOperation): Array): 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') }; } diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 91492e022..5d484b6c6 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -676,6 +676,18 @@ async function resources(): Promise { } 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 (typeof bookmark !== 'string' || bookmark === '') { + throw new Error('D1 Time Travel bookmark was malformed.'); + } + return result(0, '', called.stderr, { bookmark, databaseId: database.id }); + } case 'containers.list': return runCf(['containers', 'applications', 'list']); case 'd1.list': diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index c7f185c56..b5439ef40 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -614,6 +614,53 @@ export async function applyAndVerifyRemoteD1Migrations( if (!assertUploaderStillPinned()) { return false; } + // A Worker rollback does not undo a migration. Time Travel does, to a bookmark taken before + // the apply; it cannot be reconstructed once the migration has run. + const before = await Promise.resolve( + runWrangler( + { type: 'd1.migrations.list', database: d1MigrateToken, configPath }, + { capture: true }, + ), + ); + const pendingBefore = (before.data as { pending?: unknown } | undefined)?.pending; + if (!isCleanCloudflareExit(before) || !Array.isArray(pendingBefore)) { + log.error( + `deploy: could not read the pending D1 migrations for "${d1MigrateToken}" before applying them ` + + `(\`d1 migrations list\` exit ${String(before.status)}). Refusing to migrate without knowing ` + + 'whether a restore point is needed — rerun once wrangler can reach your account.', + ); + return false; + } + if (pendingBefore.length > 0) { + const restorePoint = await Promise.resolve( + runWrangler( + { type: 'd1.time-travel.bookmark', database: d1MigrateToken, configPath }, + { capture: true }, + ), + ); + const taken = restorePoint.data as { bookmark?: unknown; databaseId?: unknown } | undefined; + const bookmark = taken?.bookmark; + const databaseId = taken?.databaseId; + if ( + !isCleanCloudflareExit(restorePoint) || + typeof bookmark !== 'string' || + typeof databaseId !== 'string' + ) { + log.error( + `deploy: could not take a D1 Time Travel bookmark for "${d1MigrateToken}" before applying ` + + `${pendingBefore.length} migration(s). A Worker rollback does not undo a migration, and this ` + + 'bookmark is the only restore point. Refusing to migrate without one — rerun once the bookmark can be read.', + ); + return false; + } + log.info( + `Restore point before these migrations: ${bookmark}. To undo them (destructive: it overwrites ` + + `the database, and every write since): npx cf d1 time-travel restore ${databaseId} --bookmark=${bookmark}`, + ); + if (!assertUploaderStillPinned()) { + return false; + } + } const migrate = await Promise.resolve( runWrangler( { type: 'd1.migrations.apply', database: d1MigrateToken, configPath }, diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 2033673b5..856a51ad5 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3245,12 +3245,13 @@ describe('runCloudflareDeploy — resource provisioning', () => { 'build', 'gate', 'migrate-gate', + 'wrangler:d1', // migrations list (is a restore point needed?) 'wrangler:d1', // migrations apply (after the gate) 'wrangler:d1', // migrations list (pending verify) 'wrangler:deploy', ]); // migrate targets the stored db name with --remote + pinned config - const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1'); + const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1').slice(1); expect(d1Calls[0]).toEqual([ 'd1', 'migrations', @@ -4125,8 +4126,7 @@ describe('runCloudflareDeploy — D1 migrate targets the unique validated entry' const { deps, wranglerArgs, trace } = harness(); await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); // Targets the validated entry's own unique database_name (resolves to DB, not OLD). - // `.find` returns the first d1 call — the apply (the list verify follows it). - expect(wranglerArgs.find((a) => a[0] === 'd1')).toEqual([ + expect(wranglerArgs.find((a) => a[0] === 'd1' && a[2] === 'apply')).toEqual([ 'd1', 'migrations', 'apply', @@ -7881,6 +7881,12 @@ describe('runCloudflareDeploy — remote migration gates', () => { if (args[0] === 'd1' && args[2] === 'list') { return { ...cleanExit, stdout: 'Migrations to be applied:\n0000_init' }; } + if (args[0] === 'd1' && args[1] === 'time-travel') { + return { + ...cleanExit, + stdout: JSON.stringify({ bookmark: '00000085-0000024c', databaseId: REAL_D1_ID }), + }; + } return cleanExit; }), runBuild: () => { @@ -7892,6 +7898,7 @@ describe('runCloudflareDeploy — remote migration gates', () => { ).rejects.toBeInstanceOf(ExitError); expect(exitCalls).toEqual([1]); // Reached the post-apply list, then failed closed before the deploy. + expect(localTrace).toContain('wrangler:d1:apply'); expect(localTrace).toContain('wrangler:d1:list'); expect(localTrace).toContain('build'); expect(localTrace).not.toContain('wrangler:deploy'); @@ -8646,7 +8653,7 @@ describe('runCloudflareDeploy — migration gates re-checked post-build', () => await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); expect(trace).toContain('build'); const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1'); - expect(d1Calls[0]?.slice(0, 3)).toEqual(['d1', 'migrations', 'apply']); + expect(d1Calls.map((a) => a.slice(0, 3))).toContainEqual(['d1', 'migrations', 'apply']); expect(trace).toContain('wrangler:deploy'); }); }); diff --git a/packages/void/test/unit/cloudflare-operation-process-d1.test.ts b/packages/void/test/unit/cloudflare-operation-process-d1.test.ts index 8e94b44c9..b50de8edd 100644 --- a/packages/void/test/unit/cloudflare-operation-process-d1.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process-d1.test.ts @@ -79,3 +79,63 @@ syncBuiltinESMExports(); rmSync(directory, { recursive: true, force: true }); } }); + +it('takes the D1 Time Travel bookmark through cf by database id', () => { + const directory = mkdtempSync(join(tmpdir(), 'void-cf-d1-bookmark-test-')); + try { + const config = join(directory, 'wrangler.json'); + const capture = join(directory, 'cf-args.json'); + const preload = join(directory, 'capture-cf.mjs'); + writeFileSync( + config, + JSON.stringify({ d1_databases: [{ binding: 'DB', database_id: 'database-id' }] }), + ); + writeFileSync( + preload, + `import child from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { writeFileSync } from 'node:fs'; +child.spawnSync = (_binary, args) => { + writeFileSync(process.env.VOID_TEST_CF_CAPTURE, JSON.stringify(args)); + return { status: 0, stdout: '{"bookmark":"00000085-0000024c-00004ef2"}', stderr: '' }; +}; +syncBuiltinESMExports(); +`, + ); + const entry = join( + import.meta.dirname, + '..', + '..', + 'src', + 'cli', + 'cloudflare-operation-process.ts', + ); + const result = spawnSync( + process.execPath, + [ + '--import', + pathToFileURL(preload).href, + entry, + ...encodeCloudflareOperation({ + type: 'd1.time-travel.bookmark', + database: 'DB', + configPath: config, + }), + ], + { + env: { ...process.env, VOID_TEST_CF_CAPTURE: capture }, + encoding: 'utf8', + }, + ); + expect(result.status, result.stderr).toBe(0); + expect((JSON.parse(readFileSync(capture, 'utf8')) as Array).slice(1)).toEqual([ + 'd1', + 'time-travel', + 'get-bookmark', + 'database-id', + ]); + expect(result.stdout).toContain('00000085-0000024c-00004ef2'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/void/test/unit/deploy-migrations-restore-point.test.ts b/packages/void/test/unit/deploy-migrations-restore-point.test.ts new file mode 100644 index 000000000..d69100f58 --- /dev/null +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -0,0 +1,94 @@ +import type { CloudflareOperation } from '@void/deploy-cloudflare'; +import { describe, expect, it, vi } from 'vite-plus/test'; + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + log: { error: vi.fn(), info: vi.fn(), message: vi.fn(), step: vi.fn(), warn: vi.fn() }, +})); + +const { log } = await import('@voidzero-dev/vite-plus-prompts'); +const { applyAndVerifyRemoteD1Migrations } = await import('../../src/cli/deploy-migrations.ts'); + +type Answers = { pending: Array>; bookmark?: string }; + +function recordingRunner(answers: Answers) { + const calls: Array = []; + const lists = [...answers.pending]; + const run = (operation: CloudflareOperation) => { + calls.push(operation.type); + if (operation.type === 'd1.migrations.list') { + return { status: 0, stdout: '', stderr: '', data: { pending: lists.shift() ?? [] } }; + } + if (operation.type === 'd1.time-travel.bookmark') { + return answers.bookmark === undefined + ? { status: 1, stdout: '', stderr: 'Time Travel is unavailable for this database.' } + : { + status: 0, + stdout: '', + stderr: '', + data: { + bookmark: answers.bookmark, + databaseId: 'a1b2c3d4-0000-4000-8000-000000000001', + }, + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + return { calls, run }; +} + +describe('the D1 restore point a deploy takes before migrating', () => { + it('takes a Time Travel bookmark before applying pending migrations, and names how to restore it', async () => { + const runner = recordingRunner({ + pending: [['0001_add_column.sql'], []], + bookmark: '00000085-0000024c-00004ef2', + }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(true); + expect(runner.calls).toEqual([ + 'd1.migrations.list', + 'd1.time-travel.bookmark', + 'd1.migrations.apply', + 'd1.migrations.list', + ]); + expect(vi.mocked(log.info)).toHaveBeenCalledWith( + expect.stringContaining( + 'npx cf d1 time-travel restore a1b2c3d4-0000-4000-8000-000000000001 --bookmark=00000085-0000024c-00004ef2', + ), + ); + }); + + it('refuses to migrate when the bookmark cannot be taken, leaving the database unchanged', async () => { + const runner = recordingRunner({ pending: [['0001_add_column.sql']] }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(false); + expect(runner.calls).not.toContain('d1.migrations.apply'); + }); + + it('takes no bookmark when no migration is pending', async () => { + const runner = recordingRunner({ pending: [[], []] }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(true); + expect(runner.calls).not.toContain('d1.time-travel.bookmark'); + }); +}); From a6f9a7fc67b731235b2cfab1ac5e5ad6468413e2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 16 Oct 2026 17:15:23 +0800 Subject: [PATCH 2/8] feat(cloudflare): restore D1 with `void db restore` after a deploy's migrations Deploy now prints `void db restore --bookmark=` for the restore point it saves before migrating, instead of a raw `npx cf` command. The new command restores the D1 database of a direct Cloudflare deploy to that bookmark or to a time with a zone. It asks first (No by default), needs --yes without a terminal, prints the bookmark that undoes the restore, and lists the migration files the database no longer has applied. It also works from a fresh checkout whose Cloudflare config names the D1 database. Deploy refusals now include Cloudflare's redacted reason, and every failure after the restore point repeats the restore command. Bookmarks are checked against Cloudflare's hex format before they are printed or passed to cf, and restore targets reach cf as single arguments. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.vitepress/redirects/reference-cli.json | 1 + docs/guide/deployment.md | 2 +- docs/integrations/cloudflare.md | 2 +- docs/reference/cli.md | 1 + docs/reference/cli/database.md | 13 ++ docs/reference/cli/project.md | 2 +- packages/deploy-cloudflare/src/operations.ts | 6 + packages/void/src/cli/cf-command-values.ts | 5 + .../src/cli/cloudflare-operation-process.ts | 34 +++- .../void/src/cli/cloudflare-operations.ts | 15 ++ packages/void/src/cli/db-args.ts | 59 +++++- packages/void/src/cli/db.ts | 66 +++++++ packages/void/src/cli/deploy-migrations.ts | 60 +++--- packages/void/src/cli/help.ts | 34 ++++ packages/void/src/cli/rollback.ts | 7 + packages/void/test/unit/cli-help.test.ts | 9 + .../void/test/unit/cloudflare-deploy.test.ts | 32 +++- .../cloudflare-operation-process-d1.test.ts | 130 +++++++++++-- .../test/unit/cloudflare-operations.test.ts | 23 +++ packages/void/test/unit/db-args.test.ts | 28 ++- packages/void/test/unit/db-migrate.test.ts | 176 ++++++++++++++++++ .../deploy-migrations-restore-point.test.ts | 142 ++++++++++---- 22 files changed, 761 insertions(+), 86 deletions(-) diff --git a/docs/.vitepress/redirects/reference-cli.json b/docs/.vitepress/redirects/reference-cli.json index fc466f230..aa2dd6d12 100644 --- a/docs/.vitepress/redirects/reference-cli.json +++ b/docs/.vitepress/redirects/reference-cli.json @@ -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", diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 09ec0e985..52f835d37 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -35,7 +35,7 @@ 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 takes a D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) bookmark before it applies pending migrations, and prints the `cf d1 time-travel restore` command that returns the database to that point. Restoring overwrites the database and every write since the bookmark. If the bookmark cannot be taken, deploy stops before it migrates. +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. 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. Restoring discards every write made after the restore point. ### Environment variables diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 5b77b7dc8..48aba0667 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -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. #### Scope and limitations diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e20bd4dad..33e73c9b0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 ` | Run SQL against the database (--remote for deployed) | | `void db studio` | Open Drizzle Studio (--remote for a deployed external database) | +| `void db restore --bookmark ` | Restore the deployed D1 database to a restore point (direct Cloudflare) | | `void secret put ` | Set a production secret | | `void secret list` | List production secrets | | `void secret sync ` | Bulk upload secrets from dotenv file | diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index bd5babe74..c6c53fe6f 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -89,6 +89,19 @@ 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 [--yes] +void db restore --timestamp [--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`. Time Travel keeps 7 days of history on Workers Free and 30 days on Workers Paid. + +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` if the live version needs the newer schema. + ## `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. diff --git a/docs/reference/cli/project.md b/docs/reference/cli/project.md index a6ef458aa..f01683794 100644 --- a/docs/reference/cli/project.md +++ b/docs/reference/cli/project.md @@ -179,7 +179,7 @@ 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. +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. ## `void project cancel [deployId]` {#void-project-cancel-deployid} diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index bbd85f5d0..be801e95f 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -10,6 +10,12 @@ export type CloudflareOperation = | { 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 } diff --git a/packages/void/src/cli/cf-command-values.ts b/packages/void/src/cli/cf-command-values.ts index de2068b7d..2e9802833 100644 --- a/packages/void/src/cli/cf-command-values.ts +++ b/packages/void/src/cli/cf-command-values.ts @@ -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, diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 5d484b6c6..60c3df926 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -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 { @@ -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, transform?: (value: unknown) => unknown): Result { const called = runCf(command); if (called.status !== 0 || !transform) { @@ -683,10 +692,29 @@ async function resources(): Promise { return called; } const bookmark = object(parsedJson(called.stdout)).bookmark; - if (typeof bookmark !== 'string' || 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.'); } - return result(0, '', called.stderr, { bookmark, databaseId: database.id }); + // 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']); diff --git a/packages/void/src/cli/cloudflare-operations.ts b/packages/void/src/cli/cloudflare-operations.ts index 6e683e349..f2728a4d9 100644 --- a/packages/void/src/cli/cloudflare-operations.ts +++ b/packages/void/src/cli/cloudflare-operations.ts @@ -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 { + 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 { diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 5b6dfd946..f915016d6 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -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 = [ @@ -40,6 +41,23 @@ export const dbSubcommands: Array = [ { 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: '', + description: 'Restore point printed by deploy (or use --timestamp)', + }, + { + name: '--timestamp', + arg: '', + description: 'Time to restore to, with Z or an offset (or use --bookmark)', + }, + { name: '--yes', description: 'Skip the confirmation prompt' }, + ], + }, { name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' }, { name: 'export', @@ -78,6 +96,11 @@ export type DbArgs = region?: string; localOnly?: boolean; } + | { + action: 'restore'; + target: { bookmark: string } | { timestamp: string }; + yes?: boolean; + } | { action: 'rename-migrations' }; // Positional arguments each action accepts after its name. @@ -92,6 +115,7 @@ const MAX_POSITIONALS: Record = { studio: 0, 'set-url': 0, connect: 1, + restore: 0, 'rename-migrations': 0, export: 0, }; @@ -109,6 +133,7 @@ const ACTION_FLAGS: Record> = { studio: ['remote'], 'set-url': [], connect: ['provider', 'name', 'region', 'local-only'], + restore: ['bookmark', 'timestamp', 'yes'], 'rename-migrations': [], export: ['output', 'no-data', 'no-schema', 'table'], }; @@ -132,6 +157,8 @@ export function parseDbArgs(args: Array): DbArgs { region: { type: 'string' }, 'local-only': { type: 'boolean' }, yes: { type: 'boolean' }, + bookmark: { type: 'string' }, + timestamp: { type: 'string' }, }, allowPositionals: true, strict: true, @@ -140,7 +167,7 @@ export function parseDbArgs(args: Array): DbArgs { const [action, ...rest] = positionals; if (!isDbAction(action)) { throw new Error( - 'db: Usage: void db .', + 'db: Usage: void db .', ); } if (action === 'export' && values.remote) { @@ -189,6 +216,36 @@ export function parseDbArgs(args: Array): DbArgs { localOnly: values['local-only'], }; } + case 'restore': { + 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 }, yes: values.yes }; + } + if (timestamp && !bookmark) { + // Without a zone, the instant depends on where the command runs; a restore must not guess. + const time = new Date(timestamp); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test( + timestamp, + ) || + Number.isNaN(time.getTime()) + ) { + throw new Error( + 'db: --timestamp must be an ISO 8601 time with Z or an offset, for example 2026-10-16T09:30:00Z.', + ); + } + return { action, target: { timestamp: time.toISOString() }, yes: values.yes }; + } + throw new Error( + 'db: Usage: void db restore --bookmark or void db restore --timestamp .', + ); + } case 'export': return { action, diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 75fa87a1a..b6440dff7 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -211,6 +211,8 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { await runSetUrl(root, dialect); } else if (args.action === 'connect') { await runConnect(root, dialect, args); + } else if (args.action === 'restore') { + await runRestore(root, dialect, args.target, args.yes); } else if (args.action === 'rename-migrations') { await runRenameMigrations(root, dialect); } else { @@ -1419,6 +1421,70 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole } } +async function runRestore( + root: string, + dialect: DatabaseDialect, + target: { bookmark: string } | { timestamp: string }, + yes: boolean | undefined, +): Promise { + // `.void` is not checked in, so a fresh checkout or CI shows a direct deploy only through its + // Cloudflare config. + const platform = readDeploymentPlatform(root); + const directCloudflare = + platform === 'cloudflare' || + (platform === null && !readProjectConfig(root) && (await hasDeployedCloudflareDatabase(root))); + if (dialect !== 'sqlite' || !directCloudflare) { + log.error( + 'db: `void db restore` restores the D1 database of a direct Cloudflare deploy (`void deploy --platform cloudflare`).', + ); + process.exit(1); + } + const { readCloudflareD1Migrations, resolveCloudflareD1Context, restoreCloudflareD1Database } = + await import('./cloudflare-operations.ts'); + const binding = configuredD1Binding(root); + const context = resolveCloudflareD1Context(root, binding); + const database = `D1 database ${context.migrateToken} (${binding})`; + const point = 'bookmark' in target ? `bookmark ${target.bookmark}` : target.timestamp; + if (!yes) { + if (!process.stdin.isTTY) { + log.error(`db: Refusing to restore ${database} without confirmation. Re-run with --yes.`); + process.exit(1); + } + const ok = await confirm({ + message: `This restores ${database} to ${point} and discards every write made after it. Continue?`, + initialValue: false, + }); + if (isCancel(ok) || !ok) { + log.info('Cancelled.'); + process.exit(0); + } + } + + const previousBookmark = await restoreCloudflareD1Database(context, target); + if (previousBookmark) { + log.info(`To undo this restore, run \`void db restore --bookmark=${previousBookmark}\`.`); + } + // A restore also rewinds the migration table, so the next deploy applies those files again. + const paths = readProjectPaths(root); + try { + const applied = new Set(await readCloudflareD1Migrations(context)); + const unapplied = collectMigrations(paths.migrationsDir).filter( + (migration) => !applied.has(migration.name), + ); + if (unapplied.length > 0) { + log.warn( + `The remote database does not have these migrations applied: ${unapplied.map((migration) => migration.name).join(', ')}. ` + + 'The next `void deploy` applies them. Fix or remove a migration that caused the problem before you deploy.', + ); + } + } catch (error) { + log.warn( + `Could not read the applied migrations after the restore: ${getErrorMessage(error)}. Run \`void db status\` to check them.`, + ); + } + printSuccess(`Restored ${database} to ${point}.`); +} + async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolean): Promise { if (remote) { if (dialect === 'sqlite') { diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index b5439ef40..aade72c8c 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { readBindingFieldPresence } from '@void/deploy-cloudflare'; +import { cloudflareCommandDiagnostic, readBindingFieldPresence } from '@void/deploy-cloudflare'; import { Minimatch } from 'minimatch'; import { dirname, join, normalize, resolve } from 'pathslash'; import type { VoidAuthMigrationSchema } from '../auth/drizzle.ts'; @@ -15,9 +15,11 @@ import { type RequiredAuthTable, type TableInfoRow, type DeployWranglerRun, + type DeployWranglerRunResult, } from './deploy-types.ts'; -import { getErrorMessage } from './output.ts'; +import { formatCount, getErrorMessage } from './output.ts'; import type { ProvisionDriftEntry } from './provision.ts'; +import { sanitizeTerminalLogText } from './terminal-log-text.ts'; /** * Apply the checked-in migrations to an in-memory SQLite database and assert the resulting @@ -600,6 +602,12 @@ export async function checkWranglerMigrationApplySet( return { ok: true }; } +function cloudflareReason(result: DeployWranglerRunResult): string { + // stdout can hold binding values, so only the error output is shown. + const detail = cloudflareCommandDiagnostic({ ...result, stdout: '' }); + return detail ? `\nCloudflare: ${sanitizeTerminalLogText(detail)}` : ''; +} + /** Apply remote D1 changes and prove no migrations remain pending. */ export async function applyAndVerifyRemoteD1Migrations( d1MigrateToken: string | undefined, @@ -614,8 +622,8 @@ export async function applyAndVerifyRemoteD1Migrations( if (!assertUploaderStillPinned()) { return false; } - // A Worker rollback does not undo a migration. Time Travel does, to a bookmark taken before - // the apply; it cannot be reconstructed once the migration has run. + // A Worker rollback does not undo a migration. Time Travel does, and a bookmark taken right + // before the apply restores exactly the state the migrations started from. const before = await Promise.resolve( runWrangler( { type: 'd1.migrations.list', database: d1MigrateToken, configPath }, @@ -626,11 +634,15 @@ export async function applyAndVerifyRemoteD1Migrations( if (!isCleanCloudflareExit(before) || !Array.isArray(pendingBefore)) { log.error( `deploy: could not read the pending D1 migrations for "${d1MigrateToken}" before applying them ` + - `(\`d1 migrations list\` exit ${String(before.status)}). Refusing to migrate without knowing ` + - 'whether a restore point is needed — rerun once wrangler can reach your account.', + (isCleanCloudflareExit(before) + ? '(Cloudflare returned no pending migration list). ' + : `(exit ${String(before.status)}). `) + + 'Refusing to migrate without knowing whether a restore point is needed. Rerun once Cloudflare can be reached.' + + cloudflareReason(before), ); return false; } + let restoreHint = ''; if (pendingBefore.length > 0) { const restorePoint = await Promise.resolve( runWrangler( @@ -638,24 +650,21 @@ export async function applyAndVerifyRemoteD1Migrations( { capture: true }, ), ); - const taken = restorePoint.data as { bookmark?: unknown; databaseId?: unknown } | undefined; - const bookmark = taken?.bookmark; - const databaseId = taken?.databaseId; - if ( - !isCleanCloudflareExit(restorePoint) || - typeof bookmark !== 'string' || - typeof databaseId !== 'string' - ) { + const bookmark = (restorePoint.data as { bookmark?: unknown } | undefined)?.bookmark; + if (!isCleanCloudflareExit(restorePoint) || typeof bookmark !== 'string') { log.error( - `deploy: could not take a D1 Time Travel bookmark for "${d1MigrateToken}" before applying ` + - `${pendingBefore.length} migration(s). A Worker rollback does not undo a migration, and this ` + - 'bookmark is the only restore point. Refusing to migrate without one — rerun once the bookmark can be read.', + `deploy: could not save a D1 restore point for "${d1MigrateToken}" before applying ` + + `${formatCount(pendingBefore.length, 'migration')}. A Worker rollback does not undo a migration. ` + + 'Refusing to migrate without a restore point. Rerun once the restore point can be saved.' + + cloudflareReason(restorePoint), ); return false; } + restoreHint = + '\nTo undo these migrations, restore this point (discards every later write):\n' + + ` void db restore --bookmark=${bookmark}`; log.info( - `Restore point before these migrations: ${bookmark}. To undo them (destructive: it overwrites ` + - `the database, and every write since): npx cf d1 time-travel restore ${databaseId} --bookmark=${bookmark}`, + `Saved a D1 restore point before applying ${formatCount(pendingBefore.length, 'migration')}.${restoreHint}`, ); if (!assertUploaderStillPinned()) { return false; @@ -671,13 +680,17 @@ export async function applyAndVerifyRemoteD1Migrations( log.error( `deploy: remote D1 migrations failed for "${d1MigrateToken}" (exit ${String(migrate.status)}). ` + 'Fix the migration and rerun. Note: D1 always enforces foreign_keys=ON — a Drizzle ' + - 'table-rebuild using PRAGMA foreign_keys=OFF must use PRAGMA defer_foreign_keys instead.', + 'table-rebuild using PRAGMA foreign_keys=OFF must use PRAGMA defer_foreign_keys instead.' + + restoreHint, ); return false; } // A clean exit does not prove the schema is current, so read the pending set. if (!assertUploaderStillPinned()) { + if (restoreHint) { + log.error(`deploy: D1 migrations were applied before the deploy stopped.${restoreHint}`); + } return false; } const migrateList = await Promise.resolve( @@ -690,7 +703,8 @@ export async function applyAndVerifyRemoteD1Migrations( log.error( `deploy: could not verify the remote migration state for "${d1MigrateToken}" ` + `(\`d1 migrations list\` exit ${String(migrateList.status)}). Refusing to deploy against ` + - 'an unverified schema — rerun once wrangler can reach your account.', + 'an unverified schema — rerun once wrangler can reach your account.' + + restoreHint, ); return false; } @@ -700,7 +714,7 @@ export async function applyAndVerifyRemoteD1Migrations( [ `deploy: remote D1 still has UNAPPLIED migrations for "${d1MigrateToken}" after apply.`, 'The schema is not current for this deployment. Fix or apply the pending migrations, then retry.', - ].join('\n'), + ].join('\n') + restoreHint, ); return false; } @@ -713,7 +727,7 @@ export async function applyAndVerifyRemoteD1Migrations( `deploy: could not confirm the remote migration state for "${d1MigrateToken}".`, 'Cloudflare returned an invalid pending migration set. Refusing to deploy against an unverified schema.', 'Retry once the database migration state can be read.', - ].join('\n'), + ].join('\n') + restoreHint, ); return false; } diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 3166b843d..461478d05 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1181,6 +1181,10 @@ const HELP_TREE: HelpTree = { { command: 'export', description: 'Export local database as SQL' }, { command: 'connect', description: 'Connect or provision an external database' }, { command: 'set-url', description: 'Update the external database URL' }, + { + command: 'restore', + description: 'Restore the deployed D1 database to an earlier point (direct Cloudflare)', + }, { command: 'rename-migrations', description: 'Rename migrations to timestamp format' }, ], children: { @@ -1292,6 +1296,35 @@ const HELP_TREE: HelpTree = { 'Update the external database connection string for deployment.', 'void db set-url', ), + restore: { + summary: + 'Restore the deployed D1 database of a direct Cloudflare deploy to an earlier point. Discards every later write.', + usage: [ + 'void db restore --bookmark [--yes]', + 'void db restore --timestamp [--yes]', + ], + options: [ + { command: '--bookmark ', description: 'Restore point printed by deploy' }, + { + command: '--timestamp ', + description: 'Time with Z or an offset (7 days back on Workers Free, 30 on Paid)', + }, + { + command: '--yes', + description: 'Skip the confirmation prompt (required without a TTY)', + }, + ], + examples: [ + { + command: 'void db restore --bookmark 00000085-0000024c-00004ef2-0a83ea2f', + description: 'Undo the migrations of a deploy', + }, + { + command: 'void db restore --timestamp 2026-10-16T09:30:00Z', + description: 'Restore the database to a time', + }, + ], + }, 'rename-migrations': basicSubcommand( 'Rename migrations to timestamp-based format.', 'void db rename-migrations', @@ -1488,6 +1521,7 @@ export function printGlobalHelp(): void { execute: 'execute ', export: 'export [--output ]', seed: 'seed [--file ]', + restore: 'restore --bookmark ', }), ...globalOptionLines(['db', 'execute']), ...globalOptionLines(['db', 'export']), diff --git a/packages/void/src/cli/rollback.ts b/packages/void/src/cli/rollback.ts index 451cfc86f..3f9bd8cb2 100644 --- a/packages/void/src/cli/rollback.ts +++ b/packages/void/src/cli/rollback.ts @@ -221,5 +221,12 @@ async function confirmUnrevertedMigrations( 'Rolling back will NOT revert these migrations. The older code will run against the current schema.', ), ); + if (noun === 'version') { + log.message( + dim( + 'For D1, `void db restore --bookmark ` undoes them with the restore point deploy printed before migrating.', + ), + ); + } await confirmRollbackOrExit(); } diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index 952e2702b..e2a44231e 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -131,6 +131,15 @@ describe('rendered help advertises the flags the parser honours', () => { expect(line).toContain('--platform cloudflare'); }); + it('lists `db restore` and shows both of its targets', () => { + expect(getHelpPath(['db', 'restore', '--bookmark', 'x', '--help'])).toEqual(['db', 'restore']); + expect(render(['db'])).toContain('restore'); + const page = render(['db', 'restore']); + expect(page).toContain('--bookmark '); + expect(page).toContain('--timestamp '); + expect(page).toContain('--yes'); + }); + it('shows --limit and --project on `email logs`', () => { const page = render(['email', 'logs']); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 856a51ad5..f8853be9c 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3251,7 +3251,18 @@ describe('runCloudflareDeploy — resource provisioning', () => { 'wrangler:deploy', ]); // migrate targets the stored db name with --remote + pinned config - const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1').slice(1); + const d1Calls = wranglerArgs.filter((a) => a[0] === 'd1'); + // the pre-apply list reads the pending set of the same target + expect(d1Calls.shift()).toEqual([ + 'd1', + 'migrations', + 'list', + 'myapp-db', + '--remote', + '-c', + toSlash(join(root, 'wrangler.jsonc')), + '--env=', + ]); expect(d1Calls[0]).toEqual([ 'd1', 'migrations', @@ -4126,6 +4137,7 @@ describe('runCloudflareDeploy — D1 migrate targets the unique validated entry' const { deps, wranglerArgs, trace } = harness(); await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); // Targets the validated entry's own unique database_name (resolves to DB, not OLD). + expect(wranglerArgs.filter((a) => a[0] === 'd1').every((a) => a[3] === 'myapp-db')).toBe(true); expect(wranglerArgs.find((a) => a[0] === 'd1' && a[2] === 'apply')).toEqual([ 'd1', 'migrations', @@ -7884,7 +7896,7 @@ describe('runCloudflareDeploy — remote migration gates', () => { if (args[0] === 'd1' && args[1] === 'time-travel') { return { ...cleanExit, - stdout: JSON.stringify({ bookmark: '00000085-0000024c', databaseId: REAL_D1_ID }), + stdout: JSON.stringify({ bookmark: '00000085-0000024c' }), }; } return cleanExit; @@ -7913,7 +7925,14 @@ describe('runCloudflareDeploy — remote migration gates', () => { if (args[0] === 'whoami') { return { ...cleanExit, stdout: '{"loggedIn":true,"accounts":[]}' }; } - // Both `apply` and `list` exit 0 with EMPTY stdout — the failure mode when an + // The pre-apply list sees the pending migration, so a restore point is saved. + if (args[0] === 'd1' && args[2] === 'list' && !localTrace.includes('wrangler:d1:apply')) { + return { ...cleanExit, stdout: 'Migrations to be applied:\n0000_init' }; + } + if (args[0] === 'd1' && args[1] === 'time-travel') { + return { ...cleanExit, stdout: JSON.stringify({ bookmark: '00000085-0000024c' }) }; + } + // `apply` and the post-apply `list` exit 0 with EMPTY stdout — the failure mode when an // ambient WRANGLER_LOG suppressed the sentinel. Require the affirmative marker. return cleanExit; }), @@ -7926,7 +7945,12 @@ describe('runCloudflareDeploy — remote migration gates', () => { ).rejects.toBeInstanceOf(ExitError); expect(exitCalls).toEqual([1]); // Never trusts an absent marker — fails closed before DEPLOY (the build already ran). - expect(localTrace).toContain('wrangler:d1:list'); + expect(localTrace.filter((entry) => entry.startsWith('wrangler:d1:'))).toEqual([ + 'wrangler:d1:list', + 'wrangler:d1:info', + 'wrangler:d1:apply', + 'wrangler:d1:list', + ]); expect(localTrace).toContain('build'); expect(localTrace).not.toContain('wrangler:deploy'); }); diff --git a/packages/void/test/unit/cloudflare-operation-process-d1.test.ts b/packages/void/test/unit/cloudflare-operation-process-d1.test.ts index b50de8edd..ca7d90861 100644 --- a/packages/void/test/unit/cloudflare-operation-process-d1.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process-d1.test.ts @@ -3,8 +3,12 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { CloudflareOperation } from '@void/deploy-cloudflare'; import { expect, it } from 'vite-plus/test'; -import { encodeCloudflareOperation } from '../../src/cli/cloudflare-process.ts'; +import { + decodeCloudflareOperationResult, + encodeCloudflareOperation, +} from '../../src/cli/cloudflare-process.ts'; it('passes the validated custom D1 migration pattern to cf', () => { const directory = mkdtempSync(join(tmpdir(), 'void-cf-d1-pattern-test-')); @@ -80,8 +84,9 @@ syncBuiltinESMExports(); } }); -it('takes the D1 Time Travel bookmark through cf by database id', () => { - const directory = mkdtempSync(join(tmpdir(), 'void-cf-d1-bookmark-test-')); +// Runs the operation process with cf replaced, and returns cf's argv and the decoded result. +function runWithCf(operation: CloudflareOperation, cfStdout: string, cfStatus = 0, cfStderr = '') { + const directory = mkdtempSync(join(tmpdir(), 'void-cf-d1-time-travel-test-')); try { const config = join(directory, 'wrangler.json'); const capture = join(directory, 'cf-args.json'); @@ -97,7 +102,11 @@ import { syncBuiltinESMExports } from 'node:module'; import { writeFileSync } from 'node:fs'; child.spawnSync = (_binary, args) => { writeFileSync(process.env.VOID_TEST_CF_CAPTURE, JSON.stringify(args)); - return { status: 0, stdout: '{"bookmark":"00000085-0000024c-00004ef2"}', stderr: '' }; + return { + status: Number(process.env.VOID_TEST_CF_STATUS), + stdout: process.env.VOID_TEST_CF_STDOUT, + stderr: process.env.VOID_TEST_CF_STDERR, + }; }; syncBuiltinESMExports(); `, @@ -110,32 +119,117 @@ syncBuiltinESMExports(); 'cli', 'cloudflare-operation-process.ts', ); - const result = spawnSync( + const child = spawnSync( process.execPath, [ '--import', pathToFileURL(preload).href, entry, - ...encodeCloudflareOperation({ - type: 'd1.time-travel.bookmark', - database: 'DB', - configPath: config, - }), + ...encodeCloudflareOperation({ ...operation, configPath: config }), ], { - env: { ...process.env, VOID_TEST_CF_CAPTURE: capture }, + env: { + ...process.env, + VOID_TEST_CF_CAPTURE: capture, + VOID_TEST_CF_STDOUT: cfStdout, + VOID_TEST_CF_STATUS: String(cfStatus), + VOID_TEST_CF_STDERR: cfStderr, + }, encoding: 'utf8', }, ); + return { + cfArgs: (JSON.parse(readFileSync(capture, 'utf8')) as Array).slice(1), + result: decodeCloudflareOperationResult({ + status: child.status, + stdout: child.stdout, + stderr: child.stderr, + }), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +const bookmark = '00000085-0000024c-00004ef2-0a83ea2fceebc654de0640c422be4653'; + +it('takes the D1 Time Travel bookmark through cf by database id', () => { + const { cfArgs, result } = runWithCf( + { type: 'd1.time-travel.bookmark', database: 'DB', configPath: '' }, + JSON.stringify({ bookmark }), + ); + expect(result.status, result.stderr).toBe(0); + expect(cfArgs).toEqual(['d1', 'time-travel', 'get-bookmark', 'database-id']); + expect(result.data).toEqual({ bookmark }); +}); + +it.each(['""', '"00000085-0000024c; rm -rf ~"'])('rejects the malformed bookmark %s', (value) => { + const { result } = runWithCf( + { type: 'd1.time-travel.bookmark', database: 'DB', configPath: '' }, + `{"bookmark":${value}}`, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('D1 Time Travel bookmark was malformed.'); + expect(result.data).toBeUndefined(); +}); + +it('restores through cf without its prompt and returns the bookmark that undoes it', () => { + const previous = '00000085-0000024c-00004ef1-1b94fb3fdffcd765ef1751d533cf5764'; + const restored = '00000085-0000024c-00004ef3-2ca50c40e00de876f02862e644d08875'; + const { cfArgs, result } = runWithCf( + { + type: 'd1.time-travel.restore', + database: 'DB', + target: { bookmark }, + configPath: '', + }, + JSON.stringify({ bookmark: restored, message: 'Restored', previous_bookmark: previous }), + ); + expect(result.status, result.stderr).toBe(0); + expect(cfArgs).toEqual([ + 'd1', + 'time-travel', + 'restore', + 'database-id', + `--bookmark=${bookmark}`, + '--force', + ]); + expect(result.data).toEqual({ previousBookmark: previous }); +}); + +it("reports a failed restore with cf's reason and no data", () => { + const { result } = runWithCf( + { type: 'd1.time-travel.restore', database: 'DB', target: { bookmark }, configPath: '' }, + '', + 1, + 'The bookmark is outside the Time Travel window.', + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain('outside the Time Travel window'); + expect(result.data).toBeUndefined(); +}); + +it.each(['Restored.', JSON.stringify({ previous_bookmark: 'a b;rm' })])( + 'reports a restore by timestamp as done when cf returns no valid previous bookmark (%s)', + (stdout) => { + const { cfArgs, result } = runWithCf( + { + type: 'd1.time-travel.restore', + database: 'DB', + target: { timestamp: '2026-10-16T09:30:00.000Z' }, + configPath: '', + }, + stdout, + ); expect(result.status, result.stderr).toBe(0); - expect((JSON.parse(readFileSync(capture, 'utf8')) as Array).slice(1)).toEqual([ + expect(cfArgs).toEqual([ 'd1', 'time-travel', - 'get-bookmark', + 'restore', 'database-id', + '--timestamp=2026-10-16T09:30:00.000Z', + '--force', ]); - expect(result.stdout).toContain('00000085-0000024c-00004ef2'); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); + expect(result.data).toEqual({}); + }, +); diff --git a/packages/void/test/unit/cloudflare-operations.test.ts b/packages/void/test/unit/cloudflare-operations.test.ts index 8f520efc3..e4065e4fe 100644 --- a/packages/void/test/unit/cloudflare-operations.test.ts +++ b/packages/void/test/unit/cloudflare-operations.test.ts @@ -18,6 +18,7 @@ import { readCloudflareWorkerStatus, readCloudflareD1Migrations, resolveCloudflareD1Context, + restoreCloudflareD1Database, syncCloudflareSecrets, tailCloudflareWorker, type CloudflareOperationContext, @@ -623,6 +624,28 @@ describe('Cloudflare D1 operations', () => { { capture: false, input: undefined }, ]); }); + + it('restores the exact configured D1 target and returns the bookmark that undoes it', async () => { + const runner = vi + .fn() + .mockReturnValue(ok({ previousBookmark: '00000001-00000002' })); + const ctx = { + operation: context(runner), + binding: 'DB', + migrateToken: 'db-name', + migrationsDir: 'db/migrations', + migrationsTable: 'd1_migrations', + }; + await expect( + restoreCloudflareD1Database(ctx, { timestamp: '2026-10-16T09:30:00Z' }), + ).resolves.toBe('00000001-00000002'); + expect(runner.mock.calls[0][0]).toEqual({ + type: 'd1.time-travel.restore', + database: 'db-name', + target: { timestamp: '2026-10-16T09:30:00Z' }, + configPath: '/app/wrangler.jsonc', + }); + }); }); describe('Cloudflare custom domains', () => { diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 88ec4272d..afc7cd69e 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -199,9 +199,35 @@ describe('parseDbArgs', () => { ).toThrow('Provide either a connection string or --provider'); }); + it('parses restore to a bookmark or a timestamp', () => { + expect(parseDbArgs(['restore', '--bookmark=00000085-0000024c', '--yes'])).toEqual({ + action: 'restore', + target: { bookmark: '00000085-0000024c' }, + yes: true, + }); + expect(parseDbArgs(['restore', '--timestamp', '2026-10-16T09:30:00+08:00'])).toEqual({ + action: 'restore', + target: { timestamp: '2026-10-16T01:30:00.000Z' }, + yes: undefined, + }); + }); + + it.each([ + [[], 'void db restore --bookmark or'], + [['--bookmark', 'b', '--timestamp', '2026-10-16T09:30:00Z'], 'void db restore --bookmark'], + [['--timestamp', 'yesterday'], '--timestamp must be an ISO 8601 time'], + [['--timestamp', '2026'], '--timestamp must be an ISO 8601 time'], + [['--timestamp', '2026-10-16T09:30:00'], 'with Z or an offset'], + [['--bookmark=--dry-run'], '--bookmark must be a D1 restore point'], + [['--bookmark', 'not-hex'], '--bookmark must be a D1 restore point'], + [['--remote', '--bookmark', '00000085-0000024c'], 'does not support --remote'], + ])('rejects restore %j', (args, message) => { + expect(() => parseDbArgs(['restore', ...args])).toThrow(message); + }); + it('errors on unknown subcommand', () => { expect(() => parseDbArgs(['unknown'])).toThrow( - 'db: Usage: void db .', + 'db: Usage: void db .', ); }); diff --git a/packages/void/test/unit/db-migrate.test.ts b/packages/void/test/unit/db-migrate.test.ts index d797402f4..1b86c29e2 100644 --- a/packages/void/test/unit/db-migrate.test.ts +++ b/packages/void/test/unit/db-migrate.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import * as output from '../../src/cli/output.ts'; const mockIntro = vi.fn(); +const mockConfirm = vi.fn(); const mockOutro = vi.fn(); const mockLog = { error: vi.fn(), @@ -26,6 +27,8 @@ const mockRunMySqlMigrations = vi.fn(); vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ intro: (...args: Array) => mockIntro(...args), outro: (...args: Array) => mockOutro(...args), + confirm: (...args: Array) => mockConfirm(...args), + isCancel: (value: unknown) => typeof value === 'symbol', log: { error: (...args: Array) => mockLog.error(...args), info: (...args: Array) => mockLog.info(...args), @@ -142,6 +145,7 @@ function selectCloudflare(root: string): void { const mockReadCloudflareD1Migrations = vi.fn(); const mockApplyCloudflareD1Migrations = vi.fn(); +const mockRestoreCloudflareD1Database = vi.fn(); // The D1 context still comes from the real config reader; only the Cloudflare calls are fake. vi.mock('../../src/cli/cloudflare-operations.ts', async (importOriginal) => ({ @@ -149,6 +153,8 @@ vi.mock('../../src/cli/cloudflare-operations.ts', async (importOriginal) => ({ readCloudflareD1Migrations: (...args: Array) => mockReadCloudflareD1Migrations(...args), applyCloudflareD1Migrations: (...args: Array) => mockApplyCloudflareD1Migrations(...args), + restoreCloudflareD1Database: (...args: Array) => + mockRestoreCloudflareD1Database(...args), })); describe('void db migrate', () => { @@ -386,3 +392,173 @@ describe('void db migrate', () => { expect(mockRunMigrations).not.toHaveBeenCalled(); }); }); + +describe('void db restore', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + const bookmark = '00000085-0000024c-00004ef2-0a83ea2fceebc654de0640c422be4653'; + const previous = '00000085-0000024c-00004ef1-1b94fb3fdffcd765ef1751d533cf5764'; + + function d1Project(select = true): string { + const root = createProjectRoot({ + '001_create_users.sql': 'CREATE TABLE users (id integer primary key);', + '002_add_email.sql': 'ALTER TABLE users ADD email text;', + }); + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'linked-worker', + account_id: 'acc1', + d1_databases: [ + { + binding: 'DB', + database_name: 'app-db', + database_id: '3f2a9c1e-0000-4000-8000-000000000000', + migrations_dir: './db/migrations', + }, + ], + }), + ); + if (select) { + selectCloudflare(root); + } + return root; + } + + beforeEach(async () => { + vi.spyOn(output, 'printSuccess').mockImplementation(() => {}); + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('restores the configured D1 database and names the migrations the next deploy reapplies', async () => { + const root = d1Project(); + mockRestoreCloudflareD1Database.mockResolvedValue(previous); + mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql']); + + await runDbCommand(root, { action: 'restore', target: { bookmark }, yes: true }); + + expect(mockRestoreCloudflareD1Database).toHaveBeenCalledWith( + expect.objectContaining({ binding: 'DB', migrateToken: 'app-db' }), + { bookmark }, + ); + expect(mockLog.info).toHaveBeenCalledWith( + `To undo this restore, run \`void db restore --bookmark=${previous}\`.`, + ); + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'The remote database does not have these migrations applied: 002_add_email.sql.', + ), + ); + expect(output.printSuccess).toHaveBeenCalledWith( + `Restored D1 database app-db (DB) to bookmark ${bookmark}.`, + ); + }); + + it('still reports the restore when the applied migrations cannot be read afterwards', async () => { + const root = d1Project(); + mockRestoreCloudflareD1Database.mockResolvedValue(undefined); + mockReadCloudflareD1Migrations.mockRejectedValue(new Error('network down')); + + await runDbCommand(root, { + action: 'restore', + target: { timestamp: '2026-10-16T09:30:00Z' }, + yes: true, + }); + + expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('network down')); + expect(mockLog.info).not.toHaveBeenCalledWith(expect.stringContaining('To undo')); + expect(output.printSuccess).toHaveBeenCalledWith( + 'Restored D1 database app-db (DB) to 2026-10-16T09:30:00Z.', + ); + }); + + it('refuses to restore without --yes when there is no terminal', async () => { + const root = d1Project(); + const stdinTty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + try { + await expect(runDbCommand(root, { action: 'restore', target: { bookmark } })).rejects.toThrow( + 'process.exit:1', + ); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); + } + + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('Re-run with --yes')); + expect(mockRestoreCloudflareD1Database).not.toHaveBeenCalled(); + }); + + it('asks before restoring in a terminal, and changes nothing when declined', async () => { + const root = d1Project(); + mockConfirm.mockResolvedValue(false); + const stdinTty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + try { + await expect(runDbCommand(root, { action: 'restore', target: { bookmark } })).rejects.toThrow( + 'process.exit:0', + ); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); + } + + expect(mockConfirm).toHaveBeenCalledWith({ + message: `This restores D1 database app-db (DB) to bookmark ${bookmark} and discards every write made after it. Continue?`, + initialValue: false, + }); + expect(mockLog.info).toHaveBeenCalledWith('Cancelled.'); + expect(mockRestoreCloudflareD1Database).not.toHaveBeenCalled(); + }); + + it('restores from a fresh checkout whose Cloudflare config names the D1 database', async () => { + const root = d1Project(false); + mockRestoreCloudflareD1Database.mockResolvedValue(undefined); + mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql', '002_add_email.sql']); + + await runDbCommand(root, { action: 'restore', target: { bookmark }, yes: true }); + + expect(mockRestoreCloudflareD1Database).toHaveBeenCalled(); + expect(mockLog.warn).not.toHaveBeenCalled(); + expect(output.printSuccess).toHaveBeenCalled(); + }); + + it.each([ + ['has no Cloudflare deploy', () => createProjectRoot({})], + [ + 'deploys to a Void platform', + () => { + const root = d1Project(false); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(join(root, '.void', 'project.json'), JSON.stringify({ platform: 'void' })); + return root; + }, + ], + [ + 'uses PostgreSQL', + () => { + const root = createPgProjectRoot({}); + selectCloudflare(root); + return root; + }, + ], + ])('refuses a project that %s', async (_name, makeRoot) => { + const root = makeRoot(); + + await expect( + runDbCommand(root, { action: 'restore', target: { bookmark }, yes: true }), + ).rejects.toThrow('process.exit:1'); + + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('direct Cloudflare deploy')); + expect(mockRestoreCloudflareD1Database).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/deploy-migrations-restore-point.test.ts b/packages/void/test/unit/deploy-migrations-restore-point.test.ts index d69100f58..8275e2091 100644 --- a/packages/void/test/unit/deploy-migrations-restore-point.test.ts +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -1,5 +1,5 @@ -import type { CloudflareOperation } from '@void/deploy-cloudflare'; -import { describe, expect, it, vi } from 'vite-plus/test'; +import type { CloudflareCommandResult, CloudflareOperation } from '@void/deploy-cloudflare'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ log: { error: vi.fn(), info: vi.fn(), message: vi.fn(), step: vi.fn(), warn: vi.fn() }, @@ -8,39 +8,38 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ const { log } = await import('@voidzero-dev/vite-plus-prompts'); const { applyAndVerifyRemoteD1Migrations } = await import('../../src/cli/deploy-migrations.ts'); -type Answers = { pending: Array>; bookmark?: string }; +const bookmark = '00000085-0000024c-00004ef2-0a83ea2fceebc654de0640c422be4653'; +const clean = { status: 0, stdout: '', stderr: '' }; + +type Answers = Partial>>; function recordingRunner(answers: Answers) { const calls: Array = []; - const lists = [...answers.pending]; - const run = (operation: CloudflareOperation) => { + const run = (operation: CloudflareOperation): CloudflareCommandResult => { calls.push(operation.type); - if (operation.type === 'd1.migrations.list') { - return { status: 0, stdout: '', stderr: '', data: { pending: lists.shift() ?? [] } }; - } - if (operation.type === 'd1.time-travel.bookmark') { - return answers.bookmark === undefined - ? { status: 1, stdout: '', stderr: 'Time Travel is unavailable for this database.' } - : { - status: 0, - stdout: '', - stderr: '', - data: { - bookmark: answers.bookmark, - databaseId: 'a1b2c3d4-0000-4000-8000-000000000001', - }, - }; - } - return { status: 0, stdout: '', stderr: '' }; + return answers[operation.type]?.shift() ?? clean; }; return { calls, run }; } +const pending = (names: Array) => ({ ...clean, data: { pending: names } }); + +function errorText(): string { + return vi + .mocked(log.error) + .mock.calls.map(([message]) => String(message)) + .join('\n'); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe('the D1 restore point a deploy takes before migrating', () => { - it('takes a Time Travel bookmark before applying pending migrations, and names how to restore it', async () => { + it('saves a restore point before applying pending migrations, and names how to restore it', async () => { const runner = recordingRunner({ - pending: [['0001_add_column.sql'], []], - bookmark: '00000085-0000024c-00004ef2', + 'd1.migrations.list': [pending(['0001_add_column.sql']), pending([])], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], }); const applied = await applyAndVerifyRemoteD1Migrations( @@ -58,14 +57,23 @@ describe('the D1 restore point a deploy takes before migrating', () => { 'd1.migrations.list', ]); expect(vi.mocked(log.info)).toHaveBeenCalledWith( - expect.stringContaining( - 'npx cf d1 time-travel restore a1b2c3d4-0000-4000-8000-000000000001 --bookmark=00000085-0000024c-00004ef2', - ), + expect.stringContaining(`void db restore --bookmark=${bookmark}`), ); }); - it('refuses to migrate when the bookmark cannot be taken, leaving the database unchanged', async () => { - const runner = recordingRunner({ pending: [['0001_add_column.sql']] }); + it.each([ + { + status: 1, + stdout: '', + stderr: 'Time Travel is unavailable for this database.', + data: { bookmark }, + }, + { ...clean, data: {} }, + ])('refuses to migrate when the restore point cannot be saved (%o)', async (answer) => { + const runner = recordingRunner({ + 'd1.migrations.list': [pending(['0001_add_column.sql'])], + 'd1.time-travel.bookmark': [answer], + }); const applied = await applyAndVerifyRemoteD1Migrations( 'app-db', @@ -75,11 +83,79 @@ describe('the D1 restore point a deploy takes before migrating', () => { ); expect(applied).toBe(false); - expect(runner.calls).not.toContain('d1.migrations.apply'); + expect(runner.calls).toEqual(['d1.migrations.list', 'd1.time-travel.bookmark']); + expect(errorText()).toContain('Refusing to migrate without a restore point'); + if (answer.stderr) { + expect(errorText()).toContain(`Cloudflare: ${answer.stderr}`); + } else { + expect(errorText()).not.toContain('\nCloudflare:'); + } + expect(vi.mocked(log.info)).not.toHaveBeenCalled(); + }); + + it.each([ + { status: 1, stdout: '', stderr: 'Authentication error', data: { pending: [] } }, + { ...clean, data: {} }, + ])('refuses to migrate when the pending list cannot be read (%o)', async (answer) => { + const runner = recordingRunner({ 'd1.migrations.list': [answer] }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(false); + expect(runner.calls).toEqual(['d1.migrations.list']); + expect(errorText()).toContain(answer.status ? 'exit 1' : 'returned no pending migration list'); + }); + + it('checks the uploader again after the restore point, before applying', async () => { + const runner = recordingRunner({ + 'd1.migrations.list': [pending(['0001_add_column.sql'])], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + }); + const stillPinned = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + stillPinned, + ); + + expect(applied).toBe(false); + expect(runner.calls).toEqual(['d1.migrations.list', 'd1.time-travel.bookmark']); + }); + + it.each([ + { name: 'the apply fails', apply: { status: 1, stdout: '', stderr: '' } }, + { name: 'the check cannot run', verify: { status: 1, stdout: '', stderr: '' } }, + { name: 'migrations are still pending', verify: pending(['0001_add_column.sql']) }, + { name: 'the check returns no list', verify: { ...clean, data: {} } }, + { name: 'the uploader changed after the apply', pinnedChecks: 2 }, + ])('repeats the restore command when $name', async ({ apply, verify, pinnedChecks }) => { + const runner = recordingRunner({ + 'd1.migrations.list': [pending(['0001_add_column.sql']), verify ?? pending([])], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + 'd1.migrations.apply': [apply ?? clean], + }); + let checks = 0; + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => pinnedChecks === undefined || checks++ < pinnedChecks, + ); + + expect(applied).toBe(false); + expect(errorText()).toContain(`void db restore --bookmark=${bookmark}`); }); - it('takes no bookmark when no migration is pending', async () => { - const runner = recordingRunner({ pending: [[], []] }); + it('saves no restore point when no migration is pending', async () => { + const runner = recordingRunner({ 'd1.migrations.list': [pending([]), pending([])] }); const applied = await applyAndVerifyRemoteD1Migrations( 'app-db', From 51594a613927b88e531f521730913dbaa1593e4f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 16 Oct 2026 20:47:20 +0800 Subject: [PATCH 3/8] fix(cloudflare): say when a D1 restore point has expired Restore points last 7 days on Workers Free and 30 days on Workers Paid. The direct Cloudflare rollback hint, the restore failure and the docs now say so, and point to a new migration for older changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/deployment.md | 2 +- docs/integrations/cloudflare.md | 2 +- docs/reference/cli/database.md | 2 +- docs/reference/cli/project.md | 2 +- packages/void/src/cli/db.ts | 12 +++++++++++- packages/void/src/cli/rollback.ts | 3 ++- packages/void/test/unit/db-migrate.test.ts | 17 +++++++++++++++++ 7 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 52f835d37..3e92a26ea 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -35,7 +35,7 @@ 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. 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. Restoring discards every write made after the restore point. +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. 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. 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 diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 48aba0667..3acb16822 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -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. 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. +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 diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index c6c53fe6f..450ab4cbc 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -96,7 +96,7 @@ void db restore --bookmark [--yes] void db restore --timestamp [--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`. Time Travel keeps 7 days of history on Workers Free and 30 days on Workers Paid. +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`. 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. 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. diff --git a/docs/reference/cli/project.md b/docs/reference/cli/project.md index f01683794..c7b4e5873 100644 --- a/docs/reference/cli/project.md +++ b/docs/reference/cli/project.md @@ -179,7 +179,7 @@ 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. 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. +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} diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index b6440dff7..711b4d59f 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -1460,7 +1460,17 @@ async function runRestore( } } - const previousBookmark = await restoreCloudflareD1Database(context, target); + let previousBookmark: string | undefined; + try { + previousBookmark = await restoreCloudflareD1Database(context, target); + } catch (error) { + log.error( + `db: Could not restore ${database} to ${point}: ${getErrorMessage(error)}\n` + + 'Restore points expire after 7 days on Workers Free and 30 days on Workers Paid. ' + + 'To undo an older migration, write a new migration that reverses it.', + ); + process.exit(1); + } if (previousBookmark) { log.info(`To undo this restore, run \`void db restore --bookmark=${previousBookmark}\`.`); } diff --git a/packages/void/src/cli/rollback.ts b/packages/void/src/cli/rollback.ts index 3f9bd8cb2..d4663900b 100644 --- a/packages/void/src/cli/rollback.ts +++ b/packages/void/src/cli/rollback.ts @@ -224,7 +224,8 @@ async function confirmUnrevertedMigrations( if (noun === 'version') { log.message( dim( - 'For D1, `void db restore --bookmark ` undoes them with the restore point deploy printed before migrating.', + 'For D1, `void db restore --bookmark ` undoes them with the restore point deploy printed before migrating. ' + + 'Restore points expire after 7 days on Workers Free and 30 days on Paid; undo older migrations with a new migration.', ), ); } diff --git a/packages/void/test/unit/db-migrate.test.ts b/packages/void/test/unit/db-migrate.test.ts index 1b86c29e2..7e3d1493d 100644 --- a/packages/void/test/unit/db-migrate.test.ts +++ b/packages/void/test/unit/db-migrate.test.ts @@ -499,6 +499,23 @@ describe('void db restore', () => { expect(mockRestoreCloudflareD1Database).not.toHaveBeenCalled(); }); + it('explains that restore points expire when Cloudflare rejects the restore', async () => { + const root = d1Project(); + mockRestoreCloudflareD1Database.mockRejectedValue( + new Error('Cloudflare command failed (d1.time-travel.restore, exit 1): bookmark expired'), + ); + + await expect( + runDbCommand(root, { action: 'restore', target: { bookmark }, yes: true }), + ).rejects.toThrow('process.exit:1'); + + const message = String(mockLog.error.mock.calls[0]?.[0]); + expect(message).toContain('bookmark expired'); + expect(message).toContain('Restore points expire after 7 days on Workers Free'); + expect(mockReadCloudflareD1Migrations).not.toHaveBeenCalled(); + expect(output.printSuccess).not.toHaveBeenCalled(); + }); + it('asks before restoring in a terminal, and changes nothing when declined', async () => { const root = d1Project(); mockConfirm.mockResolvedValue(false); From 0ae06c864f90e6fb8fe059a6c834782a52a818b5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 16 Oct 2026 23:52:22 +0800 Subject: [PATCH 4/8] fix(cloudflare): restore after a one-off deploy and reject rolled-over dates A one-off `void deploy --platform cloudflare` keeps the saved platform, so `void db restore` refused right after it. Restore now accepts `--platform cloudflare`, and deploy prints the flag when the saved platform is not Cloudflare. `new Date` turns 2026-02-30 into March 2 and 24:00 into the next day. `--timestamp` now compares the fields as written, so it never restores to a moment the user did not type. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/cli/database.md | 6 +- packages/void/src/cli/db-args.ts | 60 +++++++++++++++---- packages/void/src/cli/db.ts | 18 ++++-- .../void/src/cli/deploy-cloudflare-migrate.ts | 2 + packages/void/src/cli/deploy-migrations.ts | 4 +- packages/void/src/cli/help.ts | 8 ++- packages/void/test/unit/db-args.test.ts | 23 ++++++- packages/void/test/unit/db-migrate.test.ts | 42 +++++++++---- .../deploy-migrations-restore-point.test.ts | 13 ++++ 9 files changed, 141 insertions(+), 35 deletions(-) diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index 450ab4cbc..5c8f63568 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -92,12 +92,14 @@ By default, targets the local database. Pass `--remote` to open Studio against t ## `void db restore` {#void-db-restore} ``` -void db restore --bookmark [--yes] -void db restore --timestamp [--yes] +void db restore --bookmark [--platform cloudflare] [--yes] +void db restore --timestamp [--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`. 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` if the live version needs the newer schema. diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index f915016d6..3edecdc77 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -55,6 +55,11 @@ export const dbSubcommands: Array = [ arg: '', 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' }, ], }, @@ -99,6 +104,7 @@ export type DbArgs = | { action: 'restore'; target: { bookmark: string } | { timestamp: string }; + platform?: 'cloudflare'; yes?: boolean; } | { action: 'rename-migrations' }; @@ -133,11 +139,42 @@ const ACTION_FLAGS: Record> = { studio: ['remote'], 'set-url': [], connect: ['provider', 'name', 'region', 'local-only'], - restore: ['bookmark', 'timestamp', 'yes'], + 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, and `new Date` rolls impossible dates over (Feb 30 becomes Mar 2), so both 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+)?)?(?: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() + : undefined; +} + function isDbAction(value: string | undefined): value is DbArgs['action'] { return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value); } @@ -159,6 +196,7 @@ export function parseDbArgs(args: Array): DbArgs { yes: { type: 'boolean' }, bookmark: { type: 'string' }, timestamp: { type: 'string' }, + platform: { type: 'string' }, }, allowPositionals: true, strict: true, @@ -217,6 +255,10 @@ export function parseDbArgs(args: Array): DbArgs { }; } 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) { @@ -225,22 +267,16 @@ export function parseDbArgs(args: Array): DbArgs { 'db: --bookmark must be a D1 restore point printed by `void deploy`, for example 00000085-0000024c-00004ef2.', ); } - return { action, target: { bookmark }, yes: values.yes }; + return { action, target: { bookmark }, platform, yes: values.yes }; } if (timestamp && !bookmark) { - // Without a zone, the instant depends on where the command runs; a restore must not guess. - const time = new Date(timestamp); - if ( - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test( - timestamp, - ) || - Number.isNaN(time.getTime()) - ) { + const time = parseRestoreTime(timestamp); + if (!time) { throw new Error( - 'db: --timestamp must be an ISO 8601 time with Z or an offset, for example 2026-10-16T09:30:00Z.', + 'db: --timestamp must be a valid ISO 8601 time with Z or an offset, for example 2026-10-16T09:30:00Z.', ); } - return { action, target: { timestamp: time.toISOString() }, yes: values.yes }; + return { action, target: { timestamp: time }, platform, yes: values.yes }; } throw new Error( 'db: Usage: void db restore --bookmark or void db restore --timestamp .', diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 711b4d59f..360d3b6f3 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -212,7 +212,7 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } else if (args.action === 'connect') { await runConnect(root, dialect, args); } else if (args.action === 'restore') { - await runRestore(root, dialect, args.target, args.yes); + await runRestore(root, dialect, args); } else if (args.action === 'rename-migrations') { await runRenameMigrations(root, dialect); } else { @@ -1424,18 +1424,24 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole async function runRestore( root: string, dialect: DatabaseDialect, - target: { bookmark: string } | { timestamp: string }, - yes: boolean | undefined, + { target, platform: explicitPlatform, yes }: Extract, ): Promise { // `.void` is not checked in, so a fresh checkout or CI shows a direct deploy only through its - // Cloudflare config. + // Cloudflare config. A one-off `deploy --platform cloudflare` keeps the saved platform, so its + // restore command passes the platform explicitly. const platform = readDeploymentPlatform(root); const directCloudflare = + explicitPlatform === 'cloudflare' || platform === 'cloudflare' || (platform === null && !readProjectConfig(root) && (await hasDeployedCloudflareDatabase(root))); - if (dialect !== 'sqlite' || !directCloudflare) { + if (dialect !== 'sqlite') { + log.error('db: `void db restore` restores D1 databases only.'); + process.exit(1); + } + if (!directCloudflare) { log.error( - 'db: `void db restore` restores the D1 database of a direct Cloudflare deploy (`void deploy --platform cloudflare`).', + 'db: `void db restore` restores the D1 database of a direct Cloudflare deploy. ' + + 'After a one-off `void deploy --platform cloudflare`, run it with `--platform cloudflare`.', ); process.exit(1); } diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index e61ec1cc8..b6118134c 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -13,6 +13,7 @@ import { CloudflareAuthSecretError } from './deploy-resources.ts'; import { type CloudflareDeployDeps } from './deploy-types.ts'; import { type CliLogger } from './log-file.ts'; import { getErrorMessage } from './output.ts'; +import { readDeploymentPlatform } from './project.ts'; export type CloudflareMigration = Awaited>; @@ -154,6 +155,7 @@ export async function migrateCloudflareWorker(input: { configPath, runWrangler, assertUploaderStillPinned, + readDeploymentPlatform(root) !== 'cloudflare', )) ) { return exit(1); diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index aade72c8c..949aa7421 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -614,6 +614,8 @@ export async function applyAndVerifyRemoteD1Migrations( configPath: string, runWrangler: DeployWranglerRun, assertUploaderStillPinned: () => boolean, + /** Set after a one-off `--platform cloudflare` deploy, which does not change the saved platform. */ + restorePlatformFlag = false, ): Promise { // Runs only after every local gate passes, so a failed gate never changes remote D1. if (d1MigrateToken) { @@ -662,7 +664,7 @@ export async function applyAndVerifyRemoteD1Migrations( } restoreHint = '\nTo undo these migrations, restore this point (discards every later write):\n' + - ` void db restore --bookmark=${bookmark}`; + ` void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; log.info( `Saved a D1 restore point before applying ${formatCount(pendingBefore.length, 'migration')}.${restoreHint}`, ); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 461478d05..a43541597 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1300,8 +1300,8 @@ const HELP_TREE: HelpTree = { summary: 'Restore the deployed D1 database of a direct Cloudflare deploy to an earlier point. Discards every later write.', usage: [ - 'void db restore --bookmark [--yes]', - 'void db restore --timestamp [--yes]', + 'void db restore --bookmark [--platform cloudflare] [--yes]', + 'void db restore --timestamp [--platform cloudflare] [--yes]', ], options: [ { command: '--bookmark ', description: 'Restore point printed by deploy' }, @@ -1309,6 +1309,10 @@ const HELP_TREE: HelpTree = { command: '--timestamp ', description: 'Time with Z or an offset (7 days back on Workers Free, 30 on Paid)', }, + { + command: '--platform cloudflare', + description: 'Restore after a one-off deploy with --platform cloudflare', + }, { command: '--yes', description: 'Skip the confirmation prompt (required without a TTY)', diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index afc7cd69e..005d4fee9 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -212,12 +212,31 @@ describe('parseDbArgs', () => { }); }); + it.each([ + ['2024-02-29T23:59:59Z', '2024-02-29T23:59:59.000Z'], + ['2026-10-16T09:30:00-05:30', '2026-10-16T15:00:00.000Z'], + ['2026-01-01T00:30:00+01:00', '2025-12-31T23:30:00.000Z'], + ])('reads the restore time %s as %s', (input, utc) => { + expect(parseDbArgs(['restore', '--timestamp', input])).toMatchObject({ + target: { timestamp: utc }, + }); + }); + + it('parses an explicit Cloudflare restore after a one-off deploy', () => { + expect( + parseDbArgs(['restore', '--platform', 'cloudflare', '--bookmark', '00000085-0000024c']), + ).toMatchObject({ platform: 'cloudflare', target: { bookmark: '00000085-0000024c' } }); + }); + it.each([ [[], 'void db restore --bookmark or'], [['--bookmark', 'b', '--timestamp', '2026-10-16T09:30:00Z'], 'void db restore --bookmark'], - [['--timestamp', 'yesterday'], '--timestamp must be an ISO 8601 time'], - [['--timestamp', '2026'], '--timestamp must be an ISO 8601 time'], + [['--timestamp', 'yesterday'], '--timestamp must be a valid ISO 8601 time'], + [['--timestamp', '2026'], '--timestamp must be a valid ISO 8601 time'], [['--timestamp', '2026-10-16T09:30:00'], 'with Z or an offset'], + [['--timestamp', '2026-02-30T09:30:00Z'], 'must be a valid ISO 8601 time'], + [['--timestamp', '2026-10-16T24:00:00Z'], 'must be a valid ISO 8601 time'], + [['--platform', 'void', '--bookmark', '00000085-0000024c'], "accepts only 'cloudflare'"], [['--bookmark=--dry-run'], '--bookmark must be a D1 restore point'], [['--bookmark', 'not-hex'], '--bookmark must be a D1 restore point'], [['--remote', '--bookmark', '00000085-0000024c'], 'does not support --remote'], diff --git a/packages/void/test/unit/db-migrate.test.ts b/packages/void/test/unit/db-migrate.test.ts index 7e3d1493d..07b5fb0ff 100644 --- a/packages/void/test/unit/db-migrate.test.ts +++ b/packages/void/test/unit/db-migrate.test.ts @@ -549,17 +549,38 @@ describe('void db restore', () => { expect(output.printSuccess).toHaveBeenCalled(); }); + function savedOnVoid(): string { + const root = d1Project(false); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync(join(root, '.void', 'project.json'), JSON.stringify({ platform: 'void' })); + return root; + } + + it('restores after a one-off Cloudflare deploy when --platform cloudflare is passed', async () => { + const root = savedOnVoid(); + mockRestoreCloudflareD1Database.mockResolvedValue(undefined); + mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql', '002_add_email.sql']); + + await runDbCommand(root, { + action: 'restore', + target: { bookmark }, + platform: 'cloudflare', + yes: true, + }); + + expect(mockRestoreCloudflareD1Database).toHaveBeenCalledWith( + expect.objectContaining({ migrateToken: 'app-db' }), + { bookmark }, + ); + }); + it.each([ - ['has no Cloudflare deploy', () => createProjectRoot({})], [ - 'deploys to a Void platform', - () => { - const root = d1Project(false); - mkdirSync(join(root, '.void'), { recursive: true }); - writeFileSync(join(root, '.void', 'project.json'), JSON.stringify({ platform: 'void' })); - return root; - }, + 'has no Cloudflare deploy', + () => createProjectRoot({}), + 'run it with `--platform cloudflare`', ], + ['is saved on a Void platform', savedOnVoid, 'run it with `--platform cloudflare`'], [ 'uses PostgreSQL', () => { @@ -567,15 +588,16 @@ describe('void db restore', () => { selectCloudflare(root); return root; }, + 'restores D1 databases only', ], - ])('refuses a project that %s', async (_name, makeRoot) => { + ])('refuses a project that %s', async (_name, makeRoot, message) => { const root = makeRoot(); await expect( runDbCommand(root, { action: 'restore', target: { bookmark }, yes: true }), ).rejects.toThrow('process.exit:1'); - expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('direct Cloudflare deploy')); + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining(message)); expect(mockRestoreCloudflareD1Database).not.toHaveBeenCalled(); }); }); diff --git a/packages/void/test/unit/deploy-migrations-restore-point.test.ts b/packages/void/test/unit/deploy-migrations-restore-point.test.ts index 8275e2091..256046420 100644 --- a/packages/void/test/unit/deploy-migrations-restore-point.test.ts +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -154,6 +154,19 @@ describe('the D1 restore point a deploy takes before migrating', () => { expect(errorText()).toContain(`void db restore --bookmark=${bookmark}`); }); + it('names the platform in the restore command after a one-off Cloudflare deploy', async () => { + const runner = recordingRunner({ + 'd1.migrations.list': [pending(['0001_add_column.sql']), pending([])], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + }); + + await applyAndVerifyRemoteD1Migrations('app-db', 'wrangler.json', runner.run, () => true, true); + + expect(vi.mocked(log.info)).toHaveBeenCalledWith( + expect.stringContaining(`void db restore --platform cloudflare --bookmark=${bookmark}`), + ); + }); + it('saves no restore point when no migration is pending', async () => { const runner = recordingRunner({ 'd1.migrations.list': [pending([]), pending([])] }); From b41038efdf84427b9d0640986a147af4f2567fa6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 00:21:18 +0800 Subject: [PATCH 5/8] fix(cloudflare): keep --platform cloudflare in rollback and restore follow-ups After a one-off `void deploy --platform cloudflare` from a project saved on Void or with deployment disabled, the recovery commands Void printed did not reach Cloudflare: `void project rollback` followed the saved platform and had no way to name Cloudflare, and the restore's undo and redeploy instructions dropped the flag. `void project rollback` now accepts `--platform cloudflare`. The rollback and restore follow-up commands carry the flag whenever Cloudflare is not the saved platform, and the restore no longer points to `void db status`, which reads only a saved Cloudflare target. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/deployment.md | 2 +- docs/reference/cli/database.md | 2 +- docs/reference/cli/project.md | 4 +- packages/void/src/cli/db.ts | 14 ++- packages/void/src/cli/help.ts | 14 ++- packages/void/src/cli/project-args.ts | 34 +++++- packages/void/src/cli/project-cmd.ts | 2 +- packages/void/src/cli/rollback.ts | 23 +++- packages/void/test/unit/db-migrate.test.ts | 33 +++++- packages/void/test/unit/project-args.test.ts | 35 ++++++ .../void/test/unit/rollback-platform.test.ts | 103 ++++++++++++++++++ 11 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 packages/void/test/unit/rollback-platform.test.ts diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 3e92a26ea..00a48d7f2 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -35,7 +35,7 @@ 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. 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. 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. +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. 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 diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index 5c8f63568..4e85a619a 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -102,7 +102,7 @@ If the project's saved destination is not Cloudflare and you deployed once with 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` if the live version needs the newer schema. +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} diff --git a/docs/reference/cli/project.md b/docs/reference/cli/project.md index c7b4e5873..4744a9fa1 100644 --- a/docs/reference/cli/project.md +++ b/docs/reference/cli/project.md @@ -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. @@ -179,6 +179,8 @@ 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. +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} diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 360d3b6f3..23c79cf48 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -1477,8 +1477,13 @@ async function runRestore( ); process.exit(1); } + // `--platform cloudflare` always reaches this database; the bare commands follow the saved + // platform. + const platformFlag = platform === 'cloudflare' ? '' : ' --platform cloudflare'; if (previousBookmark) { - log.info(`To undo this restore, run \`void db restore --bookmark=${previousBookmark}\`.`); + log.info( + `To undo this restore, run \`void db restore${platformFlag} --bookmark=${previousBookmark}\`.`, + ); } // A restore also rewinds the migration table, so the next deploy applies those files again. const paths = readProjectPaths(root); @@ -1490,12 +1495,15 @@ async function runRestore( if (unapplied.length > 0) { log.warn( `The remote database does not have these migrations applied: ${unapplied.map((migration) => migration.name).join(', ')}. ` + - 'The next `void deploy` applies them. Fix or remove a migration that caused the problem before you deploy.', + `The next \`void deploy${platformFlag}\` applies them. Fix or remove a migration that caused the problem before you deploy.`, ); } } catch (error) { log.warn( - `Could not read the applied migrations after the restore: ${getErrorMessage(error)}. Run \`void db status\` to check them.`, + `Could not read the applied migrations after the restore: ${getErrorMessage(error)}. ` + + (platformFlag + ? `The next \`void deploy${platformFlag}\` applies any migration the restore rewound.` + : 'Run `void db status` to check them.'), ); } printSuccess(`Restored ${database} to ${point}.`); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index a43541597..94a3562a5 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -773,10 +773,16 @@ const HELP_TREE: HelpTree = { }, ], }, - rollback: basicSubcommand( - 'Roll back to a previous deployment.', - 'void project rollback [deployId]', - ), + rollback: { + summary: 'Roll back to a previous deployment.', + usage: ['void project rollback [deployId] [--platform cloudflare]'], + options: [ + { + command: '--platform cloudflare', + description: 'Roll back the Cloudflare Worker after a one-off Cloudflare deploy', + }, + ], + }, cancel: basicSubcommand('Cancel an active deployment.', 'void project cancel [deployId]'), delete: { summary: 'Delete a project and all its resources.', diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index aadd3262e..dacad47ca 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -30,7 +30,11 @@ export const projectSubcommands: Array = [ }, { name: 'logs', description: 'Show runtime logs from the deployed project' }, { name: 'requests', description: 'Show request-level traffic (status, method, timing)' }, - { name: 'rollback', arg: '[deployId]', description: 'Roll back to a previous deployment' }, + { + name: 'rollback', + arg: '[deployId] [--platform cloudflare]', + description: 'Roll back to a previous deployment', + }, { name: 'cancel', arg: '[deployId]', description: 'Cancel an active deployment' }, { name: 'delete', @@ -107,7 +111,7 @@ export type ProjectArgs = level: string; } | { subcommand: 'requests'; status: string; range: string } - | { subcommand: 'rollback'; deployId: string } + | { subcommand: 'rollback'; deployId: string; platform?: 'cloudflare' } | { subcommand: 'cancel'; deployId: string } | { subcommand: 'delete'; name: string; force: boolean } | { subcommand: 'purge-cache'; projectSlug: string }; @@ -260,7 +264,31 @@ export function parseProjectArgs(args: Array): ProjectArgs { return { subcommand, status, range }; } - if (subcommand === 'rollback' || subcommand === 'cancel') { + if (subcommand === 'rollback') { + let platform: 'cloudflare' | undefined; + const positional: Array = []; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === '--platform' || arg.startsWith('--platform=')) { + const value = arg === '--platform' ? rest[++i] : arg.slice('--platform='.length); + if (value !== 'cloudflare') { + throw new Error( + `project: \`rollback --platform\` accepts only 'cloudflare', got '${value ?? ''}'.`, + ); + } + platform = 'cloudflare'; + } else { + positional.push(arg); + } + } + return { + subcommand, + deployId: parseOptionalPositional(positional), + ...(platform ? { platform } : {}), + }; + } + + if (subcommand === 'cancel') { return { subcommand, deployId: parseOptionalPositional(rest) }; } diff --git a/packages/void/src/cli/project-cmd.ts b/packages/void/src/cli/project-cmd.ts index afb74e9d9..08ea03263 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -85,7 +85,7 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis } if (args.subcommand === 'rollback') { const { runRollbackCommand } = await import('./rollback.ts'); - return runRollbackCommand(root, args.deployId || undefined); + return runRollbackCommand(root, args.deployId || undefined, args.platform); } if (args.subcommand === 'cancel') { if (cloudflare) { diff --git a/packages/void/src/cli/rollback.ts b/packages/void/src/cli/rollback.ts index d4663900b..70668f0a4 100644 --- a/packages/void/src/cli/rollback.ts +++ b/packages/void/src/cli/rollback.ts @@ -4,12 +4,18 @@ import { PlatformClient } from './client.ts'; import { createSpinner, dim, formatCount, formatRelativeTime, printSuccess } from './output.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; -export async function runRollbackCommand(root: string, deployId?: string): Promise { +export async function runRollbackCommand( + root: string, + deployId?: string, + explicitPlatform?: 'cloudflare', +): Promise { console.log(); + // A one-off `deploy --platform cloudflare` keeps the saved platform, so its rollback names + // the platform explicitly. const platform = readDeploymentPlatform(root); - if (platform === 'cloudflare') { - return runCloudflareRollback(root, deployId); + if (explicitPlatform === 'cloudflare' || platform === 'cloudflare') { + return runCloudflareRollback(root, deployId, platform !== 'cloudflare'); } if (platform === 'none') { throw new Error('rollback: Deployment is disabled. Run `void init` to choose a platform.'); @@ -106,7 +112,11 @@ export async function runRollbackCommand(root: string, deployId?: string): Promi } } -async function runCloudflareRollback(root: string, deployId?: string): Promise { +async function runCloudflareRollback( + root: string, + deployId: string | undefined, + restorePlatformFlag: boolean, +): Promise { const { activateCloudflareExistingVersion, readCloudflareWorkerStatus, @@ -177,7 +187,7 @@ async function runCloudflareRollback(root: string, deployId?: string): Promise !targetSet.has(migration), ); - await confirmUnrevertedMigrations(migrationDiff, 'version'); + await confirmUnrevertedMigrations(migrationDiff, 'version', restorePlatformFlag); } const spinner = createSpinner(); @@ -206,6 +216,7 @@ async function confirmRollbackOrExit(): Promise { async function confirmUnrevertedMigrations( migrationDiff: Array, noun: 'deployment' | 'version', + restorePlatformFlag = false, ): Promise { if (migrationDiff.length === 0) { return; @@ -224,7 +235,7 @@ async function confirmUnrevertedMigrations( if (noun === 'version') { log.message( dim( - 'For D1, `void db restore --bookmark ` undoes them with the restore point deploy printed before migrating. ' + + `For D1, \`void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark \` undoes them with the restore point deploy printed before migrating. ` + 'Restore points expire after 7 days on Workers Free and 30 days on Paid; undo older migrations with a new migration.', ), ); diff --git a/packages/void/test/unit/db-migrate.test.ts b/packages/void/test/unit/db-migrate.test.ts index 07b5fb0ff..c86e5925d 100644 --- a/packages/void/test/unit/db-migrate.test.ts +++ b/packages/void/test/unit/db-migrate.test.ts @@ -457,7 +457,8 @@ describe('void db restore', () => { ); expect(mockLog.warn).toHaveBeenCalledWith( expect.stringContaining( - 'The remote database does not have these migrations applied: 002_add_email.sql.', + 'The remote database does not have these migrations applied: 002_add_email.sql. ' + + 'The next `void deploy` applies them.', ), ); expect(output.printSuccess).toHaveBeenCalledWith( @@ -477,6 +478,7 @@ describe('void db restore', () => { }); expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('network down')); + expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('Run `void db status`')); expect(mockLog.info).not.toHaveBeenCalledWith(expect.stringContaining('To undo')); expect(output.printSuccess).toHaveBeenCalledWith( 'Restored D1 database app-db (DB) to 2026-10-16T09:30:00Z.', @@ -558,8 +560,8 @@ describe('void db restore', () => { it('restores after a one-off Cloudflare deploy when --platform cloudflare is passed', async () => { const root = savedOnVoid(); - mockRestoreCloudflareD1Database.mockResolvedValue(undefined); - mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql', '002_add_email.sql']); + mockRestoreCloudflareD1Database.mockResolvedValue(previous); + mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql']); await runDbCommand(root, { action: 'restore', @@ -572,6 +574,31 @@ describe('void db restore', () => { expect.objectContaining({ migrateToken: 'app-db' }), { bookmark }, ); + // The saved platform is Void, so the bare follow-up commands would not reach Cloudflare. + expect(mockLog.info).toHaveBeenCalledWith( + `To undo this restore, run \`void db restore --platform cloudflare --bookmark=${previous}\`.`, + ); + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('The next `void deploy --platform cloudflare` applies them.'), + ); + }); + + it('names the platform when the migrations cannot be read after a one-off restore', async () => { + const root = savedOnVoid(); + mockRestoreCloudflareD1Database.mockResolvedValue(undefined); + mockReadCloudflareD1Migrations.mockRejectedValue(new Error('network down')); + + await runDbCommand(root, { + action: 'restore', + target: { bookmark }, + platform: 'cloudflare', + yes: true, + }); + + const warning = String(mockLog.warn.mock.calls[0]?.[0]); + expect(warning).toContain('network down'); + expect(warning).toContain('`void deploy --platform cloudflare`'); + expect(warning).not.toContain('void db status'); }); it.each([ diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index d6773aee2..0ed6baef6 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -315,6 +315,41 @@ describe('parseProjectArgs', () => { ); }); + it('accepts --platform cloudflare for rollback in either position', () => { + expect(parseProjectArgs(['rollback', '--platform', 'cloudflare'])).toEqual({ + subcommand: 'rollback', + deployId: '', + platform: 'cloudflare', + }); + expect(parseProjectArgs(['rollback', 'abc123', '--platform=cloudflare'])).toEqual({ + subcommand: 'rollback', + deployId: 'abc123', + platform: 'cloudflare', + }); + expect(parseProjectArgs(['rollback', 'abc123'])).toEqual({ + subcommand: 'rollback', + deployId: 'abc123', + }); + }); + + it.each([['void'], ['']])('rejects rollback --platform %o', (value) => { + expect(() => parseProjectArgs(['rollback', `--platform=${value}`])).toThrow( + "accepts only 'cloudflare'", + ); + }); + + it('rejects rollback --platform without a value', () => { + expect(() => parseProjectArgs(['rollback', '--platform'])).toThrow( + "accepts only 'cloudflare'", + ); + }); + + it('rejects --platform for cancel', () => { + expect(() => parseProjectArgs(['cancel', '--platform', 'cloudflare'])).toThrow( + "project: Unknown flag '--platform'.", + ); + }); + it('rejects an unknown flag for cancel', () => { expect(() => parseProjectArgs(['cancel', '--foo'])).toThrow("project: Unknown flag '--foo'."); }); diff --git a/packages/void/test/unit/rollback-platform.test.ts b/packages/void/test/unit/rollback-platform.test.ts new file mode 100644 index 000000000..0eb613eee --- /dev/null +++ b/packages/void/test/unit/rollback-platform.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +const mockReadDeploymentPlatform = vi.fn(); +const mockReadCloudflareWorkerStatus = vi.fn(); +const mockActivateCloudflareExistingVersion = vi.fn(); + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + cancel: vi.fn(), + confirm: vi.fn(async () => true), + isCancel: () => false, + log: { error: vi.fn(), info: vi.fn(), message: vi.fn(), warn: vi.fn() }, + outro: vi.fn(), + select: vi.fn(), +})); +vi.mock('../../src/cli/project.ts', () => ({ + readDeploymentPlatform: mockReadDeploymentPlatform, + readProjectConfig: () => null, +})); +vi.mock('../../src/cli/auth.ts', () => ({ getToken: () => null })); +vi.mock('../../src/cli/cloudflare-operations.ts', () => ({ + activateCloudflareExistingVersion: mockActivateCloudflareExistingVersion, + readCloudflareWorkerStatus: mockReadCloudflareWorkerStatus, + resolveCloudflareOperationContext: () => ({ workerName: 'app' }), +})); + +const { log } = await import('@voidzero-dev/vite-plus-prompts'); +const output = await import('../../src/cli/output.ts'); +const { runRollbackCommand } = await import('../../src/cli/rollback.ts'); + +const metadata = (migrations: Array) => ({ + schema: 2, + migrations, + triggerConfig: { preserve: false }, +}); + +function messages(): string { + return vi + .mocked(log.message) + .mock.calls.map(([message]) => String(message)) + .join('\n'); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(output, 'printSuccess').mockImplementation(() => {}); + vi.spyOn(output, 'createSpinner').mockReturnValue({ start: vi.fn(), stop: vi.fn() } as never); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + mockReadCloudflareWorkerStatus.mockResolvedValue({ + workerName: 'app', + activeVersionId: 'new-version', + deployedVersionIds: ['old-version', 'new-version'], + versions: [ + { id: 'old-version', metadata: metadata(['001_init.sql']) }, + { id: 'new-version', metadata: metadata(['001_init.sql', '002_add_email.sql']) }, + ], + }); +}); + +describe('void project rollback --platform cloudflare', () => { + it('rolls back the Cloudflare Worker after a one-off deploy from a Void project', async () => { + mockReadDeploymentPlatform.mockReturnValue('void'); + + await runRollbackCommand('/app', 'old-version', 'cloudflare'); + + expect(mockActivateCloudflareExistingVersion).toHaveBeenCalledWith( + { workerName: 'app' }, + expect.objectContaining({ id: 'old-version' }), + expect.objectContaining({ id: 'new-version' }), + ); + // The bare restore command would follow the saved Void platform. + expect(messages()).toContain('`void db restore --platform cloudflare --bookmark `'); + expect(output.printSuccess).toHaveBeenCalledWith('Rolled back to old-version.'); + }); + + it('rolls back the Cloudflare Worker when deployment is disabled in the saved project', async () => { + mockReadDeploymentPlatform.mockReturnValue('none'); + + await runRollbackCommand('/app', 'old-version', 'cloudflare'); + + expect(mockActivateCloudflareExistingVersion).toHaveBeenCalled(); + }); + + it('keeps the bare restore command when Cloudflare is the saved platform', async () => { + mockReadDeploymentPlatform.mockReturnValue('cloudflare'); + + await runRollbackCommand('/app', 'old-version'); + + expect(mockActivateCloudflareExistingVersion).toHaveBeenCalled(); + expect(messages()).toContain('`void db restore --bookmark `'); + }); + + it('follows the saved Void platform without --platform', async () => { + mockReadDeploymentPlatform.mockReturnValue('void'); + + await expect(runRollbackCommand('/app', 'old-version')).rejects.toThrow('process.exit:1'); + + expect(mockReadCloudflareWorkerStatus).not.toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith('Not logged in. Run `void account login` first.'); + }); +}); From 426925a6bbfa0cffa5b192ca91a118f36ec0e143 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 00:35:32 +0800 Subject: [PATCH 6/8] fix(cloudflare): name only the migrations a restore point precedes Two overlapping deploys could both see a migration pending. If the other deploy applied it before this one took its bookmark, this deploy still printed that bookmark as the way to undo the migration, though restoring it would leave the migration in place. Deploy now reads the pending set again after it takes the bookmark and names only the migrations still pending then. When none remain, it prints no restore command. It refuses to migrate if that second read fails. The interactive `void project` list also offers `--platform cloudflare` as a rollback flag, instead of reading it as part of the deployment ID. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/deploy-migrations.ts | 49 +++++++---- packages/void/src/cli/project-args.ts | 9 +- .../void/test/unit/cloudflare-deploy.test.ts | 1 + .../deploy-migrations-restore-point.test.ts | 86 +++++++++++++++++-- packages/void/test/unit/project-args.test.ts | 16 +++- 5 files changed, 137 insertions(+), 24 deletions(-) diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index 949aa7421..b5195162d 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -626,22 +626,29 @@ export async function applyAndVerifyRemoteD1Migrations( } // A Worker rollback does not undo a migration. Time Travel does, and a bookmark taken right // before the apply restores exactly the state the migrations started from. - const before = await Promise.resolve( - runWrangler( - { type: 'd1.migrations.list', database: d1MigrateToken, configPath }, - { capture: true }, - ), - ); - const pendingBefore = (before.data as { pending?: unknown } | undefined)?.pending; - if (!isCleanCloudflareExit(before) || !Array.isArray(pendingBefore)) { + const readPending = async (): Promise | undefined> => { + const listed = await Promise.resolve( + runWrangler( + { type: 'd1.migrations.list', database: d1MigrateToken, configPath }, + { capture: true }, + ), + ); + const pending = (listed.data as { pending?: unknown } | undefined)?.pending; + if (isCleanCloudflareExit(listed) && Array.isArray(pending)) { + return pending; + } log.error( `deploy: could not read the pending D1 migrations for "${d1MigrateToken}" before applying them ` + - (isCleanCloudflareExit(before) + (isCleanCloudflareExit(listed) ? '(Cloudflare returned no pending migration list). ' - : `(exit ${String(before.status)}). `) + + : `(exit ${String(listed.status)}). `) + 'Refusing to migrate without knowing whether a restore point is needed. Rerun once Cloudflare can be reached.' + - cloudflareReason(before), + cloudflareReason(listed), ); + return undefined; + }; + const pendingBefore = await readPending(); + if (!pendingBefore) { return false; } let restoreHint = ''; @@ -662,12 +669,20 @@ export async function applyAndVerifyRemoteD1Migrations( ); return false; } - restoreHint = - '\nTo undo these migrations, restore this point (discards every later write):\n' + - ` void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; - log.info( - `Saved a D1 restore point before applying ${formatCount(pendingBefore.length, 'migration')}.${restoreHint}`, - ); + // An overlapping deploy may have applied some of them before the bookmark. Whatever is still + // pending now is applied after it, so the bookmark undoes exactly those. + const pendingAfterBookmark = await readPending(); + if (!pendingAfterBookmark) { + return false; + } + if (pendingAfterBookmark.length > 0) { + restoreHint = + '\nTo undo these migrations, restore this point (discards every later write):\n' + + ` void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; + log.info( + `Saved a D1 restore point before applying ${formatCount(pendingAfterBookmark.length, 'migration')}.${restoreHint}`, + ); + } if (!assertUploaderStillPinned()) { return false; } diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index dacad47ca..7640fd398 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -32,8 +32,15 @@ export const projectSubcommands: Array = [ { name: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { name: 'rollback', - arg: '[deployId] [--platform cloudflare]', + arg: '[deployId]', description: 'Roll back to a previous deployment', + flags: [ + { + name: '--platform', + arg: 'cloudflare', + description: 'Roll back after a one-off deploy with --platform cloudflare', + }, + ], }, { name: 'cancel', arg: '[deployId]', description: 'Cancel an active deployment' }, { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index f8853be9c..b2271dafd 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -7948,6 +7948,7 @@ describe('runCloudflareDeploy — remote migration gates', () => { expect(localTrace.filter((entry) => entry.startsWith('wrangler:d1:'))).toEqual([ 'wrangler:d1:list', 'wrangler:d1:info', + 'wrangler:d1:list', 'wrangler:d1:apply', 'wrangler:d1:list', ]); diff --git a/packages/void/test/unit/deploy-migrations-restore-point.test.ts b/packages/void/test/unit/deploy-migrations-restore-point.test.ts index 256046420..53fdd75f9 100644 --- a/packages/void/test/unit/deploy-migrations-restore-point.test.ts +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -38,7 +38,11 @@ beforeEach(() => { describe('the D1 restore point a deploy takes before migrating', () => { it('saves a restore point before applying pending migrations, and names how to restore it', async () => { const runner = recordingRunner({ - 'd1.migrations.list': [pending(['0001_add_column.sql']), pending([])], + 'd1.migrations.list': [ + pending(['0001_add_column.sql']), + pending(['0001_add_column.sql']), + pending([]), + ], 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], }); @@ -53,6 +57,7 @@ describe('the D1 restore point a deploy takes before migrating', () => { expect(runner.calls).toEqual([ 'd1.migrations.list', 'd1.time-travel.bookmark', + 'd1.migrations.list', 'd1.migrations.apply', 'd1.migrations.list', ]); @@ -113,7 +118,7 @@ describe('the D1 restore point a deploy takes before migrating', () => { it('checks the uploader again after the restore point, before applying', async () => { const runner = recordingRunner({ - 'd1.migrations.list': [pending(['0001_add_column.sql'])], + 'd1.migrations.list': [pending(['0001_add_column.sql']), pending(['0001_add_column.sql'])], 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], }); const stillPinned = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false); @@ -126,7 +131,11 @@ describe('the D1 restore point a deploy takes before migrating', () => { ); expect(applied).toBe(false); - expect(runner.calls).toEqual(['d1.migrations.list', 'd1.time-travel.bookmark']); + expect(runner.calls).toEqual([ + 'd1.migrations.list', + 'd1.time-travel.bookmark', + 'd1.migrations.list', + ]); }); it.each([ @@ -137,7 +146,11 @@ describe('the D1 restore point a deploy takes before migrating', () => { { name: 'the uploader changed after the apply', pinnedChecks: 2 }, ])('repeats the restore command when $name', async ({ apply, verify, pinnedChecks }) => { const runner = recordingRunner({ - 'd1.migrations.list': [pending(['0001_add_column.sql']), verify ?? pending([])], + 'd1.migrations.list': [ + pending(['0001_add_column.sql']), + pending(['0001_add_column.sql']), + verify ?? pending([]), + ], 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], 'd1.migrations.apply': [apply ?? clean], }); @@ -156,7 +169,11 @@ describe('the D1 restore point a deploy takes before migrating', () => { it('names the platform in the restore command after a one-off Cloudflare deploy', async () => { const runner = recordingRunner({ - 'd1.migrations.list': [pending(['0001_add_column.sql']), pending([])], + 'd1.migrations.list': [ + pending(['0001_add_column.sql']), + pending(['0001_add_column.sql']), + pending([]), + ], 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], }); @@ -167,6 +184,65 @@ describe('the D1 restore point a deploy takes before migrating', () => { ); }); + it('names only the migrations still pending after the restore point', async () => { + // An overlapping deploy applied 0001 between the first read and the bookmark. + const runner = recordingRunner({ + 'd1.migrations.list': [ + pending(['0001_add_column.sql', '0002_add_index.sql']), + pending(['0002_add_index.sql']), + pending([]), + ], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + }); + + await applyAndVerifyRemoteD1Migrations('app-db', 'wrangler.json', runner.run, () => true); + + expect(vi.mocked(log.info)).toHaveBeenCalledWith( + expect.stringContaining('Saved a D1 restore point before applying 1 migration.'), + ); + }); + + it('offers no restore point when an overlapping deploy applied every pending migration', async () => { + const runner = recordingRunner({ + 'd1.migrations.list': [pending(['0001_add_column.sql']), pending([]), pending([])], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + 'd1.migrations.apply': [{ status: 1, stdout: '', stderr: '' }], + }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + // The bookmark is later than those migrations, so restoring it would not undo them. + expect(applied).toBe(false); + expect(vi.mocked(log.info)).not.toHaveBeenCalled(); + expect(errorText()).not.toContain('void db restore'); + }); + + it('refuses to migrate when the pending list cannot be read after the restore point', async () => { + const runner = recordingRunner({ + 'd1.migrations.list': [ + pending(['0001_add_column.sql']), + { status: 1, stdout: '', stderr: 'Authentication error' }, + ], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(false); + expect(runner.calls).not.toContain('d1.migrations.apply'); + expect(errorText()).toContain('Cloudflare: Authentication error'); + }); + it('saves no restore point when no migration is pending', async () => { const runner = recordingRunner({ 'd1.migrations.list': [pending([]), pending([])] }); diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 0ed6baef6..f7ddaed50 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { parseProjectArgs } from '../../src/cli/project-args.ts'; +import { parseProjectArgs, projectSubcommands } from '../../src/cli/project-args.ts'; describe('parseProjectArgs', () => { describe('token', () => { @@ -332,6 +332,20 @@ describe('parseProjectArgs', () => { }); }); + it('offers --platform as a rollback flag in the interactive list', () => { + const rollback = projectSubcommands.find((entry) => entry.name === 'rollback'); + expect(rollback?.arg).toBe('[deployId]'); + expect(rollback?.flags).toEqual([ + expect.objectContaining({ name: '--platform', arg: 'cloudflare' }), + ]); + // The prompt passes the id and each flag value as separate argv items. + expect(parseProjectArgs(['rollback', 'abc123', '--platform', 'cloudflare'])).toEqual({ + subcommand: 'rollback', + deployId: 'abc123', + platform: 'cloudflare', + }); + }); + it.each([['void'], ['']])('rejects rollback --platform %o', (value) => { expect(() => parseProjectArgs(['rollback', `--platform=${value}`])).toThrow( "accepts only 'cloudflare'", From e7eea9c8d84ab97bba0e6494cf4c734809d559aa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 11:19:04 +0800 Subject: [PATCH 7/8] fix(cloudflare): name the D1 restore point again when deploy fails after migrating Deploy printed the restore command once, before the build and upload output. If the artifact check, the upload, the readiness check or the activation failed after the migrations were applied, the final error did not name it, although the live version now ran against the new schema. Each of those failures now ends with the restore command, and says to fix the error and deploy again first: restoring discards every later write. The command is printed before the process exits, and once even when publishing throws. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/deployment.md | 2 +- .../void/src/cli/deploy-cloudflare-migrate.ts | 35 +++-- packages/void/src/cli/deploy-cloudflare.ts | 46 +++++-- packages/void/src/cli/deploy-migrations.ts | 26 +++- .../void/test/unit/cloudflare-deploy.test.ts | 124 ++++++++++++++++++ .../deploy-migrations-restore-point.test.ts | 4 +- 6 files changed, 204 insertions(+), 33 deletions(-) diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 00a48d7f2..28b96bd21 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -35,7 +35,7 @@ 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. 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. +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 diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index b6118134c..768015fa5 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -8,7 +8,10 @@ import { ensureCloudflareAuthSecret } from './deploy-cloudflare-auth.ts'; import type { CloudflareBuild } from './deploy-cloudflare-build.ts'; import type { CloudflareDeploySource, CloudflarePreflight } from './deploy-cloudflare-preflight.ts'; import { log } from './deploy-helpers.ts'; -import { applyAndVerifyRemoteD1Migrations } from './deploy-migrations.ts'; +import { + applyAndVerifyRemoteD1Migrations, + logD1RestoreAfterDeployFailure, +} from './deploy-migrations.ts'; import { CloudflareAuthSecretError } from './deploy-resources.ts'; import { type CloudflareDeployDeps } from './deploy-types.ts'; import { type CliLogger } from './log-file.ts'; @@ -30,6 +33,8 @@ export async function migrateCloudflareWorker(input: { workerWasMissingBeforeSecretProvisioning: boolean; initialAuthSecret: string | undefined; appliedMigrations: number; + /** Restores the D1 state from before this deploy's migrations, when it applied any. */ + d1RestoreCommand: string | undefined; }> { const { root, deps, exit, cliLog } = input; const { @@ -149,17 +154,17 @@ export async function migrateCloudflareWorker(input: { } } - if ( - !(await applyAndVerifyRemoteD1Migrations( - d1MigrateToken, - configPath, - runWrangler, - assertUploaderStillPinned, - readDeploymentPlatform(root) !== 'cloudflare', - )) - ) { + const d1 = await applyAndVerifyRemoteD1Migrations( + d1MigrateToken, + configPath, + runWrangler, + assertUploaderStillPinned, + readDeploymentPlatform(root) !== 'cloudflare', + ); + if (!d1) { return exit(1); } + const d1RestoreCommand = d1.restoreCommand; if ( !verifyCloudflareDeployArtifact(root, deps, { @@ -167,7 +172,15 @@ export async function migrateCloudflareWorker(input: { requiredSecretNames: schemaFacts.requiredKeys, }) ) { + if (d1RestoreCommand) { + logD1RestoreAfterDeployFailure(d1RestoreCommand); + } return exit(1); } - return { workerWasMissingBeforeSecretProvisioning, initialAuthSecret, appliedMigrations }; + return { + workerWasMissingBeforeSecretProvisioning, + initialAuthSecret, + appliedMigrations, + d1RestoreCommand, + }; } diff --git a/packages/void/src/cli/deploy-cloudflare.ts b/packages/void/src/cli/deploy-cloudflare.ts index fcd7b7de1..ae435ff6f 100644 --- a/packages/void/src/cli/deploy-cloudflare.ts +++ b/packages/void/src/cli/deploy-cloudflare.ts @@ -19,6 +19,7 @@ import { runCloudflareStaticDeploy, } from './deploy-cloudflare-static.ts'; import { log } from './deploy-helpers.ts'; +import { logD1RestoreAfterDeployFailure } from './deploy-migrations.ts'; import { printDeploySuccess } from './deploy-summary.ts'; import { type RunDeployOptions, type CloudflareDeployDeps } from './deploy-types.ts'; import { renderAddressMap } from './email-cloudflare.ts'; @@ -131,19 +132,38 @@ export async function runCloudflareDeploy( preflight, build, }); - await publishCloudflareWorker({ - root, - deps, - exit, - cliLog, - atomic, - detectedFramework, - initialCloudflareCandidate, - source, - preflight, - build, - migration, - }); + // The migrations are already applied, so every later failure names the restore point again. + let restoreReported = false; + const reportRestore = () => { + if (migration.d1RestoreCommand && !restoreReported) { + restoreReported = true; + logD1RestoreAfterDeployFailure(migration.d1RestoreCommand); + } + }; + const exitAfterMigrations = (code: number): never => { + if (code !== 0) { + reportRestore(); + } + return exit(code); + }; + try { + await publishCloudflareWorker({ + root, + deps, + exit: exitAfterMigrations, + cliLog, + atomic, + detectedFramework, + initialCloudflareCandidate, + source, + preflight, + build, + migration, + }); + } catch (error) { + reportRestore(); + throw error; + } // A deploy without email setup already said so during preflight. if (preflight.emailMap) { log.message(renderAddressMap(preflight.emailMap)); diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index b5195162d..7b876a1b5 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -608,7 +608,19 @@ function cloudflareReason(result: DeployWranglerRunResult): string { return detail ? `\nCloudflare: ${sanitizeTerminalLogText(detail)}` : ''; } -/** Apply remote D1 changes and prove no migrations remain pending. */ +/** Name the restore point when deploy stops after it applied D1 migrations. */ +export function logD1RestoreAfterDeployFailure(restoreCommand: string): void { + log.error( + 'deploy: D1 migrations were applied before the deploy stopped. Fix the error and deploy again.\n' + + 'If the live version cannot run against the new schema, restore the point saved before them ' + + `(discards every later write):\n ${restoreCommand}`, + ); +} + +/** + * Apply remote D1 changes and prove no migrations remain pending. Returns the command that + * restores the point saved before them, if this deploy applied any. + */ export async function applyAndVerifyRemoteD1Migrations( d1MigrateToken: string | undefined, configPath: string, @@ -616,7 +628,8 @@ export async function applyAndVerifyRemoteD1Migrations( assertUploaderStillPinned: () => boolean, /** Set after a one-off `--platform cloudflare` deploy, which does not change the saved platform. */ restorePlatformFlag = false, -): Promise { +): Promise { + let restoreCommand: string | undefined; // Runs only after every local gate passes, so a failed gate never changes remote D1. if (d1MigrateToken) { log.step('Applying D1 migrations...'); @@ -676,9 +689,10 @@ export async function applyAndVerifyRemoteD1Migrations( return false; } if (pendingAfterBookmark.length > 0) { + restoreCommand = `void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; restoreHint = '\nTo undo these migrations, restore this point (discards every later write):\n' + - ` void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; + ` ${restoreCommand}`; log.info( `Saved a D1 restore point before applying ${formatCount(pendingAfterBookmark.length, 'migration')}.${restoreHint}`, ); @@ -705,8 +719,8 @@ export async function applyAndVerifyRemoteD1Migrations( // A clean exit does not prove the schema is current, so read the pending set. if (!assertUploaderStillPinned()) { - if (restoreHint) { - log.error(`deploy: D1 migrations were applied before the deploy stopped.${restoreHint}`); + if (restoreCommand) { + logD1RestoreAfterDeployFailure(restoreCommand); } return false; } @@ -750,5 +764,5 @@ export async function applyAndVerifyRemoteD1Migrations( } } - return true; + return { restoreCommand }; } diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index b2271dafd..5869c55a4 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -7955,6 +7955,130 @@ describe('runCloudflareDeploy — remote migration gates', () => { expect(localTrace).toContain('build'); expect(localTrace).not.toContain('wrangler:deploy'); }); + + describe('a failure after the D1 migrations were applied', () => { + const bookmark = '00000085-0000024c'; + + // Pending until the apply runs, then current. + function migratingWrangler(pending: boolean): DeployWranglerRun { + let applied = false; + return operationFixture((args) => { + if (args[0] === 'whoami') { + return { ...cleanExit, stdout: '{"loggedIn":true,"accounts":[]}' }; + } + if (args[0] === 'd1' && args[2] === 'apply') { + applied = true; + } + if (args[0] === 'd1' && args[2] === 'list') { + return { + ...cleanExit, + stdout: + pending && !applied + ? 'Migrations to be applied:\n0000_init' + : '✅ No migrations to apply!\n', + }; + } + if (args[0] === 'd1' && args[1] === 'time-travel') { + return { ...cleanExit, stdout: JSON.stringify({ bookmark }) }; + } + return cleanExit; + }); + } + + function restoreErrors(output: { mock: { calls: Array> } }): Array { + return output.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes('applied before the deploy stopped')); + } + + it('names the restore point again when the upload fails', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const order: Array = []; + const output = vi.spyOn(log, 'error').mockImplementation((message) => { + if (String(message).includes('applied before the deploy stopped')) { + order.push('restore'); + } + }); + const { deps } = harness({ + runWrangler: migratingWrangler(true), + activateVersion: async () => ({ ok: false, reason: 'upload-failed', status: 1 }), + // process.exit never returns, so the restore point must be named before it. + exit: (code) => { + order.push('exit'); + throw new ExitError(code); + }, + }); + try { + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + const reported = restoreErrors(output); + expect(order).toEqual(['restore', 'exit']); + expect(reported).toHaveLength(1); + expect(reported[0]).toContain('Fix the error and deploy again.'); + // The test project saves no platform, so the command names it. + expect(reported[0]).toContain( + `void db restore --platform cloudflare --bookmark=${bookmark}`, + ); + } finally { + output.mockRestore(); + } + }); + + it('names the restore point again when the built config fails its check', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + const { deps } = harness({ + runWrangler: migratingWrangler(true), + verifyDeployConfig: () => ({ ok: false, reason: 'different account' }), + }); + try { + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(restoreErrors(output)).toHaveLength(1); + } finally { + output.mockRestore(); + } + }); + + it('names the restore point again when publishing throws', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + const { deps } = harness({ + runWrangler: migratingWrangler(true), + activateVersion: async () => { + throw new Error('connection reset'); + }, + }); + try { + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), + ).rejects.toThrow('connection reset'); + expect(restoreErrors(output)).toHaveLength(1); + } finally { + output.mockRestore(); + } + }); + + it('names no restore point when the deploy applied no migration', async () => { + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const output = vi.spyOn(log, 'error').mockImplementation(() => {}); + const { deps } = harness({ + runWrangler: migratingWrangler(false), + activateVersion: async () => ({ ok: false, reason: 'upload-failed', status: 1 }), + }); + try { + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(restoreErrors(output)).toEqual([]); + expect(output.mock.calls.join('\n')).not.toContain('void db restore'); + } finally { + output.mockRestore(); + } + }); + }); }); // The exact set wrangler will apply must equal the gated source set. diff --git a/packages/void/test/unit/deploy-migrations-restore-point.test.ts b/packages/void/test/unit/deploy-migrations-restore-point.test.ts index 53fdd75f9..d41041742 100644 --- a/packages/void/test/unit/deploy-migrations-restore-point.test.ts +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -53,7 +53,7 @@ describe('the D1 restore point a deploy takes before migrating', () => { () => true, ); - expect(applied).toBe(true); + expect(applied).toEqual({ restoreCommand: `void db restore --bookmark=${bookmark}` }); expect(runner.calls).toEqual([ 'd1.migrations.list', 'd1.time-travel.bookmark', @@ -253,7 +253,7 @@ describe('the D1 restore point a deploy takes before migrating', () => { () => true, ); - expect(applied).toBe(true); + expect(applied).toEqual({ restoreCommand: undefined }); expect(runner.calls).not.toContain('d1.time-travel.bookmark'); }); }); From caa2a753e806dcc588e892af5968fcbf59d8bd75 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 17 Oct 2026 11:30:45 +0800 Subject: [PATCH 8/8] fix(cloudflare): reject restore times finer than a millisecond `new Date` keeps only milliseconds, so `--timestamp 2026-10-16T09:30:00.123456Z` restored to .123, an earlier instant than the one written, which could cross a write. `void db restore` now accepts at most three fractional digits and says so in its error. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/cli/database.md | 2 +- packages/void/src/cli/db-args.ts | 8 ++++---- packages/void/test/unit/db-args.test.ts | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index 4e85a619a..637b63184 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -96,7 +96,7 @@ void db restore --bookmark [--platform cloudflare] [--yes] void db restore --timestamp [--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`. 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. +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. diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 3edecdc77..841951c4a 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -146,12 +146,12 @@ const ACTION_FLAGS: Record> = { /** * Read a restore time as a UTC ISO string. A time without a zone would depend on where the - * command runs, and `new Date` rolls impossible dates over (Feb 30 becomes Mar 2), so both are - * rejected instead of restoring to a moment the user did not write. + * 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+)?)?(?:Z|([+-])(\d{2}):(\d{2}))$/i.exec( + /^(\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); @@ -273,7 +273,7 @@ export function parseDbArgs(args: Array): DbArgs { const time = parseRestoreTime(timestamp); if (!time) { throw new Error( - 'db: --timestamp must be a valid ISO 8601 time with Z or an offset, for example 2026-10-16T09:30:00Z.', + '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 }; diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 005d4fee9..71413858c 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -216,6 +216,8 @@ describe('parseDbArgs', () => { ['2024-02-29T23:59:59Z', '2024-02-29T23:59:59.000Z'], ['2026-10-16T09:30:00-05:30', '2026-10-16T15:00:00.000Z'], ['2026-01-01T00:30:00+01:00', '2025-12-31T23:30:00.000Z'], + ['2026-10-16T09:30:00.5Z', '2026-10-16T09:30:00.500Z'], + ['2026-10-16T09:30:00.123+08:00', '2026-10-16T01:30:00.123Z'], ])('reads the restore time %s as %s', (input, utc) => { expect(parseDbArgs(['restore', '--timestamp', input])).toMatchObject({ target: { timestamp: utc }, @@ -236,6 +238,8 @@ describe('parseDbArgs', () => { [['--timestamp', '2026-10-16T09:30:00'], 'with Z or an offset'], [['--timestamp', '2026-02-30T09:30:00Z'], 'must be a valid ISO 8601 time'], [['--timestamp', '2026-10-16T24:00:00Z'], 'must be a valid ISO 8601 time'], + // `new Date` would truncate this to .123, an earlier instant than the user wrote. + [['--timestamp', '2026-10-16T09:30:00.123456Z'], 'at most millisecond precision'], [['--platform', 'void', '--bookmark', '00000085-0000024c'], "accepts only 'cloudflare'"], [['--bookmark=--dry-run'], '--bookmark must be a D1 restore point'], [['--bookmark', 'not-hex'], '--bookmark must be a D1 restore point'],