diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts
index 38d21f492..8bd9ba64e 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,12 +125,48 @@ export async function prepareCloudflareDeploySource(
return null;
}
- // Custom migration patterns must stay under migrations_dir.
+ // 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, 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);
@@ -459,15 +496,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 +572,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 +583,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..2b711ae10 100644
--- a/packages/void/test/unit/cloudflare-deploy.test.ts
+++ b/packages/void/test/unit/cloudflare-deploy.test.ts
@@ -3575,6 +3575,165 @@ 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, pattern?: 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);
+ 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;
+ }
+
+ 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');
+ });
+
+ 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 = [];
+ 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(