Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions packages/void/src/cli/deploy-cloudflare-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Comment thread
Brooooooklyn marked this conversation as resolved.
) {
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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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\`.`,
);
Expand All @@ -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 {
Expand Down
19 changes: 13 additions & 6 deletions packages/void/src/cli/wrangler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": ` +
Expand All @@ -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 &&
Expand Down Expand Up @@ -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 `<dir>/`, unless the dir is ".". */
function migrationsPatternMatchesDir(pattern: string, dir: string): boolean {
const normalizedDir = normalizeMigrationsPath(dir);
Expand Down
159 changes: 159 additions & 0 deletions packages/void/test/unit/cloudflare-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`; `<root>` 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>', root);
if (pattern) {
Object.assign(config.d1_databases[0]!, {
migrations_pattern: pattern.replace('<root>', 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],
['<root>/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(
'<root>/db/migrations',
undefined,
'<root>/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<Array<string>> {
const messages: Array<string> = [];
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<ProvisionResult> => {
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', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/void/test/unit/wrangler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<migrations_dir>/`.
setup(
Expand Down
Loading