From 033484e4da1da30771e95f9f93d553d8d5de816b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:05:12 +0800 Subject: [PATCH 01/16] fix(db): ask before reset or seed drops tables on a non-local database Bug: In a PostgreSQL/MySQL project, `void db connect ` (or `--provider neon`) saves one URL as both `.env` DATABASE_URL and the linked project's deployment database. A later `void db reset` or `void db seed` then drops every table in that deployed database with no prompt. The docs said reset "does not affect the deployed database". Repro: void db connect postgresql://user:pass@prod-host/app; create a table there; run `void db reset` or `void db seed`. The table is gone and no prompt was shown. Cause: resetDatabase in packages/void/src/cli/db.ts treats whatever `.env` DATABASE_URL points at as a throwaway local database and runs DROP TABLE on every table (PostgreSQL public schema, MySQL current database) with no target check and no confirmation. runConnect writes the same URL as the deployment URL. Fix: resetDatabase now calls confirmDropAllTables before the PostgreSQL/MySQL drops. localhost, 127.0.0.1, ::1 and Unix sockets skip it: Hyperdrive reaches only public hosts, so these can never be the deployed database. For PostgreSQL the host comes from pg's own Client parser, not from the URL authority, because pg lets a `?host=` query parameter or PGHOST (when the URL has no host) replace it. MySQL uses the URL host, which is what mysql2 connects to. Any other host gets a prompt that names the database and host (never the credentials). Without a TTY the command stops unless `--yes` is passed. reset and seed accept `--yes`; CLI help and docs/reference/cli/database.md are updated. `void db connect` and D1 behave as before. Test: packages/void/test/unit/db-commands.test.ts "PostgreSQL: asks before dropping every table in a non-local DATABASE_URL" fails without the fix (reset runs to completion instead of stopping). Parser: db-args.test.ts "parses --yes for reset and seed". The same test also runs reset with DATABASE_URL postgresql://user:secret@localhost/prod?host=db.example.com and expects it to stop. The pg mock extends the real pg Client, so pg parses the URL itself. Note: Scripts or CI that reset/seed a non-local host (for example a compose service named `postgres`) now need `--yes`. Stopping without a TTY is on purpose, unlike `db execute --remote`, which skips its prompt without a TTY. `void db connect` still writes the deployment URL to .env; changing that is left to the owner. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/reference/cli/database.md | 12 +++-- packages/void/src/cli/db-args.ts | 9 ++-- packages/void/src/cli/db.ts | 54 ++++++++++++++++++--- packages/void/src/cli/help.ts | 19 +++++++- packages/void/test/unit/db-args.test.ts | 5 ++ packages/void/test/unit/db-commands.test.ts | 51 +++++++++++++++++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/docs/reference/cli/database.md b/docs/reference/cli/database.md index d47efaf2a..e661da896 100644 --- a/docs/reference/cli/database.md +++ b/docs/reference/cli/database.md @@ -24,15 +24,21 @@ Show migration status. Displays which migrations are applied or pending locally, ## `void db reset` {#void-db-reset} -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`. Does not affect the deployed database. +``` +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`. + +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. ## `void db seed` {#void-db-seed} ``` -void db seed [--file ] +void db seed [--file ] [--yes] ``` -Reset the local database, re-apply all migrations, then execute a seed file. +Reset the local database, re-apply all migrations, then execute a seed file. The reset works like `void db reset`, including the prompt and `--yes`. If `--file` is omitted, Void looks for default seed files in this order: `db/seed.ts`, `db/seed.mts`, `db/seed.js`, `db/seed.mjs`, `db/seed.sql`. diff --git a/packages/void/src/cli/db-args.ts b/packages/void/src/cli/db-args.ts index 54914903d..dd9ac0de9 100644 --- a/packages/void/src/cli/db-args.ts +++ b/packages/void/src/cli/db-args.ts @@ -57,8 +57,8 @@ export type DbArgs = | { action: 'push' } | { action: 'generate' } | { action: 'status' } - | { action: 'reset' } - | { action: 'seed'; file?: string } + | { action: 'reset'; yes?: boolean } + | { action: 'seed'; file?: string; yes?: boolean } | { action: 'execute'; command?: string; file?: string; remote?: boolean } | { action: 'migrate'; remote?: boolean } | { action: 'studio'; remote?: boolean } @@ -114,6 +114,7 @@ export function parseDbArgs(args: Array): DbArgs { name: { type: 'string' }, region: { type: 'string' }, 'local-only': { type: 'boolean' }, + yes: { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -134,8 +135,10 @@ export function parseDbArgs(args: Array): DbArgs { } switch (action) { + case 'reset': + return { action, yes: values.yes }; case 'seed': - return { action, file: values.file }; + return { action, file: values.file, yes: values.yes }; case 'execute': { const [command] = rest; if (!command && !values.file) { diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 1167fe17c..2825c81c2 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -192,9 +192,9 @@ export async function runDbCommand(root: string, args: DbArgs): Promise { } else if (args.action === 'status') { await runStatus(root, dialect); } else if (args.action === 'reset') { - await runReset(root, dialect); + await runReset(root, dialect, args.yes); } else if (args.action === 'seed') { - await runSeed(root, args.file, dialect); + await runSeed(root, args.file, dialect, args.yes); } else if (args.action === 'execute') { await runExecute(root, args.command, args.file, dialect, args.remote); } else if (args.action === 'migrate') { @@ -525,7 +525,39 @@ async function runStatus(root: string, dialect: DatabaseDialect): Promise outro(parts.join(', ')); } -async function resetDatabase(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]']); + +/** + * `.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`. + */ +async function confirmDropAllTables( + host: string, + target: string, + yes: boolean | undefined, +): Promise { + if (yes || LOCAL_DATABASE_HOSTS.has(host) || host.startsWith('/')) { + return; + } + if (!process.stdin.isTTY) { + log.error( + `db: Refusing to drop every table in ${target} without confirmation. Re-run with --yes.`, + ); + process.exit(1); + } + const ok = await confirm({ message: `This drops every table in ${target}. Continue?` }); + if (isCancel(ok) || !ok) { + log.info('Cancelled.'); + process.exit(0); + } +} + +async function resetDatabase( + root: string, + dialect: DatabaseDialect, + yes?: boolean, +): Promise { // Coherence check must happen before any destructive work. Report a // malformed `_journal.json` like `runStatus`, `runMigrate`, and the deploy // path do, not as a raw stack trace. @@ -540,6 +572,12 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { const tables = await client.query( @@ -554,6 +592,9 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { const tables = await client.query( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", @@ -586,8 +627,8 @@ async function resetDatabase(root: string, dialect: DatabaseDialect): Promise { - const count = await resetDatabase(root, dialect); +async function runReset(root: string, dialect: DatabaseDialect, yes?: boolean): Promise { + const count = await resetDatabase(root, dialect, yes); if (dialect === 'sqlite') { log.info(dim("Restart your dev server if it's running.")); } @@ -598,9 +639,10 @@ async function runSeed( root: string, file: string | undefined, dialect: DatabaseDialect, + yes?: boolean, ): Promise { // Reset first (drop + re-run migrations) - const migrationCount = await resetDatabase(root, dialect); + const migrationCount = await resetDatabase(root, dialect, yes); log.step(`Reset database. Applied ${formatCount(migrationCount, 'migration')}.`); const seed = resolveSeedTarget(root, file); diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 833b778bb..0ed8403b7 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1169,11 +1169,26 @@ const HELP_TREE: HelpTree = { push: basicSubcommand('Push schema to local D1 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: basicSubcommand('Reset local DB and re-run migrations.', 'void db reset'), + reset: { + summary: 'Reset local DB and re-run migrations.', + usage: ['void db reset [options]'], + options: [ + { + command: '--yes', + description: 'Skip the prompt for a non-local PostgreSQL/MySQL database', + }, + ], + }, seed: { summary: 'Reset local DB, run migrations, and seed data.', usage: ['void db seed [options]'], - options: [{ command: '--file ', description: 'Seed from a specific file' }], + options: [ + { command: '--file ', description: 'Seed from a specific file' }, + { + command: '--yes', + description: 'Skip the prompt for a non-local PostgreSQL/MySQL database', + }, + ], }, execute: { summary: diff --git a/packages/void/test/unit/db-args.test.ts b/packages/void/test/unit/db-args.test.ts index 8df712327..7d984cba8 100644 --- a/packages/void/test/unit/db-args.test.ts +++ b/packages/void/test/unit/db-args.test.ts @@ -21,6 +21,11 @@ describe('parseDbArgs', () => { }); }); + it('parses --yes for reset and seed', () => { + expect(parseDbArgs(['reset', '--yes'])).toEqual({ action: 'reset', yes: true }); + expect(parseDbArgs(['seed', '--yes'])).toEqual({ action: 'seed', yes: true }); + }); + it('parses execute with inline SQL', () => { expect(parseDbArgs(['execute', 'SELECT * FROM users'])).toEqual({ action: 'execute', diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index 1ed02a7ee..9d31c417a 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -13,6 +13,8 @@ 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 mockConfirm = vi.fn(); const mockGetMiniflareDatabasePath = vi.fn(); const mockOpenSqliteDatabase = vi.fn(); const mockGetAppliedMigrations = vi.fn(); @@ -23,6 +25,17 @@ vi.mock('node:child_process', () => ({ execFileSync: (...args: Array) => mockExecFileSync(...args), })); +vi.mock('pg', async (importOriginal) => { + // Keep pg's own connection-string parsing; stub only the network calls. + class Client extends (await importOriginal()).Client {} + Object.assign(Client.prototype, { + async connect() {}, + async end() {}, + query: (sql: string) => mockPgQuery(sql), + }); + return { Client }; +}); + vi.mock('../../src/migrations/runner', async () => { const actual = await vi.importActual( '../../src/migrations/runner', @@ -99,6 +112,8 @@ const mockLog = { }; vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + confirm: (...args: Array) => mockConfirm(...args), + isCancel: () => false, intro: vi.fn(), outro: vi.fn(), log: { @@ -357,6 +372,42 @@ describe('void db reset', () => { expect(existsSync(fakeDbPath)).toBe(true); expect(mockRunMigrations).not.toHaveBeenCalled(); }); + + it('PostgreSQL: asks before dropping every table in a non-local DATABASE_URL', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-reset-pg-remote-')); + rootsToClean.push(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync(join(root, 'void.json'), JSON.stringify({ database: 'pg' })); + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://user:secret@db.example.com:5432/prod"\n', + ); + const stdinTty = process.stdin.isTTY; + try { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + await expect(runDbCommand(root, { action: 'reset' })).rejects.toThrow('process.exit:1'); + const message = String(mockLog.error.mock.calls[0][0]); + expect(message).toContain('"prod" on db.example.com:5432'); + expect(message).toContain('--yes'); + expect(message).not.toContain('secret'); + + writeFileSync( + join(root, '.env'), + 'DATABASE_URL="postgresql://user:secret@localhost/prod?host=db.example.com"\n', + ); + await expect(runDbCommand(root, { action: 'reset' })).rejects.toThrow('process.exit:1'); + expect(String(mockLog.error.mock.lastCall?.[0])).toContain('"prod" on db.example.com:5432'); + + 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(mockPgQuery).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); + } + }); }); describe('void db status', () => { From e04ca214398210d1ec1f6155ac4aca100966a07f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:25:57 +0800 Subject: [PATCH 02/16] fix(db): drop MySQL tables on reset by aliasing the table name column Bug: On MySQL 8.x, `void db reset` prints "Database reset. Applied N migrations." but drops nothing. Old rows and `_void_migrations` stay, so no migration runs again. `void db seed` resets first the same way, then its seed SQL fails on the old rows (e.g. "Duplicate entry '1' for key 'users.PRIMARY'"). Repro: with `"database": "mysql"` in void.json and a local MySQL 8 DATABASE_URL, run `void db migrate`, insert a row, run `void db reset`. The row is still there. The server log shows only "DROP TABLE IF EXISTS `undefined`". Cause: resetDatabase in packages/void/src/cli/db.ts runs `SELECT table_name FROM information_schema.tables ...` and reads `row.table_name`. Since 8.0, MySQL returns that unaliased column under the key `TABLE_NAME`, so the read is undefined and every DROP targets a table named `undefined`. Fix: alias the column (`SELECT table_name AS table_name`) so the key is lowercase on 5.7 and 8.x. The code that reads the rows is unchanged. Test: packages/void/test/unit/db-commands.test.ts, "void db reset > MySQL: drops every table and re-applies migrations". Its fake mysql2 returns unaliased info-schema columns as `TABLE_NAME`, like MySQL 8. It fails without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/db.ts | 3 +- packages/void/test/unit/db-commands.test.ts | 41 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 2825c81c2..01b43e74b 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -596,8 +596,9 @@ async function resetDatabase( const target = `database "${decodeURIComponent(pathname.slice(1))}" on ${host}`; await confirmDropAllTables(hostname, target, yes); await withMySqlClient(root, async (client) => { + // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`. const tables = await client.query( - "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", + "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", ); await client.query('SET FOREIGN_KEY_CHECKS = 0'); try { diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index 9d31c417a..a73a70baf 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -36,6 +36,28 @@ vi.mock('pg', async (importOriginal) => { return { Client }; }); +const mysqlQueries = vi.hoisted((): Array => []); + +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_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' }], []]; + } + return [[], []]; + }, + }), +})); + vi.mock('../../src/migrations/runner', async () => { const actual = await vi.importActual( '../../src/migrations/runner', @@ -408,6 +430,25 @@ describe('void db reset', () => { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: stdinTty }); } }); + + it('MySQL: drops every table and re-applies migrations', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-reset-mysql-')); + rootsToClean.push(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(root, 'db', 'migrations', '0000_init.sql'), + 'CREATE TABLE `users` (id INT);', + ); + 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; + + await runDbCommand(root, { action: 'reset' }); + + expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `_void_migrations`'); + expect(mysqlQueries).toContain('DROP TABLE IF EXISTS `users`'); + expect(mysqlQueries).toContain('CREATE TABLE `users` (id INT);'); + }); }); describe('void db status', () => { From 593a89a7b20c36946efb8d10095f24ba4bc9cc40 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:37:19 +0800 Subject: [PATCH 03/16] fix(db): load seed and schema modules that use extensionless imports Bug: `void db seed` with a TS/JS seed (db/seed.ts, .mts, .js, .mjs or --file) fails with ERR_MODULE_NOT_FOUND when db/schema.ts is a barrel like `export * from "./schema/posts";`. That is the exact file `void gen model` writes, and the form the database guide shows. The command resets the local database before it loads the seed, so the user loses their data and gets no seed rows. Same for sqlite, postgresql and mysql. Repro: `void gen model posts title:string`, add db/seed.ts that inserts into schema.posts, run `void db seed` under plain Node: "Cannot find module '.../db/schema/posts' imported from .../db/schema.ts", and the table is left empty. Cause: importLocalModule in packages/void/src/cli/db.ts loaded the seed file and the schema with Node's native import(). Native ESM needs a file extension on relative specifiers, so the extensionless re-export does not resolve. drizzle-kit and the Vite plugin accept that form; only the seed loader did not. Fix: load these modules with jiti, the same loader (and the same options) already used for void.config.ts. jiti resolves extensionless relative imports. moduleCache: false keeps the old fresh-load behavior of the `?t=` query, and interopDefault: false keeps plain namespace objects, so the default/`seed` export lookup and the schema export filter behave as before. gen output and the reset order are unchanged. Test: packages/void/test/unit/db-seed.test.ts, "void db seed > seeds when db/schema.ts re-exports an extensionless module". It runs the command in a child Node process, because Vitest's module runner resolves extensionless imports and hides the bug. The child start is slow on a busy machine, so the test has a 30s timeout and the child a 20s limit. It fails without the fix with ERR_MODULE_NOT_FOUND. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/db.ts | 9 +++++- packages/void/test/unit/db-seed.test.ts | 42 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 01b43e74b..c4c66afa1 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -11,6 +11,7 @@ import { import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { confirm, isCancel, outro, log, text } from '@voidzero-dev/vite-plus-prompts'; +import { createJiti } from 'jiti'; import { basename, dirname, extname, join, relative, resolve } from 'pathslash'; import { readConfig, @@ -2197,7 +2198,13 @@ function collectSchemaModuleFiles(dir: string): Array { } async function importLocalModule(modulePath: string): Promise> { - return import(`${pathToFileURL(modulePath).href}?t=${Date.now()}`); + // Native import() cannot resolve the extensionless re-exports that `void gen model` writes. + const jiti = createJiti(import.meta.url, { + fsCache: false, + moduleCache: false, + interopDefault: false, + }); + return jiti.import>(modulePath); } function getDrizzleSchemaOptions( diff --git a/packages/void/test/unit/db-seed.test.ts b/packages/void/test/unit/db-seed.test.ts index f1ee2a776..24fd3f149 100644 --- a/packages/void/test/unit/db-seed.test.ts +++ b/packages/void/test/unit/db-seed.test.ts @@ -1,5 +1,7 @@ +import { spawnSync } from 'node:child_process'; import { 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'; import * as output from '../../src/cli/output.ts'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; @@ -146,6 +148,46 @@ export const messages = sqliteTable("messages", { expect(output.printSuccess).toHaveBeenCalledWith('Seeded from db/seed.ts.'); }); + it('seeds when db/schema.ts re-exports an extensionless module', async () => { + const root = createProjectRoot({ + schema: 'export * from "./schema/messages";\n', + seedFile: { + name: 'seed.ts', + content: `export default async function seed({ db, schema }) { + await db.insert(schema.messages).values({ text: "from barrel" }); +} +`, + }, + }); + mkdirSync(join(root, 'db', 'schema')); + writeFileSync( + join(root, 'db', 'schema', 'messages.ts'), + `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const messages = sqliteTable("messages", { + id: integer("id").primaryKey({ autoIncrement: true }), + text: text("text").notNull(), +}); +`, + ); + const dbModule = pathToFileURL(join(import.meta.dirname, '../../src/cli/db.ts')).href; + + // Plain Node, because Vitest's module runner resolves extensionless imports. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `const { runDbCommand } = await import(${JSON.stringify(dbModule)}); +await runDbCommand(${JSON.stringify(root)}, { action: 'seed' });`, + ], + { encoding: 'utf8', timeout: 20_000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(await readMessages(root)).toEqual([{ id: 1, text: 'from barrel' }]); + }, 30_000); + it('fails when multiple default seed files exist', async () => { const root = createProjectRoot({ seedFile: { From 41371cc720d96439af2a0e5b857e6e7597d947cb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 20:59:58 +0800 Subject: [PATCH 04/16] fix(init): keep existing pnpm build approvals in pnpm-workspace.yaml Bug: `void init` in a pnpm project rewrote the allowBuilds map in pnpm-workspace.yaml to only Void's four entries. In a pnpm workspace member it edits the shared root file, so the whole monorepo was hit. The user's own build approvals were deleted, an explicit `better-sqlite3: true` (which Void's SQLite error tells users to set) went back to false, and the legacy onlyBuiltDependencies list was dropped. pnpm 12 then blocks those builds on the next install. When allowBuilds was written on one line (`allowBuilds: {...}`), init added a second `allowBuilds:` key, and every pnpm command then failed with "duplicate mapping key". Repro: in an existing Vite app with pnpm-lock.yaml, write pnpm-workspace.yaml as allowBuilds: "@parcel/watcher": true better-sqlite3: true onlyBuiltDependencies: - bcrypt and run `void init`. Only better-sqlite3: false, esbuild, sharp and workerd are left. Cause: ensurePnpmAllowBuilds in packages/void/src/cli/init.ts removed the whole allowBuilds and onlyBuiltDependencies blocks and inserted a fixed list. Its key regex only matches the block form, so a same-line value stayed in place and a duplicate key was added. Fix: removeTopLevelYamlBlock now also returns the lines it removes. pnpmAllowBuildsYaml keeps the existing entries as written, turns each legacy onlyBuiltDependencies item into `name: true`, and adds only the Void defaults that are missing. pnpm's "set this to true or false" placeholder is replaced only for a name init has a value for. A same-line allowBuilds value is left untouched, and init warns with the entries to add. Fresh projects and pnpm's placeholder file still get exactly the four Void defaults, so the existing tests are unchanged. The legacy list is also read when its items start at column 0 (`onlyBuiltDependencies:` then `- bcrypt`), the form pnpm's own docs use. Before, those items were left behind as a stray top-level sequence, which made the file invalid YAML. Test: packages/void/test/unit/init.test.ts, "keeps the existing pnpm build approvals when adding the Void defaults". It covers the indented and the column-0 legacy lists and fails without the fix. Note: a value the user already set for a Void entry (for example better-sqlite3: true) now wins over Void's default, and legacy onlyBuiltDependencies entries become allowBuilds approvals before that block is removed. This matches how init merged the list before the move to allowBuilds. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/init.ts | 96 ++++++++++++++++++++++------ packages/void/test/unit/init.test.ts | 35 ++++++++++ 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index fec669831..31ccddfdf 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -132,7 +132,15 @@ const EMPTY_PROJECT_ALLOWED_ENTRIES = new Set([ '.zencoder', ]); -const PNPM_ALLOWED_BUILDS = ['esbuild', 'sharp', 'workerd'] as const; +// better-sqlite3 13 already ships its Node-API binaries; no native build is needed. +const PNPM_BUILD_DEFAULTS = [ + ['better-sqlite3', false], + ['esbuild', true], + ['sharp', true], + ['workerd', true], +] as const; +// pnpm writes this value for each blocked build it wants a decision on. +const PNPM_BUILD_PLACEHOLDER = 'set this to true or false'; // Packages a previous Void Pages starter can leave behind, so re-running init // after deleting the app files still takes the starter path. @@ -1883,9 +1891,14 @@ function ensureGitignore(root: string): void { log.step('Updated .gitignore with Void defaults'); } -function removeTopLevelYamlBlock(yaml: string, key: string): string { +/** Removes each block-style top-level `key:` block; `removed` holds its non-blank lines. */ +function removeTopLevelYamlBlock( + yaml: string, + key: string, +): { yaml: string; removed: Array } { const lines = yaml.split(/\r?\n/); const next: Array = []; + const removed: Array = []; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (!new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line)) { @@ -1894,26 +1907,53 @@ function removeTopLevelYamlBlock(yaml: string, key: string): string { } i += 1; - while (i < lines.length && (lines[i].trim() === '' || /^\s/.test(lines[i]))) { + while (i < lines.length && (lines[i].trim() === '' || /^(?:\s|-(?:\s|$))/.test(lines[i]))) { + if (lines[i].trim() !== '') { + removed.push(lines[i]); + } i += 1; } i -= 1; } - return next.join('\n'); + return { yaml: next.join('\n'), removed }; +} + +function pnpmAllowBuildsEntryName(line: string): string | undefined { + return /^\s+(?!#)(["']?)(.+?)\1\s*:(?:\s|$)/.exec(line)?.[2]; } -function pnpmAllowBuildsYaml(): string { - // better-sqlite3 13 already ships its Node-API binaries; no native build is needed. - const lines = ['allowBuilds:', ' better-sqlite3: false']; - for (const dependency of PNPM_ALLOWED_BUILDS) { - lines.push(` ${dependency}: true`); +/** + * Keeps the existing `allowBuilds` entries as written, approves the legacy + * `onlyBuiltDependencies` list, and adds each Void default that is missing. + */ +function pnpmAllowBuildsYaml(entries: Array, legacyList: Array): string { + const legacy = legacyList.flatMap((line) => { + const name = /^\s*-\s+(["']?)(.+?)\1\s*(?:#.*)?$/.exec(line)?.[2]; + return name === undefined ? [] : [name]; + }); + const decided = new Set([...legacy, ...PNPM_BUILD_DEFAULTS.map(([name]) => name)]); + const lines = entries.filter((line) => { + const name = pnpmAllowBuildsEntryName(line); + return !(name !== undefined && decided.has(name) && line.includes(PNPM_BUILD_PLACEHOLDER)); + }); + const names = new Set(lines.map((line) => pnpmAllowBuildsEntryName(line))); + const firstEntry = lines.find((line) => pnpmAllowBuildsEntryName(line) !== undefined); + const indent = /^\s+/.exec(firstEntry ?? '')?.[0] ?? ' '; + const additions: Array = [ + ...legacy.map((name) => [name, true] as const), + ...PNPM_BUILD_DEFAULTS, + ]; + for (const [name, value] of additions) { + if (!names.has(name)) { + names.add(name); + lines.push(`${indent}${/^[\w.-]+$/.test(name) ? name : JSON.stringify(name)}: ${value}`); + } } - return lines.join('\n'); + return ['allowBuilds:', ...lines].join('\n'); } -function insertPnpmAllowBuilds(yaml: string): string { +function insertPnpmAllowBuilds(yaml: string, allowBuilds: string): string { const trimmed = yaml.trim(); - const allowBuilds = pnpmAllowBuildsYaml(); if (trimmed === '') { return `${allowBuilds}\n`; } @@ -1956,19 +1996,33 @@ function resolvePnpmConfigRoot(root: string, pkg: JsonObject): string { function ensurePnpmAllowBuilds(root: string, pkg: JsonObject): void { const configRoot = resolvePnpmConfigRoot(root, pkg); const workspacePath = join(configRoot, 'pnpm-workspace.yaml'); + const displayPath = configRoot === root ? 'pnpm-workspace.yaml' : relative(root, workspacePath); const before = existsSync(workspacePath) ? readFileSync(workspacePath, 'utf-8') : ''; - const withoutLegacy = removeTopLevelYamlBlock( - removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'), - 'allowBuilds', + // A same-line value such as `allowBuilds: {...}` cannot be merged line by + // line, and a second `allowBuilds:` key would make the file invalid. + const inline = /^allowBuilds:[ \t]*([^\s#].*)$/m.exec(before)?.[1]; + if (inline !== undefined) { + const missing = PNPM_BUILD_DEFAULTS.filter( + ([name]) => !new RegExp(`[{,]\\s*["']?${name}["']?\\s*:`).test(inline), + ); + if (missing.length > 0) { + log.warn( + `Add ${missing.map(([name, value]) => `${name}: ${value}`).join(', ')} to allowBuilds in ${displayPath}. ` + + 'Void only edits allowBuilds when each entry is on its own line.', + ); + } + return; + } + + const legacy = removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'); + const current = removeTopLevelYamlBlock(legacy.yaml, 'allowBuilds'); + const after = insertPnpmAllowBuilds( + current.yaml, + pnpmAllowBuildsYaml(current.removed, legacy.removed), ); - const after = insertPnpmAllowBuilds(withoutLegacy); if (after !== before) { writeFileSync(workspacePath, after); - log.step( - configRoot === root - ? 'Updated pnpm-workspace.yaml with approved builds' - : `Updated ${relative(root, workspacePath)} with approved builds`, - ); + log.step(`Updated ${displayPath} with approved builds`); } } diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 401a8d47e..94e85f1d7 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -3596,6 +3596,41 @@ export default config; rmSync(root, { recursive: true, force: true }); } }); + + it('keeps the existing pnpm build approvals when adding the Void defaults', async () => { + const roots: Array = []; + const initWithWorkspaceYaml = async (yaml: string): Promise => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + roots.push(root); + writeExistingViteApp(root); + writeFileSync(join(root, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n"); + writeFileSync(join(root, 'pnpm-workspace.yaml'), yaml); + mockSelect.mockResolvedValueOnce('none'); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + await runInitCommand(root); + return readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf-8'); + }; + try { + expect( + await initWithWorkspaceYaml( + 'allowBuilds:\n "@parcel/watcher": true\n better-sqlite3: true\nonlyBuiltDependencies:\n - bcrypt\n', + ), + ).toBe( + 'allowBuilds:\n "@parcel/watcher": true\n better-sqlite3: true\n bcrypt: true\n esbuild: true\n sharp: true\n workerd: true\n', + ); + expect(await initWithWorkspaceYaml('onlyBuiltDependencies:\n- bcrypt\n')).toBe( + 'allowBuilds:\n bcrypt: true\n better-sqlite3: false\n esbuild: true\n sharp: true\n workerd: true\n', + ); + + const flow = 'allowBuilds: {"@parcel/watcher": true}\n'; + expect(await initWithWorkspaceYaml(flow)).toBe(flow); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining('esbuild: true')); + } finally { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } + } + }); }); describe('detectProjectState', () => { From 492f78daecdc718ddb4efd9c3e52b72c6cff8560 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 22:05:01 +0800 Subject: [PATCH 05/16] fix(cli): spawn node without a shell on Windows Bug: On Windows, `void init` with a database starter fails with "Failed to generate the starter database migration. 'C:\Program' is not recognized as an internal or external command". This happens when node is installed in the default MSI location (C:\Program Files\nodejs\node.exe). When the project path has a space, drizzle-kit gets split arguments instead ("Unrecognized options for command 'generate'"). `void gen migration ` on a project that has a migration journal fails the same way. When node is under a path with spaces, such as C:\Program Files, the Yarn PnP check in `void init` ("does `void` resolve from here?") also wrongly answers no. Its args hold no paths, so a project path with a space does not break it. Node also prints a DEP0190 deprecation warning. Repro: on Windows with node under C:\Program Files, run `void init` in an empty folder and pick the D1 starter. Or run `void gen migration backfill` in a project that has db/migrations/meta/_journal.json. Cause: three spawnSync(process.execPath, args) calls set `shell: process.platform === 'win32'`. These are generateStarterMigration and the PnP probe in isVoidResolvableFrom (packages/void/src/cli/init.ts), and runMigration (packages/void/src/cli/gen.ts). With a shell, Node joins the file and the args with plain spaces and does not quote them. Then it runs `cmd.exe /d /s /c ""`, and cmd splits that string on whitespace again. The option was copied from the package-manager install spawn, which needs a shell for the pnpm/npm/yarn .cmd shims. process.execPath is a real .exe and needs no shell. Fix: drop the `shell` option from those three node spawns, so the args reach node as a real argv. db.ts and deploy-drift.ts already spawn node and drizzle-kit this way. The package-manager install spawn keeps `shell` on Windows, because it still needs it for the .cmd shims. Test: packages/void/test/unit/init.test.ts "generateStarterMigration > runs drizzle-kit and the Yarn PnP probe without a shell on Windows", and packages/void/test/unit/gen.test.ts "gen migration > runs drizzle-kit without a shell on Windows". Both set process.platform to win32 and check that the node spawns get no shell. Both fail without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/gen.ts | 1 - packages/void/src/cli/init.ts | 2 -- packages/void/test/unit/gen.test.ts | 14 ++++++++++++++ packages/void/test/unit/init.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/void/src/cli/gen.ts b/packages/void/src/cli/gen.ts index 2e951d3ec..8396f50d5 100644 --- a/packages/void/src/cli/gen.ts +++ b/packages/void/src/cli/gen.ts @@ -576,7 +576,6 @@ function runMigration(root: string, name: string): void { cwd: root, stdio: 'pipe', encoding: 'utf-8', - shell: process.platform === 'win32', // Yarn PnP projects need the PnP loader to resolve `void/schema-*`. env: buildDrizzleKitSpawnEnv(root), }, diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 31ccddfdf..3c345c541 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -730,7 +730,6 @@ export function isVoidResolvableFrom(start: string): boolean { const probe = spawnSync(process.execPath, probeArgs, { cwd: start, stdio: 'ignore', - shell: process.platform === 'win32', env: buildDrizzleKitSpawnEnv(start), }); return !probe.error && probe.status === 0; @@ -2105,7 +2104,6 @@ export function generateStarterMigration( cwd: root, stdio: 'pipe', encoding: 'utf-8', - shell: process.platform === 'win32', env: buildDrizzleKitSpawnEnv(root), }); diff --git a/packages/void/test/unit/gen.test.ts b/packages/void/test/unit/gen.test.ts index c60e5dcd2..d3a4bc2c9 100644 --- a/packages/void/test/unit/gen.test.ts +++ b/packages/void/test/unit/gen.test.ts @@ -739,6 +739,20 @@ describe('gen migration', () => { ).toEqual({ fresh: { name: 'fresh' } }); }); + // cmd.exe would split `C:\Program Files\nodejs\node.exe` and paths with spaces. + it('runs drizzle-kit without a shell on Windows', async () => { + seedJournalBackedProject(); + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + await runGenCommand(TMP, { subcommand: 'migration', name: 'backfill_users' }); + } finally { + Object.defineProperty(process, 'platform', platform); + } + + expect(findGenerateCall()!.options).not.toHaveProperty('shell', true); + }); + it('refuses when the journal dialect disagrees with the void.json dialect', async () => { const { migrationsDir, journalPath, journal } = seedJournalBackedProject(); writeFileSync(join(TMP, 'void.json'), JSON.stringify({ database: 'pg' })); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 94e85f1d7..20760122b 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -3846,4 +3846,29 @@ describe('generateStarterMigration', () => { expect(existsSync(join(migrationsDir, '0001_new.sql'))).toBe(false); }); }); + + // cmd.exe would split `C:\Program Files\nodejs\node.exe` and paths with spaces. + it('runs drizzle-kit and the Yarn PnP probe without a shell on Windows', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); + try { + withTempDir((root) => { + seedStarterSchema(root); + writeFileSync(join(root, '.pnp.cjs'), '// pnp stub\n'); + mkdirSync(join(root, '.yarn'), { recursive: true }); + writeFileSync(join(root, '.yarn', 'install-state.gz'), ''); + mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }); + + generateStarterMigration(root, 'sqlite'); + expect(isVoidResolvableFrom(root)).toBe(true); + + const nodeSpawns = mockSpawnSync.mock.calls.filter( + ([command]) => command === process.execPath, + ); + expect(nodeSpawns.map(([, , options]) => Boolean(options.shell))).toEqual([false, false]); + }); + } finally { + Object.defineProperty(process, 'platform', platform); + } + }); }); From a4301054256e69e0a724a022d639ff66f2a1a054 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:08:53 +0800 Subject: [PATCH 06/16] fix(cli): upload Worker limits, placement and cache with pinned secrets Bug: `void deploy --platform cloudflare` fails on every deploy after the first with "Cloudflare Worker setting limits cannot be uploaded with pinned secrets." when the Worker already has a remote secret (from `void secret put`, or the BETTER_AUTH_SECRET an auth app stores on its first deploy) and the config sets the documented `worker.limits.cpu_ms`, or passes `placement`, `cache`, `logpush` or `tail_consumers` through `cloudflare`. Nothing is uploaded and there is no fallback. Repro: an app with `worker: { limits: { cpu_ms: 30000 } }`; run `void secret put FOO`; deploy once (works); deploy again (fails). Cause: once the live Worker has a secret, ordinary revisions upload through uploadPinnedCloudflareVersion in packages/void/src/cli/cf-version-api.ts, so the new version can inherit secrets from the exact active version. That function only accepted a fixed list of Build Output Worker keys and threw on anything else. The Build Output carries limits, placement, cache, logpush and tailConsumers, and the version POST never sent the first three. The installed `cf` version upload, used before the Worker had secrets, sends limits, placement and cache. `cf workers versions create` does not apply logpush either. Fix: accept those keys. Send `limits` (cpu_ms, subrequests), `placement` and `cache_options` (enabled, cross_version_cache) in the Workers beta version body, which documents all three. For placement, `mode: 'off'` sends nothing, and a hint or `mode: 'smart'` sends smart placement, the same as cf does for its own version uploads. Region, host or hostname placement fails before any API request. The error names the key that is set and says what to do: "Cloudflare placement.region cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }." Public reports show that the beta version endpoint can drop the target of a targeted placement. With `--atomic`, the new version is live at once, so it could serve traffic without its target before a read-back can find the problem. `logpush` and `tail_consumers` are Worker-level settings that version uploads cannot set, so they are accepted but not sent, the same as `observability` on this path. The change stays inside the pinned uploader; the cf path and the rest of the deploy flow are untouched. Test: packages/void/test/unit/cf-version-runner.test.ts, "uploads Worker limits, placement and cache with pinned secrets". Without the fix it rejects with "Cloudflare Worker setting tailConsumers cannot be uploaded with pinned secrets." The same file has "rejects targeted placement with pinned secrets before any upload", for region, host and hostname. It checks the full error for each key, hint included, and that the fake API gets no request. If targeted placement reaches the version POST, the upload succeeds and the test fails. Note: no ordinary version upload applies a change to `logpush` or `tail_consumers`, with or without pinned secrets. Cloudflare documents both as not available for version uploads. The live Worker keeps its current values, as it already does for `observability` on this path. With pinned secrets, this is also true for `--atomic` deploys and for the first deploy after `void secret put`. Without secrets, those two paths run `cf deploy`, which applies them with a Worker settings update. Applying them here would need that separate update. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/cf-version-api.ts | 46 +++++++ .../void/test/unit/cf-version-runner.test.ts | 120 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index c0aa2d548..597128db5 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -8,6 +8,7 @@ import { getWorkerBundleDir, readBuildOutput, } from '@cloudflare/build-output-utils'; +import type { ParsedOutputWorkerConfig } from '@cloudflare/config'; import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; import ignore from 'ignore'; import mime from 'mime'; @@ -390,6 +391,31 @@ function assetConfigFromOutput(value: unknown): Json | undefined { }; } +/** + * The version API placement. Smart placement is normalized the same way as cf's own version + * uploads. Targeted placement is rejected: the Workers beta version endpoint can drop its + * target, and an atomic upload is live before a read-back could catch that. + */ +function placementFromOutput(placement: ParsedOutputWorkerConfig['placement']): Json | undefined { + if (!placement) { + return undefined; + } + const hint = 'hint' in placement ? placement.hint : undefined; + if (hint || placement.mode === 'smart') { + return { mode: 'smart', ...(hint ? { hint } : {}) }; + } + const target = + ('region' in placement && placement.region && 'region') || + ('host' in placement && placement.host && 'host') || + ('hostname' in placement && placement.hostname && 'hostname'); + if (target) { + throw new Error( + `Cloudflare placement.${target} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`, + ); + } + return undefined; +} + async function apiResult( url: string, token: string, @@ -1016,6 +1042,13 @@ export async function uploadPinnedCloudflareVersion(options: { 'exports', 'manifest', 'triggers', + 'limits', + 'placement', + 'cache', + // Worker-level settings that version uploads cannot set. Like observability, the + // live Worker keeps its current values. + 'logpush', + 'tailConsumers', ]).has(key), ); if (unsupported.length) { @@ -1023,6 +1056,7 @@ export async function uploadPinnedCloudflareVersion(options: { `Cloudflare Worker setting ${unsupported[0]} cannot be uploaded with pinned secrets.`, ); } + const placement = placementFromOutput(config.placement); const unsafe = object(config.unsafe); const metadata = object(unsafe?.metadata); if ( @@ -1145,6 +1179,18 @@ export async function uploadPinnedCloudflareVersion(options: { compatibility_date: config.compatibilityDate, compatibility_flags: config.compatibilityFlags, bindings, + ...(config.limits + ? { limits: { cpu_ms: config.limits.cpuMs, subrequests: config.limits.subrequests } } + : {}), + ...(placement ? { placement } : {}), + ...(config.cache + ? { + cache_options: { + enabled: config.cache.enabled, + cross_version_cache: config.cache.crossVersionCache, + }, + } + : {}), ...(exports ? { exports } : {}), ...(containers.length ? { containers } : {}), ...(jwt ? { assets: { jwt, ...(assetConfig ? { config: assetConfig } : {}) } } : {}), diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 72283e421..0e534b39e 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -931,6 +931,126 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it('uploads Worker limits, placement and cache with pinned secrets', async () => { + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + limits: { cpu_ms: 30_000 }, + placement: { mode: 'smart' }, + cache: { enabled: true, cross_version_cache: true }, + logpush: true, + tail_consumers: [{ service: 'tail-worker' }], + }); + try { + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const uploads: Array> = []; + const server = createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + if (request.method === 'POST') { + uploads.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: + request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + const uploaded = await uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: await listen(server), + workerName: 'void-test', + token: 'synthetic-token', + atomic: false, + verifyUploader: () => true, + }); + expect(uploaded.id).toBe('22222222-2222-4222-8222-222222222222'); + expect(uploads).toHaveLength(1); + expect(uploads[0]).toMatchObject({ + limits: { cpu_ms: 30_000 }, + placement: { mode: 'smart' }, + cache_options: { enabled: true, cross_version_cache: true }, + bindings: [ + { + name: 'AUTH_SECRET', + type: 'inherit', + version_id: '11111111-1111-4111-8111-111111111111', + }, + ], + }); + expect(uploads[0]).not.toHaveProperty('logpush'); + expect(uploads[0]).not.toHaveProperty('tail_consumers'); + } finally { + prepared.dispose(); + } +}); + +it.each([ + { region: 'aws:us-east-1' }, + { host: 'db.example.test:5432' }, + { hostname: 'api.example.test' }, +])('rejects targeted placement with pinned secrets before any upload: %j', async (placement) => { + const prepared = await output({ secrets: { required: ['AUTH_SECRET'] }, placement }); + try { + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: + request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: await listen(server), + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + }), + ).rejects.toThrow( + `Cloudflare placement.${Object.keys(placement)[0]} cannot be deployed while the Worker has secrets. Remove cloudflare.placement from void.config.ts, or set it to { mode: 'smart' }.`, + ); + expect(requests).toEqual([]); + } finally { + prepared.dispose(); + } +}); + it.each(['missing name', 'duplicate name', 'external collision', 'class collision'] as const)( 'rejects an invalid local Workflow declaration before any provider request: %s', async (mode) => { From 5e8d17c59bcc15f8fa55f3f42190482de48c573c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:04:11 +0800 Subject: [PATCH 07/16] fix(deploy): send custom binding names in the managed deploy manifest Bug: `void deploy` to a Void platform, for a Void app or a Class A framework app whose void.config.ts sets custom names such as `inference: { bindings: { db: 'MY_DB', kv: 'MY_KV', storage: 'MY_BUCKET' } }`, asks the platform for the default DB, KV and STORAGE bindings. The built worker reads MY_DB, MY_KV and MY_BUCKET, so every void/db, void/kv and void/storage call throws "env: Missing Cloudflare binding 'MY_KV'." and the route returns 500. Migrations still run, so the deploy looks healthy. Repro: set `inference.bindings.kv = 'MY_KV'` and `storage = 'MY_BUCKET'`, run `void deploy`; the finalize manifest carries `bindings: { kv: ['KV'], r2: ['STORAGE'] }`, while the built wrangler.json and worker use MY_KV and MY_BUCKET. Cause: runFullDeploy (packages/void/src/cli/deploy-managed-full.ts) builds the package options for assembleVoidManifest without `bindingNames`, so applyResourceBindings in cli/package.ts falls back to DB/KV/STORAGE. The platform binds each resource under the manifest name. Only the framework path (runFrameworkDeploy) passed the names. Fix: pass `analysis.bindingNames` in the package options, the same way runFrameworkDeploy does. With no custom names, resolveBindingNames returns undefined and the defaults stay unchanged; it never sets a sandbox name, so the Sandbox binding is unaffected. Test: packages/void/test/integration/full-deploy-manifest.test.ts, "sends custom inference.bindings names in the manifest bindings". It fails without the fix (got kv: ['KV'], r2: ['STORAGE']). Note: the platform keys resources by binding name, so an existing managed project with custom names gets new MY_* resources on its next deploy and migrations re-run on the new D1 database. The old DB/KV/STORAGE resources stay in place; the worker could never reach them through void/db, void/kv or void/storage, so they hold only migration or out-of-band data. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/deploy-managed-full.ts | 1 + .../integration/full-deploy-manifest.test.ts | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index 6915dec8e..092efc450 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -388,6 +388,7 @@ export async function runFullDeploy( }); const packageStartedAt = Date.now(); const voidPackageOptions = { + bindingNames: analysis.bindingNames, compatibilityDate: wranglerCompat?.compatibilityDate, compatibilityFlags: wranglerCompat?.compatibilityFlags, cpuMs: voidConfig.worker?.limits?.cpu_ms, diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index 13e542ab8..0ef702709 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -88,7 +88,9 @@ let originalFetch: typeof globalThis.fetch | undefined; let savedToken: string | undefined; let savedProject: string | undefined; -type Captured = { finalizeBody: { manifest?: { pages?: unknown } } | null }; +type Captured = { + finalizeBody: { manifest?: { pages?: unknown; bindings?: Record } } | null; +}; // Every asset is reported as present, so the deploy skips uploads and goes straight to finalize. function installPlatformMocks(role: ProjectRole = 'owner'): Captured { @@ -292,6 +294,30 @@ describe('runFullDeploy emits manifest.pages (Void pages app, end-to-end)', () = expect(captured.finalizeBody!.manifest?.pages).toBeUndefined(); }); + it('sends custom inference.bindings names in the manifest bindings', async () => { + const configPath = join(fixture, 'void.config.ts'); + const original = readFileSync(configPath, 'utf8'); + writeFileSync( + configPath, + original.replace( + 'export default defineConfig({', + "export default defineConfig({\n inference: { bindings: { kv: 'MY_KV', storage: 'MY_BUCKET' } },", + ), + ); + try { + const captured = installPlatformMocks(); + + await runDeploy(fixture, { skipBuild: true }); + + expect(captured.finalizeBody?.manifest?.bindings).toMatchObject({ + kv: ['MY_KV'], + r2: ['MY_BUCKET'], + }); + } finally { + writeFileSync(configPath, original); + } + }); + it('uses the alternate project for Worker packaging and prerendering while retaining the saved link', async () => { const original = readFileSync(projectJson, 'utf8'); const saved = JSON.stringify({ From 8630a813a44aed15a071f9e8135c3be99eaa59a7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:06:14 +0800 Subject: [PATCH 08/16] fix(astro): stop ignoring src/middleware.ts after the dev server stops Bug: In an Astro app that uses voidPlugin() and has no middleware, `astro dev` / `void dev` writes a temporary src/middleware.ts for the /__void/scheduled and /__void/queue dev routes and appends `src/middleware.ts` to the app's .gitignore. When dev stops, the file is removed but the .gitignore line stays. Any src/middleware.ts the developer writes later (for example an auth guard) is silently ignored by git: `git status` does not show it, `git add -A` skips it, and CI or deploys built from git ship without it. Repro: Astro app with .gitignore = "node_modules/" and no middleware; run `astro dev`, press Ctrl-C; .gitignore now ends with `src/middleware.ts`. Create src/middleware.ts and run `git add -A`: the file is not staged (`git check-ignore -v` points at the added line). Cause: astroDevTriggerInjectPlugin in packages/void/src/plugin-framework-triggers.ts calls ensureGitignoreLines for a path the user owns, and its cleanup only deletes the file. Nothing ever removes a .gitignore line. Nuxt and Analog are not affected because they ignore file names that only Void writes. Fix: ensureGitignoreLines now returns the lines it actually appended. The Astro cleanup (dev server close and process exit) removes exactly those lines with a new removeGitignoreLines helper, then clears the list so the second cleanup call does nothing. A line that was already in .gitignore before dev started is treated as the user's and kept. While dev runs the generated file is still ignored, so it cannot be committed by mistake. Nuxt and Analog ignore the return value and behave as before. Also drop the leftover `src/middleware.ts` line from playground/astro/.gitignore, which an earlier dev session added. Test: packages/void/test/unit/plugin-framework-triggers.test.ts, "astroDevTriggerInjectPlugin > stops ignoring src/middleware.ts after the dev server closes". It fails without the fix: .gitignore still reads "node_modules/\nsrc/middleware.ts\n" after close. Note: A hard kill (SIGKILL, crash) skips cleanup, so the line stays, as the generated file already does. Apps that got the line from an older Void keep it: Void cannot tell its old line from one the user wrote, so there is no migration; those developers must delete it by hand. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- .../void/src/plugin-framework-triggers.ts | 33 ++++++++++++++++--- .../unit/plugin-framework-triggers.test.ts | 22 +++++++++++++ playground/astro/.gitignore | 1 - 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index b7989deed..9f66ab397 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -255,8 +255,8 @@ export async function handleDevTrigger(request, env, ctx) { `; } -/** Append any missing `lines` to the project's `.gitignore`. */ -function ensureGitignoreLines(root: string, lines: ReadonlyArray): void { +/** Append any missing `lines` to the project's `.gitignore` and return the appended ones. */ +function ensureGitignoreLines(root: string, lines: ReadonlyArray): ReadonlyArray { const gitignorePath = join(root, '.gitignore'); let contents = ''; if (existsSync(gitignorePath)) { @@ -265,10 +265,28 @@ function ensureGitignoreLines(root: string, lines: ReadonlyArray): void const existing = new Set(contents.split('\n').map((line) => line.trim())); const missing = lines.filter((line) => !existing.has(line)); if (missing.length === 0) { - return; + return []; } const sep = contents.length === 0 || contents.endsWith('\n') ? '' : '\n'; writeFileSync(gitignorePath, `${contents}${sep}${missing.join('\n')}\n`); + return missing; +} + +/** Remove `lines` from the project's `.gitignore`, if it still exists. */ +function removeGitignoreLines(root: string, lines: ReadonlyArray): void { + const gitignorePath = join(root, '.gitignore'); + if (lines.length === 0 || !existsSync(gitignorePath)) { + return; + } + const contents = readFileSync(gitignorePath, 'utf-8'); + const remove = new Set(lines); + const next = contents + .split('\n') + .filter((line) => !remove.has(line.trim())) + .join('\n'); + if (next !== contents) { + writeFileSync(gitignorePath, next); + } } const NITRO_MIDDLEWARE_FILENAME = '__void-dev-trigger.ts'; @@ -546,12 +564,15 @@ export const onRequest = defineMiddleware(async (context, next) => { * one-shot snippet they can paste into their existing middleware. * * If we wrote the file we remove it on dev-server close so the user's - * source tree stays clean. + * source tree stays clean. `src/middleware.ts` belongs to the user, so the + * `.gitignore` line we add for it is removed too; otherwise a middleware they + * write later would be silently left out of git. */ export function astroDevTriggerInjectPlugin(root: string): Plugin { const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE); const middlewareDirIndexPath = join(root, 'src', 'middleware', 'index.ts'); let wroteFile = false; + let addedGitignoreLines: ReadonlyArray = []; const ensureFile = () => { if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) { @@ -571,6 +592,8 @@ export function astroDevTriggerInjectPlugin(root: string): Plugin { if (wroteFile) { rmSync(middlewarePath, { force: true }); } + removeGitignoreLines(root, addedGitignoreLines); + addedGitignoreLines = []; }; return { @@ -579,7 +602,7 @@ export function astroDevTriggerInjectPlugin(root: string): Plugin { configureServer(server) { ensureFile(); if (wroteFile) { - ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); + addedGitignoreLines = ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); } server.httpServer?.once('close', cleanup); process.once('exit', cleanup); diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index 374d2b6d7..2ec2b9822 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -555,6 +555,28 @@ describe('astroDevTriggerInjectPlugin', () => { 'Astro middleware already exists', ); }); + + it('stops ignoring src/middleware.ts after the dev server closes', () => { + const root = createTempRoot(); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + let onClose: (() => void) | undefined; + // The temp root is removed after each test, so skip the process exit cleanup hook. + vi.spyOn(process, 'once').mockReturnValue(process); + callConfigureServer(astroDevTriggerInjectPlugin(root), { + httpServer: { + once(_event: string, cb: () => void) { + onClose = cb; + }, + }, + }); + + expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(true); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toContain('src/middleware.ts'); + + onClose!(); + expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }); }); describe('analogDevTriggerInjectPlugin', () => { diff --git a/playground/astro/.gitignore b/playground/astro/.gitignore index b580d5b7c..dc1c8fb66 100644 --- a/playground/astro/.gitignore +++ b/playground/astro/.gitignore @@ -3,4 +3,3 @@ dist/ .void/ .wrangler/ node_modules/ -src/middleware.ts From 7d7e1dc11227a99dab79ad33d51db7e412645ddd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 20:38:23 +0800 Subject: [PATCH 09/16] fix(void): support D1 sessions on the remote dev binding Bug: With `remote: true` in void.config.ts (or `VOID_REMOTE=1 void dev`), any route that uses `db` from `void/db`, `createDb()`, or Better Auth (its adapter wraps `void/db`) fails with "TypeError: d1.withSession is not a function". Build-time prerender in remote mode hits the same path. Repro: in an app with D1, run `void connect`, `void project link`, then `VOID_REMOTE=1 void dev` and request a route that runs `db.select().from(users)`. Cause: `createDb()` and `createPrimaryDb()` in runtime/db.ts, and the generated `void/db` module in plugin-drizzle.ts, now call `d1.withSession(...)` before handing the binding to Drizzle. In remote mode, `createRemoteEnv()` swaps env.DB for `ProxyD1Database` (runtime/remote/d1.ts), which only had prepare/batch/exec/dump. Fix: add `withSession()` (returns the same proxy) and `getBookmark()` (returns null) to `ProxyD1Database`. Each proxied statement is its own HTTP call that the platform runs on the deployed binding without a session, so there is no replica or bookmark state to keep. Drizzle's D1 session only calls prepare/bind/all/raw/run/batch on the client, which the proxy already has, so no other file needs to change. Test: packages/void/test/unit/remote/d1.test.ts, "ProxyD1Database > createDb() > runs Drizzle queries through the remote binding". It fails without the fix with "TypeError: d1.withSession is not a function". Note: session constraints and bookmarks are not forwarded to the platform in remote mode; `getBookmark()` always returns null. Forwarding them would need a platform protocol change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/runtime/remote/d1.ts | 11 +++++++++++ packages/void/test/unit/remote/d1.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/void/src/runtime/remote/d1.ts b/packages/void/src/runtime/remote/d1.ts index b682ee67a..7f9804f76 100644 --- a/packages/void/src/runtime/remote/d1.ts +++ b/packages/void/src/runtime/remote/d1.ts @@ -47,6 +47,17 @@ export class ProxyD1Database { dump(): Promise { throw new Error('dump() is not supported with remote bindings'); } + + // Each statement is a separate proxy call that the platform runs on the + // deployed binding without a session, so there is no replica or bookmark + // state to carry. The proxy itself serves as the session. + withSession(_constraintOrBookmark?: string): ProxyD1Database { + return this; + } + + getBookmark(): string | null { + return null; + } } /** diff --git a/packages/void/test/unit/remote/d1.test.ts b/packages/void/test/unit/remote/d1.test.ts index be3e4908e..96e6b50b0 100644 --- a/packages/void/test/unit/remote/d1.test.ts +++ b/packages/void/test/unit/remote/d1.test.ts @@ -1,4 +1,6 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { describe, it, expect, vi, afterEach } from 'vite-plus/test'; +import { createDb } from '../../../src/runtime/db.ts'; import { ProxyClient } from '../../../src/runtime/remote/client.ts'; import { ProxyD1Database } from '../../../src/runtime/remote/d1.ts'; @@ -165,6 +167,23 @@ describe('ProxyD1Database', () => { }); }); + describe('createDb()', () => { + it('runs Drizzle queries through the remote binding', async () => { + const users = sqliteTable('users', { id: integer('id').primaryKey(), name: text('name') }); + const fetchMock = mockFetch({ columns: ['id', 'name'], results: [[1, 'Alice']] }); + + const db = createDb(new ProxyD1Database(createClient(), 'DB') as unknown as D1Database); + const rows = await db.select().from(users); + + expect(rows).toEqual([{ id: 1, name: 'Alice' }]); + const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const body = JSON.parse(String(call[1].body)); + expect(body.binding).toBe('DB'); + expect(body.method).toBe('raw'); + expect(body.sql).toMatch(/^select .* from "users"$/); + }); + }); + describe('bind()', () => { it('replaces params only on the returned statement', async () => { const db = new ProxyD1Database(createClient(), 'DB'); From 68476fd92e2f5bca5cf662bf19dc3f30f2db9aed Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 20:54:46 +0800 Subject: [PATCH 10/16] fix(db): split drizzle statement breakpoints in MySQL migrations Bug: on a MySQL app, a migration made by `void db generate` with more than one statement fails to apply. This hits `void db migrate` (local and remote), `void db reset`/`seed`, `void dev` auto-migrate, direct Cloudflare deploys, and managed deploys through POST /__void/migrate. MySQL returns ER_PARSE_ERROR near `--> statement-breakpoint`. The statements before the first breakpoint stay applied (MySQL DDL commits on its own), the migration is not recorded, and every retry then fails with "Table ... already exists". Repro: a schema with two tables gives drizzle-kit output like CREATE TABLE `a` (...); --> statement-breakpoint CREATE TABLE `b` (...); Run `void db migrate` against MySQL 8: table `a` is created, then the parse error, and `_void_migrations` stays empty. Cause: applyMySqlMigrations in migrations/runner-mysql.ts and runMySqlMigrations in runtime/migration-handler-mysql.ts send the whole file as one multi-statement query. drizzle-kit puts a `--> statement-breakpoint` line between statements. PostgreSQL reads it as a comment. MySQL does not, because MySQL needs whitespace after `--` for a comment. The D1 path already splits on these lines; the MySQL paths never did. Fix: export the existing line-based splitMigrationSql from runtime/migration-handler.ts (D1 behavior unchanged) and run each chunk as its own query in both MySQL runners. This splits the same way drizzle-orm's own migrator does. Files with no breakpoint lines come back as one chunk, so hand-written multi-statement migrations still run as one query (the connection keeps multipleStatements). Test: packages/void/test/unit/runner-mysql.test.ts, "runs each drizzle statement-breakpoint chunk as its own query". It fails without the fix (the whole file arrives as one query). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/migrations/runner-mysql.ts | 6 ++- .../src/runtime/migration-handler-mysql.ts | 12 ++++- .../void/src/runtime/migration-handler.ts | 2 +- packages/void/test/unit/runner-mysql.test.ts | 48 ++++++++++++++++++- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index ab20c753a..ca130b02d 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,4 +1,5 @@ import type { Connection } from 'mysql2/promise'; +import { splitMigrationSql } from '../runtime/migration-handler.ts'; import { collectMigrations } from './collect.ts'; export type MySqlClient = { @@ -64,7 +65,10 @@ export async function applyMySqlMigrations( const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); for (const migration of pending) { - await client.query(migration.sql); + // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. + for (const statement of splitMigrationSql(migration.sql)) { + await client.query(statement); + } await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ migration.name, new Date().toISOString(), diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index 6a9839c32..e5563f39b 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -1,4 +1,9 @@ -import type { MigrationEntry, MigrationResponse, MigrationResult } from './migration-handler.ts'; +import { + splitMigrationSql, + type MigrationEntry, + type MigrationResponse, + type MigrationResult, +} from './migration-handler.ts'; export function createMigrationHandler( env: Record, @@ -62,7 +67,10 @@ async function runMySqlMigrations( } const start = Date.now(); try { - await connection.query(migration.sql); + // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. + for (const statement of splitMigrationSql(migration.sql)) { + await connection.query(statement); + } await connection.query( 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [migration.name, new Date().toISOString()], diff --git a/packages/void/src/runtime/migration-handler.ts b/packages/void/src/runtime/migration-handler.ts index f815914f3..b12dfc49a 100644 --- a/packages/void/src/runtime/migration-handler.ts +++ b/packages/void/src/runtime/migration-handler.ts @@ -711,7 +711,7 @@ async function recordLegacyMigration(db: D1Database, name: string): Promise { +export function splitMigrationSql(sql: string): Array { const lines = sql.split(/\r?\n/); if ( !lines.some((line) => isStatementBreakpointLine(line) || hasInlineStatementBreakpoint(line)) diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 5356093f2..11031b0db 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,9 +1,22 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { applyMySqlMigrations, readMySqlAppliedMigrations, type MySqlClient, } from '../../src/migrations/runner-mysql.ts'; +import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; + +const mysqlQueries = vi.hoisted(() => [] as Array); + +vi.mock('mysql2/promise', () => ({ + createConnection: async () => ({ + query: async (sql: string) => { + mysqlQueries.push(sql); + return [[]]; + }, + end: async () => {}, + }), +})); describe('MySQL migration runner', () => { it('applies only pending migrations and records them with MySQL placeholders', async () => { @@ -58,4 +71,37 @@ describe('MySQL migration runner', () => { }; await expect(readMySqlAppliedMigrations(denied)).rejects.toBe(permissionError); }); + + it('runs each drizzle statement-breakpoint chunk as its own query', async () => { + const sql = + 'CREATE TABLE `a` (`id` int);\n--> statement-breakpoint\nCREATE INDEX `a_id_idx` ON `a` (`id`);'; + const statements = ['CREATE TABLE `a` (`id` int);', 'CREATE INDEX `a_id_idx` ON `a` (`id`);']; + const migrationQueries = (queries: Array) => + queries.filter((query) => !query.includes('_void_migrations')); + + const calls: Array = []; + const client: MySqlClient = { + async query> = Array>>( + query: string, + ): Promise { + calls.push(query); + return [] as unknown as T; + }, + }; + await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); + expect(migrationQueries(calls)).toEqual(statements); + + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + [{ name: '001_init.sql', sql }], + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + expect(await response.json()).toMatchObject({ ok: true }); + expect(migrationQueries(mysqlQueries)).toEqual(statements); + }); }); From c14338e86f956c76857034f21fbba95f6e614f6e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 20:50:02 +0800 Subject: [PATCH 11/16] fix(auth): keep MySQL auth migrations idempotent on MySQL 8 Bug: On a MySQL app with auth enabled, the second auth migration run fails with "Duplicate key name 'user_email_uidx'". This hits `void dev` on every restart (printed as "Migration error: ...") and the deployed POST /__void/migrate endpoint (returns 500). New auth fields and the varchar-to-text widening of OAuth token columns are never applied. Repro: Point a MySQL 8 database at an auth app, run the auth migrations once (tables are created), then run them again, or add a field to the `user` table and run them again. Cause: applyMySqlAuthMigrations in packages/void/src/runtime/auth-migrations.ts reads `row.table_name`, `row.column_name`, `row.data_type` and `row.index_name` from unaliased information_schema queries. MySQL 8 labels those columns in uppercase (TABLE_NAME, ...), so every lookup is undefined and every auth table looks missing. CREATE TABLE IF NOT EXISTS is a no-op, then CREATE UNIQUE INDEX (no IF NOT EXISTS in MySQL) throws. Once the lookups work, the column-diff path emits `ADD COLUMN IF NOT EXISTS`, which MySQL 8 rejects as a syntax error (that form is MariaDB-only). Fix: Alias each information_schema column to the lowercase name the code reads, and emit `ADD COLUMN IF NOT EXISTS` only for PostgreSQL. The MySQL path adds a column only after information_schema shows it is missing, so the guard is not needed there; SQLite output is unchanged. The unused character_maximum_length column is dropped from the rewritten query. Test: packages/void/test/unit/auth-migrations.test.ts, "re-runs MySQL auth migrations against MySQL 8 uppercase information_schema labels". Without the fix it receives CREATE TABLE IF NOT EXISTS plus a duplicate CREATE UNIQUE INDEX instead of a single ADD COLUMN. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/runtime/auth-migrations.ts | 16 +++-- .../void/test/unit/auth-migrations.test.ts | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/void/src/runtime/auth-migrations.ts b/packages/void/src/runtime/auth-migrations.ts index 3d04a2c29..98ab45a93 100644 --- a/packages/void/src/runtime/auth-migrations.ts +++ b/packages/void/src/runtime/auth-migrations.ts @@ -195,7 +195,8 @@ function buildAddColumnSql( fieldName: string, field: VoidAuthMigrationField, ): string { - const ifNotExists = schema.dialect === 'sqlite' ? '' : ' IF NOT EXISTS'; + // Only PostgreSQL accepts ADD COLUMN IF NOT EXISTS; callers check the column exists first. + const ifNotExists = schema.dialect === 'postgresql' ? ' IF NOT EXISTS' : ''; return `ALTER TABLE ${quoteIdentifier(tableName, schema.dialect)} ADD COLUMN${ifNotExists} ${buildColumnDefinition( schema.dialect, schema, @@ -350,8 +351,9 @@ export async function applyMySqlAuthMigrations( 'SELECT DATABASE() AS database_name', ); const databaseName = String(databaseRows[0]?.database_name ?? ''); + // MySQL 8 labels unaliased information_schema columns in uppercase, so alias each one. const tableRows = await runner.all<{ table_name?: string }>( - "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", + "SELECT TABLE_NAME AS table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", [databaseName], ); const existingTables = new Set(tableRows.map((row) => String(row.table_name))); @@ -361,12 +363,8 @@ export async function applyMySqlAuthMigrations( await runner.run(buildCreateTableSql(schema, table)); existingTables.add(table.name); } else { - const columnRows = await runner.all<{ - column_name?: string; - data_type?: string; - character_maximum_length?: number | string | null; - }>( - 'SELECT column_name, data_type, character_maximum_length FROM information_schema.columns WHERE table_schema = ? AND table_name = ?', + const columnRows = await runner.all<{ column_name?: string; data_type?: string }>( + 'SELECT COLUMN_NAME AS column_name, DATA_TYPE AS data_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ?', [databaseName, table.name], ); const existingColumns = new Map( @@ -386,7 +384,7 @@ export async function applyMySqlAuthMigrations( } const indexRows = await runner.all<{ index_name?: string }>( - 'SELECT index_name FROM information_schema.statistics WHERE table_schema = ? AND table_name = ?', + 'SELECT INDEX_NAME AS index_name FROM information_schema.statistics WHERE table_schema = ? AND table_name = ?', [databaseName, table.name], ); const existingIndexes = new Set(indexRows.map((row) => String(row.index_name))); diff --git a/packages/void/test/unit/auth-migrations.test.ts b/packages/void/test/unit/auth-migrations.test.ts index ee85acf59..28d3c13e3 100644 --- a/packages/void/test/unit/auth-migrations.test.ts +++ b/packages/void/test/unit/auth-migrations.test.ts @@ -222,4 +222,66 @@ describe('auth migration runner', () => { ]); expect(statements.some((sql) => sql.includes('`userId`'))).toBe(false); }); + + it('re-runs MySQL auth migrations against MySQL 8 uppercase information_schema labels', async () => { + const statements: Array = []; + const resolved = resolveVoidAuthMigrationSchema({ dialect: 'mysql', providers: null }); + const user = resolved.tables.find((table) => table.name === 'user')!; + const schema: VoidAuthMigrationSchema = { + ...resolved, + tables: [ + { + ...user, + fields: { + ...user.fields, + nickname: { + type: 'string', + required: false, + unique: false, + index: false, + bigint: false, + }, + }, + }, + ], + }; + // MySQL 8 labels an unaliased information_schema column with its uppercase name. + const row = (sql: string, values: Record) => + Object.fromEntries( + Object.entries(values).map(([column, value]) => [ + new RegExp(`\\bAS ${column}\\b`).test(sql) ? column : column.toUpperCase(), + value, + ]), + ); + + await applyMySqlAuthMigrations( + { + async all>(sql: string) { + if (sql.includes('SELECT DATABASE()')) { + return [row(sql, { database_name: 'app' })] as unknown as Array; + } + if (sql.includes('information_schema.tables')) { + return [row(sql, { table_name: 'user' })] as unknown as Array; + } + if (sql.includes('information_schema.columns')) { + return ['id', ...Object.keys(user.fields)].map((column_name) => + row(sql, { column_name, data_type: 'varchar' }), + ) as unknown as Array; + } + if (sql.includes('information_schema.statistics')) { + return ['PRIMARY', 'user_email_uidx'].map((index_name) => + row(sql, { index_name }), + ) as unknown as Array; + } + return [] as Array; + }, + async run(sql: string) { + statements.push(sql); + }, + }, + schema, + ); + + expect(statements).toEqual(['ALTER TABLE `user` ADD COLUMN `nickname` varchar(255)']); + }); }); From 6ac0e4dba983f62c0462b05a377abb2253b8ce25 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:02:45 +0800 Subject: [PATCH 12/16] fix(react): reject the page render when a React page throws during SSR Bug: an HTML request to a React Pages route whose page or layout throws while rendering (for example reading props.user.name with no user) never gets a response. In workerd the request hangs until the runtime cancels it ("Worker's code had hung"), and Node logs an uncaught exception. The custom 500 page and the dev error overlay never show. The platform admin UI renders through the same function and hangs the same way. Repro: a React page component that throws during render; request it with a browser (not an X-VoidPages fetch). Cause: renderReactPage in packages/react/src/runtime/pages-server.ts passed an onError to renderToReadableStream that rethrows every error. React calls onError from its own scheduled work loop without a guard, so the throw skips React's shell rejection, escapes as an uncaught exception, and the renderToReadableStream promise never settles. Even a non-throwing onError would not reject: the blanket Suspense around the page turns its errors into a client-render fallback. Fix: onError no longer throws. It records the first error seen before the shell is ready; renderReactPage then cancels the React stream and rejects with that original error, so the Pages protocol tags it and serves the 500 page or dev overlay as it already expects. Errors after the shell is sent keep React's client-render fallback and are logged with console.error (React's default). The deferred-shell cancel reason is still ignored. Only this function changes; callers are untouched. Test: packages/react/test/unit/pages-server.test.ts, "rejects with the error a page throws while rendering"; without the fix it times out after 5s and reports the error as an uncaught exception. Note: an error thrown on the first render pass, even inside a user's own Suspense boundary, now fails the page with a 500 instead of React's client-render fallback; only errors after the shell is sent fall back. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/react/src/runtime/pages-server.ts | 16 +++++++++++++++- packages/react/test/unit/pages-server.test.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/react/test/unit/pages-server.test.ts diff --git a/packages/react/src/runtime/pages-server.ts b/packages/react/src/runtime/pages-server.ts index 25c09f374..c4392040b 100644 --- a/packages/react/src/runtime/pages-server.ts +++ b/packages/react/src/runtime/pages-server.ts @@ -100,14 +100,28 @@ export async function renderReactPage({ ) : element; + // React calls onError inside its work loop, so it must not throw. The outer + // Suspense turns page errors into a client-render fallback; fail the render + // instead when they happen before the shell, so the caller serves its error page. + let shellError: { error: unknown } | undefined; + let shellSent = false; const appStream = await renderToReadableStream(renderElement, { onError(error) { if (error === DEFERRED_SHELL_CANCEL_REASON) { return; } - throw error; + if (shellSent) { + console.error(error); + return; + } + shellError ??= { error }; }, }); + if (shellError) { + await appStream.cancel(shellError.error); + throw shellError.error; + } + shellSent = true; const renderedAppStream = pageObj.deferredKeys ? readDeferredShell(appStream) : appStream; const pageData = serializePageData(pageObj); const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ''; diff --git a/packages/react/test/unit/pages-server.test.ts b/packages/react/test/unit/pages-server.test.ts new file mode 100644 index 000000000..7d02bd23c --- /dev/null +++ b/packages/react/test/unit/pages-server.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vite-plus/test'; +import { renderReactPage } from '../../src/runtime/pages-server.ts'; + +it('rejects with the error a page throws while rendering', async () => { + const boom = new TypeError('page render failed'); + function Page(): never { + throw boom; + } + + await expect( + renderReactPage({ + pageObj: { component: 'page', props: {}, url: '/', params: {}, shared: {}, errors: null }, + assetTags: { css: '', preloads: '', body: '' }, + components: { page: async () => ({ default: Page }) }, + layoutTree: {}, + }), + ).rejects.toBe(boom); +}); From 4ab32eef50f723293e6149a98827472ba16f44d1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sun, 11 Oct 2026 21:13:28 +0800 Subject: [PATCH 13/16] fix(solid): keep layouts and page state across navigation and updates Bug: In a Solid Pages app (voidSolid()), every client-side update rebuilt the whole page tree. SPA navigation through Link, router.visit or back/forward replaced the root layout, so layout state was lost. A useForm submit that came back with validation errors re-created the page and cleared the inputs the user had typed. A resolving defer() prop re-created the page and every layout too. The preserveState option on Link and router.visit had no effect. Repro: in playground/solid-pages, open /, set a marker on the