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 67394387c..28b96bd21 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 saves a D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) restore point before it applies pending migrations and prints the [`void db restore`](../reference/cli/database.md#void-db-restore) command for it. If no restore point can be saved, deploy stops before it migrates. If deploy fails after it applied the migrations, for example when the upload fails, it prints the command again. Usually you fix the error and deploy again; restore only if the live version cannot run against the new schema. To recover from a bad migration, roll back the code with `void project rollback` if the new version is live, restore the database, then fix or remove the migration before you deploy again. After a one-off `void deploy --platform cloudflare`, add `--platform cloudflare` to each of these commands. Restoring discards every write made after the restore point. Restore points expire after 7 days on Workers Free and 30 days on Workers Paid; after that, undo a migration with a new one. + ### Environment variables Declare values in `env.ts` and set production server secrets with `void secret put`. Deploy never uploads your local `.env`. Supply public `VITE_*` values through the build environment. See [Environment Variables](./env-vars.md). diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 5b77b7dc8..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. +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.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..637b63184 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -89,6 +89,21 @@ By default, targets the local database. Pass `--remote` to open Studio against t - **Direct Cloudflare PostgreSQL and MySQL projects**: use `DATABASE_URL` from the current shell. - **D1 projects**: remote Studio is not available. Use `void db execute --remote` to query your deployed D1 database. +## `void db restore` {#void-db-restore} + +``` +void db restore --bookmark [--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`, with at most millisecond precision. Time Travel keeps 7 days of history on Workers Free and 30 days on Workers Paid. An older bookmark or time can no longer be restored: Cloudflare rejects it and the database stays unchanged. To undo an older migration, write a new migration that reverses it. + +If the project's saved destination is not Cloudflare and you deployed once with `void deploy --platform cloudflare`, pass `--platform cloudflare`; deploy prints the command with it. + +Restoring replaces the whole database and discards every write made after that point. Void names the database and asks before it restores. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. Afterwards Void prints the `void db restore` command that undoes the restore. + +A restore also rewinds the record of applied migrations. Void lists the migration files the database no longer has applied; the next deploy applies them, so fix or remove the one that caused the problem first. Restoring does not change the deployed code: use [`void project rollback`](./project.md#void-project-rollback-deployid) if the live version needs the newer schema. After a one-off Cloudflare deploy, pass `--platform cloudflare` to `void project rollback` and `void deploy` as well. + ## `void db rename-migrations` {#void-db-rename-migrations} Rename existing migrations from the old numeric prefix format (`0001_name.sql`) to timestamp-based format (`20260410161500_name.sql`). Updates local tracking table and remote records if logged in with a linked project. A project that deploys to Void must be linked first with `void project link`; otherwise the command stops without changes. diff --git a/docs/reference/cli/project.md b/docs/reference/cli/project.md index a6ef458aa..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,7 +179,9 @@ Roll back to a previous deployment or Worker Version. On a Void platform, you can select a retained deployment. On Cloudflare, use a complete Worker Version ID or an unambiguous prefix. Void activates that version at 100%. When both versions have complete trigger snapshots, it also restores the selected version's schedules, queues, workflows, routes, and custom domains. Otherwise, rollback keeps the current triggers and restores the code, including versions originally deployed outside Void. -Rollback doesn't reverse database migrations. If older code may run against a newer schema, or migration metadata is missing, Void explains the risk and asks for confirmation. +If the project's saved destination is not Cloudflare and you deployed once with `void deploy --platform cloudflare`, pass `--platform cloudflare` to roll back that Worker. + +Rollback doesn't reverse database migrations. If older code may run against a newer schema, or migration metadata is missing, Void explains the risk and asks for confirmation. On Cloudflare, undo D1 migrations with [`void db restore`](./database.md#void-db-restore) and the restore point that deploy printed before it applied them, within 7 days on Workers Free or 30 days on Workers Paid. ## `void project cancel [deployId]` {#void-project-cancel-deployid} diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index b0fd230d2..be801e95f 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -9,6 +9,13 @@ export type CloudflareOperation = | { type: 'd1.create'; name: string; binding: string; configPath: string } | { type: 'd1.execute'; database: string; sql: string; configPath: string } | { type: 'd1.migrations.list' | 'd1.migrations.apply'; database: string; configPath: string } + | { type: 'd1.time-travel.bookmark'; database: string; configPath: string } + | { + type: 'd1.time-travel.restore'; + database: string; + target: { bookmark: string } | { timestamp: string }; + configPath: string; + } | { type: 'kv.namespace.create'; name: string; binding: string; configPath: string } | ({ type: 'r2.bucket.info'; name: string } & ConfigTarget) | { type: 'r2.bucket.create'; name: string; binding: string; configPath: string } 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/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 91492e022..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) { @@ -676,6 +685,37 @@ 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 (!isD1Bookmark(bookmark)) { + throw new Error('D1 Time Travel bookmark was malformed.'); + } + return result(0, '', called.stderr, { bookmark }); + } + case 'd1.time-travel.restore': { + const database = d1Database(operation.database); + if ('bookmark' in operation.target && !isD1Bookmark(operation.target.bookmark)) { + throw new Error('D1 Time Travel bookmark was malformed.'); + } + // One argv item each, so cf cannot read the value as a flag. + const target = + 'bookmark' in operation.target + ? `--bookmark=${operation.target.bookmark}` + : `--timestamp=${operation.target.timestamp}`; + // Void asks before it restores. cf cannot ask in this non-interactive child, so it would + // refuse without --force. + const called = runCf(['d1', 'time-travel', 'restore', database.id, target, '--force']); + if (called.status !== 0) { + return called; + } + // The database is restored now, so a response without the previous bookmark is not a failure. + return result(0, '', called.stderr, { previousBookmark: previousD1Bookmark(called.stdout) }); + } case 'containers.list': return runCf(['containers', 'applications', 'list']); case 'd1.list': 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..841951c4a 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,28 @@ 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: '--platform', + arg: 'cloudflare', + description: 'Restore after a one-off deploy with --platform cloudflare', + }, + { name: '--yes', description: 'Skip the confirmation prompt' }, + ], + }, { name: 'rename-migrations', description: 'Rename migrations to timestamp-based format' }, { name: 'export', @@ -78,6 +101,12 @@ export type DbArgs = region?: string; localOnly?: boolean; } + | { + action: 'restore'; + target: { bookmark: string } | { timestamp: string }; + platform?: 'cloudflare'; + yes?: boolean; + } | { action: 'rename-migrations' }; // Positional arguments each action accepts after its name. @@ -92,6 +121,7 @@ const MAX_POSITIONALS: Record = { studio: 0, 'set-url': 0, connect: 1, + restore: 0, 'rename-migrations': 0, export: 0, }; @@ -109,10 +139,42 @@ const ACTION_FLAGS: Record> = { studio: ['remote'], 'set-url': [], connect: ['provider', 'name', 'region', 'local-only'], + restore: ['bookmark', 'timestamp', 'platform', 'yes'], 'rename-migrations': [], export: ['output', 'no-data', 'no-schema', 'table'], }; +/** + * Read a restore time as a UTC ISO string. A time without a zone would depend on where the + * command runs, `new Date` rolls impossible dates over (Feb 30 becomes Mar 2), and it keeps only + * milliseconds, so all three are rejected instead of restoring to a moment the user did not write. + */ +function parseRestoreTime(value: string): string | undefined { + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|([+-])(\d{2}):(\d{2}))$/i.exec( + value, + ); + const time = new Date(value); + if (!match || Number.isNaN(time.getTime())) { + return undefined; + } + const offsetMinutes = match[7] + ? (match[7] === '-' ? -1 : 1) * (Number(match[8]) * 60 + Number(match[9])) + : 0; + const written = new Date(time.getTime() + offsetMinutes * 60_000); + const fields = [ + written.getUTCFullYear(), + written.getUTCMonth() + 1, + written.getUTCDate(), + written.getUTCHours(), + written.getUTCMinutes(), + written.getUTCSeconds(), + ]; + return fields.every((field, index) => field === Number(match[index + 1] ?? 0)) + ? time.toISOString() + : undefined; +} + function isDbAction(value: string | undefined): value is DbArgs['action'] { return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value); } @@ -132,6 +194,9 @@ export function parseDbArgs(args: Array): DbArgs { region: { type: 'string' }, 'local-only': { type: 'boolean' }, yes: { type: 'boolean' }, + bookmark: { type: 'string' }, + timestamp: { type: 'string' }, + platform: { type: 'string' }, }, allowPositionals: true, strict: true, @@ -140,7 +205,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 +254,34 @@ export function parseDbArgs(args: Array): DbArgs { localOnly: values['local-only'], }; } + case 'restore': { + if (values.platform !== undefined && values.platform !== 'cloudflare') { + throw new Error("db: `void db restore --platform` accepts only 'cloudflare'."); + } + const platform = values.platform; + const bookmark = values.bookmark?.trim(); + const timestamp = values.timestamp?.trim(); + if (bookmark && !timestamp) { + if (!isD1Bookmark(bookmark)) { + throw new Error( + 'db: --bookmark must be a D1 restore point printed by `void deploy`, for example 00000085-0000024c-00004ef2.', + ); + } + return { action, target: { bookmark }, platform, yes: values.yes }; + } + if (timestamp && !bookmark) { + const time = parseRestoreTime(timestamp); + if (!time) { + throw new Error( + 'db: --timestamp must be a valid ISO 8601 time with Z or an offset and at most millisecond precision, for example 2026-10-16T09:30:00Z.', + ); + } + return { action, target: { timestamp: time }, platform, yes: values.yes }; + } + throw new Error( + 'db: Usage: void db restore --bookmark 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..23c79cf48 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); } else if (args.action === 'rename-migrations') { await runRenameMigrations(root, dialect); } else { @@ -1419,6 +1421,94 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole } } +async function runRestore( + root: string, + dialect: DatabaseDialect, + { 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. 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') { + 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. ' + + 'After a one-off `void deploy --platform cloudflare`, run it with `--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); + } + } + + 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); + } + // `--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${platformFlag} --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${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)}. ` + + (platformFlag + ? `The next \`void deploy${platformFlag}\` applies any migration the restore rewound.` + : '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-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index e61ec1cc8..768015fa5 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -8,11 +8,15 @@ 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'; import { getErrorMessage } from './output.ts'; +import { readDeploymentPlatform } from './project.ts'; export type CloudflareMigration = Awaited>; @@ -29,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 { @@ -148,16 +154,17 @@ export async function migrateCloudflareWorker(input: { } } - if ( - !(await applyAndVerifyRemoteD1Migrations( - d1MigrateToken, - configPath, - runWrangler, - assertUploaderStillPinned, - )) - ) { + const d1 = await applyAndVerifyRemoteD1Migrations( + d1MigrateToken, + configPath, + runWrangler, + assertUploaderStillPinned, + readDeploymentPlatform(root) !== 'cloudflare', + ); + if (!d1) { return exit(1); } + const d1RestoreCommand = d1.restoreCommand; if ( !verifyCloudflareDeployArtifact(root, deps, { @@ -165,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 c7f185c56..7b876a1b5 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,13 +602,34 @@ export async function checkWranglerMigrationApplySet( return { ok: true }; } -/** Apply remote D1 changes and prove no migrations remain pending. */ +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)}` : ''; +} + +/** 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, runWrangler: DeployWranglerRun, assertUploaderStillPinned: () => boolean, -): Promise { + /** Set after a one-off `--platform cloudflare` deploy, which does not change the saved platform. */ + restorePlatformFlag = false, +): 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...'); @@ -614,6 +637,70 @@ export async function applyAndVerifyRemoteD1Migrations( if (!assertUploaderStillPinned()) { return false; } + // 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 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(listed) + ? '(Cloudflare returned no pending migration list). ' + : `(exit ${String(listed.status)}). `) + + 'Refusing to migrate without knowing whether a restore point is needed. Rerun once Cloudflare can be reached.' + + cloudflareReason(listed), + ); + return undefined; + }; + const pendingBefore = await readPending(); + if (!pendingBefore) { + return false; + } + let restoreHint = ''; + if (pendingBefore.length > 0) { + const restorePoint = await Promise.resolve( + runWrangler( + { type: 'd1.time-travel.bookmark', database: d1MigrateToken, configPath }, + { capture: true }, + ), + ); + const bookmark = (restorePoint.data as { bookmark?: unknown } | undefined)?.bookmark; + if (!isCleanCloudflareExit(restorePoint) || typeof bookmark !== 'string') { + log.error( + `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; + } + // 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) { + restoreCommand = `void db restore${restorePlatformFlag ? ' --platform cloudflare' : ''} --bookmark=${bookmark}`; + restoreHint = + '\nTo undo these migrations, restore this point (discards every later write):\n' + + ` ${restoreCommand}`; + log.info( + `Saved a D1 restore point before applying ${formatCount(pendingAfterBookmark.length, 'migration')}.${restoreHint}`, + ); + } + if (!assertUploaderStillPinned()) { + return false; + } + } const migrate = await Promise.resolve( runWrangler( { type: 'd1.migrations.apply', database: d1MigrateToken, configPath }, @@ -624,13 +711,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 (restoreCommand) { + logD1RestoreAfterDeployFailure(restoreCommand); + } return false; } const migrateList = await Promise.resolve( @@ -643,7 +734,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; } @@ -653,7 +745,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; } @@ -666,11 +758,11 @@ 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; } } - return true; + return { restoreCommand }; } diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 3166b843d..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.', @@ -1181,6 +1187,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 +1302,39 @@ 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 [--platform cloudflare] [--yes]', + 'void db restore --timestamp [--platform cloudflare] [--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: '--platform cloudflare', + description: 'Restore after a one-off deploy with --platform cloudflare', + }, + { + 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 +1531,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/project-args.ts b/packages/void/src/cli/project-args.ts index aadd3262e..7640fd398 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -30,7 +30,18 @@ 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]', + 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' }, { name: 'delete', @@ -107,7 +118,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 +271,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 451cfc86f..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; @@ -221,5 +232,13 @@ 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${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.', + ), + ); + } 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 2033673b5..5869c55a4 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3245,12 +3245,24 @@ 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'); + // 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', @@ -4125,8 +4137,8 @@ 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.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', 'apply', @@ -7881,6 +7893,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' }), + }; + } return cleanExit; }), runBuild: () => { @@ -7892,6 +7910,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'); @@ -7906,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; }), @@ -7919,10 +7945,140 @@ 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:list', + 'wrangler:d1:apply', + 'wrangler:d1:list', + ]); 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. @@ -8646,7 +8802,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..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-')); @@ -79,3 +83,153 @@ syncBuiltinESMExports(); rmSync(directory, { recursive: true, force: true }); } }); + +// 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'); + 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: Number(process.env.VOID_TEST_CF_STATUS), + stdout: process.env.VOID_TEST_CF_STDOUT, + stderr: process.env.VOID_TEST_CF_STDERR, + }; +}; +syncBuiltinESMExports(); +`, + ); + const entry = join( + import.meta.dirname, + '..', + '..', + 'src', + 'cli', + 'cloudflare-operation-process.ts', + ); + const child = spawnSync( + process.execPath, + [ + '--import', + pathToFileURL(preload).href, + entry, + ...encodeCloudflareOperation({ ...operation, configPath: config }), + ], + { + 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(cfArgs).toEqual([ + 'd1', + 'time-travel', + 'restore', + 'database-id', + '--timestamp=2026-10-16T09:30:00.000Z', + '--force', + ]); + 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..71413858c 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -199,9 +199,58 @@ 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([ + ['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 }, + }); + }); + + 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 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'], + // `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'], + [['--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..c86e5925d 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,239 @@ 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. ' + + 'The next `void deploy` applies them.', + ), + ); + 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.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.', + ); + }); + + 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('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); + 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(); + }); + + 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(previous); + mockReadCloudflareD1Migrations.mockResolvedValue(['001_create_users.sql']); + + await runDbCommand(root, { + action: 'restore', + target: { bookmark }, + platform: 'cloudflare', + yes: true, + }); + + expect(mockRestoreCloudflareD1Database).toHaveBeenCalledWith( + 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([ + [ + 'has no Cloudflare deploy', + () => createProjectRoot({}), + 'run it with `--platform cloudflare`', + ], + ['is saved on a Void platform', savedOnVoid, 'run it with `--platform cloudflare`'], + [ + 'uses PostgreSQL', + () => { + const root = createPgProjectRoot({}); + selectCloudflare(root); + return root; + }, + 'restores D1 databases only', + ], + ])('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(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 new file mode 100644 index 000000000..d41041742 --- /dev/null +++ b/packages/void/test/unit/deploy-migrations-restore-point.test.ts @@ -0,0 +1,259 @@ +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() }, +})); + +const { log } = await import('@voidzero-dev/vite-plus-prompts'); +const { applyAndVerifyRemoteD1Migrations } = await import('../../src/cli/deploy-migrations.ts'); + +const bookmark = '00000085-0000024c-00004ef2-0a83ea2fceebc654de0640c422be4653'; +const clean = { status: 0, stdout: '', stderr: '' }; + +type Answers = Partial>>; + +function recordingRunner(answers: Answers) { + const calls: Array = []; + const run = (operation: CloudflareOperation): CloudflareCommandResult => { + calls.push(operation.type); + 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('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(['0001_add_column.sql']), + pending([]), + ], + 'd1.time-travel.bookmark': [{ ...clean, data: { bookmark } }], + }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toEqual({ restoreCommand: `void db restore --bookmark=${bookmark}` }); + expect(runner.calls).toEqual([ + 'd1.migrations.list', + 'd1.time-travel.bookmark', + 'd1.migrations.list', + 'd1.migrations.apply', + 'd1.migrations.list', + ]); + expect(vi.mocked(log.info)).toHaveBeenCalledWith( + expect.stringContaining(`void db restore --bookmark=${bookmark}`), + ); + }); + + 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', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toBe(false); + 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']), 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', + 'd1.migrations.list', + ]); + }); + + 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']), + 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('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(['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('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([])] }); + + const applied = await applyAndVerifyRemoteD1Migrations( + 'app-db', + 'wrangler.json', + runner.run, + () => true, + ); + + expect(applied).toEqual({ restoreCommand: undefined }); + expect(runner.calls).not.toContain('d1.time-travel.bookmark'); + }); +}); diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index d6773aee2..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', () => { @@ -315,6 +315,55 @@ 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('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'", + ); + }); + + 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.'); + }); +});