diff --git a/docs/guide/database/d1.md b/docs/guide/database/d1.md index 0cc0cc102..491e46a32 100644 --- a/docs/guide/database/d1.md +++ b/docs/guide/database/d1.md @@ -8,9 +8,9 @@ outline: deep ## Local Database -`void dev` and local `void db` commands share the same database. If `cloudflare.d1_databases` in `void.config.ts` declares the D1 binding, Void uses its ID. A `preview_database_id` takes precedence during local development. +The dev server and local `void db` commands share the same database. If `cloudflare.d1_databases` in `void.config.ts` declares the D1 binding, Void uses its ID. A `preview_database_id` takes precedence during local development. -To store local data outside `.void`, set `voidPlugin({ persistTo: 'local-state' })` in your Vite config. Start `void dev` once to record that directory before running database commands. After changing the Vite config, start `void dev` again to refresh it. Frameworks that manage their own Cloudflare runtime use `.wrangler/state`. +To store local data outside `.void`, set `voidPlugin({ persistTo: 'local-state' })` in your Vite config. Start the dev server once to record that directory before running database commands. After changing the Vite config, start it again to refresh it. Frameworks that manage their own Cloudflare runtime use `.wrangler/state`. ## Migrations & Prototyping diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index b75dc3dcb..a6622411b 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -22,7 +22,7 @@ Add a direct connection for local development: DATABASE_URL=mysql://user:password@host:3306/app ``` -Void reads `.env` for `void dev` and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding. +Void reads `.env` for the dev server and `void db` commands. In production, the Worker connects through the inferred `HYPERDRIVE` binding. ## Define a schema diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index 05ca902b7..bd5babe74 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -20,7 +20,7 @@ For SQLite, Void checks that the migration history applies to a fresh database. ## `void db status` {#void-db-status} -Show migration status. Displays which migrations are applied or pending locally, then uses the saved deployment target for remote status: the hosted API for Void projects, the pinned D1 database and its configured migration table for direct Cloudflare SQLite projects, or the shell `DATABASE_URL` for direct Cloudflare PostgreSQL/MySQL projects. If the remote credential or service is unavailable, local status is still shown. +Show migration status. Displays which migrations are applied or pending locally, then uses the saved deployment target for remote status: the hosted API for Void projects, the pinned D1 database and its configured migration table for direct Cloudflare SQLite projects, or the shell `DATABASE_URL` for direct Cloudflare PostgreSQL/MySQL projects. If the remote credential or service is unavailable, local status is still shown. If the local PostgreSQL or MySQL database cannot be read, local status shows `unknown`; the command fails when remote status is not available either. ## `void db reset` {#void-db-reset} @@ -28,7 +28,7 @@ Show migration status. Displays which migrations are applied or pending locally, void db reset [--yes] ``` -Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`. +Drop the development database and re-apply all migrations. D1 resets the local database. PostgreSQL drops the tables, views, sequences, and custom types in the `public` schema of the `DATABASE_URL` database from `.env` and keeps installed extensions. If a table, view, or function in another schema uses one of those views, sequences, or types, reset stops before it drops anything and lists those objects. MySQL drops every table and view in that database. When that `DATABASE_URL` points to a host other than `localhost`, Void names the database and asks before dropping its tables. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. If `.env` uses the same URL as your deployment (for example after `void db connect`), reset drops the deployed tables. @@ -73,7 +73,7 @@ void db migrate [--remote] Apply pending migrations to the local database without resetting. Unlike `void db reset`, this preserves existing data and only runs migrations that haven't been applied yet. -Pass `--remote` to apply pending migrations to the saved target. Hosted projects require a Void login and link. Direct Cloudflare D1 projects use the binding's configured migration directory, table, and pattern; direct PostgreSQL and MySQL projects use the shell `DATABASE_URL`. +Pass `--remote` to apply pending migrations to the saved target. Hosted projects require a Void login and link. Direct Cloudflare D1 projects use the binding's configured migration directory, table, and pattern. The D1 binding must set `migrations_dir`. Direct PostgreSQL and MySQL projects use the shell `DATABASE_URL`. ## `void db studio` {#void-db-studio} @@ -91,7 +91,9 @@ By default, targets the local database. Pass `--remote` to open Studio against t ## `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. +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. + +Direct Cloudflare deploys are not supported: Void cannot rename the records in the deployed database, so the command stops without changes. Keep the existing names; new migrations use timestamps and run after them. ## `void db connect` {#void-db-connect} diff --git a/docs/reference/cli/generate.md b/docs/reference/cli/generate.md index 4771765b0..56977637a 100644 --- a/docs/reference/cli/generate.md +++ b/docs/reference/cli/generate.md @@ -22,7 +22,7 @@ Creates: - An export from `db/schema.ts` - `routes/api/posts/index.ts`: `GET` for list and `POST` for insert with validation - `routes/api/posts/[id].ts`: `GET` by id with `404` handling -- Regenerated `.void/db.d.ts` +- Regenerated types and TypeScript configuration in `.void/` The generated routes automatically detect your validation library from `package.json` (`valibot`, `zod`, or `arktype`). If none is found, you will be prompted to choose one or skip validation. Run `void db push` to apply the schema locally, or `void db generate` to create migrations before deploying. diff --git a/packages/void/src/auth/drizzle.ts b/packages/void/src/auth/drizzle.ts index f30a15d3c..3e495fa36 100644 --- a/packages/void/src/auth/drizzle.ts +++ b/packages/void/src/auth/drizzle.ts @@ -30,6 +30,8 @@ export type VoidAuthMigrationField = { index: boolean; bigint: boolean; unbounded?: boolean; + /** A fixed default of a required field. Adding the column fills rows that exist with it. */ + defaultValue?: string | number | boolean; references?: { table: string; field: string; @@ -177,6 +179,24 @@ function usesUnboundedMySqlText( ); } +/** + * The default of a required field when the database can store it as a literal. + * Function, date and JSON defaults have none. + */ +function resolveFixedDefault( + field: DBFieldAttribute, +): Pick { + if (field.required === false || field.type === 'date' || isJsonField(field)) { + return {}; + } + const value = field.defaultValue; + return typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ? { defaultValue: value } + : {}; +} + function resolveReferenceFieldName( schema: BetterAuthDBSchema, model: string, @@ -307,6 +327,13 @@ function createColumnDefinition( if (dialect === 'mysql' && idStrategy === 'serial') { parts.push('.autoincrement()'); } + // Better Auth leaves these ids out of inserts on PostgreSQL, so the database makes them. + if (dialect === 'postgresql' && idStrategy === 'serial') { + parts.push('.generatedByDefaultAsIdentity()'); + } + if (dialect === 'postgresql' && idStrategy === 'uuid') { + parts.push('.defaultRandom()'); + } } if (field.required !== false || actualFieldName === 'id') { parts.push('.notNull()'); @@ -358,9 +385,6 @@ function collectCoreImports( for (const [defaultFieldName, field] of Object.entries(table.fields)) { const actualFieldName = resolveFieldName(defaultFieldName, field); if (actualFieldName === 'id' || field.references?.field === 'id') { - if (idStrategy === 'serial') { - imports.add('integer'); - } if (idStrategy === 'uuid' && dialect === 'postgresql') { imports.add('uuid'); } @@ -536,6 +560,7 @@ export function resolveVoidAuthMigrationSchema( index: field.index === true, bigint: field.bigint === true, ...(unboundedMySqlFields.has(`${name}\0${fieldName}`) ? { unbounded: true } : {}), + ...resolveFixedDefault(field), ...(field.references ? { references: { diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index fa30fa3a7..161519fc1 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -1,3 +1,5 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'pathslash'; import { readConfig } from '../config.ts'; import { getMiniflareDatabasePath } from '../migrations/runner.ts'; import { @@ -18,7 +20,10 @@ export async function runLocalVoidAuthMigrations( persistRoot: string, databaseId: string, configPath: string | null = findVoidAuthConfig(root), - /** The database to change. Without it, DATABASE_URL from the shell, then from `.env`. */ + /** + * The database to change. Without it, DATABASE_URL from the shell, then from `.env`. An empty + * shell value counts as unset. + */ databaseUrl?: string, ): Promise { const authConfig = await loadVoidAuthConfig(configPath); @@ -27,12 +32,22 @@ export async function runLocalVoidAuthMigrations( providers: readConfig(root).auth?.providers ?? null, userConfig: authConfig, }; - const env = { ...loadLocalEnv(root), ...process.env } as unknown as VoidAuthEnv; + const localEnv = loadLocalEnv(root); + const connectionString = + databaseUrl ?? (process.env.DATABASE_URL || localEnv.DATABASE_URL || undefined); + // The auth config sees the DATABASE_URL that the migration uses. + const env = { + ...localEnv, + ...process.env, + ...(connectionString !== undefined ? { DATABASE_URL: connectionString } : {}), + } as unknown as VoidAuthEnv; const migrationSchema = resolveVoidAuthMigrationSchema(runtime, env); if (dialect === 'sqlite') { const { DatabaseSync } = await import('node:sqlite'); const dbPath = getMiniflareDatabasePath(persistRoot, databaseId); + // The first dev run gets here before Miniflare creates its D1 directory. + mkdirSync(dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); try { await applySqliteAuthMigrations( @@ -52,11 +67,6 @@ export async function runLocalVoidAuthMigrations( return; } - const connectionString = - databaseUrl ?? - (typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0 - ? env.DATABASE_URL - : undefined); if (!connectionString) { return; } diff --git a/packages/void/src/cli/cloudflare-operations.ts b/packages/void/src/cli/cloudflare-operations.ts index a54698ee6..6e683e349 100644 --- a/packages/void/src/cli/cloudflare-operations.ts +++ b/packages/void/src/cli/cloudflare-operations.ts @@ -63,7 +63,8 @@ export type CloudflareD1Context = { operation: CloudflareOperationContext; binding: string; migrateToken: string; - migrationsDir: string; + /** Unset when the binding has no `migrations_dir`; Cloudflare then applies `./migrations`. */ + migrationsDir: string | undefined; migrationsTable: string; }; @@ -206,7 +207,7 @@ export function resolveCloudflareD1Context( operation, binding, migrateToken: target.token, - migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : 'db/migrations', + migrationsDir: migrationsDir.present ? (migrationsDir.value as string) : undefined, migrationsTable: migrationsTable.present ? (migrationsTable.value as string) : 'd1_migrations', }; } diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index cefe62e2b..5b6dfd946 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -2,7 +2,7 @@ import { parseArgs } from 'node:util'; import type { SubcommandDef } from './subcommand-prompt.ts'; export const dbSubcommands: Array = [ - { name: 'push', description: 'Push schema to local D1 (no migration files)' }, + { name: 'push', description: 'Push schema to the development database (no migration files)' }, { name: 'generate', description: 'Generate SQL migrations from schema changes' }, { name: 'status', description: 'Show migration status (local + remote)' }, { name: 'reset', description: 'Reset local DB and re-run migrations' }, @@ -96,6 +96,23 @@ const MAX_POSITIONALS: Record = { export: 0, }; +// Flags each action reads. Every other flag stops the command, so `reset --remote` cannot +// quietly act on the local database. +const ACTION_FLAGS: Record> = { + push: [], + generate: [], + status: [], + reset: ['yes'], + seed: ['file', 'yes'], + execute: ['file', 'remote'], + migrate: ['remote'], + studio: ['remote'], + 'set-url': [], + connect: ['provider', 'name', 'region', 'local-only'], + 'rename-migrations': [], + export: ['output', 'no-data', 'no-schema', 'table'], +}; + function isDbAction(value: string | undefined): value is DbArgs['action'] { return value !== undefined && Object.hasOwn(MAX_POSITIONALS, value); } @@ -131,6 +148,10 @@ export function parseDbArgs(args: Array): DbArgs { 'db: `void db export` reads only the local database; --remote is not supported.', ); } + const unsupported = Object.keys(values).find((flag) => !ACTION_FLAGS[action].includes(flag)); + if (unsupported) { + throw new Error(`db: \`void db ${action}\` does not support --${unsupported}.`); + } const maxPositionals = MAX_POSITIONALS[action]; if (rest.length > maxPositionals) { throw new Error(`db: Unexpected argument '${rest[maxPositionals]}'.`); diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index d2b9c1df7..75fa87a1a 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -153,12 +153,17 @@ async function withPgClient(root: string, fn: (client: PgClient) => Promise( root: string, fn: (client: MySqlClient) => Promise, + options: { textValues?: boolean } = {}, ): Promise { const { toMySqlClient } = await import('../migrations/runner-mysql.ts'); const { createConnection } = await import('mysql2/promise'); const connection = await createConnection({ uri: getLocalDatabaseUrl(root, 'mysql'), multipleStatements: true, + // Read dates, JSON and large integers as MySQL's own text, not as JavaScript values. + ...(options.textValues + ? { dateStrings: true, jsonStrings: true, supportBigNumbers: true, bigNumberStrings: true } + : {}), }); try { return await fn(toMySqlClient(connection)); @@ -417,7 +422,7 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise const allMigrations = collectMigrations(migrationsDir); try { - assertJournalCoherence(migrationsDir, allMigrations); + assertJournalCoherence(migrationsDir, allMigrations, paths.sourceRelative); } catch (err: unknown) { log.error(getErrorMessage(err)); process.exit(1); @@ -429,27 +434,29 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise return; } - let localApplied: Array; + // A local database that cannot be read has an unknown status, not "pending". + let localApplied: Array | undefined; + let localError: unknown; if (dialect === 'postgresql') { try { const { readPgAppliedMigrations } = await import('../migrations/runner-pg.ts'); localApplied = await withPgClient(root, readPgAppliedMigrations); - } catch { - localApplied = []; + } catch (error) { + localError = error; } } else if (dialect === 'mysql') { try { const { readMySqlAppliedMigrations } = await import('../migrations/runner-mysql.ts'); localApplied = await withMySqlClient(root, readMySqlAppliedMigrations); - } catch { - localApplied = []; + } catch (error) { + localError = error; } } else { const { getAppliedMigrations } = await import('../migrations/runner.ts'); const { databasePath: dbPath } = getLocalD1Target(root); localApplied = (await getAppliedMigrations(dbPath)).map((m) => m.name); } - const localSet = new Set(localApplied); + const localSet = localApplied ? new Set(localApplied) : null; let remoteApplied: Array<{ name: string; applied_at: string }> | null = null; const deploymentPlatform = readDeploymentPlatform(root); @@ -495,9 +502,23 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise const remoteSet = remoteApplied ? new Set(remoteApplied.map((m) => m.name)) : null; + if (localSet === null) { + const message = `Local migration status is unavailable: ${getErrorMessage(localError)}`; + if (remoteSet === null) { + log.error(message); + process.exit(1); + } + log.warn(message); + } + const lines: Array = []; for (const migration of allMigrations) { - const localStatus = localSet.has(migration.name) ? pc.green('applied') : pc.yellow('pending'); + const localStatus = + localSet === null + ? dim('unknown') + : localSet.has(migration.name) + ? pc.green('applied') + : pc.yellow('pending'); let remotePart = ''; if (remoteSet !== null) { @@ -512,13 +533,16 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise log.info(lines.join('\n')); - const localPending = allMigrations.length - localSet.size; + // Count files that are not applied; the database may record names that have no file here. const parts = [formatCount(allMigrations.length, 'migration')]; - if (localPending > 0) { - parts.push(`${localPending} pending locally`); + if (localSet !== null) { + const localPending = allMigrations.filter((m) => !localSet.has(m.name)).length; + if (localPending > 0) { + parts.push(`${localPending} pending locally`); + } } if (remoteSet !== null) { - const remotePending = allMigrations.length - remoteSet.size; + const remotePending = allMigrations.filter((m) => !remoteSet.has(m.name)).length; if (remotePending > 0) { parts.push(`${remotePending} pending remotely`); } @@ -529,6 +553,71 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise // Hyperdrive reaches only public hosts, so a loopback or socket database is never the deployed one. const LOCAL_DATABASE_HOSTS = new Set(['', 'localhost', '127.0.0.1', '::1', '[::1]']); +/** + * Objects that `void db reset` drops in `public` before it applies every migration again: + * tables and views first, then standalone sequences, then enum, domain, range and composite + * types. drizzle-kit creates them without `IF NOT EXISTS`, so any one that stays makes the + * migration fail. Extension members stay, and so do sequences that a column owns: the table + * drop removes them, and their names change when a table is renamed. + */ +const PG_RESET_OBJECTS = `SELECT kind, name FROM ( + SELECT CASE rel.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW' + WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END AS kind, + rel.relname AS name, CASE rel.relkind WHEN 'S' THEN 1 ELSE 0 END AS step + FROM pg_class rel JOIN pg_namespace ns ON ns.oid = rel.relnamespace + WHERE ns.nspname = 'public' AND rel.relkind IN ('r', 'p', 'v', 'm', 'S') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend dep WHERE dep.classid = 'pg_class'::regclass AND dep.objid = rel.oid + AND (dep.deptype = 'e' OR (rel.relkind = 'S' AND dep.deptype IN ('a', 'i'))) + ) + UNION ALL + SELECT 'TYPE', typ.typname, 2 + FROM pg_type typ JOIN pg_namespace ns ON ns.oid = typ.typnamespace + WHERE ns.nspname = 'public' + AND (typ.typtype IN ('e', 'd', 'r') + OR (typ.typtype = 'c' AND (SELECT relkind FROM pg_class WHERE oid = typ.typrelid) = 'c')) + AND NOT EXISTS ( + SELECT 1 FROM pg_depend dep WHERE dep.classid = 'pg_type'::regclass AND dep.objid = typ.oid + AND dep.deptype = 'e' + ) +) objects ORDER BY step, name`; + +/** + * Objects in other schemas that `CASCADE` would also drop with the types ($1) and the views, + * materialized views and sequences ($2) that reset drops in `public`: a column of that type or + * its array type, a view, a function, a column default and so on, also through a type or + * function in `public` that goes with them. Reset must not remove these, so it stops when there + * are any. The TOAST table of a materialized view is part of it, so it does not count. The table + * drops still remove what depends on a `public` table in another schema (a view, a foreign key, + * an inheriting table); a view that also uses one of these objects is listed here too. + */ +const PG_RESET_OUTSIDE_DEPENDENTS = `WITH RECURSIVE dropped(classid, objid) AS ( + SELECT 'pg_type'::regclass::oid, typ.oid FROM pg_type typ + WHERE typ.typnamespace = 'public'::regnamespace AND typ.typname = ANY($1::text[]) + UNION ALL + SELECT 'pg_class'::regclass::oid, rel.oid FROM pg_class rel + WHERE rel.relnamespace = 'public'::regnamespace AND rel.relname = ANY($2::text[]) + UNION + SELECT dep.classid, dep.objid FROM dropped + JOIN pg_depend dep ON dep.refclassid = dropped.classid AND dep.refobjid = dropped.objid + WHERE dep.classid IN ('pg_type'::regclass, 'pg_proc'::regclass) + AND (pg_identify_object(dep.classid, dep.objid, 0)).schema = 'public' +) +SELECT DISTINCT pg_describe_object(dep.classid, dep.objid, dep.objsubid) || ' uses ' + || pg_describe_object(dep.refclassid, dep.refobjid, 0) AS object +FROM dropped +JOIN pg_depend dep ON dep.refclassid = dropped.classid AND dep.refobjid = dropped.objid +WHERE NOT (dep.classid = 'pg_class'::regclass AND dep.deptype = 'i') + AND COALESCE((pg_identify_object(dep.classid, dep.objid, dep.objsubid)).schema, ( + SELECT ns.nspname FROM pg_class rel JOIN pg_namespace ns ON ns.oid = rel.relnamespace + WHERE rel.oid = CASE dep.classid + WHEN 'pg_attrdef'::regclass THEN (SELECT adrelid FROM pg_attrdef WHERE oid = dep.objid) + WHEN 'pg_rewrite'::regclass THEN (SELECT ev_class FROM pg_rewrite WHERE oid = dep.objid) + WHEN 'pg_trigger'::regclass THEN (SELECT tgrelid FROM pg_trigger WHERE oid = dep.objid) + WHEN 'pg_policy'::regclass THEN (SELECT polrelid FROM pg_policy WHERE oid = dep.objid) + END)) <> 'public' +ORDER BY object`; + /** * `.env` may hold the deployed database URL (`void db connect` writes it there), so ask before * dropping every table on any other host. Without a TTY, require `--yes`. @@ -554,6 +643,25 @@ async function confirmDropAllTables( } } +async function confirmReset( + root: string, + dialect: DatabaseDialect, + yes: boolean | undefined, +): Promise { + if (dialect === 'postgresql') { + const { Client } = await import('pg'); + // `?host=` and PGHOST can replace the URL host, so check the host pg will connect to. + const { host, port, database } = new Client({ + connectionString: getLocalDatabaseUrl(root, dialect), + }); + await confirmDropAllTables(host, `database "${database}" on ${host}:${port}`, yes); + } else if (dialect === 'mysql') { + const { hostname, host, pathname } = new URL(getLocalDatabaseUrl(root, dialect)); + const target = `database "${decodeURIComponent(pathname.slice(1))}" on ${host}`; + await confirmDropAllTables(hostname, target, yes); + } +} + async function resetDatabase( root: string, dialect: DatabaseDialect, @@ -566,26 +674,35 @@ async function resetDatabase( const migrationsDir = paths.migrationsDir; const migrations = collectMigrations(migrationsDir); try { - assertJournalCoherence(migrationsDir, migrations); + assertJournalCoherence(migrationsDir, migrations, paths.sourceRelative); } catch (err: unknown) { log.error(getErrorMessage(err)); process.exit(1); } + await confirmReset(root, dialect, yes); if (dialect === 'postgresql') { - const { Client } = await import('pg'); - // `?host=` and PGHOST can replace the URL host, so check the host pg will connect to. - const { host, port, database } = new Client({ - connectionString: getLocalDatabaseUrl(root, dialect), - }); - await confirmDropAllTables(host, `database "${database}" on ${host}:${port}`, yes); const { applyPgMigrations } = await import('../migrations/runner-pg.ts'); await withPgClient(root, async (client) => { - const tables = await client.query( - "SELECT tablename FROM pg_tables WHERE schemaname = 'public'", - ); - for (const row of tables.rows) { - await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`); + const objects = await client.query<{ kind: string; name: string }>(PG_RESET_OBJECTS); + const names = (kind: string) => + objects.rows.filter((row) => row.kind === kind).map((row) => row.name); + const types = names('TYPE'); + const relations = [...names('VIEW'), ...names('MATERIALIZED VIEW'), ...names('SEQUENCE')]; + const outside = + types.length > 0 || relations.length > 0 + ? await client.query<{ object: string }>(PG_RESET_OUTSIDE_DEPENDENTS, [types, relations]) + : { rows: [] }; + if (outside.rows.length > 0) { + throw new Error( + 'db: Reset stopped before dropping anything. Dropping the views, sequences and types in the public schema would also remove these objects in other schemas:\n' + + outside.rows.map((row) => ` - ${row.object}\n`).join('') + + 'Change or drop these objects, or move the views, sequences and types they use out of the public schema, then run the command again.', + ); + } + for (const { kind, name } of objects.rows) { + // Qualify the name: search_path could find an object of that name in another schema. + await client.query(`DROP ${kind} IF EXISTS public.${quoteSqlIdentifier(name)} CASCADE`); } await applyPgMigrations(client, migrations); }); @@ -593,18 +710,16 @@ async function resetDatabase( } if (dialect === 'mysql') { - const { hostname, host, pathname } = new URL(getLocalDatabaseUrl(root, dialect)); - const target = `database "${decodeURIComponent(pathname.slice(1))}" on ${host}`; - await confirmDropAllTables(hostname, target, yes); const { applyPendingMySqlMigrations, withMySqlMigrationLock } = await import('../migrations/runner-mysql.ts'); // The migration lock keeps deploys and other migration runs out from the first drop until // the files are applied again. await withMySqlClient(root, (client) => withMySqlMigrationLock(client, async () => { - // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. + // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. A view stays + // when its tables are dropped, and its migration would then fail, so drop views too. const tables = await client.query( - "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", + "SELECT table_name AS table_name, table_type AS table_type FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type IN ('BASE TABLE', 'VIEW')", ); await client.query('SET FOREIGN_KEY_CHECKS = 0'); try { @@ -613,8 +728,9 @@ async function resetDatabase( if (row.table_name === '_void_migration_lock') { continue; } + const kind = row.table_type === 'VIEW' ? 'VIEW' : 'TABLE'; const table = String(row.table_name).replaceAll('`', '``'); - await client.query(`DROP TABLE IF EXISTS \`${table}\``); + await client.query(`DROP ${kind} IF EXISTS \`${table}\``); } } finally { await client.query('SET FOREIGN_KEY_CHECKS = 1'); @@ -650,15 +766,18 @@ async function runSeed( dialect: DatabaseDialect, yes?: boolean, ): Promise { - // Reset first (drop + re-run migrations) - const migrationCount = await resetDatabase(root, dialect, yes); - log.step(`Reset database. Applied ${formatCount(migrationCount, 'migration')}.`); - + // Load the seed before the reset, so a wrong path or a broken module stops the command + // before any data is dropped. Importing a module runs its code, so ask about the database first. const seed = resolveSeedTarget(root, file); + const sql = seed.kind === 'sql' ? readFileSync(seed.path, 'utf-8') : undefined; + await confirmReset(root, dialect, yes); + const seedFn = sql === undefined ? await loadSeedFunction(seed.path) : undefined; - if (seed.kind === 'sql') { - const sql = readFileSync(seed.path, 'utf-8'); + // Reset first (drop + re-run migrations). The drop is confirmed above, so do not ask again. + const migrationCount = await resetDatabase(root, dialect, true); + log.step(`Reset database. Applied ${formatCount(migrationCount, 'migration')}.`); + if (sql !== undefined) { if (dialect === 'postgresql') { await withPgClient(root, async (client) => { await client.query(sql); @@ -686,7 +805,7 @@ async function runSeed( db.close(); } } else { - await runSeedModule(root, seed.path, dialect); + await runSeedModule(root, seedFn!, dialect); } log.info(dim("Restart your dev server if it's running.")); @@ -801,7 +920,7 @@ async function runExecute( ): Promise { if (remote) { const directCloudflare = readDeploymentPlatform(root) === 'cloudflare'; - const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; + const sql = file ? readFileSync(resolve(root, file), 'utf-8') : command!; if (directCloudflare) { const shouldProceed = await confirmDestructiveRemote(sql); @@ -934,7 +1053,7 @@ async function runExecute( } if (dialect === 'postgresql') { - const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; + const sql = file ? readFileSync(resolve(root, file), 'utf-8') : command!; await withPgClient(root, async (client) => { const result = await client.query(sql); if (result.rows && result.rows.length > 0) { @@ -946,7 +1065,7 @@ async function runExecute( } if (dialect === 'mysql') { - const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; + const sql = file ? readFileSync(resolve(root, file), 'utf-8') : command!; await withMySqlClient(root, async (client) => { const rows = await client.query(sql); if (rows.length > 0) { @@ -961,11 +1080,11 @@ async function runExecute( const { databasePath: dbPath } = getLocalD1Target(root); if (!existsSync(dbPath)) { - log.error('No local database found. Run `void dev` first.'); + log.error('No local database found. Start the dev server first.'); process.exit(1); } - const sql = file ? readFileSync(join(root, file), 'utf-8') : command!; + const sql = file ? readFileSync(resolve(root, file), 'utf-8') : command!; const db = await openSqliteDatabase(dbPath); try { @@ -1018,7 +1137,7 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole const rawMigrations = collectMigrations(migrationsDir); try { - assertJournalCoherence(migrationsDir, rawMigrations); + assertJournalCoherence(migrationsDir, rawMigrations, paths.sourceRelative); } catch (err: unknown) { log.error(getErrorMessage(err)); process.exit(1); @@ -1108,6 +1227,15 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole log.error(getErrorMessage(error)); process.exit(1); } + // Without `migrations_dir`, Cloudflare applies `./migrations`, which the check below + // would not read. + if (!context.migrationsDir) { + const dir = paths.sourceRelative('db/migrations'); + log.error( + `db: D1 binding ${binding} has no "migrations_dir", so Cloudflare would apply ./migrations instead of ${dir}/. Add "migrations_dir": "./${dir}" to that D1 binding in your Cloudflare config, then run the command again.`, + ); + process.exit(1); + } const { checkWranglerMigrationApplySet, parseRootWranglerConfig } = await import('./deploy.ts'); const applySet = await checkWranglerMigrationApplySet( @@ -1116,6 +1244,7 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole binding, context.migrationsDir, rawMigrations, + paths.sourceRelative('db/migrations'), ); if (!applySet.ok) { log.error(applySet.reason); @@ -1278,7 +1407,7 @@ async function runMigrate(root: string, dialect: DatabaseDialect, remote?: boole process.exit(1); } - const applied = await runMigrations(migrationsDir, dbPath); + const applied = await runMigrations(migrationsDir, dbPath, paths.sourceRelative); if (applied.length === 0) { log.info('All migrations are up to date.'); } else { @@ -1339,7 +1468,7 @@ async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolea const { databasePath: dbPath } = getLocalD1Target(root); if (!existsSync(dbPath)) { - log.error('No local database found. Run `void dev` first.'); + log.error('No local database found. Start the dev server first.'); process.exit(1); } @@ -1353,8 +1482,29 @@ async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolea } } +/** A direct Cloudflare deploy records its D1 or Hyperdrive id in the checked-in Cloudflare config. */ +async function hasDeployedCloudflareDatabase(root: string): Promise { + const { readRootWranglerConfig } = await import('./wrangler.ts'); + const { isRealD1Id, isRealHyperdriveId } = await import('./provision.ts'); + const config = readRootWranglerConfig(root)?.config; + const entries = (key: string): Array> => { + const value = config?.[key]; + return Array.isArray(value) + ? value.filter( + (entry): entry is Record => !!entry && typeof entry === 'object', + ) + : []; + }; + return ( + entries('d1_databases').some((entry) => isRealD1Id(entry.database_id)) || + entries('hyperdrive').some((entry) => isRealHyperdriveId(entry.id)) + ); +} + async function runRenameMigrations(root: string, dialect: DatabaseDialect): Promise { - const migrationsDir = readProjectPaths(root).migrationsDir; + const paths = readProjectPaths(root); + const migrationsDir = paths.migrationsDir; + const dir = paths.sourceRelative('db/migrations'); if (!existsSync(migrationsDir)) { log.info('No migrations directory found.'); @@ -1364,7 +1514,7 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom // Refuse drift or a partial drizzle-kit state before changing anything, local or remote. const currentMigrations = collectMigrations(migrationsDir); try { - assertJournalCoherence(migrationsDir, currentMigrations); + assertJournalCoherence(migrationsDir, currentMigrations, paths.sourceRelative); } catch (err: unknown) { log.error(getErrorMessage(err)); process.exit(1); @@ -1373,10 +1523,10 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom // The bundled `migrations.js` imports each `.sql` file by name, so renaming would break it. if (existsSync(join(migrationsDir, 'migrations.js'))) { log.error( - "This project uses Drizzle's bundled migration format (db/migrations/migrations.js).\n" + + `This project uses Drizzle's bundled migration format (${dir}/migrations.js).\n` + '`void db rename-migrations` cannot rename migrations in bundled mode because\n' + '`migrations.js` contains static imports of the SQL files by filename.\n\n' + - 'Delete db/migrations/migrations.js and re-run `void db generate` to migrate away\n' + + `Delete ${dir}/migrations.js and re-run \`void db generate\` to migrate away\n` + 'from bundled mode, then retry `void db rename-migrations`.', ); process.exit(1); @@ -1393,6 +1543,31 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom return; } + // Void cannot rename the records in a direct Cloudflare database, and `void deploy` decides + // what is pending by file name, so a renamed applied migration would run again. `.void` is not + // checked in, so a fresh checkout shows a direct deploy only through its Cloudflare config. + const platform = readDeploymentPlatform(root); + const config = readProjectConfig(root); + if ( + platform === 'cloudflare' || + (!config && platform !== 'void' && (await hasDeployedCloudflareDatabase(root))) + ) { + log.error( + '`void db rename-migrations` does not support direct Cloudflare deploys.\n' + + 'Void cannot rename the migration records in your deployed database, so the next deploy would run the renamed migrations again.\n' + + 'Keep the current file names. New migrations use timestamps and run after them.', + ); + process.exit(1); + } + // A hosted project renames its deployed records only through its link. + if (platform === 'void' && !config) { + log.error( + 'This project deploys to Void but is not linked, so Void cannot rename the migration records in its deployed database.\n' + + 'Run `void project link`, then run `void db rename-migrations` again.', + ); + process.exit(1); + } + // Renamed files sort just before the earliest existing timestamped migration. const timestampFiles = files.filter((f) => /^\d{14}_/.test(f)); @@ -1426,7 +1601,7 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom const originalJournalBytes: Buffer | null = existsSync(journalPath) ? readFileSync(journalPath) : null; - const journal = readJournal(migrationsDir); + const journal = readJournal(migrationsDir, paths.sourceRelative); // Drizzle-kit finds a snapshot by the part of the journal tag before the first underscore. const snapshotPrefix = (tag: string): string => tag.split('_')[0]; const snapshotPathFor = (tag: string): string => @@ -1448,7 +1623,7 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom 'db: Cannot rename migrations because one or more Drizzle snapshot files are missing:', ); for (const p of missingSnapshots) { - log.error(` - db/migrations/${p}`); + log.error(` - ${dir}/${p}`); } log.error( '\nRestore the missing snapshot(s) from version control, or reset the migration directory with `void db generate`, then retry.', @@ -1459,7 +1634,6 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom // Update remote tracking first. If this fails, nothing local has changed yet. const token = getToken(root); - const config = readProjectConfig(root); let remoteRenamed = false; if (config && !token) { log.error( @@ -1513,7 +1687,7 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom writeFileSync(journalPath, originalJournalBytes); } catch (error) { log.warn( - 'Could not restore db/migrations/meta/_journal.json after rollback. ' + + `Could not restore ${dir}/meta/_journal.json after rollback. ` + 'The journal may be in a partial state.\n' + 'Regenerate it with `void db generate`, or restore it from version control.', ); @@ -1554,10 +1728,10 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom // Rewrite journal tags before the tracking tables, so a tracking failure also restores it. if (journal) { try { - const current = readJournal(migrationsDir); + const current = readJournal(migrationsDir, paths.sourceRelative); if (!current) { throw new Error( - 'db: db/migrations/meta/_journal.json disappeared mid-run. File renames will be reverted.', + `db: ${dir}/meta/_journal.json disappeared mid-run. File renames will be reverted.`, ); } const renameMap = new Map(); @@ -1577,7 +1751,7 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom }); } catch (err) { await rollback( - 'db: Failed to update db/migrations/meta/_journal.json. File renames have been reverted.', + `db: Failed to update ${dir}/meta/_journal.json. File renames have been reverted.`, err, ); } @@ -1619,11 +1793,19 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom if (rows.length === 0) { return; } - for (const { from, to } of renames) { - await client.query('UPDATE `_void_migrations` SET `name` = ? WHERE `name` = ?', [ - to, - from, - ]); + await client.query('START TRANSACTION'); + try { + for (const { from, to } of renames) { + await client.query('UPDATE `_void_migrations` SET `name` = ? WHERE `name` = ?', [ + to, + from, + ]); + } + await client.query('COMMIT'); + } catch (err) { + // A lost connection also rolls back on the server, so report the first error. + await client.query('ROLLBACK').catch(() => {}); + throw err; } }); } catch (err) { @@ -1674,6 +1856,25 @@ async function runRenameMigrations(root: string, dialect: DatabaseDialect): Prom printSuccess(`Renamed ${formatCount(renames.length, 'migration')} to timestamp format.`); } +// Void and Cloudflare bookkeeping tables. A full export skips them; app tables starting with `_` stay. +const INTERNAL_TABLES = new Set([ + '_void_migrations', + '_void_migration_attempts', + '_void_migration_lock', + '_void_migration_fences', + '__drizzle_migrations', +]); + +function isInternalTable(name: string, dialect: DatabaseDialect): boolean { + if (INTERNAL_TABLES.has(name)) { + return true; + } + return ( + dialect === 'sqlite' && + (name === '_migrations' || name.startsWith('_cf_') || name.startsWith('sqlite_')) + ); +} + function formatMySqlExportValue(value: unknown): string { if (value === null || value === undefined) { return 'NULL'; @@ -1687,8 +1888,7 @@ function formatMySqlExportValue(value: unknown): string { if (value instanceof Uint8Array) { return `X'${Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('')}'`; } - const serialized = value instanceof Date ? value.toISOString() : String(value); - return `'${serialized.replaceAll('\\', '\\\\').replaceAll("'", "''")}'`; + return `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "''")}'`; } function formatSqliteExportValue(value: unknown): string { @@ -1726,9 +1926,11 @@ async function runExport( tables = [args.table]; } else { const result = await client.query( - "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT LIKE '\\_%' ORDER BY tablename", + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename", ); - tables = result.rows.map((row) => String(row.tablename)); + tables = result.rows + .map((row) => String(row.tablename)) + .filter((name) => !isInternalTable(name, dialect)); } if (!args.noSchema) { @@ -1811,7 +2013,7 @@ async function runExport( const output = parts.join('\n'); if (args.output) { - writeFileSync(join(root, args.output), output); + writeFileSync(resolve(root, args.output), output); printSuccess(`Exported to ${args.output}`); } else { process.stdout.write(output); @@ -1821,7 +2023,7 @@ async function runExport( if (dialect === 'mysql') { const parts: Array = []; - await withMySqlClient(root, async (client) => { + const exportTables = async (client: MySqlClient) => { let tables: Array; if (args.table) { const rows = await client.query>( @@ -1836,14 +2038,18 @@ async function runExport( } else { // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. const rows = await client.query>( - "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' AND table_name NOT LIKE '\\_%' ORDER BY table_name", + "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name", ); - tables = rows.map((row) => String(row.table_name)); + tables = rows + .map((row) => String(row.table_name)) + .filter((name) => !isInternalTable(name, dialect)); } // Tables come out in name order with inline foreign keys, so a child - // table can precede its parent; restore with the checks off. - parts.push('SET FOREIGN_KEY_CHECKS = 0;'); + // table can precede its parent; restore with the checks off. TIMESTAMP + // text follows the session time zone, so read and restore it in UTC. + await client.query("SET time_zone = '+00:00'"); + parts.push('SET FOREIGN_KEY_CHECKS = 0;', "SET time_zone = '+00:00';"); for (const table of tables) { const escapedTable = table.replaceAll('`', '``'); if (!args.noSchema) { @@ -1870,11 +2076,12 @@ async function runExport( parts.push(''); } parts.push('SET FOREIGN_KEY_CHECKS = 1;', ''); - }); + }; + await withMySqlClient(root, exportTables, { textValues: true }); const output = parts.join('\n'); if (args.output) { - writeFileSync(join(root, args.output), output); + writeFileSync(resolve(root, args.output), output); printSuccess(`Exported to ${args.output}`); } else { process.stdout.write(output); @@ -1886,7 +2093,7 @@ async function runExport( const { databasePath: dbPath } = getLocalD1Target(root); if (!existsSync(dbPath)) { - log.error('No local database found. Run `void dev` first.', errOut); + log.error('No local database found. Start the dev server first.', errOut); process.exit(1); } @@ -1905,11 +2112,11 @@ async function runExport( } else { tables = db .prepare<{ name: unknown }>( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name", + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", ) .all() .map((row) => String(row.name)) - .filter((name) => !name.startsWith('sqlite_')); + .filter((name) => !isInternalTable(name, dialect)); } const parts: Array = []; @@ -1978,7 +2185,7 @@ async function runExport( continue; } if (type === 'view') { - if (args.table || object.name.startsWith('_')) { + if (args.table || isInternalTable(object.name, dialect)) { continue; } } else if (!tables.includes(object.tbl_name)) { @@ -1992,7 +2199,7 @@ async function runExport( const output = parts.join('\n'); if (args.output) { - writeFileSync(join(root, args.output), output); + writeFileSync(resolve(root, args.output), output); printSuccess(`Exported to ${args.output}`); } else { // Print raw SQL to stdout for piping — no clack formatting @@ -2098,10 +2305,9 @@ function toSeedTarget(root: string, seedPath: string, displayPath?: string): See async function runSeedModule( root: string, - seedPath: string, + seed: SeedFn>, dialect: DatabaseDialect, ): Promise { - const seed = await loadSeedFunction(seedPath); const schema = await loadSeedSchema(root); if (dialect === 'postgresql') { diff --git a/packages/void/src/cli/deploy-cloudflare-build.ts b/packages/void/src/cli/deploy-cloudflare-build.ts index 127058b33..e5a6d4cba 100644 --- a/packages/void/src/cli/deploy-cloudflare-build.ts +++ b/packages/void/src/cli/deploy-cloudflare-build.ts @@ -5,7 +5,7 @@ import { resolveD1MigrateTarget, assertCloudflareDurableObjectLifecycleSupported, } from '@void/deploy-cloudflare'; -import { dirname } from 'pathslash'; +import { dirname, relative } from 'pathslash'; import { type VoidConfig } from '../config.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { SqliteMigrationError } from '../migrations/sqlite-validation.ts'; @@ -311,6 +311,7 @@ export async function buildCloudflareWorker(input: { desired.d1.binding, d1MigrationsDir, migrations, + relative(root, migrationsDir), ); if (!applyCheck.ok) { log.error(applyCheck.reason); diff --git a/packages/void/src/cli/deploy-drift.ts b/packages/void/src/cli/deploy-drift.ts index ccaaf4188..803bf1388 100644 --- a/packages/void/src/cli/deploy-drift.ts +++ b/packages/void/src/cli/deploy-drift.ts @@ -133,7 +133,7 @@ export function getDeploySchemaDriftMessage(root: string): string { } return [ - 'Schema drift detected between your Drizzle schema and db/migrations/.', + `Schema drift detected between your Drizzle schema and ${relative(root, migrationsDir)}/.`, 'Run `void db generate`, review the generated migration, commit it yourself, then rerun `void deploy`.', ].join('\n'); } diff --git a/packages/void/src/cli/deploy-helpers.ts b/packages/void/src/cli/deploy-helpers.ts index ee750185c..4833ec9d0 100644 --- a/packages/void/src/cli/deploy-helpers.ts +++ b/packages/void/src/cli/deploy-helpers.ts @@ -461,9 +461,10 @@ export function getGitCommit(root: string): string | null { export function validateDeployMigrations( migrationsDir: string, rawMigrations: Array<{ name: string; sql: string }>, + sourceRelative?: (path: string) => string, ): Array | undefined { try { - assertJournalCoherence(migrationsDir, rawMigrations); + assertJournalCoherence(migrationsDir, rawMigrations, sourceRelative); } catch (err: unknown) { log.error(getErrorMessage(err)); process.exit(1); diff --git a/packages/void/src/cli/deploy-managed-framework.ts b/packages/void/src/cli/deploy-managed-framework.ts index 121efb52d..cf6eae8d0 100644 --- a/packages/void/src/cli/deploy-managed-framework.ts +++ b/packages/void/src/cli/deploy-managed-framework.ts @@ -108,7 +108,11 @@ export async function runFrameworkDeploy( assertDeploySchemaDriftFree(root, dialect); // Validate migrations before the Hyperdrive check so migrations can gate provisioning. - const validatedMigrations = validateDeployMigrations(paths.migrationsDir, analysis.migrations); + const validatedMigrations = validateDeployMigrations( + paths.migrationsDir, + analysis.migrations, + paths.sourceRelative, + ); // Pre-deploy: ensure Hyperdrive is provisioned for external SQL projects. if (dialect !== 'sqlite' && analysis.needsDatabase) { diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index f2ed2a52f..c6aad32ce 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -207,7 +207,11 @@ export async function runFullDeploy( assertDeploySchemaDriftFree(root, dialect); // Validate migrations before the Hyperdrive check so migrations can gate provisioning. - const validatedMigrations = validateDeployMigrations(paths.migrationsDir, analysis.migrations); + const validatedMigrations = validateDeployMigrations( + paths.migrationsDir, + analysis.migrations, + paths.sourceRelative, + ); // Pre-deploy: ensure Hyperdrive is provisioned for external SQL projects. // Migrations on disk also gate this — even if inference says needsD1: false, diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index f90892c23..c7f185c56 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -6,6 +6,7 @@ import type { VoidAuthMigrationSchema } from '../auth/drizzle.ts'; import { type DatabaseDialect } from '../config.ts'; import { openMigrationValidationDatabase } from '../migrations/sqlite-validation.ts'; import { assertJournalCoherence, validateMigrations } from '../migrations/validate.ts'; +import { readProjectPaths } from '../project-paths.ts'; import { isCleanCloudflareExit } from './cloudflare-process.ts'; import { hasDeploySchemaDrift, getDeploySchemaDriftMessage } from './deploy-drift.ts'; import { log } from './deploy-helpers.ts'; @@ -394,7 +395,7 @@ export function assertRemoteMigrationsDeployable( if (hasDeploySchemaDrift(root, dialect)) { throw new Error(getDeploySchemaDriftMessage(root)); } - assertJournalCoherence(migrationsDir, migrations); + assertJournalCoherence(migrationsDir, migrations, readProjectPaths(root).sourceRelative); if (migrations.length > 0) { validateMigrations(migrations); } @@ -519,7 +520,8 @@ function listWranglerMigrationFiles(directory: string, pattern: string): Array, + displayDir = 'db/migrations', ): Promise<{ ok: true } | { ok: false; reason: string }> { const absDir = resolve(dirname(configPath), migrationsDirRaw); if (!existsSync(absDir)) { @@ -535,7 +538,7 @@ export async function checkWranglerMigrationApplySet( reason: `deploy: configured migrations_dir ${JSON.stringify(migrationsDirRaw)} resolves to ` + `${absDir}, which does not exist — wrangler would have nothing to apply (or error). ` + - `Align "migrations_dir" with your db/migrations, then rerun.`, + `Align "migrations_dir" with your ${displayDir}, then rerun.`, }; } const resolvedPattern = resolveWranglerMigrationPattern(config, binding, migrationsDirRaw); @@ -569,7 +572,7 @@ export async function checkWranglerMigrationApplySet( lines.push(` • content differs between Void's copy and wrangler's: ${changed.join(', ')}`); } lines.push( - 'Only Void-managed db/migrations/*.sql may be applied. Remove stray .sql (e.g. seed.sql) ' + + `Only Void-managed ${displayDir}/*.sql may be applied. Remove stray .sql (e.g. seed.sql) ` + 'from migrations_dir, then rerun.', ); return { ok: false, reason: lines.join('\n') }; @@ -587,8 +590,9 @@ export async function checkWranglerMigrationApplySet( ` • Void's validated order: ${gatedNames.join(', ')}`, "This happens when a migration's numeric prefix is not zero-padded (e.g. `2_` vs `10_`),", 'so it sorts differently for wrangler (numeric) than for Void (lexicographic) — remote D1', - 'would receive them out of the validated order. Zero-pad the prefixes (0001_, 0002_, …),', - 'e.g. with `void db rename-migrations`, then rerun.', + 'would receive them out of the validated order. Rename only migrations that are not applied', + 'yet (a renamed applied migration runs again), or give the new file a name that sorts last', + 'in both orders, then rerun.', ].join('\n'), }; } diff --git a/packages/void/src/cli/gen.ts b/packages/void/src/cli/gen.ts index 2594070f7..8378db16a 100644 --- a/packages/void/src/cli/gen.ts +++ b/packages/void/src/cli/gen.ts @@ -2,11 +2,8 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { log, select, isCancel, cancel } from '@voidzero-dev/vite-plus-prompts'; import { join, dirname, relative } from 'pathslash'; -import { generateDbDts } from '../codegen/db-types.ts'; -import { ensureProjectTsconfig } from '../codegen/project-tsconfig.ts'; import { readConfig, getDatabaseDialect, type DatabaseDialect } from '../config.ts'; import { PLACEHOLDER_DB_URL, writeDrizzleConfig } from '../drizzle/config.ts'; -import { discoverSchema } from '../drizzle/discover.ts'; import { snapshotDrizzleOutputs } from '../drizzle/generate.ts'; import { collectMigrations, readJournal } from '../migrations/collect.ts'; import { assertJournalCoherence } from '../migrations/validate.ts'; @@ -464,9 +461,9 @@ async function runModel(root: string, name: string, columns: Array): writeFileSync(barrelPath, exportLine + '\n'); } - // 3. Regenerate .void/db.d.ts and .void/tsconfig.json - generateDbDts(join(root, '.void'), discoverSchema(root, paths), dialect); - ensureProjectTsconfig(root, undefined, true, true, { paths }); + // 3. Regenerate .void, including every file that .void/tsconfig.json lists + const { prepareProject } = await import('../codegen/prepare.ts'); + await prepareProject(root); log.step(`Created ${relative(root, modelPath)}`); log.step(`Updated ${relative(root, barrelPath)}`); @@ -483,7 +480,9 @@ async function runModel(root: string, name: string, columns: Array): } function runMigration(root: string, name: string): void { - const migrationsDir = readProjectPaths(root).migrationsDir; + const paths = readProjectPaths(root); + const migrationsDir = paths.migrationsDir; + const dir = paths.sourceRelative('db/migrations'); mkdirSync(migrationsDir, { recursive: true }); // The same preflight as the other db commands, before any write. It runs @@ -491,7 +490,7 @@ function runMigration(root: string, name: string): void { // gets the partial-state recovery advice. const currentMigrations = collectMigrations(migrationsDir); try { - assertJournalCoherence(migrationsDir, currentMigrations); + assertJournalCoherence(migrationsDir, currentMigrations, paths.sourceRelative); } catch (err: unknown) { log.error(err instanceof Error ? err.message : String(err)); process.exit(1); @@ -502,11 +501,11 @@ function runMigration(root: string, name: string): void { const bundlePath = join(migrationsDir, 'migrations.js'); if (existsSync(bundlePath)) { log.error( - "Your project uses Drizzle's bundled migration format (db/migrations/migrations.js).\n" + + `Your project uses Drizzle's bundled migration format (${dir}/migrations.js).\n` + '`void gen migration` cannot add custom hand-written migrations to a bundled project\n' + 'because the bundle must be regenerated by drizzle-kit.\n\n' + 'Either run `drizzle-kit generate` to regenerate the bundle, or migrate away from\n' + - 'bundled mode by deleting db/migrations/migrations.js and re-running `void db generate`.', + `bundled mode by deleting ${dir}/migrations.js and re-running \`void db generate\`.`, ); process.exit(1); } @@ -514,7 +513,7 @@ function runMigration(root: string, name: string): void { // With journal entries, delegate to `drizzle-kit generate --custom`: it also // snapshots the current schema, so the next `void db generate` does not // re-emit DDL that the custom migration already applies. - const existingJournal = readJournal(migrationsDir); + const existingJournal = readJournal(migrationsDir, paths.sourceRelative); if (existingJournal !== undefined && existingJournal.entries.length > 0) { const lastEntry = existingJournal.entries[existingJournal.entries.length - 1]; const metaDir = join(migrationsDir, 'meta'); @@ -524,7 +523,7 @@ function runMigration(root: string, name: string): void { const prevSnapshotPath = join(metaDir, `${lastSnapshotPrefix}_snapshot.json`); if (!existsSync(prevSnapshotPath)) { log.error( - `db/migrations/meta/_journal.json's last entry is \`${lastEntry.tag}\` but the matching snapshot \`${lastSnapshotPrefix}_snapshot.json\` is missing under meta/.`, + `${dir}/meta/_journal.json's last entry is \`${lastEntry.tag}\` but the matching snapshot \`${lastSnapshotPrefix}_snapshot.json\` is missing under meta/.`, ); log.error( 'Restore the snapshot from version control, or regenerate migrations with `void db generate` before running `void gen migration`.', @@ -546,20 +545,20 @@ function runMigration(root: string, name: string): void { journalDialect = 'mysql'; } else { log.error( - `db/migrations/meta/_journal.json has an unsupported dialect: \`${existingJournal.dialect}\`.`, + `${dir}/meta/_journal.json has an unsupported dialect: \`${existingJournal.dialect}\`.`, ); log.error( - 'void supports `sqlite`, `postgresql`, and `mysql` only. Restore the journal from version control, or drop db/migrations/ and regenerate from scratch.', + `void supports \`sqlite\`, \`postgresql\`, and \`mysql\` only. Restore the journal from version control, or drop ${dir}/ and regenerate from scratch.`, ); process.exit(1); } const configDialect = getDatabaseDialect(readConfig(root)); if (configDialect !== journalDialect) { log.error( - `db/migrations/meta/_journal.json was generated for ${journalDialect}, but void.config.ts is configured for ${configDialect}.`, + `${dir}/meta/_journal.json was generated for ${journalDialect}, but void.config.ts is configured for ${configDialect}.`, ); log.error( - `Generating a new custom migration now would append a snapshot in the wrong dialect. Revert void.config.ts's \`database\` field to ${journalDialect}, or drop db/migrations/ and regenerate from scratch.`, + `Generating a new custom migration now would append a snapshot in the wrong dialect. Revert void.config.ts's \`database\` field to ${journalDialect}, or drop ${dir}/ and regenerate from scratch.`, ); process.exit(1); } @@ -592,7 +591,7 @@ function runMigration(root: string, name: string): void { `Failed to run \`drizzle-kit generate --custom --name ${name}\`.`, ...(detail ? [detail] : []), '', - 'This is usually a schema-compilation error. Fix any TypeScript errors in db/schema.ts and try again.', + `This is usually a schema-compilation error. Fix any TypeScript errors in ${paths.sourceRelative('db/schema.ts')} and try again.`, ].join('\n'), ); process.exit(1); @@ -602,7 +601,7 @@ function runMigration(root: string, name: string): void { .filter((f) => f.endsWith('.sql')) .find((f) => !sqlFilesBefore.has(f)); if (newSqlFile) { - log.step(`Created db/migrations/${newSqlFile}`); + log.step(`Created ${dir}/${newSqlFile}`); } printSuccess('Migration created and journal updated.'); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index f0339c569..3166b843d 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1161,7 +1161,10 @@ const HELP_TREE: HelpTree = { summary: 'Manage local and remote database state.', usage: ['void db '], commands: [ - { command: 'push', description: 'Push schema to local D1 without migration files' }, + { + command: 'push', + description: 'Push schema to the development database without migration files', + }, { command: 'generate', description: 'Generate SQL migrations from schema changes' }, { command: 'status', description: 'Show migration status (local and remote)' }, { command: 'reset', description: 'Reset local DB and re-run migrations' }, @@ -1181,7 +1184,10 @@ const HELP_TREE: HelpTree = { { command: 'rename-migrations', description: 'Rename migrations to timestamp format' }, ], children: { - push: basicSubcommand('Push schema to local D1 without migration files.', 'void db push'), + push: basicSubcommand( + 'Push schema to the development database without migration files.', + 'void db push', + ), generate: basicSubcommand('Generate SQL migrations from schema changes.', 'void db generate'), status: basicSubcommand('Show migration status for local and remote DBs.', 'void db status'), reset: { diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 99762ea92..2b26fad89 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2469,18 +2469,33 @@ function setupExistingProject(root: string, framework?: DetectedFramework): bool return dependencyAdded; } -/** - * Scaffolds the "SQL → types → handler → fetch" demo into an existing project. - * `setupExistingProject` has already declared `void` in `package.json`. - */ -function scaffoldDemo(root: string): void { - const devCommand = getDevCommand(root); - const paths = readProjectPaths(root); +/** The demo `posts` table for the database dialect that init configured. */ +function demoSchemaSource(dialect: DatabaseDialect): string { + if (dialect === 'postgresql') { + return `import { pgTable, serial, text, timestamp } from "void/schema-pg"; - mkdirSync(paths.dbDir, { recursive: true }); - writeFileSync( - paths.schemaFile, - `import { sql } from "void/db"; +export const posts = pgTable("posts", { + id: serial("id").primaryKey(), + title: text("title").notNull(), + body: text("body").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); +`; + } + + if (dialect === 'mysql') { + return `import { int, mysqlTable, text, timestamp } from "void/schema-mysql"; + +export const posts = mysqlTable("posts", { + id: int("id").autoincrement().primaryKey(), + title: text("title").notNull(), + body: text("body").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); +`; + } + + return `import { sql } from "void/db"; import { integer, sqliteTable, text } from "void/schema-d1"; export const posts = sqliteTable("posts", { @@ -2489,8 +2504,19 @@ export const posts = sqliteTable("posts", { body: text("body").notNull(), createdAt: text("created_at").notNull().default(sql\`(datetime('now'))\`), }); -`, - ); +`; +} + +/** + * Scaffolds the "SQL → types → handler → fetch" demo into an existing project. + * `setupExistingProject` has already declared `void` in `package.json`. + */ +function scaffoldDemo(root: string, dialect: DatabaseDialect): void { + const devCommand = getDevCommand(root); + const paths = readProjectPaths(root); + + mkdirSync(paths.dbDir, { recursive: true }); + writeFileSync(paths.schemaFile, demoSchemaSource(dialect)); log.step('Created db/schema.ts'); // API route @@ -2769,7 +2795,9 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise process.exit(0); } if (scaffold) { - scaffoldDemo(root); + // Read the dialect from config, as later `void db` commands do. + const demoDialect = getDatabaseDialect(readConfig(root)); + scaffoldDemo(root, demoDialect); // The existing-project step already queued any install or workspace deferral the demo needs. const voidResolvable = isVoidResolvableFrom(root); const deferral = resolveWorkspaceDeferral(root, readPackageJson(root)); @@ -2782,7 +2810,7 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise 'Workspace subpackage', ); } - scaffoldedStarterMode = 'sqlite'; + scaffoldedStarterMode = demoDialect; } } } diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 3d96948ed..82a22a318 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -239,7 +239,7 @@ export function isRealKvId(value: unknown): value is string { return typeof value === 'string' && value !== KV_ID_PLACEHOLDER && HEX32_ID_RE.test(value); } /** A Hyperdrive config `id` that is a real 32-hex string (no placeholder exists). */ -function isRealHyperdriveId(value: unknown): value is string { +export function isRealHyperdriveId(value: unknown): value is string { return typeof value === 'string' && HEX32_ID_RE.test(value); } /** An R2 `bucket_name` satisfying wrangler's bucket-name GRAMMAR (format only). */ diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index 0fb4aa7f0..35cde1908 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -64,7 +64,9 @@ export async function prepareProject(root: string): Promise 0) { + // .void/tsconfig.json lists queues.d.ts whenever the queues directory exists, + // so write it then, even with no queue files. + if (existsSync(paths.queuesDir)) { const dts = generateQueueTypes(scan.queues, paths.queuesDir, outDir); writeGeneratedFile(outDir, 'queues.d.ts', dts, written); } else { diff --git a/packages/void/src/deploy.ts b/packages/void/src/deploy.ts index 89e47a102..953f8b181 100644 --- a/packages/void/src/deploy.ts +++ b/packages/void/src/deploy.ts @@ -51,7 +51,11 @@ export async function prepareDeployArtifact( const clientDir = join(dist, 'client'); const { paths, dialect, bindings, authEnabled, scan, websockets } = analysis; assertDeploySchemaDriftFree(root, dialect); - const migrations = validateDeployMigrations(paths.migrationsDir, analysis.migrations); + const migrations = validateDeployMigrations( + paths.migrationsDir, + analysis.migrations, + paths.sourceRelative, + ); const { schedules, queues } = toManifestSchedulesAndQueues(analysis.jobs, analysis.queues); const pages = existsSync(paths.pagesDir); const ssr = Boolean(validateSsrEntry(root, paths)) || pages; diff --git a/packages/void/src/local-d1.ts b/packages/void/src/local-d1.ts index bbd0bb6fa..84656d9fa 100644 --- a/packages/void/src/local-d1.ts +++ b/packages/void/src/local-d1.ts @@ -138,7 +138,9 @@ function readLocalD1PersistState(root: string): LocalD1PersistState | undefined try { state = JSON.parse(readFileSync(path, 'utf-8')) as LocalD1PersistState; } catch { - throw new Error(`db: local D1 state in ${path} is invalid. Run \`void dev\` to refresh it.`); + throw new Error( + `db: local D1 state in ${path} is invalid. Start the dev server to refresh it.`, + ); } const projectRoot = resolve(root); if ( @@ -147,14 +149,14 @@ function readLocalD1PersistState(root: string): LocalD1PersistState | undefined typeof state.persistRoot !== 'string' || state.persistRoot.length === 0 ) { - throw new Error(`db: local D1 state in ${path} is stale. Run \`void dev\` to refresh it.`); + throw new Error(`db: local D1 state in ${path} is stale. Start the dev server to refresh it.`); } if (state.viteConfig) { const { path: configPath, hash } = state.viteConfig; if (!existsSync(configPath) || hashFile(configPath) !== hash) { throw new Error( `db: voidPlugin persistence configuration changed since the local D1 state was written. ` + - 'Run `void dev` to refresh it before using `void db`.', + 'Start the dev server to refresh it before using `void db`.', ); } } diff --git a/packages/void/src/migrations/collect.ts b/packages/void/src/migrations/collect.ts index dff57c26a..71228d513 100644 --- a/packages/void/src/migrations/collect.ts +++ b/packages/void/src/migrations/collect.ts @@ -37,9 +37,13 @@ export type DrizzleJournal = { * Read `db/migrations/meta/_journal.json` if it exists. Drizzle maintains * this file whenever `drizzle-kit generate` runs; every `.sql` migration * under `db/migrations/` should correspond to an entry whose `tag` is the - * filename without the `.sql` suffix. + * filename without the `.sql` suffix. `sourceRelative` adds the configured + * `sourceDir` to the path in the parse error. */ -export function readJournal(migrationsDir: string): DrizzleJournal | undefined { +export function readJournal( + migrationsDir: string, + sourceRelative: (path: string) => string = (path) => path, +): DrizzleJournal | undefined { const journalPath = join(migrationsDir, 'meta', '_journal.json'); if (!existsSync(journalPath)) { return undefined; @@ -48,7 +52,7 @@ export function readJournal(migrationsDir: string): DrizzleJournal | undefined { return JSON.parse(readFileSync(journalPath, 'utf-8')) as DrizzleJournal; } catch (err) { throw new Error( - `db: Failed to parse db/migrations/meta/_journal.json: '${(err as Error).message}'.`, + `db: Failed to parse ${sourceRelative('db/migrations/meta/_journal.json')}: '${(err as Error).message}'.`, ); } } diff --git a/packages/void/src/migrations/runner.ts b/packages/void/src/migrations/runner.ts index d7ee3c861..1e7466702 100644 --- a/packages/void/src/migrations/runner.ts +++ b/packages/void/src/migrations/runner.ts @@ -182,6 +182,7 @@ function assertLocalMigrationState( migrations: Array<{ name: string; sql: string }>, appliedNames: Set, applicationTables: Array, + dir: string, ): void { const migrationNames = new Set(migrations.map((migration) => migration.name)); const missingFiles = [...appliedNames].filter((name) => !migrationNames.has(name)).sort(); @@ -189,12 +190,12 @@ function assertLocalMigrationState( if (missingFiles.length > 0) { throw new Error( [ - 'Local database migration history does not match db/migrations/.', + `Local database migration history does not match ${dir}/.`, '', 'These migrations are recorded as applied locally but no longer exist on disk:', ...missingFiles.map((name) => ` - ${name}`), '', - 'If the migration files were renamed, run `void db rename-migrations`.', + 'If the migration files were renamed, restore their old names.', 'If the migration history was intentionally rewritten, run `void db reset` to recreate the local database from the current files.', ].join('\n'), ); @@ -208,7 +209,7 @@ function assertLocalMigrationState( `Existing tables: ${applicationTables.join(', ')}`, '', 'This usually means the schema was applied with `void db push` or the local database was created before migration tracking existed.', - 'Run `void db reset` to recreate the local database from db/migrations/, or delete the local D1 state if you do not need its data.', + `Run \`void db reset\` to recreate the local database from ${dir}/, or delete the local D1 state if you do not need its data.`, ].join('\n'), ); } @@ -217,8 +218,14 @@ function assertLocalMigrationState( /** * Apply pending SQL migrations and record them in `_void_migrations`. * Each file runs through one `exec` so SQLite parses multi-statement SQL itself. + * `sourceRelative` (`ProjectPaths.sourceRelative`) adds the configured `sourceDir` to the + * migrations folder that errors name. */ -export async function runMigrations(migrationsDir: string, dbPath: string): Promise> { +export async function runMigrations( + migrationsDir: string, + dbPath: string, + sourceRelative: (path: string) => string = (path) => path, +): Promise> { const allMigrations = collectMigrations(migrationsDir); if (allMigrations.length === 0) { return []; @@ -245,7 +252,12 @@ export async function runMigrations(migrationsDir: string, dbPath: string): Prom ); const applicationTables = getApplicationTableNames(db); - assertLocalMigrationState(allMigrations, appliedSet, applicationTables); + assertLocalMigrationState( + allMigrations, + appliedSet, + applicationTables, + sourceRelative('db/migrations'), + ); const pending = allMigrations.filter((m) => !appliedSet.has(m.name)); if (pending.length === 0) { diff --git a/packages/void/src/migrations/validate.ts b/packages/void/src/migrations/validate.ts index 231fdd485..46811ea42 100644 --- a/packages/void/src/migrations/validate.ts +++ b/packages/void/src/migrations/validate.ts @@ -123,13 +123,17 @@ export function validateMigrations( /** * Enumerate drizzle-kit metadata artifacts in a migrations directory. * Returns repo-relative paths like `db/migrations/meta/0000_snapshot.json` - * and `db/migrations/migrations.js`. Empty when none are present. + * and `db/migrations/migrations.js`, passed through `sourceRelative` so they + * include the configured `sourceDir`. Empty when none are present. * * Shared across `assertJournalCoherence`, `void gen migration`, and * `void db rename-migrations` so all three agree on what counts as a * "partial drizzle-kit state" (journal gone but other metadata lingering). */ -export function listDrizzleArtifacts(migrationsDir: string): Array { +export function listDrizzleArtifacts( + migrationsDir: string, + sourceRelative: (path: string) => string = (path) => path, +): Array { const metaDir = join(migrationsDir, 'meta'); const snapshotFiles = existsSync(metaDir) ? readdirSync(metaDir).filter((f) => f.endsWith('_snapshot.json')) @@ -137,10 +141,10 @@ export function listDrizzleArtifacts(migrationsDir: string): Array { const bundledPath = join(migrationsDir, 'migrations.js'); const out: Array = []; for (const f of snapshotFiles) { - out.push(`db/migrations/meta/${f}`); + out.push(sourceRelative(`db/migrations/meta/${f}`)); } if (existsSync(bundledPath)) { - out.push('db/migrations/migrations.js'); + out.push(sourceRelative('db/migrations/migrations.js')); } return out; } @@ -156,13 +160,18 @@ export function listDrizzleArtifacts(migrationsDir: string): Array { * hand-written `.sql` projects without `db/schema.ts` are supported and are * tracked by filename in `_void_migrations`. They cannot run * `void db generate`, so the check is skipped for them. + * + * `sourceRelative` (`ProjectPaths.sourceRelative`) adds the configured + * `sourceDir` to the paths in error messages. */ export function assertJournalCoherence( migrationsDir: string, collected: Array<{ name: string; sql: string }>, + sourceRelative: (path: string) => string = (path) => path, ): void { - const journal = readJournal(migrationsDir); - const drizzleArtifacts = listDrizzleArtifacts(migrationsDir); + const dir = sourceRelative('db/migrations'); + const journal = readJournal(migrationsDir, sourceRelative); + const drizzleArtifacts = listDrizzleArtifacts(migrationsDir, sourceRelative); // `undefined` means the file is absent. Parseable scalars such as `null` or // `false` are malformed journals, not missing ones, so avoid a falsy check. @@ -172,7 +181,7 @@ export function assertJournalCoherence( if (drizzleArtifacts.length > 0) { throw new Error( [ - 'db/migrations/meta/_journal.json is missing, but other drizzle-kit metadata is still present:', + `${dir}/meta/_journal.json is missing, but other drizzle-kit metadata is still present:`, ...drizzleArtifacts.map((p) => ` - ${p}`), '', 'This is a partial drizzle-kit state. Restore _journal.json from version control, or delete the orphaned metadata files above if you intended to revert to hand-written migrations.', @@ -193,7 +202,7 @@ export function assertJournalCoherence( !Array.isArray((journal as DrizzleJournal).entries) ) { throw new Error( - 'db/migrations/meta/_journal.json is malformed: expected an object with string `version`, string `dialect`, and an `entries` array. Restore the file from version control, or delete it if you intended to revert to hand-written migrations.', + `${dir}/meta/_journal.json is malformed: expected an object with string \`version\`, string \`dialect\`, and an \`entries\` array. Restore the file from version control, or delete it if you intended to revert to hand-written migrations.`, ); } @@ -211,7 +220,7 @@ export function assertJournalCoherence( typeof (entry as DrizzleJournalEntry).breakpoints !== 'boolean' ) { throw new Error( - `db/migrations/meta/_journal.json is malformed: entry at index ${i} is missing required fields (\`idx\`, \`version\`, \`tag\`, \`breakpoints\`). Restore the file from version control, or delete it if you intended to revert to hand-written migrations.`, + `${dir}/meta/_journal.json is malformed: entry at index ${i} is missing required fields (\`idx\`, \`version\`, \`tag\`, \`breakpoints\`). Restore the file from version control, or delete it if you intended to revert to hand-written migrations.`, ); } } @@ -225,7 +234,7 @@ export function assertJournalCoherence( } throw new Error( [ - 'db/migrations/meta/_journal.json has no entries, but drizzle-kit metadata is still present:', + `${dir}/meta/_journal.json has no entries, but drizzle-kit metadata is still present:`, ...drizzleArtifacts.map((p) => ` - ${p}`), '', 'This is a partial drizzle-kit state. Restore _journal.json from version control (or run `void db generate`), or delete the orphaned metadata files above if you intended to revert to hand-written migrations.', @@ -243,7 +252,7 @@ export function assertJournalCoherence( if (duplicateTags.length > 0) { throw new Error( [ - 'db/migrations/meta/_journal.json has duplicate migration entries:', + `${dir}/meta/_journal.json has duplicate migration entries:`, ...duplicateTags.map((t) => ` - ${t} (appears ${tagCounts.get(t)} times)`), '', 'Each migration must have a unique tag. Restore _journal.json from version control, or hand-edit to remove the duplicates.', @@ -266,22 +275,22 @@ export function assertJournalCoherence( } const lines: Array = [ - 'Migration drift detected between db/migrations/ and db/migrations/meta/_journal.json.', + `Migration drift detected between ${dir}/ and ${dir}/meta/_journal.json.`, ]; if (orphans.length > 0) { lines.push('', 'These files exist on disk but are not tracked in _journal.json:'); for (const f of orphans) { - lines.push(` - db/migrations/${f}`); + lines.push(` - ${dir}/${f}`); } lines.push( '', - 'Delete the orphan file(s), or move their contents into `db/schema.ts` and re-run `void db generate`.', + `Delete the orphan file(s), or move their contents into \`${sourceRelative('db/schema.ts')}\` and re-run \`void db generate\`.`, ); } if (missing.length > 0) { lines.push('', 'These entries are in _journal.json but the matching file is missing:'); for (const f of missing) { - lines.push(` - db/migrations/${f}`); + lines.push(` - ${dir}/${f}`); } lines.push('', 'Restore the missing file(s) from version control, or regenerate migrations.'); } diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 5c34d33d1..220feb1b2 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { join } from 'pathslash'; import type { Plugin } from 'vite'; import type { DatabaseDialect } from './config.ts'; -import { voidLog, voidWarn, voidError } from './log.ts'; +import { voidLog, voidError } from './log.ts'; import { afterServerReady } from './plugin-dev-config.ts'; import { readProjectPaths } from './project-paths.ts'; import { loadLocalEnv } from './shared/env.ts'; @@ -11,9 +11,12 @@ function readDatabaseUrl(root: string): string | undefined { return loadLocalEnv(root).DATABASE_URL; } -/** DATABASE_URL as Better Auth's local migrations read it: the shell before `.env`. */ +/** + * DATABASE_URL as Better Auth's local migrations read it: the shell before `.env`. An empty + * shell value counts as unset. + */ function readAuthDatabaseUrl(root: string): string | undefined { - return (process.env.DATABASE_URL ?? readDatabaseUrl(root)) || undefined; + return process.env.DATABASE_URL || readDatabaseUrl(root) || undefined; } /** @@ -58,6 +61,16 @@ export function migrationPlugin( voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); } }; + // A pending migration that fails validation stops every migration for this start, as + // `void db migrate` does, so files never apply out of order. The dev server still starts. + const refuse = (err: unknown) => { + afterServerReady(server, () => { + console.log(); + voidError( + `${err instanceof Error ? err.message : String(err)}\nNo migrations were applied. Fix the files, then restart the dev server.`, + ); + }); + }; try { if (dialect !== 'sqlite') { @@ -70,7 +83,7 @@ export function migrationPlugin( // the lock of the database it changes. const url = runFiles ? readDatabaseUrl(root) : undefined; const authUrl = mysqlAuth ? readAuthDatabaseUrl(root) : undefined; - if ((runFiles && !url) || (mysqlAuth && !authUrl)) { + if ((runFiles && !url) || (auth?.enabled && !readAuthDatabaseUrl(root))) { afterServerReady(server, () => { console.log(); voidError( @@ -98,7 +111,8 @@ export function migrationPlugin( try { validateMigrations(pending); } catch (err) { - voidWarn(err instanceof Error ? err.message : String(err)); + refuse(err); + return; } } @@ -144,11 +158,12 @@ export function migrationPlugin( try { validateMigrations(pending); } catch (err) { - voidWarn(err instanceof Error ? err.message : String(err)); + refuse(err); + return; } } - const applied = await runMigrations(migrationsDir, dbPath); + const applied = await runMigrations(migrationsDir, dbPath, paths.sourceRelative); if (applied.length > 0) { voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); } diff --git a/packages/void/src/runtime/auth-migrations.ts b/packages/void/src/runtime/auth-migrations.ts index 98ab45a93..345545b92 100644 --- a/packages/void/src/runtime/auth-migrations.ts +++ b/packages/void/src/runtime/auth-migrations.ts @@ -105,7 +105,8 @@ function resolveColumnType( return 'integer'; } if (idStrategy === 'uuid') { - return 'uuid'; + // Better Auth leaves the id out of PostgreSQL inserts when it uses uuids. + return fieldName === 'id' ? 'uuid DEFAULT gen_random_uuid()' : 'uuid'; } return 'text'; } @@ -133,12 +134,36 @@ function resolveColumnType( return 'TEXT'; } +function renderDefaultLiteral( + dialect: VoidAuthMigrationSchema['dialect'], + value: string | number | boolean, +): string { + if (typeof value === 'boolean') { + return dialect === 'sqlite' ? (value ? '1' : '0') : value ? 'TRUE' : 'FALSE'; + } + if (typeof value === 'number') { + return String(value); + } + if (dialect === 'mysql' && value.includes('\\')) { + // NO_BACKSLASH_ESCAPES changes what a backslash means in a MySQL string. A hex string does not change. + const hex = Array.from(new TextEncoder().encode(value), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + return `_utf8mb4 X'${hex}'`; + } + if (dialect === 'postgresql' && value.includes('\\')) { + // standard_conforming_strings changes what a backslash means in '...'. E'...' does not change. + return `E'${value.replaceAll('\\', '\\\\').replaceAll("'", "''")}'`; + } + return `'${value.replaceAll("'", "''")}'`; +} + function buildColumnDefinition( dialect: 'sqlite' | 'postgresql' | 'mysql', schema: VoidAuthMigrationSchema, fieldName: string, field: VoidAuthMigrationField, - options: { primaryKey?: boolean } = {}, + options: { primaryKey?: boolean; withDefault?: boolean } = {}, ): string { const parts = [ `${quoteIdentifier(fieldName, dialect)} ${resolveColumnType(dialect, schema.idStrategy, fieldName, field)}`, @@ -150,6 +175,9 @@ function buildColumnDefinition( if (field.required || options.primaryKey) { parts.push('NOT NULL'); } + if (options.withDefault && field.defaultValue !== undefined) { + parts.push(`DEFAULT ${renderDefaultLiteral(dialect, field.defaultValue)}`); + } if (field.references) { parts.push( `REFERENCES ${quoteIdentifier(field.references.table, dialect)}(${quoteIdentifier( @@ -197,11 +225,13 @@ function buildAddColumnSql( ): string { // Only PostgreSQL accepts ADD COLUMN IF NOT EXISTS; callers check the column exists first. const ifNotExists = schema.dialect === 'postgresql' ? ' IF NOT EXISTS' : ''; + // The default fills rows that exist. New tables keep no database default. return `ALTER TABLE ${quoteIdentifier(tableName, schema.dialect)} ADD COLUMN${ifNotExists} ${buildColumnDefinition( schema.dialect, schema, fieldName, field, + { withDefault: true }, )}`; } @@ -273,6 +303,55 @@ async function ensurePgIndexes( await createMissingIndexes(runner, 'postgresql', table, existingIndexes); } +/** + * Name the fields that a table with rows cannot take, and the fix, before the table changes. + * SQLite and PostgreSQL cannot add a NOT NULL column with no default to a table that has rows; + * MySQL fills those rows itself. On each dialect, a new required column gives every row the same + * value, so its unique index fails when the table has two or more rows. + */ +async function assertRequiredColumnsCanBeAdded( + runner: Pick, + dialect: VoidAuthMigrationSchema['dialect'], + table: VoidAuthMigrationTable, + missing: Array, +): Promise { + const required = missing.filter((fieldName) => table.fields[fieldName]!.required); + const unique = required.filter((fieldName) => table.fields[fieldName]!.unique); + const noDefault = + dialect === 'mysql' + ? [] + : required.filter((fieldName) => table.fields[fieldName]!.defaultValue === undefined); + if (unique.length === 0 && noDefault.length === 0) { + return; + } + // One row blocks a field with no default. Two rows block a unique field. + const rows = await runner.all( + `SELECT 1 AS present FROM ${quoteIdentifier(table.name, dialect)} LIMIT 2`, + ); + const describe = (fieldNames: Array) => ({ + names: fieldNames.map((fieldName) => JSON.stringify(fieldName)).join(', '), + noun: fieldNames.length === 1 ? 'field' : 'fields', + }); + if (rows.length >= 2 && unique.length > 0) { + const { names, noun } = describe(unique); + throw new Error( + `Cannot add required unique auth ${noun} ${names} to table ${JSON.stringify(table.name)}: ` + + 'the table has more than one row, and a new column gives every row the same value. ' + + `Set \`required: false\` on the ${noun}, ` + + `or add the ${unique.length === 1 ? 'column' : 'columns'} in a migration that gives each row its own value.`, + ); + } + if (rows.length >= 1 && noDefault.length > 0) { + const { names, noun } = describe(noDefault); + throw new Error( + `Cannot add required auth ${noun} ${names} to table ${JSON.stringify(table.name)}: ` + + 'the table has rows, and there is no fixed default value to fill them. ' + + `Set \`required: false\` on the ${noun}. ` + + 'A string, number or boolean field can use a fixed `defaultValue` instead.', + ); + } +} + export async function applySqliteAuthMigrations( runner: SqliteMigrationRunner, schema: VoidAuthMigrationSchema, @@ -294,6 +373,12 @@ export async function applySqliteAuthMigrations( `PRAGMA table_info(${quotePragmaArg(table.name)})`, ); const existingColumns = new Set(columnRows.map((row) => String(row.name))); + await assertRequiredColumnsCanBeAdded( + runner, + 'sqlite', + table, + Object.keys(table.fields).filter((fieldName) => !existingColumns.has(fieldName)), + ); for (const [fieldName, field] of Object.entries(table.fields)) { if (existingColumns.has(fieldName)) { @@ -331,6 +416,12 @@ export async function applyPgAuthMigrations( [schemaName, table.name], ); const existingColumns = new Set(columnRows.map((row) => String(row.column_name))); + await assertRequiredColumnsCanBeAdded( + runner, + 'postgresql', + table, + Object.keys(table.fields).filter((fieldName) => !existingColumns.has(fieldName)), + ); for (const [fieldName, field] of Object.entries(table.fields)) { if (existingColumns.has(fieldName)) { @@ -370,6 +461,12 @@ export async function applyMySqlAuthMigrations( const existingColumns = new Map( columnRows.map((row) => [String(row.column_name), row] as const), ); + await assertRequiredColumnsCanBeAdded( + runner, + 'mysql', + table, + Object.keys(table.fields).filter((fieldName) => !existingColumns.has(fieldName)), + ); for (const [fieldName, field] of Object.entries(table.fields)) { const existingColumn = existingColumns.get(fieldName); if (!existingColumn) { diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index 2ad581ced..a7412eb55 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -21,14 +21,16 @@ export function createMigrationHandler( ); } + // A later failure, such as Better Auth's step, still reports the files that committed. + const results: Array = []; try { - return Response.json(await runMySqlMigrations(env, migrations, runAuthMigrations)); + return Response.json(await runMySqlMigrations(env, migrations, results, runAuthMigrations)); } catch (error) { return Response.json( { ok: false, error: error instanceof Error ? error.message : String(error), - migrations: [], + migrations: results, } satisfies MigrationResponse, { status: 500 }, ); @@ -39,6 +41,7 @@ export function createMigrationHandler( async function runMySqlMigrations( env: Record, migrations: Array, + results: Array, runAuthMigrations?: () => Promise, ): Promise { const hyperdrive = env.HYPERDRIVE as { connectionString?: string } | undefined; @@ -69,7 +72,6 @@ async function runMySqlMigrations( ); const [rows] = await connection.query('SELECT `name` FROM `_void_migrations`'); const applied = new Set((rows as Array<{ name: string }>).map((row) => String(row.name))); - const results: Array = []; for (const migration of migrations) { if (applied.has(migration.name)) { diff --git a/packages/void/src/runtime/migration-handler-pg.ts b/packages/void/src/runtime/migration-handler-pg.ts index a5e93405b..888b05813 100644 --- a/packages/void/src/runtime/migration-handler-pg.ts +++ b/packages/void/src/runtime/migration-handler-pg.ts @@ -20,7 +20,19 @@ export function createMigrationHandler( try { const result = await runPgMigrations(env, migrations); if (result.ok && runAuthMigrations) { - await runAuthMigrations(); + // The files are already committed, so a Better Auth failure still reports them. + try { + await runAuthMigrations(); + } catch (err) { + return Response.json( + { + ...result, + ok: false, + error: err instanceof Error ? err.message : String(err), + } satisfies MigrationResponse, + { status: 500 }, + ); + } } return Response.json(result); } catch (err) { diff --git a/packages/void/test/integration/prepare-cli.test.ts b/packages/void/test/integration/prepare-cli.test.ts index 408fa832d..a3709f5a6 100644 --- a/packages/void/test/integration/prepare-cli.test.ts +++ b/packages/void/test/integration/prepare-cli.test.ts @@ -115,6 +115,34 @@ describe('void prepare (subprocess)', () => { expect(existsSync(join(root, '.void', 'env.d.ts'))).toBe(false); }); + it('writes every file that .void/tsconfig.json lists when queues/ has only helper files', () => { + const root = createProject({ withSources: true }); + mkdirSync(join(root, 'queues')); + writeFileSync(join(root, 'queues', '_shared.ts'), 'export const helper = 1;\n'); + + const { status } = runVoid(root, ['prepare']); + + expect(status).toBe(0); + + const outDir = join(root, '.void'); + const tsconfig = JSON.parse(readFileSync(join(outDir, 'tsconfig.json'), 'utf8')) as { + files: Array; + compilerOptions?: { paths?: Record> }; + }; + expect(tsconfig.files).toContain('queues.d.ts'); + for (const file of tsconfig.files) { + expect(existsSync(join(outDir, file)), file).toBe(true); + } + for (const targets of Object.values(tsconfig.compilerOptions?.paths ?? {})) { + for (const target of targets.filter((path) => path.startsWith('./'))) { + expect(existsSync(join(outDir, target)), target).toBe(true); + } + } + expect(readFileSync(join(outDir, 'queues.d.ts'), 'utf8')).toContain( + 'export interface QueueMap', + ); + }); + it('generates project-local files from sourceDir conventions', () => { const root = createProject({ withSources: true, sourceDir: 'src' }); mkdirSync(join(root, 'routes', 'api'), { recursive: true }); diff --git a/packages/void/test/unit/auth-drizzle.test.ts b/packages/void/test/unit/auth-drizzle.test.ts index 4363be9e3..199b55131 100644 --- a/packages/void/test/unit/auth-drizzle.test.ts +++ b/packages/void/test/unit/auth-drizzle.test.ts @@ -87,6 +87,25 @@ describe('Better Auth Drizzle codegen', () => { expect(code).toContain('"unbounded": true'); }); + it('gives PostgreSQL serial and uuid ids a database default', () => { + const generate = (dialect: 'postgresql' | 'mysql', generateId: 'serial' | 'uuid') => + generateVoidAuthDrizzleSchema({ + dialect, + providers: null, + userConfig: ({ defaults }) => ({ ...defaults, advanced: { database: { generateId } } }), + }); + + expect(generate('postgresql', 'serial')).toContain( + '"id": integer("id").primaryKey().generatedByDefaultAsIdentity().notNull()', + ); + expect(generate('postgresql', 'uuid')).toContain( + '"id": uuid("id").primaryKey().defaultRandom().notNull()', + ); + expect(generate('mysql', 'serial')).toContain( + 'import { boolean, index, int, mysqlTable, text, timestamp, varchar } from "void/schema-mysql"', + ); + }); + it('does not add MySQL storage metadata to PostgreSQL or SQLite schemas', () => { for (const dialect of ['postgresql', 'sqlite'] as const) { const code = generateVoidAuthDrizzleSchema({ dialect, providers: ['google'] }); diff --git a/packages/void/test/unit/auth-migrations.test.ts b/packages/void/test/unit/auth-migrations.test.ts index 28d3c13e3..a857ec795 100644 --- a/packages/void/test/unit/auth-migrations.test.ts +++ b/packages/void/test/unit/auth-migrations.test.ts @@ -1,12 +1,77 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { DatabaseSync } from 'node:sqlite'; +import { PGlite } from '@electric-sql/pglite'; +import type { DBFieldAttribute } from 'better-auth/db'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import type { VoidAuthMigrationSchema } from '../../src/auth/drizzle.ts'; import { resolveVoidAuthMigrationSchema } from '../../src/auth/drizzle.ts'; +import { runLocalVoidAuthMigrations } from '../../src/auth/node.ts'; +import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; import { applyMySqlAuthMigrations, applyPgAuthMigrations, applySqliteAuthMigrations, + pgMigrationRunner, } from '../../src/runtime/auth-migrations.ts'; +// PGlite serves the `pg` clients, and each client records the database it names. +const state = vi.hoisted(() => ({ pg: null as PGlite | null, connections: [] as Array })); +vi.mock('pg', () => ({ + Client: class { + constructor(options: { connectionString: string }) { + state.connections.push(options.connectionString); + } + async connect() {} + async end() {} + query(sql: string, params?: Array) { + return state.pg!.query(sql, params); + } + }, +})); + +beforeAll(async () => { + // Load PostgreSQL once: Windows' cold WASM startup belongs to the hook budget. + state.pg = new PGlite(); + await state.pg.waitReady; +}, 30_000); +beforeEach(async () => { + await state.pg!.exec('DROP SCHEMA public CASCADE; CREATE SCHEMA public'); +}); +afterAll(async () => { + await state.pg?.close(); + state.pg = null; +}); + +function resolveWithUserFields( + dialect: VoidAuthMigrationSchema['dialect'], + additionalFields: Record = {}, +): VoidAuthMigrationSchema { + return resolveVoidAuthMigrationSchema({ + dialect, + providers: null, + userConfig: ({ defaults }) => ({ ...defaults, user: { additionalFields } }), + }); +} + +function sqliteRunner(db: DatabaseSync) { + return { + async all>(sql: string) { + return db.prepare(sql).all() as Array; + }, + async run(sql: string) { + db.prepare(sql).run(); + }, + }; +} + +function sqliteColumns(db: DatabaseSync, table: string): Array { + return (db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>).map( + (column) => column.name, + ); +} + describe('auth migration runner', () => { it('applies the sqlite auth schema idempotently', async () => { const { DatabaseSync } = await import('node:sqlite'); @@ -93,6 +158,39 @@ describe('auth migration runner', () => { ); }); + it('gives postgres uuid auth ids a random default', async () => { + const statements: Array = []; + const schema = resolveVoidAuthMigrationSchema({ + dialect: 'postgresql', + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + advanced: { database: { generateId: 'uuid' } }, + }), + }); + + await applyPgAuthMigrations( + { + async all>(sql: string) { + if (sql.includes('current_schema')) { + return [{ schema: 'public' }] as unknown as Array; + } + return [] as Array; + }, + async run(sql: string) { + statements.push(sql); + }, + }, + schema, + ); + + const session = statements.find( + (sql) => sql.startsWith('CREATE TABLE') && sql.includes('"session"'), + ); + expect(session).toContain('"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY NOT NULL'); + expect(session).toContain('"userId" uuid NOT NULL REFERENCES'); + }); + it('uses string-literal table names for sqlite PRAGMA introspection', async () => { const reads: Array = []; @@ -284,4 +382,332 @@ describe('auth migration runner', () => { expect(statements).toEqual(['ALTER TABLE `user` ADD COLUMN `nickname` varchar(255)']); }); + + it('fills rows that exist with the fixed default of a new required field (sqlite)', async () => { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(':memory:'); + try { + const runner = sqliteRunner(db); + await applySqliteAuthMigrations(runner, resolveWithUserFields('sqlite')); + db.prepare( + "INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) VALUES ('u1', 'A', 'a@example.com', 0, 0, 0)", + ).run(); + + await applySqliteAuthMigrations( + runner, + resolveWithUserFields('sqlite', { + plan: { type: 'string', defaultValue: 'free' }, + banned: { type: 'boolean', defaultValue: false }, + credits: { type: 'number', defaultValue: -3 }, + }), + ); + + expect(db.prepare('SELECT plan, banned, credits FROM user').get()).toEqual({ + plan: 'free', + banned: 0, + credits: -3, + }); + } finally { + db.close(); + } + }); + + it('fills rows that exist with the fixed default of a new required field (postgres)', async () => { + const pg = state.pg!; + const runner = pgMigrationRunner(pg); + // With standard_conforming_strings off, a backslash in '...' starts an escape. + for (const mode of ['on', 'off']) { + await pg.exec( + `DROP SCHEMA public CASCADE; CREATE SCHEMA public; SET standard_conforming_strings = ${mode}`, + ); + try { + await applyPgAuthMigrations(runner, resolveWithUserFields('postgresql')); + await pg.query( + `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt", "updatedAt") VALUES ('u1', 'A', 'a@example.com', false, now(), now())`, + ); + + await applyPgAuthMigrations( + runner, + resolveWithUserFields('postgresql', { + plan: { type: 'string', defaultValue: "it's free" }, + motto: { type: 'string', defaultValue: "it's a\\b" }, + folder: { type: 'string', defaultValue: 'end\\' }, + banned: { type: 'boolean', defaultValue: true }, + }), + ); + + expect( + (await pg.query('SELECT plan, motto, folder, banned FROM "user"')).rows, + `standard_conforming_strings = ${mode}`, + ).toEqual([{ plan: "it's free", motto: "it's a\\b", folder: 'end\\', banned: true }]); + } finally { + await pg.exec('RESET standard_conforming_strings'); + } + } + }); + + it('refuses a required field without a default only when the table has rows', async () => { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(':memory:'); + try { + const runner = sqliteRunner(db); + await applySqliteAuthMigrations(runner, resolveWithUserFields('sqlite')); + // An empty table takes the column: no row needs a value. + await applySqliteAuthMigrations( + runner, + resolveWithUserFields('sqlite', { nickname: { type: 'string' } }), + ); + expect(sqliteColumns(db, 'user')).toContain('nickname'); + db.prepare( + "INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt, nickname) VALUES ('u1', 'A', 'a@example.com', 0, 0, 0, 'a')", + ).run(); + + await expect( + applySqliteAuthMigrations( + runner, + resolveWithUserFields('sqlite', { + nickname: { type: 'string' }, + plan: { type: 'string' }, + }), + ), + ).rejects.toThrow( + 'Cannot add required auth field "plan" to table "user": the table has rows, and there is no fixed default value to fill them. Set `required: false` on the field.', + ); + expect(sqliteColumns(db, 'user')).not.toContain('plan'); + } finally { + db.close(); + } + }); + + it('refuses a required postgres field without a default when the table has rows', async () => { + const pg = state.pg!; + const runner = pgMigrationRunner(pg); + await applyPgAuthMigrations(runner, resolveWithUserFields('postgresql')); + await pg.query( + `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt", "updatedAt") VALUES ('u1', 'A', 'a@example.com', false, now(), now())`, + ); + + await expect( + applyPgAuthMigrations( + runner, + resolveWithUserFields('postgresql', { plan: { type: 'string' } }), + ), + ).rejects.toThrow( + 'Cannot add required auth field "plan" to table "user": the table has rows, and there is no fixed default value to fill them. Set `required: false` on the field.', + ); + const columns = await pg.query<{ column_name: string }>( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'user'", + ); + expect(columns.rows.map((row) => row.column_name)).not.toContain('plan'); + }); + + it('refuses a required unique field only when the table has more than one row', async () => { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(':memory:'); + try { + const runner = sqliteRunner(db); + await applySqliteAuthMigrations(runner, resolveWithUserFields('sqlite')); + db.prepare( + "INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) VALUES ('u1', 'A', 'a@example.com', 0, 0, 0)", + ).run(); + // One row can take the default of a unique field. + await applySqliteAuthMigrations( + runner, + resolveWithUserFields('sqlite', { + code: { type: 'string', unique: true, defaultValue: 'a' }, + }), + ); + expect(sqliteColumns(db, 'user')).toContain('code'); + db.prepare( + "INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt, code) VALUES ('u2', 'B', 'b@example.com', 0, 0, 0, 'b')", + ).run(); + + await expect( + applySqliteAuthMigrations( + runner, + resolveWithUserFields('sqlite', { + code: { type: 'string', unique: true, defaultValue: 'a' }, + handle: { type: 'string', unique: true, defaultValue: 'x' }, + }), + ), + ).rejects.toThrow( + 'Cannot add required unique auth field "handle" to table "user": the table has more than one row, and a new column gives every row the same value. Set `required: false` on the field, or add the column in a migration that gives each row its own value.', + ); + expect(sqliteColumns(db, 'user')).not.toContain('handle'); + } finally { + db.close(); + } + }); + + it('adds a new required MySQL column with its fixed default', async () => { + const statements: Array = []; + const resolved = resolveWithUserFields('mysql', { + plan: { type: 'string', defaultValue: 'free' }, + motto: { type: 'string', defaultValue: "it's a\\b" }, + nickname: { type: 'string' }, + }); + const user = resolved.tables.find((table) => table.name === 'user')!; + const existing = ['id', 'name', 'email', 'emailVerified', 'image', 'createdAt', 'updatedAt']; + + await applyMySqlAuthMigrations( + { + async all>(sql: string) { + if (sql.includes('SELECT DATABASE()')) { + return [{ database_name: 'app' }] as unknown as Array; + } + if (sql.includes('information_schema.tables')) { + return [{ table_name: 'user' }] as unknown as Array; + } + if (sql.includes('information_schema.columns')) { + return existing.map((column_name) => ({ + column_name, + data_type: 'varchar', + })) as unknown as Array; + } + if (sql.includes('information_schema.statistics')) { + return [{ index_name: 'user_email_uidx' }] as unknown as Array; + } + // The user table has rows. + return [{ present: 1 }] as unknown as Array; + }, + async run(sql: string) { + statements.push(sql); + }, + }, + { ...resolved, tables: [user] }, + ); + + expect(statements).toEqual([ + "ALTER TABLE `user` ADD COLUMN `plan` varchar(255) NOT NULL DEFAULT 'free'", + // A hex string keeps the backslash when the server sets NO_BACKSLASH_ESCAPES. + "ALTER TABLE `user` ADD COLUMN `motto` varchar(255) NOT NULL DEFAULT _utf8mb4 X'6974277320615c62'", + 'ALTER TABLE `user` ADD COLUMN `nickname` varchar(255) NOT NULL', + ]); + }); + + it('refuses a required unique MySQL field when the table has more than one row', async () => { + const statements: Array = []; + const resolved = resolveWithUserFields('mysql', { + code: { type: 'string', unique: true, defaultValue: 'x' }, + handle: { type: 'string', unique: true }, + }); + const user = resolved.tables.find((table) => table.name === 'user')!; + const existing = ['id', 'name', 'email', 'emailVerified', 'image', 'createdAt', 'updatedAt']; + + await expect( + applyMySqlAuthMigrations( + { + async all>(sql: string) { + if (sql.includes('SELECT DATABASE()')) { + return [{ database_name: 'app' }] as unknown as Array; + } + if (sql.includes('information_schema.tables')) { + return [{ table_name: 'user' }] as unknown as Array; + } + if (sql.includes('information_schema.columns')) { + return existing.map((column_name) => ({ + column_name, + data_type: 'varchar', + })) as unknown as Array; + } + if (sql === 'SELECT 1 AS present FROM `user` LIMIT 2') { + return [{ present: 1 }, { present: 1 }] as unknown as Array; + } + return [] as Array; + }, + async run(sql: string) { + statements.push(sql); + }, + }, + { ...resolved, tables: [user] }, + ), + ).rejects.toThrow( + 'Cannot add required unique auth fields "code", "handle" to table "user": the table has more than one row, and a new column gives every row the same value. Set `required: false` on the fields, or add the columns in a migration that gives each row its own value.', + ); + expect(statements).toEqual([]); + }); +}); + +describe('runLocalVoidAuthMigrations', () => { + // This test writes a SQLite file on disk. Windows CI disk writes can stall for 30 seconds. + it( + 'creates the local D1 directory on the first dev run', + async () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-migrations-')); + try { + const persistRoot = join(root, '.void'); + await runLocalVoidAuthMigrations(root, 'sqlite', persistRoot, 'local', null); + + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(getMiniflareDatabasePath(persistRoot, 'local')); + try { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as Array<{ name: string }>; + expect(tables.map((table) => table.name)).toEqual([ + 'account', + 'session', + 'user', + 'verification', + ]); + } finally { + db.close(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + process.platform === 'win32' ? 60_000 : undefined, + ); + + // An empty DATABASE_URL in the shell counts as unset, so the one in .env is used. + it('uses DATABASE_URL from .env when the shell value is empty', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-migrations-')); + writeFileSync(join(root, '.env'), 'DATABASE_URL=postgres://localhost/app\n'); + vi.stubEnv('DATABASE_URL', ''); + state.connections.length = 0; + try { + await runLocalVoidAuthMigrations(root, 'postgresql', join(root, '.void'), 'local', null); + } finally { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + + expect(state.connections).toEqual(['postgres://localhost/app']); + const tables = await state.pg!.query<{ table_name: string }>( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name", + ); + expect(tables.rows.map((row) => row.table_name)).toEqual([ + 'account', + 'session', + 'user', + 'verification', + ]); + }); + + it('gives a function-form config the DATABASE_URL that the migration uses', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-migrations-')); + writeFileSync(join(root, '.env'), 'DATABASE_URL=postgres://localhost/app\n'); + const configPath = join(root, 'auth.mjs'); + writeFileSync( + configPath, + 'export default ({ defaults, env }) => { globalThis.authConfigDatabaseUrl = env.DATABASE_URL; return defaults; };', + ); + vi.stubEnv('DATABASE_URL', ''); + const recorded = globalThis as { authConfigDatabaseUrl?: unknown }; + try { + await runLocalVoidAuthMigrations( + root, + 'postgresql', + join(root, '.void'), + 'local', + configPath, + ); + expect(recorded.authConfigDatabaseUrl).toBe('postgres://localhost/app'); + } finally { + delete recorded.authConfigDatabaseUrl; + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index 041efccc7..952e2702b 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -154,6 +154,11 @@ describe('rendered help advertises the flags the parser honours', () => { expect(render(['gen'])).not.toContain('CRUD'); }); + it('describes db push for every database, not only D1', () => { + expect(render(['db', 'push'])).not.toContain('local D1'); + expect(render(['db'])).not.toContain('local D1'); + }); + it('lists proxy token rotation under platform system with its scope flags', () => { expect(render(['platform', 'system'])).toContain('rotate-proxy-tokens'); const page = render(['platform', 'system', 'rotate-proxy-tokens']); diff --git a/packages/void/test/unit/cloudflare-operations.test.ts b/packages/void/test/unit/cloudflare-operations.test.ts index 60a637263..8f520efc3 100644 --- a/packages/void/test/unit/cloudflare-operations.test.ts +++ b/packages/void/test/unit/cloudflare-operations.test.ts @@ -570,6 +570,21 @@ describe('Cloudflare D1 operations', () => { }); }); + it('leaves migrationsDir unset when the D1 binding has none', () => { + const root = project(`{ + "name": "my-worker", + "account_id": "account-1", + "d1_databases": [{ + "binding": "DB", + "database_name": "my-data", + "database_id": "11111111-2222-3333-4444-555555555555" + }] +}\n`); + const ctx = resolveCloudflareD1Context(root, 'DB', vi.fn()); + expect(ctx.migrationsDir).toBeUndefined(); + expect(ctx.migrationsTable).toBe('d1_migrations'); + }); + it('reads applied migration names from a custom tracking table', async () => { const runner = vi .fn() diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index d48fd31df..88ec4272d 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -131,6 +131,24 @@ describe('parseDbArgs', () => { ); }); + it('rejects flags the action does not use', () => { + expect(() => parseDbArgs(['reset', '--remote'])).toThrow( + 'db: `void db reset` does not support --remote.', + ); + expect(() => parseDbArgs(['seed', '--remote'])).toThrow( + 'db: `void db seed` does not support --remote.', + ); + expect(() => parseDbArgs(['push', '--remote'])).toThrow( + 'db: `void db push` does not support --remote.', + ); + expect(() => parseDbArgs(['migrate', '--file', 'x.sql'])).toThrow( + 'db: `void db migrate` does not support --file.', + ); + expect(() => parseDbArgs(['status', '--yes'])).toThrow( + 'db: `void db status` does not support --yes.', + ); + }); + it('parses studio', () => { expect(parseDbArgs(['studio'])).toEqual({ action: 'studio', remote: undefined }); }); diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index 5b6d5b411..65a83a4d8 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -13,7 +13,9 @@ import { toSlash } from 'pathslash'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vite-plus/test'; const mockExecFileSync = vi.hoisted(() => vi.fn()); -const mockPgQuery = vi.hoisted(() => vi.fn(async (_sql: string) => ({ rows: [] }))); +const mockPgQuery = vi.hoisted(() => + vi.fn(async (_sql: string) => ({ rows: [] as Array> })), +); const mockConfirm = vi.fn(); const mockGetMiniflareDatabasePath = vi.fn(); const mockOpenSqliteDatabase = vi.fn(); @@ -37,31 +39,54 @@ vi.mock('pg', async (importOriginal) => { }); const mysqlQueries = vi.hoisted((): Array => []); +const mysqlConnectOptions = vi.hoisted((): Array> => []); +// Runs after each query is recorded, so a test can make one statement fail. +const mockMySqlQuery = vi.hoisted(() => vi.fn<(sql: string) => void>()); vi.mock('mysql2/promise', () => ({ - createConnection: async () => ({ - async end() {}, - async query(sql: string) { - mysqlQueries.push(sql); - // MySQL 8 labels an unaliased information_schema column in upper case. - const tables = /^SELECT table_name(?: AS (\w+))? FROM information_schema\.tables/.exec(sql); - if (tables) { - const key = tables[1] ?? 'TABLE_NAME'; - return [ - [{ [key]: '_void_migration_lock' }, { [key]: '_void_migrations' }, { [key]: 'users' }], - [], - ]; - } - if (sql === 'SELECT `name` FROM `_void_migrations`') { - const dropped = mysqlQueries.includes('DROP TABLE IF EXISTS `_void_migrations`'); - return [dropped ? [] : [{ name: '0000_init.sql' }], []]; - } - if (sql.startsWith('SELECT GET_LOCK')) { - return [[{ acquired: 1 }], []]; - } - return [[], []]; - }, - }), + createConnection: async (options: Record) => { + mysqlConnectOptions.push(options); + return { + async end() {}, + async query(sql: string) { + mysqlQueries.push(sql); + mockMySqlQuery(sql); + // MySQL 8 labels an unaliased information_schema column in upper case. + const tables = + /^SELECT table_name(?: AS (\w+))?(, table_type(?: AS (\w+))?)? FROM information_schema\.tables/.exec( + sql, + ); + if (tables) { + const key = tables[1] ?? 'TABLE_NAME'; + // `_drafts` is an app table whose name starts with an underscore. + const names = ['_drafts', '_void_migration_lock', '_void_migrations', 'users']; + if (!tables[2]) { + return [names.map((name) => ({ [key]: name })), []]; + } + // A query that reads table_type also sees views. + const typeKey = tables[3] ?? 'TABLE_TYPE'; + return [ + [ + ...names.map((name) => ({ [key]: name, [typeKey]: 'BASE TABLE' })), + { [key]: 'user_names', [typeKey]: 'VIEW' }, + ], + [], + ]; + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + const dropped = mysqlQueries.includes('DROP TABLE IF EXISTS `_void_migrations`'); + return [dropped ? [] : [{ name: '0000_init.sql' }], []]; + } + if (sql === 'SELECT * FROM `_drafts`') { + return [[{ id: '1', title: 'draft' }], []]; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [[{ acquired: 1 }], []]; + } + return [[], []]; + }, + }; + }, })); vi.mock('../../src/migrations/runner', async () => { @@ -106,6 +131,7 @@ vi.mock('../../src/migrations/collect', async () => { const mockGetToken = vi.fn<() => string | null>(() => null); const mockReadProjectConfig = vi.fn<() => { projectId: string; slug: string } | null>(() => null); +const mockReadDeploymentPlatform = vi.fn<() => string | null>(() => 'void'); const mockRenameMigrations = vi.fn< (projectId: string, renames: Array<{ from: string; to: string }>) => Promise<{ ok: boolean }> @@ -117,7 +143,7 @@ vi.mock('../../src/cli/auth', () => ({ vi.mock('../../src/cli/project', () => ({ readProjectConfig: () => mockReadProjectConfig(), - readDeploymentPlatform: () => 'void', + readDeploymentPlatform: () => mockReadDeploymentPlatform(), })); vi.mock('../../src/cli/client', () => ({ @@ -428,6 +454,8 @@ describe('void db reset', () => { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); mockConfirm.mockResolvedValue(false); + // Seed checks its file before the prompt. + writeFileSync(join(root, 'db', 'seed.sql'), ''); await expect(runDbCommand(root, { action: 'seed' })).rejects.toThrow('process.exit:0'); expect(mockConfirm).toHaveBeenCalledOnce(); @@ -453,6 +481,9 @@ describe('void db reset', () => { expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `_void_migrations`'); expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `users`'); + // A view stays when its tables are dropped, so reset drops it as a view. + expect(mysqlQueries).toContain('DROP VIEW IF EXISTS `user_names`'); + expect(mysqlQueries).not.toContain('DROP TABLE IF EXISTS `user_names`'); expect(mysqlQueries).toContain('CREATE TABLE `users` (id INT);'); // The migration lock covers the drops and the new run, and deploys keep their lease table. const lock = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT GET_LOCK')); @@ -506,6 +537,94 @@ describe('void db export', () => { expect(lines[0]).toBe('SET FOREIGN_KEY_CHECKS = 0;'); expect(lines.at(-1)).toBe('SET FOREIGN_KEY_CHECKS = 1;'); }); + + it('MySQL: skips only Void bookkeeping tables and reads values as MySQL text', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-export-mysql-')); + rootsToClean.push(root); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'mysql' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="mysql://root@127.0.0.1:3306/app"\n'); + mysqlQueries.length = 0; + mysqlConnectOptions.length = 0; + let stdout = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + + await runDbCommand(root, { action: 'export' }); + + expect(mysqlQueries).toContain('SHOW CREATE TABLE `users`'); + expect(mysqlQueries).toContain('SHOW CREATE TABLE `_drafts`'); + expect(stdout).toContain("INSERT INTO `_drafts` (`id`, `title`) VALUES ('1', 'draft');"); + expect(mysqlQueries).not.toContain('SHOW CREATE TABLE `_void_migrations`'); + expect(mysqlQueries).not.toContain('SHOW CREATE TABLE `_void_migration_lock`'); + expect(mysqlConnectOptions[0]).toMatchObject({ + dateStrings: true, + jsonStrings: true, + supportBigNumbers: true, + bigNumberStrings: true, + }); + // TIMESTAMP text follows the session time zone, so the dump pins it for the restore. + expect(mysqlQueries).toContain("SET time_zone = '+00:00'"); + expect(stdout).toContain("SET time_zone = '+00:00';"); + }); + + it('PostgreSQL: writes an absolute --output path as given', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-export-pg-')); + const elsewhere = mkdtempSync(join(tmpdir(), 'void-export-out-')); + rootsToClean.push(root, elsewhere); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="postgresql://u:p@127.0.0.1:5432/app"\n'); + const output = join(elsewhere, 'dump.sql'); + + await runDbCommand(root, { action: 'export', output }); + + expect(existsSync(output)).toBe(true); + }); +}); + +describe('void db execute', () => { + let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + runDbCommand = (await import('../../src/cli/db.ts')).runDbCommand; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of rootsToClean.splice(0, rootsToClean.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('reads an absolute --file path as given', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-execute-abs-')); + const elsewhere = mkdtempSync(join(tmpdir(), 'void-execute-sql-')); + rootsToClean.push(root, elsewhere); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'mysql' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="mysql://root@127.0.0.1:3306/app"\n'); + writeFileSync(join(elsewhere, 'query.sql'), 'UPDATE users SET id = id;'); + mysqlQueries.length = 0; + + await runDbCommand(root, { action: 'execute', file: join(elsewhere, 'query.sql') }); + + expect(mysqlQueries).toContain('UPDATE users SET id = id;'); + }); + + it('PostgreSQL: reads an absolute --file path as given', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-execute-abs-')); + const elsewhere = mkdtempSync(join(tmpdir(), 'void-execute-sql-')); + rootsToClean.push(root, elsewhere); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="postgresql://u:p@127.0.0.1:5432/app"\n'); + writeFileSync(join(elsewhere, 'query.sql'), 'UPDATE users SET id = id;'); + + await runDbCommand(root, { action: 'execute', file: join(elsewhere, 'query.sql') }); + + expect(mockPgQuery).toHaveBeenCalledWith('UPDATE users SET id = id;'); + }); }); describe('void db status', () => { @@ -548,6 +667,58 @@ describe('void db status', () => { const errorMessage = mockLog.error.mock.calls[0][0] as string; expect(errorMessage).toContain('_journal.json'); }); + + it('PostgreSQL: reports an unreachable local database instead of pending migrations', async () => { + const root = createRenameProjectRoot({ files: { '0000_init.sql': 'SELECT 1;' } }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="postgresql://u:p@127.0.0.1:5432/app"\n'); + mockPgQuery.mockRejectedValueOnce( + Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }), + ); + + await expect(runDbCommand(root, { action: 'status' })).rejects.toThrow('process.exit:1'); + + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED')); + expect(mockLog.info).not.toHaveBeenCalled(); + }); + + it('PostgreSQL: reports a missing DATABASE_URL instead of pending migrations', async () => { + const root = createRenameProjectRoot({ files: { '0000_init.sql': 'SELECT 1;' } }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + + await expect(runDbCommand(root, { action: 'status' })).rejects.toThrow('process.exit:1'); + + expect(mockLog.error).toHaveBeenCalledWith( + expect.stringContaining('DATABASE_URL not found in .env'), + ); + expect(mockLog.info).not.toHaveBeenCalled(); + }); + + it('counts remote pending files, not tracked names, in the status summary', async () => { + // Another branch applied 0001 and 0002 remotely, which are not on disk here. + const root = createRenameProjectRoot({ + files: { '0000_a.sql': 'SELECT 1;', '0003_d.sql': 'SELECT 1;' }, + }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + mockReadDeploymentPlatform.mockReturnValueOnce('cloudflare'); + mockPgQuery.mockResolvedValueOnce({ + rows: [{ name: '0000_a.sql' }, { name: '0001_b.sql' }, { name: '0002_c.sql' }], + }); + const databaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = 'postgresql://u:p@db.example.com:5432/prod'; + try { + await runDbCommand(root, { action: 'status' }); + } finally { + if (databaseUrl === undefined) { + delete process.env.DATABASE_URL; + } else { + process.env.DATABASE_URL = databaseUrl; + } + } + + const { outro } = await import('@voidzero-dev/vite-plus-prompts'); + expect(outro).toHaveBeenCalledWith('2 migrations, 1 pending remotely'); + }); }); describe('void db rename-migrations', () => { @@ -560,6 +731,8 @@ describe('void db rename-migrations', () => { // afterEach resets these mocks, so reinstall the defaults. mockGetToken.mockImplementation(() => null); mockReadProjectConfig.mockImplementation(() => null); + // A project that was never deployed has no saved deployment platform. + mockReadDeploymentPlatform.mockImplementation(() => null); mockRenameMigrations.mockReset(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { @@ -577,6 +750,8 @@ describe('void db rename-migrations', () => { mockGetToken.mockReset(); mockReadProjectConfig.mockReset(); mockRenameMigrations.mockReset(); + mockReadDeploymentPlatform.mockReset(); + mockMySqlQuery.mockReset(); for (const root of rootsToClean.splice(0, rootsToClean.length)) { rmSync(root, { recursive: true, force: true }); } @@ -828,9 +1003,6 @@ describe('void db rename-migrations', () => { expect(existsSync(bundledPath)).toBe(true); expect(readFileSync(bundledPath).equals(originalBundleBytes)).toBe(true); - expect(mockOpenSqliteDatabase).not.toHaveBeenCalled(); - expect(mockWriteJournal).not.toHaveBeenCalled(); - const errorMessages = mockLog.error.mock.calls.map((call) => String(call[0])).join('\n'); expect(errorMessages).toMatch(/bundled migration format/); expect(errorMessages).toContain('db/migrations/migrations.js'); @@ -914,4 +1086,169 @@ describe('void db rename-migrations', () => { expect(errorMessages).toContain('snapshot files are missing'); expect(errorMessages).toContain('db/migrations/meta/0001_snapshot.json'); }); + + it('refuses direct Cloudflare deploys before renaming anything', async () => { + mockReadDeploymentPlatform.mockReturnValue('cloudflare'); + // A project that moved from Void keeps its old link, so the hosted rename must not run either. + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj-under-test', slug: 'under-test' }); + const root = createRenameProjectRoot({ + files: { '0001_a.sql': '-- a\n', '0002_b.sql': '-- b\n' }, + withJournal: false, + }); + const migrationsDir = join(root, 'db', 'migrations'); + + await expect(runDbCommand(root, { action: 'rename-migrations' })).rejects.toThrow( + 'process.exit:1', + ); + + const files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')); + expect(files.sort()).toEqual(['0001_a.sql', '0002_b.sql']); + expect(mockRenameMigrations).not.toHaveBeenCalled(); + const errorMessages = mockLog.error.mock.calls.map((call) => String(call[0])).join('\n'); + expect(errorMessages).toContain('direct Cloudflare'); + }); + + // `.void/project.json` is not checked in, so a fresh checkout of a deployed app has no saved platform. + it.each([ + { + name: 'a root Wrangler config with a D1 database id', + write(root: string): void { + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ + d1_databases: [ + { + binding: 'DB', + database_name: 'app', + database_id: '11111111-2222-3333-4444-555555555555', + }, + ], + }), + ); + }, + }, + { + name: 'void.lock.json with a Hyperdrive id', + write(root: string): void { + rmSync(join(root, 'void.json')); + writeFileSync(join(root, 'void.config.ts'), "export default { database: 'pg' };\n"); + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://postgres@127.0.0.1:5432/app"\n', + ); + const resolved = { hyperdrive: [{ binding: 'HYPERDRIVE', id: 'a'.repeat(32) }] }; + writeFileSync( + join(root, 'void.lock.json'), + JSON.stringify({ version: 1, source: {}, resolved, text: JSON.stringify(resolved) }), + ); + }, + }, + ])('refuses a fresh checkout of a direct Cloudflare app ($name)', async ({ write }) => { + const root = createRenameProjectRoot({ + files: { '0001_a.sql': '-- a\n', '0002_b.sql': '-- b\n' }, + withJournal: false, + }); + write(root); + + await expect(runDbCommand(root, { action: 'rename-migrations' })).rejects.toThrow( + 'process.exit:1', + ); + + const files = readdirSync(join(root, 'db', 'migrations')).filter((f) => f.endsWith('.sql')); + expect(files.sort()).toEqual(['0001_a.sql', '0002_b.sql']); + const errorMessages = mockLog.error.mock.calls.map((call) => String(call[0])).join('\n'); + expect(errorMessages).toContain('direct Cloudflare'); + }); + + // Deploy creates the Hyperdrive and replaces any id that is not a real one. + it('renames a fresh checkout whose Hyperdrive id is a placeholder', async () => { + const root = createRenameProjectRoot({ + files: { '0001_a.sql': '-- a\n', '0002_b.sql': '-- b\n' }, + withJournal: false, + }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="postgresql://postgres@127.0.0.1:5432/app"\n'); + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + + await runDbCommand(root, { action: 'rename-migrations' }); + + const files = readdirSync(join(root, 'db', 'migrations')).filter((f) => f.endsWith('.sql')); + expect(files.sort()).toEqual([ + expect.stringMatching(/^\d{14}_a\.sql$/), + expect.stringMatching(/^\d{14}_b\.sql$/), + ]); + expect(mockLog.error).not.toHaveBeenCalled(); + }); + + it('refuses a Void project that is not linked', async () => { + mockReadDeploymentPlatform.mockReturnValue('void'); + const root = createRenameProjectRoot({ + files: { '0001_a.sql': '-- a\n', '0002_b.sql': '-- b\n' }, + withJournal: false, + }); + + await expect(runDbCommand(root, { action: 'rename-migrations' })).rejects.toThrow( + 'process.exit:1', + ); + + const files = readdirSync(join(root, 'db', 'migrations')).filter((f) => f.endsWith('.sql')); + expect(files.sort()).toEqual(['0001_a.sql', '0002_b.sql']); + const errorMessages = mockLog.error.mock.calls.map((call) => String(call[0])).join('\n'); + expect(errorMessages).toContain('`void project link`'); + }); + + function createMySqlRenameRoot(): string { + const root = createRenameProjectRoot({ + files: { '0001_a.sql': '-- a\n', '0002_b.sql': '-- b\n' }, + withJournal: false, + }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'mysql' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL="mysql://root@127.0.0.1:3306/app"\n'); + mysqlQueries.length = 0; + return root; + } + + it('MySQL: commits the tracking renames in one transaction', async () => { + const root = createMySqlRenameRoot(); + + await runDbCommand(root, { action: 'rename-migrations' }); + + const begin = mysqlQueries.indexOf('START TRANSACTION'); + expect(begin).toBeGreaterThanOrEqual(0); + expect(mysqlQueries.slice(begin).map((sql) => sql.split(' ')[0])).toEqual([ + 'START', + 'UPDATE', + 'UPDATE', + 'COMMIT', + ]); + }); + + it('MySQL: rolls back every tracking rename when one fails', async () => { + const root = createMySqlRenameRoot(); + let updates = 0; + mockMySqlQuery.mockImplementation((sql) => { + if (sql.startsWith('UPDATE') && ++updates === 2) { + throw new Error('Lock wait timeout exceeded'); + } + }); + + await expect(runDbCommand(root, { action: 'rename-migrations' })).rejects.toThrow( + 'process.exit:1', + ); + + const begin = mysqlQueries.indexOf('START TRANSACTION'); + expect(begin).toBeGreaterThanOrEqual(0); + expect(mysqlQueries.slice(begin).map((sql) => sql.split(' ')[0])).toEqual([ + 'START', + 'UPDATE', + 'UPDATE', + 'ROLLBACK', + ]); + const files = readdirSync(join(root, 'db', 'migrations')).filter((f) => f.endsWith('.sql')); + expect(files.sort()).toEqual(['0001_a.sql', '0002_b.sql']); + }); }); diff --git a/packages/void/test/unit/db-export-roundtrip.test.ts b/packages/void/test/unit/db-export-roundtrip.test.ts index 2406bc177..2a85e0537 100644 --- a/packages/void/test/unit/db-export-roundtrip.test.ts +++ b/packages/void/test/unit/db-export-roundtrip.test.ts @@ -148,6 +148,60 @@ describe('SQL export restore', () => { } }); + it('exports SQLite app tables and views whose names start with an underscore', async () => { + const root = project(); + const { databasePath } = getLocalD1Target(root); + mkdirSync(join(databasePath, '..'), { recursive: true }); + const source = new Database(databasePath); + source.exec(`CREATE TABLE _drafts (id INTEGER PRIMARY KEY, title TEXT); + CREATE VIEW _draft_titles AS SELECT title FROM _drafts; + CREATE TABLE _void_migrations (name TEXT PRIMARY KEY, applied_at TEXT); + CREATE TABLE _cf_METADATA (key INTEGER PRIMARY KEY, value BLOB); + INSERT INTO _drafts VALUES (1, 'draft');`); + source.close(); + await runDbCommand(root, { action: 'export', output: 'dump.sql' }); + const dump = readFileSync(join(root, 'dump.sql'), 'utf8'); + expect(dump).not.toContain('_void_migrations'); + expect(dump).not.toContain('_cf_METADATA'); + const restored = new Database(':memory:'); + try { + restored.exec(dump); + expect(restored.prepare('SELECT * FROM _draft_titles').all()).toEqual([{ title: 'draft' }]); + } finally { + restored.close(); + } + }); + + it('exports PostgreSQL app tables whose names start with an underscore', async () => { + const pg = state.pg!; + const root = project(true); + await pg.exec(`CREATE TABLE _drafts (id integer PRIMARY KEY, title text); + CREATE TABLE posts (id integer PRIMARY KEY, draft_id integer REFERENCES _drafts(id)); + CREATE TABLE _void_migrations (name text PRIMARY KEY, applied_at text NOT NULL); + INSERT INTO _drafts VALUES (1, 'draft'); INSERT INTO posts VALUES (1, 1);`); + await runDbCommand(root, { action: 'export', output: 'dump.sql' }); + const dump = readFileSync(join(root, 'dump.sql'), 'utf8'); + expect(dump).not.toContain('_void_migrations'); + await pg.exec('DROP SCHEMA public CASCADE; CREATE SCHEMA public'); + await pg.exec(dump); + expect( + (await pg.query('SELECT title FROM posts JOIN _drafts ON _drafts.id = posts.draft_id')).rows, + ).toEqual([{ title: 'draft' }]); + }); + + it('writes an export to an absolute --output path', async () => { + const root = project(); + const outDir = mkdtempSync(join(tmpdir(), 'void-export-out-')); + roots.push(outDir); + const { databasePath } = getLocalD1Target(root); + mkdirSync(join(databasePath, '..'), { recursive: true }); + const source = new Database(databasePath); + source.exec('CREATE TABLE notes (id INTEGER PRIMARY KEY)'); + source.close(); + await runDbCommand(root, { action: 'export', output: join(outDir, 'dump.sql') }); + expect(readFileSync(join(outDir, 'dump.sql'), 'utf8')).toContain('CREATE TABLE notes'); + }); + it('round-trips SQLite blobs and generated columns', async () => { const root = project(); const { databasePath } = getLocalD1Target(root); diff --git a/packages/void/test/unit/db-export-stdout.test.ts b/packages/void/test/unit/db-export-stdout.test.ts index c3319f146..2af02827f 100644 --- a/packages/void/test/unit/db-export-stdout.test.ts +++ b/packages/void/test/unit/db-export-stdout.test.ts @@ -22,7 +22,7 @@ vi.mock('../../src/migrations/runner', () => { { name: 'title', hidden: 0 }, ]; } - if (sql.includes('NOT LIKE')) { + if (sql.startsWith("SELECT name FROM sqlite_master WHERE type='table'")) { return [{ name: 'notes' }]; } if (sql.startsWith('SELECT * FROM')) { diff --git a/packages/void/test/unit/db-migrate.test.ts b/packages/void/test/unit/db-migrate.test.ts index 2ed56d216..d797402f4 100644 --- a/packages/void/test/unit/db-migrate.test.ts +++ b/packages/void/test/unit/db-migrate.test.ts @@ -13,6 +13,7 @@ const mockLog = { info: vi.fn(), step: vi.fn(), success: vi.fn(), + warn: vi.fn(), }; const mockGetMiniflareDatabasePath = vi.fn(); const mockGetAppliedMigrations = vi.fn(); @@ -30,6 +31,7 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ info: (...args: Array) => mockLog.info(...args), step: (...args: Array) => mockLog.step(...args), success: (...args: Array) => mockLog.success(...args), + warn: (...args: Array) => mockLog.warn(...args), }, })); @@ -41,11 +43,14 @@ vi.mock('../../src/migrations/runner', () => ({ vi.mock('../../src/migrations/runner-pg', () => ({ getPgAppliedMigrations: (...args: Array) => mockGetPgAppliedMigrations(...args), + readPgAppliedMigrations: vi.fn(), runPgMigrations: (...args: Array) => mockRunPgMigrations(...args), })); vi.mock('../../src/migrations/runner-mysql', () => ({ getMySqlAppliedMigrations: (...args: Array) => mockGetMySqlAppliedMigrations(...args), + readMySqlAppliedMigrations: vi.fn(), + toMySqlClient: vi.fn(), runMySqlMigrations: (...args: Array) => mockRunMySqlMigrations(...args), })); @@ -135,6 +140,17 @@ function selectCloudflare(root: string): void { ); } +const mockReadCloudflareD1Migrations = vi.fn(); +const mockApplyCloudflareD1Migrations = 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) => ({ + ...(await importOriginal()), + readCloudflareD1Migrations: (...args: Array) => mockReadCloudflareD1Migrations(...args), + applyCloudflareD1Migrations: (...args: Array) => + mockApplyCloudflareD1Migrations(...args), +})); + describe('void db migrate', () => { let runDbCommand: typeof import('../../src/cli/db.ts').runDbCommand; @@ -188,6 +204,7 @@ describe('void db migrate', () => { expect(mockRunMigrations).toHaveBeenCalledWith( toSlash(join(root, 'db', 'migrations')), join(tmpdir(), 'void-db-migrate.sqlite'), + expect.any(Function), ); expect(output.printSuccess).toHaveBeenCalledWith('Applied 1 migration.'); }); @@ -223,6 +240,38 @@ describe('void db migrate', () => { expect(mockRunMigrations).not.toHaveBeenCalled(); }); + it('refuses remote D1 migrate when the binding has no migrations_dir', async () => { + const root = createProjectRoot({ + '001_create_users.sql': 'CREATE TABLE users (id integer primary key);', + }); + 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', + }, + ], + }), + ); + // Cloudflare's default folder holds SQL that Void never checked. + mkdirSync(join(root, 'migrations')); + writeFileSync(join(root, 'migrations', '0001_legacy.sql'), 'DROP TABLE users;'); + selectCloudflare(root); + mockReadCloudflareD1Migrations.mockResolvedValue([]); + + await expect(runDbCommand(root, { action: 'migrate', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + + expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('"migrations_dir"')); + expect(mockApplyCloudflareD1Migrations).not.toHaveBeenCalled(); + }); + it('applies pending PostgreSQL migrations directly for a Cloudflare target', async () => { const root = createPgProjectRoot({ '001_create_users.sql': 'CREATE TABLE users (id text primary key);', @@ -270,8 +319,11 @@ describe('void db migrate', () => { await runDbCommand(root, { action: 'status' }); expect(mockGetPgAppliedMigrations).toHaveBeenCalledWith(process.env.DATABASE_URL); + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('DATABASE_URL not found in .env'), + ); expect(infoMessagesWithoutColor()).toContain( - ' 001_create_users.sql local: pending remote: applied', + ' 001_create_users.sql local: unknown remote: applied', ); }); @@ -286,8 +338,51 @@ describe('void db migrate', () => { await runDbCommand(root, { action: 'status' }); expect(mockGetMySqlAppliedMigrations).toHaveBeenCalledWith(process.env.DATABASE_URL); + expect(mockLog.warn).toHaveBeenCalledWith( + expect.stringContaining('DATABASE_URL not found in .env'), + ); expect(infoMessagesWithoutColor()).toContain( - ' 001_create_users.sql local: pending remote: applied', + ' 001_create_users.sql local: unknown remote: applied', + ); + }); + + it('counts pending files, not tracked names, in the status summary', async () => { + // Another branch applied 002 and 003, which are not on disk here. + const root = createProjectRoot({ + '001_a.sql': 'CREATE TABLE a (id integer);', + '004_d.sql': 'CREATE TABLE d (id integer);', + }); + mockGetAppliedMigrations.mockResolvedValue( + ['001_a.sql', '002_b.sql', '003_c.sql'].map((name) => ({ name, applied_at: '' })), ); + + await runDbCommand(root, { action: 'status' }); + + expect(mockOutro).toHaveBeenCalledWith('2 migrations, 1 pending locally'); + }); + + it('names journal drift under sourceDir', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-db-migrate-')); + roots.push(root); + writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' }, null, 2) + '\n'); + const migrationsDir = join(root, 'src', 'db', 'migrations'); + mkdirSync(join(migrationsDir, 'meta'), { recursive: true }); + writeFileSync(join(migrationsDir, '0000_init.sql'), 'CREATE TABLE a (id integer);'); + writeFileSync(join(migrationsDir, '0001_orphan.sql'), 'CREATE TABLE b (id integer);'); + writeFileSync( + join(migrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + version: '7', + dialect: 'sqlite', + entries: [{ idx: 0, version: '7', when: 0, tag: '0000_init', breakpoints: true }], + }), + ); + + await expect(runDbCommand(root, { action: 'migrate' })).rejects.toThrow('process.exit:1'); + + expect(mockLog.error).toHaveBeenCalledWith( + expect.stringContaining(' - src/db/migrations/0001_orphan.sql'), + ); + expect(mockRunMigrations).not.toHaveBeenCalled(); }); }); diff --git a/packages/void/test/unit/db-reset-pg.test.ts b/packages/void/test/unit/db-reset-pg.test.ts new file mode 100644 index 000000000..4a88767b5 --- /dev/null +++ b/packages/void/test/unit/db-reset-pg.test.ts @@ -0,0 +1,189 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { citext } from '@electric-sql/pglite/contrib/citext'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { runDbCommand } from '../../src/cli/db.ts'; + +const state = vi.hoisted(() => ({ pg: null as PGlite | null })); +vi.mock('pg', () => ({ + Client: class { + async connect() {} + async end() {} + async query(sql: string, params?: Array) { + return params + ? state.pg!.query(sql, params) + : ((await state.pg!.exec(sql)).at(-1) ?? { rows: [] }); + } + }, +})); +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + intro() {}, + outro() {}, + confirm: async () => true, + isCancel: () => false, + log: { error() {}, info() {}, step() {}, success() {} }, +})); + +const BREAKPOINT = '--> statement-breakpoint\n'; +const roots: Array = []; + +beforeAll(async () => { + // Load PostgreSQL once: Windows' cold WASM startup belongs to the hook budget. + state.pg = new PGlite({ extensions: { citext } }); + await state.pg.waitReady; +}, 30_000); +beforeEach(async () => { + await state.pg!.exec( + 'DROP SCHEMA IF EXISTS analytics CASCADE; DROP SCHEMA public CASCADE; CREATE SCHEMA public', + ); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); +}); +afterAll(async () => { + await state.pg?.close(); + state.pg = null; +}); +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function project(statements: Array): string { + const root = mkdtempSync(join(tmpdir(), 'void-reset-pg-')); + roots.push(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync(join(root, '.env'), 'DATABASE_URL=postgres://unused:unused@localhost/isolated'); + writeFileSync(join(root, 'db', 'migrations', '0000_init.sql'), statements.join(BREAKPOINT)); + return root; +} + +describe('void db reset on PostgreSQL', () => { + it('drops types, sequences and views so the migrations apply again, and keeps extensions', async () => { + const pg = state.pg!; + // Installed by hand, outside the migrations. + await pg.exec('CREATE EXTENSION citext'); + const root = project([ + `CREATE TYPE "public"."mood" AS ENUM('happy', 'sad');`, + 'CREATE SEQUENCE "public"."order_no" START WITH 1000;', + 'CREATE DOMAIN "public"."score" AS integer CHECK (VALUE >= 0);', + 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "mood" "mood" NOT NULL, "email" citext, "points" "score");', + `CREATE VIEW "public"."app_info" AS (select 'v1'::text as version);`, + ]); + await runDbCommand(root, { action: 'migrate' }); + await pg.exec( + "INSERT INTO users (mood, email, points) VALUES ('happy', 'Ada@example.com', 1); SELECT nextval('order_no');", + ); + + await runDbCommand(root, { action: 'reset', yes: true }); + + expect((await pg.query('SELECT count(*)::int AS count FROM users')).rows).toEqual([ + { count: 0 }, + ]); + expect((await pg.query("SELECT nextval('order_no')::int AS next")).rows).toEqual([ + { next: 1000 }, + ]); + expect((await pg.query('SELECT name FROM _void_migrations')).rows).toEqual([ + { name: '0000_init.sql' }, + ]); + expect( + (await pg.query("SELECT extname FROM pg_extension WHERE extname = 'citext'")).rows, + ).toEqual([{ extname: 'citext' }]); + }); + + it('drops a type that has the name of a built-in type', async () => { + const pg = state.pg!; + const root = project([ + `CREATE TYPE "public"."interval" AS ENUM('month', 'year');`, + 'CREATE TABLE "plans" ("id" serial PRIMARY KEY NOT NULL, "every" "public"."interval" NOT NULL);', + ]); + await runDbCommand(root, { action: 'migrate' }); + + await runDbCommand(root, { action: 'reset', yes: true }); + + expect( + (await pg.query('SELECT enum_range(NULL::public.interval)::text AS labels')).rows, + ).toEqual([{ labels: '{month,year}' }]); + }); + + it('stops before dropping anything when another schema uses a public type or sequence', async () => { + const pg = state.pg!; + const root = project([ + `CREATE TYPE "public"."mood" AS ENUM('happy', 'sad');`, + 'CREATE SEQUENCE "public"."order_no" START WITH 1000;', + 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "mood" "mood" NOT NULL);', + ]); + await runDbCommand(root, { action: 'migrate' }); + // Made by hand, outside the migrations. + await pg.exec(` + INSERT INTO users (mood) VALUES ('happy'); + CREATE DOMAIN public.score AS integer; + CREATE SCHEMA analytics; + CREATE TABLE analytics.events ( + id integer DEFAULT nextval('public.order_no'), + mood public.mood, + moods public.mood[], + score public.score + ); + INSERT INTO analytics.events VALUES (1, 'sad', '{happy}', 7); + `); + + const error = await runDbCommand(root, { action: 'reset', yes: true }).catch( + (err: unknown) => err, + ); + + const message = String(error); + expect(message).toContain('column mood of table analytics.events'); + expect(message).toContain('column moods of table analytics.events'); + expect(message).toContain('column score of table analytics.events'); + expect(message).toContain('default value for column id of table analytics.events'); + expect( + (await pg.query('SELECT id, mood, moods::text, score FROM analytics.events')).rows, + ).toEqual([{ id: 1, mood: 'sad', moods: '{happy}', score: 7 }]); + expect( + ( + await pg.query( + "SELECT pg_get_expr(adbin, adrelid) AS expr FROM pg_attrdef WHERE adrelid = 'analytics.events'::regclass", + ) + ).rows, + ).toEqual([{ expr: "nextval('order_no'::regclass)" }]); + expect((await pg.query('SELECT count(*)::int AS count FROM users')).rows).toEqual([ + { count: 1 }, + ]); + }); + + it('stops before dropping anything when another schema uses a public view', async () => { + const pg = state.pg!; + const root = project([`CREATE VIEW "public"."app_info" AS (select 'v1'::text as version);`]); + await runDbCommand(root, { action: 'migrate' }); + // Made by hand, outside the migrations. + await pg.exec(` + CREATE SCHEMA analytics; + CREATE VIEW analytics.versions AS SELECT version FROM public.app_info; + `); + + const error = await runDbCommand(root, { action: 'reset', yes: true }).catch( + (err: unknown) => err, + ); + + expect(String(error)).toContain('rule _RETURN on view analytics.versions uses view app_info'); + expect((await pg.query('SELECT version FROM analytics.versions')).rows).toEqual([ + { version: 'v1' }, + ]); + }); +}); diff --git a/packages/void/test/unit/db-seed.test.ts b/packages/void/test/unit/db-seed.test.ts index 24fd3f149..14f5a6515 100644 --- a/packages/void/test/unit/db-seed.test.ts +++ b/packages/void/test/unit/db-seed.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -8,14 +8,32 @@ import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; const mockIntro = vi.fn(); const mockOutro = vi.fn(); +const mockConfirm = vi.fn(); const mockLog = { error: vi.fn(), info: vi.fn(), step: vi.fn(), success: vi.fn(), }; +const pgQueries = vi.hoisted((): Array => []); + +vi.mock('pg', async (importOriginal) => { + // Keep pg's own connection-string parsing; record the queries instead of sending them. + class Client extends (await importOriginal()).Client {} + Object.assign(Client.prototype, { + async connect() {}, + async end() {}, + async query(sql: string) { + pgQueries.push(sql); + return { rows: [] }; + }, + }); + return { Client }; +}); vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + confirm: (...args: Array) => mockConfirm(...args), + isCancel: () => false, intro: (...args: Array) => mockIntro(...args), outro: (...args: Array) => mockOutro(...args), log: { @@ -188,6 +206,66 @@ await runDbCommand(${JSON.stringify(root)}, { action: 'seed' });`, expect(await readMessages(root)).toEqual([{ id: 1, text: 'from barrel' }]); }, 30_000); + it('checks the seed file before it resets the database', async () => { + const root = createProjectRoot({ + seedFile: { + name: 'seed.sql', + content: "INSERT INTO messages (text) VALUES ('kept');\n", + }, + }); + writeFileSync(join(root, 'db', 'no-export.ts'), 'export const value = 1;\n'); + await runDbCommand(root, { action: 'seed' }); + + await expect(runDbCommand(root, { action: 'seed', file: 'db/missing.sql' })).rejects.toThrow( + 'process.exit:1', + ); + await expect(runDbCommand(root, { action: 'seed', file: 'db/no-export.ts' })).rejects.toThrow( + 'must export a default function', + ); + + expect(await readMessages(root)).toEqual([{ id: 1, text: 'kept' }]); + }); + + it('asks about a non-local database before it imports the seed module', async () => { + const root = createProjectRoot({ seedFile: { name: 'seed.ts', content: '' } }); + const marker = join(root, 'seed-module-ran'); + writeFileSync( + join(root, 'db', 'seed.ts'), + `import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(marker)}, ""); +export default async function seed() {} +`, + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://user:secret@db.example.com:5432/prod"\n', + ); + pgQueries.length = 0; + const stdinTty = process.stdin.isTTY; + try { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + await expect(runDbCommand(root, { action: 'seed' })).rejects.toThrow('process.exit:1'); + expect(existsSync(marker)).toBe(false); + + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + mockConfirm.mockResolvedValue(false); + await expect(runDbCommand(root, { action: 'seed' })).rejects.toThrow('process.exit:0'); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(existsSync(marker)).toBe(false); + // Nothing reached the database. + expect(pgQueries).toEqual([]); + + // One answer covers the reset too. + mockConfirm.mockResolvedValue(true); + await runDbCommand(root, { action: 'seed' }); + expect(mockConfirm).toHaveBeenCalledTimes(2); + expect(existsSync(marker)).toBe(true); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); + } + }); + it('fails when multiple default seed files exist', async () => { const root = createProjectRoot({ seedFile: { diff --git a/packages/void/test/unit/db-studio-remote.test.ts b/packages/void/test/unit/db-studio-remote.test.ts index 801d17592..4011b464b 100644 --- a/packages/void/test/unit/db-studio-remote.test.ts +++ b/packages/void/test/unit/db-studio-remote.test.ts @@ -244,7 +244,9 @@ describe('void db studio and push (local)', () => { await expect(runDbCommand(root, { action: 'studio' })).rejects.toThrow('process.exit:1'); - expect(clackLog.error).toHaveBeenCalledWith('No local database found. Run `void dev` first.'); + expect(clackLog.error).toHaveBeenCalledWith( + 'No local database found. Start the dev server first.', + ); expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); expect(mockExecFileSync).not.toHaveBeenCalled(); }); diff --git a/packages/void/test/unit/deploy-drift.test.ts b/packages/void/test/unit/deploy-drift.test.ts index 379e54418..e0dbf5150 100644 --- a/packages/void/test/unit/deploy-drift.test.ts +++ b/packages/void/test/unit/deploy-drift.test.ts @@ -168,6 +168,18 @@ describe('hasDeploySchemaDrift', { timeout: driftTestTimeout }, () => { ); }); + it('names the migrations folder under sourceDir in the drift message', () => { + const root = createTempApp(); + expect(getDeploySchemaDriftMessage(root)).toContain( + 'Schema drift detected between your Drizzle schema and db/migrations/.', + ); + + writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' })); + expect(getDeploySchemaDriftMessage(root)).toContain( + 'Schema drift detected between your Drizzle schema and src/db/migrations/.', + ); + }); + it('throws when drizzle-kit cannot evaluate the schema', () => { const root = createTempApp(); writeFileSync( diff --git a/packages/void/test/unit/gen.test.ts b/packages/void/test/unit/gen.test.ts index 85b32b22e..74360dccd 100644 --- a/packages/void/test/unit/gen.test.ts +++ b/packages/void/test/unit/gen.test.ts @@ -452,6 +452,28 @@ describe('gen model', () => { expect(read('.void/db.d.ts')).toContain('NodePgDatabase'); }); + it('writes every file .void/tsconfig.json lists in a project with routes and queues', async () => { + writePkg({ valibot: '^1.0.0' }); + mkdirSync(join(TMP, 'routes', 'api'), { recursive: true }); + writeFileSync(join(TMP, 'routes', 'api', 'hello.ts'), 'export const GET = () => "hi";\n'); + mkdirSync(join(TMP, 'queues'), { recursive: true }); + writeFileSync(join(TMP, 'queues', 'emails.ts'), 'export default async () => {};\n'); + + await runGenCommand(TMP, { + subcommand: 'model', + name: 'posts', + columns: [{ name: 'title', type: 'string', nullable: false }], + }); + + const tsconfig = JSON.parse(read('.void/tsconfig.json')) as { files: Array }; + expect(tsconfig.files).toEqual( + expect.arrayContaining(['routes.d.ts', 'db.d.ts', 'queues.d.ts']), + ); + for (const file of tsconfig.files) { + expect(existsSync(join(TMP, '.void', file)), file).toBe(true); + } + }); + it('skips existing files without overwriting', async () => { writePkg({ valibot: '^1.0.0' }); const routePath = join(TMP, 'routes/api/posts/index.ts'); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index dd8d0b3f0..5d15c1bc4 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -3327,6 +3327,35 @@ export default config; } }); + it.each([ + ['postgresql', 'pg', 'void/schema-pg'], + ['mysql', 'mysql', 'void/schema-mysql'], + ] as const)( + 'scaffolds the demo for the chosen %s database', + async (dialect, configValue, schemaModule) => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeExistingViteApp(root, { void: '^0.0.1' }); + mockSelect.mockResolvedValueOnce(dialect); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(readConfig(root).database).toBe(configValue); + const schema = readFileSync(join(root, 'db', 'schema.ts'), 'utf-8'); + expect(schema).toContain(`from "${schemaModule}"`); + expect(schema).not.toContain('void/schema-d1'); + expect(drizzleGenerateCalls()).toHaveLength(1); + const drizzleConfig = JSON.parse( + readFileSync(join(root, '.void', 'drizzle.config.json'), 'utf-8'), + ); + expect(drizzleConfig.dialect).toBe(dialect); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + it('runs starter migration with injected PnP NODE_OPTIONS in a Yarn Berry PnP project', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { diff --git a/packages/void/test/unit/local-d1.test.ts b/packages/void/test/unit/local-d1.test.ts index b0f947da3..2538155cd 100644 --- a/packages/void/test/unit/local-d1.test.ts +++ b/packages/void/test/unit/local-d1.test.ts @@ -228,6 +228,7 @@ describe('local D1 identity', () => { writeFileSync(viteConfigPath, 'export default { changed: true }'); expect(() => getLocalD1Target(root)).toThrow('persistence configuration changed'); + expect(() => getLocalD1Target(root)).toThrow('Start the dev server to refresh it'); // A fresh plugin evaluation replaces stale state with the current setting. writeLocalD1PersistState(root, customPersistRoot); diff --git a/packages/void/test/unit/migration-concurrency.test.ts b/packages/void/test/unit/migration-concurrency.test.ts index 2936807d2..5f9adb39f 100644 --- a/packages/void/test/unit/migration-concurrency.test.ts +++ b/packages/void/test/unit/migration-concurrency.test.ts @@ -291,7 +291,7 @@ describe('D1 migration ownership', () => { const paused = deferred(); const resume = deferred(); let once = true; - const { handler } = createDatabase({ + const { sqlite, handler } = createDatabase({ afterLockWrite: async () => { if (once) { once = false; @@ -304,6 +304,12 @@ describe('D1 migration ownership', () => { const pending = owner(apply('owner')); await paused.promise; try { + // The contender gets 409 with or without the owner's attempt row, so check the row too. + expect( + sqlite + .prepare("SELECT status FROM _void_migration_attempts WHERE attempt_id = 'owner'") + .get(), + ).toEqual({ status: 'running' }); expect((await handler([])(apply('contender'))).status).toBe(409); } finally { resume.resolve(); diff --git a/packages/void/test/unit/migration-handler-pg.test.ts b/packages/void/test/unit/migration-handler-pg.test.ts index 4a3f946bb..7d5e5f648 100644 --- a/packages/void/test/unit/migration-handler-pg.test.ts +++ b/packages/void/test/unit/migration-handler-pg.test.ts @@ -1,6 +1,26 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { createMigrationHandler } from '../../src/runtime/migration-handler-pg.ts'; import { disablePgPassFallback } from '../../src/runtime/pg.ts'; +// The names in `_void_migrations`, kept in memory by the fake client below. +const pgApplied = vi.hoisted((): Array => []); + +vi.mock('pg', () => ({ + Client: class { + async connect() {} + async end() {} + async query(sql: string, params?: Array) { + if (sql.startsWith('SELECT name FROM _void_migrations')) { + return { rows: pgApplied.map((name) => ({ name })) }; + } + if (sql.startsWith('INSERT INTO _void_migrations')) { + pgApplied.push(String(params?.[0])); + } + return { rows: [] }; + } + }, +})); + describe('PostgreSQL migration handler', () => { it('prevents pgpass fallback when Hyperdrive connection strings have no password', () => { const client: { @@ -28,4 +48,39 @@ describe('PostgreSQL migration handler', () => { expect(client.password).toBe('secret'); expect(client.connectionParameters.password).toBe('secret'); }); + + it('reports the applied files when Better Auth migrations fail', async () => { + pgApplied.length = 0; + const handler = createMigrationHandler( + { + HYPERDRIVE: { connectionString: 'postgresql://localhost/app' }, + __VOID_PROXY_TOKEN: 'token', + }, + [ + { name: '0001_init.sql', sql: 'CREATE TABLE posts (id integer);' }, + { name: '0002_more.sql', sql: 'ALTER TABLE posts ADD COLUMN title text;' }, + ], + async () => { + throw new Error('Better Auth migration failed'); + }, + ); + + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + ok: false, + error: 'Better Auth migration failed', + migrations: [ + { name: '0001_init.sql', applied: true, durationMs: expect.any(Number) }, + { name: '0002_more.sql', applied: true, durationMs: expect.any(Number) }, + ], + }); + expect(pgApplied).toEqual(['0001_init.sql', '0002_more.sql']); + }); }); diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index c414a1128..6c0a7cb5d 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; import { migrationPlugin } from '../../src/plugin-migration.ts'; @@ -242,3 +243,132 @@ describe('migrationPlugin with MySQL and Better Auth', () => { expect(failed).toBeGreaterThan(applied); }); }); + +async function captureConsoleLog(run: () => Promise): Promise> { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await run(); + return log.mock.calls.map(([line]) => String(line)); + } finally { + log.mockRestore(); + } +} + +describe('migrationPlugin with destructive migrations', () => { + // Like `void db migrate`, dev applies none of the pending files when one of them is + // destructive without the pragma, and the dev server still starts. + it('applies no SQLite migrations when a pending file is destructive without the pragma', async () => { + const root = createProjectRoot(); + await runConfigureServer(migrationPlugin(root, 'sqlite')); + const dbPath = getMiniflareDatabasePath(join(root, '.void'), 'local'); + const seed = new DatabaseSync(dbPath); + seed.exec("INSERT INTO users (name) VALUES ('a'), ('b'), ('c');"); + seed.close(); + const migrationsDir = join(root, 'db', 'migrations'); + writeFileSync(join(migrationsDir, '002_create_posts.sql'), 'CREATE TABLE posts (id INTEGER);'); + writeFileSync(join(migrationsDir, '003_wipe_users.sql'), 'DELETE FROM users;'); + + const lines = await captureConsoleLog(() => + runConfigureServer(migrationPlugin(root, 'sqlite')), + ); + + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + expect(db.prepare('SELECT COUNT(*) AS count FROM users').get()).toEqual({ count: 3 }); + expect( + db + .prepare('SELECT name FROM _void_migrations ORDER BY name') + .all() + .map((row) => row.name), + ).toEqual(['001_create_users.sql']); + } finally { + db.close(); + } + expect( + lines.some( + (line) => + line.includes('Destructive migrations detected without pragma') && + line.includes('No migrations were applied'), + ), + ).toBe(true); + }); + + it('runs no MySQL migration or Better Auth step when a pending file is destructive without the pragma', async () => { + const root = createProjectRoot(); + writeFileSync(join(root, 'db', 'migrations', '001_create_users.sql'), 'DELETE FROM users;'); + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://root@127.0.0.1:3306/app\n'); + mysqlQueries.length = 0; + + const lines = await captureConsoleLog(() => + runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ), + ); + + expect(mysqlQueries).not.toContain('DELETE FROM users;'); + expect(mysqlQueries.some((sql) => sql.startsWith('INSERT INTO `_void_migrations`'))).toBe( + false, + ); + expect(mysqlQueries).not.toContain('Better Auth migrations'); + expect( + lines.some((line) => line.includes('Destructive migrations detected without pragma')), + ).toBe(true); + }); +}); + +describe('migrationPlugin with PostgreSQL and Better Auth', () => { + it('reports a missing DATABASE_URL instead of logging success', async () => { + const root = createProjectRoot(); + rmSync(join(root, 'db'), { recursive: true }); + vi.stubEnv('DATABASE_URL', undefined); + mysqlSteps.length = 0; + + let lines: Array; + try { + lines = await captureConsoleLog(() => + runConfigureServer( + migrationPlugin(root, 'postgresql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ), + ); + } finally { + vi.unstubAllEnvs(); + } + + expect(lines.some((line) => line.includes('DATABASE_URL not found'))).toBe(true); + expect(lines.some((line) => line.includes('Applied Better Auth migrations'))).toBe(false); + expect(mysqlSteps).toEqual([]); + }); + + // An empty DATABASE_URL in the shell counts as unset, so the one in .env is used. + it('uses DATABASE_URL from .env when the shell value is empty', async () => { + const root = createProjectRoot(); + rmSync(join(root, 'db'), { recursive: true }); + writeFileSync(join(root, '.env'), 'DATABASE_URL=postgres://localhost/app\n'); + vi.stubEnv('DATABASE_URL', ''); + mysqlSteps.length = 0; + + let lines: Array; + try { + lines = await captureConsoleLog(() => + runConfigureServer( + migrationPlugin(root, 'postgresql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ), + ); + } finally { + vi.unstubAllEnvs(); + } + + expect(lines.some((line) => line.includes('DATABASE_URL not found'))).toBe(false); + expect(lines.some((line) => line.includes('Applied Better Auth migrations'))).toBe(true); + expect(mysqlSteps).toEqual(['auth (no URL)']); + }); +}); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 185e71e45..dcef1423e 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -975,6 +975,27 @@ describe('MySQL Better Auth migrations under the lock', () => { expect(database.lease!.expiresAt).toBeLessThan(Date.now()); }); + it('reports the applied files when Better Auth migrations fail', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + + const result = await migrate( + [{ name: '001_create.sql', sql: 'CREATE TABLE posts (id INT);' }], + async () => { + throw new Error('Better Auth migration failed'); + }, + ); + + expect(result).toEqual({ + status: 500, + body: { + ok: false, + error: 'Better Auth migration failed', + migrations: [{ name: '001_create.sql', applied: true, durationMs: expect.any(Number) }], + }, + }); + expect(database.applied).toEqual(['001_create.sql']); + }); + it('does not start Better Auth migrations after another run took the lease', async () => { const database = useDatabase(new FakeMySqlDatabase()); const query = database.query.bind(database); diff --git a/packages/void/test/unit/runner-sqlite.test.ts b/packages/void/test/unit/runner-sqlite.test.ts index 51efed08b..75b16d55e 100644 --- a/packages/void/test/unit/runner-sqlite.test.ts +++ b/packages/void/test/unit/runner-sqlite.test.ts @@ -148,9 +148,16 @@ describe('runMigrations (local SQLite)', () => { writeMigration(migDir, '001_init.sql', 'CREATE TABLE users (id TEXT PRIMARY KEY)'); - await expect(runMigrations(migDir, dbPath)).rejects.toThrow( - 'Local database migration history does not match db/migrations/.', + const error = await runMigrations(migDir, dbPath, (path) => `src/${path}`).catch( + (caught: unknown) => caught, + ); + const message = (error as Error).message; + expect(message).toContain( + 'Local database migration history does not match src/db/migrations/.', ); + // Renaming files back is the fix; rename-migrations cannot help once the files are renamed. + expect(message).toContain('restore their old names'); + expect(message).not.toContain('rename-migrations'); }); it('applies multi-statement migrations in order', async () => { diff --git a/packages/void/test/unit/validate-migrations.test.ts b/packages/void/test/unit/validate-migrations.test.ts index 756766a4f..5a1a509ae 100644 --- a/packages/void/test/unit/validate-migrations.test.ts +++ b/packages/void/test/unit/validate-migrations.test.ts @@ -439,4 +439,26 @@ describe('assertJournalCoherence', () => { expect(message).toContain('duplicate'); expect(message).toContain('0000_initial'); }); + + it('names files under the source directory', () => { + const sourceRelative = (path: string) => `src/${path}`; + + writeJournal(['0000_initial']); + expect(() => + assertJournalCoherence( + migrationsDir, + [ + { name: '0000_initial.sql', sql: 'CREATE TABLE posts (id TEXT);' }, + { name: '0001_orphan.sql', sql: 'SELECT 1;' }, + ], + sourceRelative, + ), + ).toThrow(' - src/db/migrations/0001_orphan.sql'); + + rmSync(join(migrationsDir, 'meta', '_journal.json')); + writeFileSync(join(migrationsDir, 'meta', '0000_snapshot.json'), JSON.stringify({ id: 'abc' })); + expect(() => assertJournalCoherence(migrationsDir, [], sourceRelative)).toThrow( + 'src/db/migrations/meta/0000_snapshot.json', + ); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 2eba2563a..df369179f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,8 +23,10 @@ const cliUnitTests = [ ]; // SDK unit files that start PGlite. Add new PGlite unit files here. const sdkPgliteUnitTests = [ + 'packages/void/test/unit/auth-migrations.test.ts', 'packages/void/test/unit/db-export-roundtrip.test.ts', 'packages/void/test/unit/db-request-pool.test.ts', + 'packages/void/test/unit/db-reset-pg.test.ts', 'packages/void/test/unit/platform-database-lock.test.ts', 'packages/void/test/unit/platform-database-migrations.test.ts', 'packages/void/test/unit/runner-pg.test.ts',