From 536b04fe048520291d64aee5381da4781687775e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 16:26:16 +0800 Subject: [PATCH 1/2] fix(deploy): match D1 migrations_dir by folder, not by text A migrations_dir such as "db/migrations" names the same folder as the "./db/migrations" Void expects, but the preflight compared the raw text. The provisioner kept the equal spelling, so every Cloudflare deploy stopped with "provisioning did not set migrations_dir". Both checks now resolve the value against the config folder, which also accepts an absolute path. A migrations_dir for another folder stops the deploy before any account call, with a message that names the expected folder and the file to edit. Fixes #499 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cli/deploy-cloudflare-preflight.ts | 53 ++++++- packages/void/src/cli/wrangler.ts | 19 ++- .../void/test/unit/cloudflare-deploy.test.ts | 142 ++++++++++++++++++ packages/void/test/unit/wrangler.test.ts | 18 +++ 4 files changed, 218 insertions(+), 14 deletions(-) diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts index 38d21f492..1337c572b 100644 --- a/packages/void/src/cli/deploy-cloudflare-preflight.ts +++ b/packages/void/src/cli/deploy-cloudflare-preflight.ts @@ -9,8 +9,9 @@ import { type ApprovedCloudflareKvBinding, } from '@void/deploy-cloudflare'; import { confirm, isCancel } from '@voidzero-dev/vite-plus-prompts'; -import { dirname, relative } from 'pathslash'; +import { basename, dirname, relative } from 'pathslash'; import { analyzeApplicationSync, type ApplicationAnalysis } from '../application-analysis.ts'; +import { MATERIALIZED_CLOUDFLARE_FILE } from '../cloudflare-config.ts'; import { resolveBindingNames, type VoidConfig } from '../config.ts'; import { detectFramework } from '../plugin-inference.ts'; import { assertSandboxNamespaceConfig, resolveSandboxConfig } from '../sandbox/config.ts'; @@ -66,7 +67,7 @@ import { provisionCloudflareResources, type ProvisionDriftEntry, } from './provision.ts'; -import { writeProvisionedWranglerConfig } from './wrangler.ts'; +import { sameMigrationsDir, writeProvisionedWranglerConfig } from './wrangler.ts'; /** Validate source-level native deploy inputs before account operations or project code. */ export async function prepareCloudflareDeploySource( @@ -124,6 +125,41 @@ export async function prepareCloudflareDeploySource( return null; } + // Void generates and applies migrations from one folder only, and the provisioner never + // rewrites a set migrations_dir. Any spelling of that folder matches; another folder stops + // the deploy here. A handoff applies no migrations, so it is kept. + const storedDir = + desired.d1 && + readStoredResourceName( + parseRootWranglerConfig(configPath), + 'd1_databases', + desired.d1.binding, + 'migrations_dir', + ); + if ( + !(options.reuseExistingWorker || readCloudflareLink(root)) && + desired.d1?.migrationsDir !== undefined && + storedDir !== undefined && + !sameMigrationsDir(configPath, storedDir, desired.d1.migrationsDir) + ) { + const expected = JSON.stringify(desired.d1.migrationsDir); + let fix = `Set migrations_dir to ${expected} in d1_databases of ${basename(configPath)}, or remove it and Void will fill it in.`; + if (basename(configPath) === MATERIALIZED_CLOUDFLARE_FILE) { + // Removing helps only for an authored value; one Void filled in lives in void.lock.json. + const authored = readStoredResourceName( + voidConfig.cloudflare ?? {}, + 'd1_databases', + desired.d1.binding, + 'migrations_dir', + ); + fix = `Set migrations_dir to ${expected} in cloudflare.d1_databases of void.config.ts${authored === undefined ? '.' : ', or remove it and Void will fill it in.'}`; + } + log.error( + `deploy: D1 binding ${JSON.stringify(desired.d1.binding)} sets migrations_dir ${JSON.stringify(storedDir)}, but Void generates and applies migrations from ${expected}. ${fix}`, + ); + return null; + } + // Custom migration patterns must stay under migrations_dir. if (desired.d1) { const migrationsPattern = resolveWranglerMigrationPattern( @@ -459,15 +495,16 @@ export async function preflightCloudflareWorker(input: { cliLog.info('cloudflare_sandbox_available'); } - // Whether the root config's D1 migrations_dir differs from the source-derived directory. - const d1MigrationsDirDiffers = (): boolean => - desired.d1 != null && + // Whether the root config lacks the D1 migrations_dir. A set one for another folder already + // stopped the deploy in prepareCloudflareDeploySource. + const d1MigrationsDirMissing = (): boolean => + desired.d1?.migrationsDir !== undefined && readStoredResourceName( parseRootWranglerConfig(configPath), 'd1_databases', desired.d1.binding, 'migrations_dir', - ) !== desired.d1.migrationsDir; + ) === undefined; // Create missing inferred resources. Missing ids trigger this directly, absent queues after // the probe below; `--provision` forces it. The provisioner is read-first and idempotent. @@ -534,7 +571,7 @@ export async function preflightCloudflareWorker(input: { ); return exit(1); } - if (desired.d1 && d1MigrationsDirDiffers()) { + if (desired.d1 && d1MigrationsDirMissing()) { log.error( `deploy: provisioning did not set migrations_dir for D1 binding ${desired.d1.binding}. Retry \`void deploy --platform cloudflare\`.`, ); @@ -545,7 +582,7 @@ export async function preflightCloudflareWorker(input: { const { missing: initiallyMissing } = checkCloudflareProvisionDrift(configPath, desired); // A handoff applies no migrations, so preserve the existing local migration settings. // Reconciling migrations_dir must not send an already-bound database to the provisioner. - const d1NeedsConfigReconciliation = !existingWorker && d1MigrationsDirDiffers(); + const d1NeedsConfigReconciliation = !existingWorker && d1MigrationsDirMissing(); if (options.provision || initiallyMissing.length > 0 || d1NeedsConfigReconciliation) { await provisionAndVerify(); } else { diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 22e4fcfec..2f0ba1b18 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -18,7 +18,7 @@ import { type Node, type ParseError, } from 'jsonc-parser'; -import { basename, dirname, join, normalize } from 'pathslash'; +import { basename, dirname, join, normalize, resolve } from 'pathslash'; import { materializeCloudflareConfig, MATERIALIZED_CLOUDFLARE_FILE, @@ -1297,10 +1297,7 @@ export function writeProvisionedWranglerConfig( ); } if (typeof existingDir === 'string') { - if ( - normalizeMigrationsPath(existingDir) !== - normalizeMigrationsPath(patch.d1MigrationsDir.dir) - ) { + if (!sameMigrationsDir(configPath, existingDir, patch.d1MigrationsDir.dir)) { throw new Error( `deploy: cannot update "${configPath}" — the D1 entry for binding ` + `${JSON.stringify(patch.d1MigrationsDir.binding)} already sets "migrations_dir": ` + @@ -1311,7 +1308,8 @@ export function writeProvisionedWranglerConfig( `move the migrations and keep your directory, then retry.`, ); } - // An equivalent spelling (`./db/migrations`, a trailing slash) is kept as written. + // An equivalent spelling (`./db/migrations`, a trailing slash, an absolute path) is + // kept as written. } else { if ( existingPattern !== undefined && @@ -2518,6 +2516,15 @@ function bindingSetError(problem: string, hidden: string): Error { ); } +/** + * Whether two `migrations_dir` values name the same folder. Wrangler resolves the value + * against the config file's directory and accepts either slash. + */ +export function sameMigrationsDir(configPath: string, a: string, b: string): boolean { + const base = dirname(configPath); + return resolve(base, a.replaceAll('\\', '/')) === resolve(base, b.replaceAll('\\', '/')); +} + /** Wrangler's rule: the normalized pattern starts with `/`, unless the dir is ".". */ function migrationsPatternMatchesDir(pattern: string, dir: string): boolean { const normalizedDir = normalizeMigrationsPath(dir); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 542385d64..1548d3da5 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3575,6 +3575,148 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { expect(trace).toContain('build'); expect(trace).toContain('wrangler:deploy'); }); + + /** A D1 app whose wrangler.jsonc sets `migrations_dir`; `` becomes the project path. */ + function d1ProjectWithDir(migrationsDir: string, sourceDir?: string): string { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify(sourceDir ? { sourceDir } : {}), + [`${sourceDir ? `${sourceDir}/` : ''}routes/index.ts`]: `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + }); + const config = structuredClone(PROVISIONED_D1); + config.d1_databases[0]!.migrations_dir = migrationsDir.replace('', root); + writeFileSync(join(root, 'wrangler.jsonc'), JSON.stringify(config, null, 2)); + return root; + } + + it.each([ + ['db/migrations', undefined], + ['db/migrations/', undefined], + ['./db/migrations/', undefined], + ['db\\migrations', undefined], + ['/db/migrations', undefined], + ['src/db/migrations', 'src'], + ])('accepts migrations_dir %s naming the same folder without provisioning', async (dir, src) => { + const root = d1ProjectWithDir(dir, src); + const { deps, trace, exitCalls } = harness(); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(trace).not.toContain('provision'); + expect(trace).toContain('wrangler:deploy'); + }); + + /** Run a deploy that must stop on the migrations_dir check; returns the logged errors. */ + async function rejectedDeployMessages(root: string): Promise> { + const messages: Array = []; + const restore = log.error; + log.error = (message: string) => messages.push(message); + try { + const { deps, trace, wranglerArgs } = harness(); + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toMatchObject({ code: 1 }); + expect(trace).not.toContain('provision'); + expect(trace).not.toContain('build'); + expect(wranglerArgs).toEqual([]); + } finally { + log.error = restore; + } + return messages; + } + + it.each([ + ['wrangler.jsonc', undefined, 'd1_databases of wrangler.jsonc'], + ['wrangler.jsonc', 'src', 'd1_databases of wrangler.jsonc'], + ['void.config.ts', undefined, 'cloudflare.d1_databases of void.config.ts'], + ])( + 'rejects a migrations_dir for another folder in %s (sourceDir %s) before any account call', + async (file, sourceDir, where) => { + const database = { + binding: 'DB', + database_name: 'myapp-db', + database_id: REAL_D1_ID, + migrations_dir: 'drizzle', + }; + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + [`${sourceDir ? `${sourceDir}/` : ''}routes/index.ts`]: `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + ...(file === 'void.config.ts' + ? { + 'void.config.ts': `export default { cloudflare: ${JSON.stringify({ account_id: 'acc123', name: 'myapp', d1_databases: [database] })} };`, + } + : { + 'void.json': JSON.stringify(sourceDir ? { sourceDir } : {}), + 'wrangler.jsonc': JSON.stringify( + { account_id: 'acc123', name: 'myapp', d1_databases: [database] }, + null, + 2, + ), + }), + }); + const expected = sourceDir ? `./${sourceDir}/db/migrations` : './db/migrations'; + expect(await rejectedDeployMessages(root)).toContain( + `deploy: D1 binding "DB" sets migrations_dir "drizzle", but Void generates and applies migrations from "${expected}". Set migrations_dir to "${expected}" in ${where}, or remove it and Void will fill it in.`, + ); + }, + ); + + it('does not offer removal when the other folder comes only from void.lock.json', async () => { + const cloudflare = { + account_id: 'acc123', + name: 'myapp', + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: REAL_D1_ID }], + }; + const resolved = structuredClone(PROVISIONED_D1); + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.config.ts': `export default { sourceDir: 'src', cloudflare: ${JSON.stringify(cloudflare)} };`, + 'void.lock.json': JSON.stringify({ + version: 1, + source: cloudflare, + resolved, + text: `${JSON.stringify(resolved, null, 2)}\n`, + }), + 'src/routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + }); + expect(await rejectedDeployMessages(root)).toContain( + 'deploy: D1 binding "DB" sets migrations_dir "./db/migrations", but Void generates and applies migrations from "./src/db/migrations". Set migrations_dir to "./src/db/migrations" in cloudflare.d1_databases of void.config.ts.', + ); + }); + + it('provisions a void.config.ts D1 binding with migrations_dir "db/migrations", then deploys without provisioning', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.config.ts': `export default { cloudflare: { account_id: 'acc123', name: 'myapp', d1_databases: [{ binding: 'DB', database_name: 'myapp-db', migrations_dir: 'db/migrations' }] } };`, + 'routes/index.ts': `export const GET = (c) => c.env.DB.prepare('SELECT 1').first();`, + ...INIT_MIGRATION, + }); + for (const expectedProvisions of [1, 0]) { + let provisions = 0; + const { deps, trace, exitCalls, wranglerArgs } = harness({ + // Adopt the database the way the provisioner does; the writer keeps the spelling. + provision: (async (_root, configPath): Promise => { + provisions++; + writeProvisionedWranglerConfig( + root, + { + d1: { binding: 'DB', databaseName: 'myapp-db', databaseId: REAL_D1_ID }, + d1MigrationsDir: { binding: 'DB', dir: './db/migrations' }, + }, + { configPath }, + ); + return { resources: [], configPath, accountIdWritten: false }; + }) as CloudflareDeployDeps['provision'], + }); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(provisions).toBe(expectedProvisions); + expect(wranglerArgs.some((a) => a[0] === 'd1' && a[2] === 'apply')).toBe(true); + expect(trace).toContain('wrangler:deploy'); + expect( + parseRootWranglerConfig(join(root, '.void-wrangler.jsonc')).d1_databases, + ).toMatchObject([{ database_id: REAL_D1_ID, migrations_dir: 'db/migrations' }]); + } + }); }); describe('runCloudflareDeploy — full Void app queues', () => { diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 716eeb583..9d6408b51 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1452,6 +1452,24 @@ describe('writeProvisionedWranglerConfig', () => { } }); + it('keeps an absolute migrations_dir that names the same folder', () => { + const existing = join(tmpDir, 'db', 'migrations'); + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { "binding": "DB", "database_name": "app", "database_id": "id", "migrations_dir": ${JSON.stringify(existing)} } + ] +}`, + ); + const before = readRaw('wrangler.jsonc'); + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: './db/migrations' }, + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + it('refuses to fill migrations_dir when migrations_pattern is outside it — throws without writing', () => { // Wrangler requires migrations_pattern to start with `/`. setup( From 1956714a759e20b8b5099f6939b90f1c1e5afc6d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 16:31:42 +0800 Subject: [PATCH 2/2] fix(deploy): check migrations_pattern against migrations_dir as written With an absolute migrations_dir that names Void's folder, the pattern check still used "./db/migrations", so a pattern with the same absolute prefix was rejected. Wrangler checks the pattern against the configured spelling; do the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cli/deploy-cloudflare-preflight.ts | 5 +++-- .../void/test/unit/cloudflare-deploy.test.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts index 1337c572b..8bd9ba64e 100644 --- a/packages/void/src/cli/deploy-cloudflare-preflight.ts +++ b/packages/void/src/cli/deploy-cloudflare-preflight.ts @@ -160,12 +160,13 @@ export async function prepareCloudflareDeploySource( return null; } - // Custom migration patterns must stay under migrations_dir. + // Custom migration patterns must stay under migrations_dir, as written: Wrangler checks the + // pattern against that spelling. if (desired.d1) { const migrationsPattern = resolveWranglerMigrationPattern( parseRootWranglerConfig(configPath), desired.d1.binding, - desired.d1.migrationsDir ?? relative(dirname(configPath), migrationsDir), + storedDir ?? desired.d1.migrationsDir ?? relative(dirname(configPath), migrationsDir), ); if (!migrationsPattern.ok) { log.error(migrationsPattern.reason); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 1548d3da5..2b711ae10 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3577,7 +3577,7 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { }); /** A D1 app whose wrangler.jsonc sets `migrations_dir`; `` becomes the project path. */ - function d1ProjectWithDir(migrationsDir: string, sourceDir?: string): string { + function d1ProjectWithDir(migrationsDir: string, sourceDir?: string, pattern?: string): string { const root = makeProject({ 'package.json': JSON.stringify({ name: 'myapp' }), 'void.json': JSON.stringify(sourceDir ? { sourceDir } : {}), @@ -3585,6 +3585,11 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { }); const config = structuredClone(PROVISIONED_D1); config.d1_databases[0]!.migrations_dir = migrationsDir.replace('', root); + if (pattern) { + Object.assign(config.d1_databases[0]!, { + migrations_pattern: pattern.replace('', root), + }); + } writeFileSync(join(root, 'wrangler.jsonc'), JSON.stringify(config, null, 2)); return root; } @@ -3605,6 +3610,18 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { expect(trace).toContain('wrangler:deploy'); }); + it('checks a custom migrations_pattern against migrations_dir as written', async () => { + const root = d1ProjectWithDir( + '/db/migrations', + undefined, + '/db/migrations/**/*.sql', + ); + const { deps, trace, exitCalls } = harness(); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + expect(trace).toContain('wrangler:deploy'); + }); + /** Run a deploy that must stop on the migrations_dir check; returns the logged errors. */ async function rejectedDeployMessages(root: string): Promise> { const messages: Array = [];