From e6222f39ebf875363b62f3e3e713cacb3e1387bd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 13 Oct 2026 21:50:27 +0800 Subject: [PATCH 01/17] fix(void): run MySQL void/db and migrations in workerd MySQL apps failed in workerd in three ways: - vp dev: void/db imported mysql2 by file path. Vite only sends bare imports to the dep optimizer, so workerd got raw CommonJS and every void/db route returned "require is not defined". With Void auth on, the dev server did not start. - dev and built Workers: no mysql2 connection set disableEval, so row-returning queries failed with "Code generation from strings disallowed for this context". This also broke the hosted /__void/migrate and auth migration routes. void/db now lets drizzle build the mysql2 pool, so the only mysql2 import is drizzle's bare one. Void-owned worker runtimes prebundle 'void > mysql2' for MySQL apps. Every mysql2 connection that runs in workerd sets disableEval: true, as Cloudflare's Hyperdrive docs require. Also correct the deploy migration comment: a failed MySQL file can leave earlier statements applied, so reruns are not idempotent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- .../void/src/cli/deploy-cloudflare-migrate.ts | 4 +- packages/void/src/index.ts | 1 + packages/void/src/plugin-drizzle.ts | 28 ++--- .../void/src/plugin-worker-environment.ts | 12 ++- .../void/src/runtime/better-auth-mysql.ts | 5 +- .../src/runtime/migration-handler-mysql.ts | 1 + .../void/test/helpers/fake-mysql-server.ts | 102 ++++++++++++++++++ .../void/test/integration/mysql-test.test.ts | 88 +++++++++++++++ .../unit/plugin-dev-asset-routing.test.ts | 37 ++++++- .../void/test/unit/plugin-drizzle.test.ts | 102 ++++++++++++++---- packages/void/test/unit/runner-mysql.test.ts | 50 +++++++-- playground/mysql-test/db/schema.ts | 7 ++ playground/mysql-test/package.json | 17 +++ playground/mysql-test/routes/api/users.ts | 8 ++ .../mysql-test/routes/api/users/create-db.ts | 8 ++ playground/mysql-test/tsconfig.json | 15 +++ playground/mysql-test/vite.config.ts | 6 ++ playground/mysql-test/void.config.ts | 8 ++ pnpm-lock.yaml | 15 +++ test/integration-timings.ts | 1 + 20 files changed, 468 insertions(+), 47 deletions(-) create mode 100644 packages/void/test/helpers/fake-mysql-server.ts create mode 100644 packages/void/test/integration/mysql-test.test.ts create mode 100644 playground/mysql-test/db/schema.ts create mode 100644 playground/mysql-test/package.json create mode 100644 playground/mysql-test/routes/api/users.ts create mode 100644 playground/mysql-test/routes/api/users/create-db.ts create mode 100644 playground/mysql-test/tsconfig.json create mode 100644 playground/mysql-test/vite.config.ts create mode 100644 playground/mysql-test/void.config.ts diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index e47de676e..eb794913e 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -119,7 +119,9 @@ export async function migrateCloudflareWorker(input: { let appliedMigrations = 0; // Cloudflare never returns the Hyperdrive password, so the deploy environment provides the - // production URL. The runners record applied names, so retries are idempotent. + // production URL. A rerun skips migrations already recorded as finished. A failed PostgreSQL + // file rolls back. The MySQL runner uses no transaction and MySQL DDL commits on its own, so a + // failed MySQL file can leave its earlier statements applied. if (dialect !== 'sqlite' && migrations.length > 0) { const mysql = dialect === 'mysql'; const label = mysql ? 'MySQL' : 'PostgreSQL'; diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 26384479c..5ccd150a2 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -210,6 +210,7 @@ export function voidPlugin(options?: { // framework and Node targets that do not all use bindingRuntimePlugin. const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin( !isNodeTarget(config.target) && !frameworkOwnsCloudflareRuntime, + dialect, ); // Scan queues/websocket routes early so both framework-mode and default-mode // branches can safely close over these bindings. diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 7d5c6ea51..ac927ab5b 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -16,7 +16,6 @@ const envModuleResolved = fileURLToPath(import.meta.resolve('#self/_env')); const drizzleNodePgResolved = fileURLToPath(import.meta.resolve('drizzle-orm/node-postgres')); const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); const pgResolved = fileURLToPath(import.meta.resolve('pg')); -const mysql2Resolved = fileURLToPath(import.meta.resolve('mysql2')); const VIRTUAL_DB_ID = '\0virtual:void-db'; const VIRTUAL_SCHEMA_DB_ID = '\0virtual:void-db-schema'; @@ -147,16 +146,14 @@ export function createDb() { const schemaImport = schema ? `import * as schema from ${JSON.stringify(schema.path)};` : ''; - const drizzleConfig = schema - ? dialect === 'mysql' - ? ', { schema, mode: "default" }' - : ', { schema }' - : ''; + const drizzleConfig = schema ? ', { schema }' : ''; if (dialect === 'mysql') { + // drizzle builds the pool from `connection`, so its bare mysql2 import is the only one + // and the worker prebundle serves it. Its config check rejects `mode` without `schema`. + const mysqlDrizzleConfig = schema ? ', schema, mode: "default"' : ''; return ` import { drizzle } from ${JSON.stringify(drizzleMysql2Resolved)}; -import { createPool } from ${JSON.stringify(mysql2Resolved)}; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -173,13 +170,16 @@ function getConnectionString() { function getInstance() { return getRuntimeRequestResource(_requestResourceKey, () => { - const pool = createPool({ - uri: getConnectionString(), - connectionLimit: 1, - maxIdle: 1, - idleTimeout: 1000, + return drizzle({ + connection: { + uri: getConnectionString(), + connectionLimit: 1, + maxIdle: 1, + idleTimeout: 1000, + // Workers forbid eval, which mysql2 uses to build its row parsers. + disableEval: true, + }${mysqlDrizzleConfig}, }); - return drizzle(pool${drizzleConfig}); }); } @@ -190,7 +190,7 @@ export const db = new Proxy({}, { }); export function createDb(connectionString) { - return drizzle(connectionString${drizzleConfig}); + return drizzle({ connection: { uri: connectionString, disableEval: true }${mysqlDrizzleConfig} }); } `; } diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index 020b09a24..acde5aa50 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url'; import { type Plugin } from 'vite'; import { ensureWranglerCompat } from './cli/wrangler.ts'; -import { readConfig, isNodeTarget } from './config.ts'; +import { readConfig, isNodeTarget, type DatabaseDialect } from './config.ts'; const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; @@ -130,7 +130,10 @@ export function createWranglerCompatPersistPlugin( }; } -export function createServerRuntimeOptimizerPlugin(voidOwnsWorkerRuntime: boolean): Plugin { +export function createServerRuntimeOptimizerPlugin( + voidOwnsWorkerRuntime: boolean, + dialect: DatabaseDialect, +): Plugin { const serverRuntimeOptimizerPlugin: Plugin = { name: 'void:server-runtime-optimizer', configEnvironment(name) { @@ -146,8 +149,9 @@ export function createServerRuntimeOptimizerPlugin(voidOwnsWorkerRuntime: boolea // Excluding void also skips converting its CommonJS dependencies, which workerd cannot // evaluate: an installed void/email fails with "exports is not defined". Runtimes a // framework owns stay untouched; Node loads CommonJS itself, and forcing an include - // there turns on SSR prebundling the framework did not ask for. - include: ['void > email-validator'], + // there turns on SSR prebundling the framework did not ask for. mysql2 is CommonJS too: + // void/db leaves its import to drizzle, so this nested prebundle serves it. + include: ['void > email-validator', ...(dialect === 'mysql' ? ['void > mysql2'] : [])], }, }; }, diff --git a/packages/void/src/runtime/better-auth-mysql.ts b/packages/void/src/runtime/better-auth-mysql.ts index 051a54f5c..adb9a9476 100644 --- a/packages/void/src/runtime/better-auth-mysql.ts +++ b/packages/void/src/runtime/better-auth-mysql.ts @@ -12,7 +12,10 @@ async function applyMigrations( migrationSchema: VoidAuthMigrationSchema, ): Promise { const { createConnection } = await import('mysql2/promise'); - const connection = await createConnection(resolveConnectionString(env, 'MySQL')); + const connection = await createConnection({ + uri: resolveConnectionString(env, 'MySQL'), + disableEval: true, + }); try { await applyMySqlAuthMigrations(mysqlMigrationRunner(connection), migrationSchema); } finally { diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index e5563f39b..ef79a7ea8 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -51,6 +51,7 @@ async function runMySqlMigrations( const connection = await createConnection({ uri: hyperdrive.connectionString, multipleStatements: true, + disableEval: true, }); try { await connection.query( diff --git a/packages/void/test/helpers/fake-mysql-server.ts b/packages/void/test/helpers/fake-mysql-server.ts new file mode 100644 index 000000000..da4a8bdd5 --- /dev/null +++ b/packages/void/test/helpers/fake-mysql-server.ts @@ -0,0 +1,102 @@ +import { once } from 'node:events'; +import { createServer, type AddressInfo, type Socket } from 'node:net'; +import mysql from 'mysql2'; + +/** The parts of mysql2's server-side connection that the fake server uses. */ +type ServerConnection = { + sequenceId: number; + serverHandshake(options: Record): void; + on(event: 'query', listener: (sql: string) => void): void; + on(event: 'error', listener: (error: Error) => void): void; + writeColumns(columns: Array>): void; + writeTextRow(values: Array): void; + writeEof(): void; + writeOk(): void; +}; + +const { Connection, ConnectionConfig } = mysql as unknown as { + Connection: new (options: { config: unknown }) => ServerConnection; + ConnectionConfig: new (options: { stream: Socket; isServer: true }) => unknown; +}; + +const CHARSET = mysql.Charsets.UTF8_GENERAL_CI; +// Every basic capability except CLIENT_SSL (0x0800), so clients never ask for TLS. +const CAPABILITY_FLAGS = 0xffff & ~0x0800; + +export type FakeMySqlRow = Record; + +export type FakeMySqlServer = { + port: number; + close(): Promise; +}; + +/** + * A MySQL wire-protocol server built on mysql2's own server API. It accepts any user, answers + * every SELECT with `rows` (columns in key order of the first row) and every other statement + * with OK. It speaks the text protocol only, which is what drizzle's mysql2 driver uses. + */ +export async function startFakeMySqlServer(rows: Array): Promise { + const columnNames = Object.keys(rows[0] ?? {}); + const columns = columnNames.map((name) => ({ + catalog: 'def', + schema: 'app', + table: 'fake', + orgTable: 'fake', + name, + orgName: name, + characterSet: CHARSET, + columnLength: 255, + columnType: typeof rows[0]?.[name] === 'number' ? mysql.Types.LONG : mysql.Types.VAR_STRING, + flags: 0, + decimals: 0, + })); + const sockets = new Set(); + + const server = createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + const connection = new Connection({ + config: new ConnectionConfig({ stream: socket, isServer: true }), + }); + connection.serverHandshake({ + protocolVersion: 10, + serverVersion: '8.4.0-void-test', + connectionId: 1, + statusFlags: 2, + characterSet: CHARSET, + capabilityFlags: CAPABILITY_FLAGS, + authCallback(_params: unknown, done: (error: null) => void) { + done(null); + // Each command starts a new packet sequence; the server API leaves the reset to us. + connection.sequenceId = 0; + }, + }); + connection.on('query', (sql) => { + if (/^\s*select\b/i.test(sql)) { + connection.writeColumns(columns); + for (const row of rows) { + connection.writeTextRow(columnNames.map((name) => row[name])); + } + connection.writeEof(); + } else { + connection.writeOk(); + } + connection.sequenceId = 0; + }); + // A client that drops its socket emits an error here; unhandled, it would end the test run. + connection.on('error', () => {}); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + return { + port: (server.address() as AddressInfo).port, + async close() { + for (const socket of sockets) { + socket.destroy(); + } + server.close(); + await once(server, 'close'); + }, + }; +} diff --git a/packages/void/test/integration/mysql-test.test.ts b/packages/void/test/integration/mysql-test.test.ts new file mode 100644 index 000000000..56639b2e8 --- /dev/null +++ b/packages/void/test/integration/mysql-test.test.ts @@ -0,0 +1,88 @@ +import { execSync, type ChildProcess } from 'node:child_process'; +import { rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + killChild, + spawnPlaygroundServer, + startServer, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = join(ROOT, 'playground/mysql-test'); +const ENV_PATH = join(PLAYGROUND, '.env'); +const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, +]; + +// workerd cannot evaluate mysql2's CommonJS source or the eval its row parsers use by default. +describe('playground/mysql-test', () => { + let mysqlServer: FakeMySqlServer; + let child: ChildProcess | undefined; + + beforeAll(async () => { + mysqlServer = await startFakeMySqlServer(USERS); + writeFileSync(ENV_PATH, `DATABASE_URL=mysql://void@127.0.0.1:${mysqlServer.port}/app\n`); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + await mysqlServer?.close(); + try { + unlinkSync(ENV_PATH); + } catch { + // Ignore missing cleanup files between test runs. + } + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer( + (port) => (child = spawnPlaygroundServer(ROOT, 'mysql-test', script, port)), + 30_000, + ); + } + + async function stop(): Promise { + if (child) { + await killChild(child); + child = undefined; + } + } + + async function expectUsers({ base, output }: StartedServer): Promise { + for (const path of ['/api/users', '/api/users/create-db']) { + const response = await fetch(base + path); + const result = { path, status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ + path, + status: 200, + body: JSON.stringify(USERS), + }); + } + } + + it('queries MySQL through void/db and createDb in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); + + it('queries MySQL through void/db and createDb in the built Worker', async () => { + rmSync(join(PLAYGROUND, 'dist'), { recursive: true, force: true }); + execSync('vp run --filter ./playground/mysql-test build', { cwd: ROOT, stdio: 'pipe' }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); +}); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 6b7859fc7..1dbd6d651 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Plugin } from 'vite'; @@ -90,6 +90,13 @@ function createNodeProjectRoot(): string { return root; } +function useDatabase(root: string, database: 'pg' | 'mysql'): string { + const configPath = join(root, 'void.json'); + const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + writeFileSync(configPath, JSON.stringify({ ...config, database })); + return root; +} + function createDevOnlyApiProjectRoot(): string { const root = createProjectRoot(); mkdirSync(join(root, 'routes/api'), { recursive: true }); @@ -249,6 +256,34 @@ describe('voidPlugin dev asset routing', () => { }); }); + it('prebundles mysql2 for void/db only in worker runtimes Void owns', async () => { + await expect( + resolveRuntimeOptimizerConfig( + useDatabase(createApiOnlyProjectRoot(), 'mysql'), + 'void_worker', + ), + ).resolves.toEqual({ + optimizeDeps: { exclude: ['void'], include: ['void > email-validator', 'void > mysql2'] }, + }); + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(useDatabase(createApiOnlyProjectRoot(), 'pg'), 'void_worker'), + ).resolves.toEqual({ + optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, + }); + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig( + useDatabase(createFrameworkOwnedRuntimeProjectRoot(), 'mysql'), + 'ssr', + ), + ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(useDatabase(createNodeProjectRoot(), 'mysql'), 'ssr'), + ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); + }); + it('leaves client dependency optimization unchanged', async () => { await expect( resolveRuntimeOptimizerConfig(createPagesProjectRoot(), 'client'), diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 3937d9cb2..308816a90 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -32,6 +32,7 @@ function createProjectRoot(options?: { schema?: boolean }): string { type FakePoolRecord = { dialect: 'postgresql' | 'mysql'; options: Record; + drizzleConfig?: Record; }; type FakeDatabaseState = { @@ -133,23 +134,20 @@ export function drizzle(pool) { `import { getRuntimeBinding as getTestRuntimeBinding } from ${JSON.stringify(envUrl)}; const state = ${stateExpression}; ${createDatabaseSource} -export function createPool(options) { - const pool = { options }; - state.pools.push({ dialect: "mysql", options }); - return pool; -} -export function drizzle(pool) { - if (typeof pool === "string") pool = { options: { uri: pool } }; - return createDatabase("mysql", pool); +export function drizzle(config) { + if (typeof config?.connection !== "object" || config.connection === null) { + throw new Error("void/db must let drizzle build the mysql2 pool from a connection object"); + } + const { connection, ...drizzleConfig } = config; + state.pools.push({ dialect: "mysql", options: connection, drizzleConfig }); + return createDatabase("mysql", { options: connection }); } `, ); - code = code - .replace(/^import \{ drizzle \} from .*;$/m, `import { drizzle } from "./fake-driver.mjs";`) - .replace( - /^import \{ createPool \} from .*;$/m, - `import { createPool } from "./fake-driver.mjs";`, - ); + code = code.replace( + /^import \{ drizzle \} from .*;$/m, + `import { drizzle } from "./fake-driver.mjs";`, + ); } code = code.replace( @@ -371,28 +369,94 @@ describe('drizzlePlugin db typing generation', () => { ); expect(await background).toBe(responseInstance); - expect(state.pools).toHaveLength(6); - for (const pool of state.pools) { + // MySQL createDb() builds its own pool through drizzle; the other pools are per request. + const requestPools = state.pools.filter((pool) => pool.options.uri !== explicitOwner); + expect(requestPools).toHaveLength(6); + for (const pool of requestPools) { if (dialect === 'postgresql') { expect(pool.options).toMatchObject({ max: 1, idleTimeoutMillis: 1000 }); } else { expect(pool.options).toMatchObject({ connectionLimit: 1, maxIdle: 1, idleTimeout: 1000 }); } } + if (dialect === 'mysql') { + expect(state.pools).toHaveLength(7); + expect(state.pools.find((pool) => pool.options.uri === explicitOwner)?.options).toEqual({ + uri: explicitOwner, + disableEval: true, + }); + for (const pool of state.pools) { + expect(pool.options).toMatchObject({ disableEval: true }); + expect(pool.drizzleConfig).toEqual({ schema: expect.anything(), mode: 'default' }); + } + } }, ); - it('uses the required default mode for MySQL databases with a schema', () => { + it('leaves the MySQL driver import to drizzle', () => { const root = createProjectRoot(); const plugin = drizzlePlugin(root, false, 'mysql'); const load = getHookHandler(plugin.load) as (this: object, id: string) => string; const code = load.call({}, '\0virtual:void-db'); + const specifiers = Array.from( + code.matchAll(/\b(?:from|import)\s*\(?\s*("[^"]+")/g), + (match) => JSON.parse(match[1]!) as string, + ); - expect(code).toContain('return drizzle(pool, { schema, mode: "default" })'); - expect(code).toContain('drizzle(connectionString, { schema, mode: "default" })'); + // The worker optimizer serves drizzle's bare mysql2 import from a prebundle; a direct import + // by file path would reach workerd as raw CommonJS. + expect(specifiers.filter((specifier) => specifier.includes('mysql2'))).toEqual([ + expect.stringMatching(/drizzle-orm[\\/]mysql2[\\/]/), + ]); }); + it.each([true, false])( + 'gives drizzle a mysql2 pool with eval disabled (schema: %s)', + async (hasSchema) => { + const root = createProjectRoot({ schema: hasSchema }); + const plugin = drizzlePlugin(root, false, 'mysql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + writeFileSync( + join(root, 'harness.mjs'), + `import { db, createDb } from "./virtual-db.mjs"; +import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; +export { createDb }; +export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); +`, + ); + type MySqlPool = { config: { connectionConfig: { disableEval: boolean } }; end(): void }; + const harness = (await import( + `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` + )) as { + createDb(url: string): { $client: MySqlPool }; + requestClient(env: Record): MySqlPool; + }; + + const pools = [ + harness.createDb('mysql://void@127.0.0.1:9/app').$client, + harness.requestClient({ DATABASE_URL: 'mysql://void@127.0.0.1:9/app' }), + ]; + try { + for (const pool of pools) { + expect(pool.config.connectionConfig.disableEval).toBe(true); + } + } finally { + for (const pool of pools) { + pool.end?.(); + } + } + }, + ); + it('serves schema imports of void/db from an operator-only virtual module', () => { const root = createProjectRoot(); const plugin = drizzlePlugin(root, false, 'sqlite'); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 11031b0db..0a2e6f29b 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -4,18 +4,23 @@ import { readMySqlAppliedMigrations, type MySqlClient, } from '../../src/migrations/runner-mysql.ts'; +import { runVoidAuthMigrations } from '../../src/runtime/better-auth-mysql.ts'; import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; const mysqlQueries = vi.hoisted(() => [] as Array); +const mysqlConnections = vi.hoisted(() => [] as Array); vi.mock('mysql2/promise', () => ({ - createConnection: async () => ({ - query: async (sql: string) => { - mysqlQueries.push(sql); - return [[]]; - }, - end: async () => {}, - }), + createConnection: async (options: unknown) => { + mysqlConnections.push(options); + return { + query: async (sql: string) => { + mysqlQueries.push(sql); + return [[]]; + }, + end: async () => {}, + }; + }, })); describe('MySQL migration runner', () => { @@ -105,3 +110,34 @@ describe('MySQL migration runner', () => { expect(migrationQueries(mysqlQueries)).toEqual(statements); }); }); + +describe('MySQL connections in Workers', () => { + // Workers forbid eval, which mysql2 uses to build row parsers unless disableEval is set. + it('opens the hosted migration connection with eval disabled', async () => { + mysqlConnections.length = 0; + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + [], + ); + 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(mysqlConnections).toEqual([ + { uri: 'mysql://localhost/app', multipleStatements: true, disableEval: true }, + ]); + }); + + it('opens the Better Auth migration connection with eval disabled', async () => { + mysqlConnections.length = 0; + await runVoidAuthMigrations( + { DATABASE_URL: 'mysql://localhost/auth' }, + { dialect: 'mysql', providers: null }, + { dialect: 'mysql', idStrategy: 'text', tables: [] }, + ); + expect(mysqlConnections).toEqual([{ uri: 'mysql://localhost/auth', disableEval: true }]); + }); +}); diff --git a/playground/mysql-test/db/schema.ts b/playground/mysql-test/db/schema.ts new file mode 100644 index 000000000..bf519fe1e --- /dev/null +++ b/playground/mysql-test/db/schema.ts @@ -0,0 +1,7 @@ +import { int, mysqlTable, varchar } from 'void/schema-mysql'; + +export const users = mysqlTable('users', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull().unique(), +}); diff --git a/playground/mysql-test/package.json b/playground/mysql-test/package.json new file mode 100644 index 000000000..6f8f11eb9 --- /dev/null +++ b/playground/mysql-test/package.json @@ -0,0 +1,17 @@ +{ + "name": "playground-mysql-test", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/mysql-test/routes/api/users.ts b/playground/mysql-test/routes/api/users.ts new file mode 100644 index 000000000..a4b9fb04d --- /dev/null +++ b/playground/mysql-test/routes/api/users.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { db } from 'void/db'; + +export const GET = defineHandler(async () => { + const all = await db.select().from(users); + return all; +}); diff --git a/playground/mysql-test/routes/api/users/create-db.ts b/playground/mysql-test/routes/api/users/create-db.ts new file mode 100644 index 000000000..c2b25cc9b --- /dev/null +++ b/playground/mysql-test/routes/api/users/create-db.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { createDb } from 'void/db'; + +export const GET = defineHandler(async (c) => { + const all = await createDb(String(c.env.DATABASE_URL)).select().from(users); + return all; +}); diff --git a/playground/mysql-test/tsconfig.json b/playground/mysql-test/tsconfig.json new file mode 100644 index 000000000..55e4a6313 --- /dev/null +++ b/playground/mysql-test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["void/env"], + "allowImportingTsExtensions": true + }, + "include": ["routes", "db"] +} diff --git a/playground/mysql-test/vite.config.ts b/playground/mysql-test/vite.config.ts new file mode 100644 index 000000000..2bae9de06 --- /dev/null +++ b/playground/mysql-test/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [voidPlugin()], +}); diff --git a/playground/mysql-test/void.config.ts b/playground/mysql-test/void.config.ts new file mode 100644 index 000000000..1d772b776 --- /dev/null +++ b/playground/mysql-test/void.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + database: 'mysql', + worker: { + compatibility_date: '2026-02-24', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b182cfe3..f46cbbcd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1251,6 +1251,21 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/mysql-test: + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.1.0(@types/node@26.6.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.1.0(@types/node@26.6.4)(@vitest/ui@5.0.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2(@typescript-eslint/types@8.71.0))(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/named-layouts: dependencies: hono: diff --git a/test/integration-timings.ts b/test/integration-timings.ts index 903906097..959b992f6 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -21,6 +21,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/full-deploy-manifest.test.ts': 6370, 'packages/void/test/integration/kitchen-sink-build.test.ts': 7365, 'packages/void/test/integration/kitchen-sink.test.ts': 20887, + 'packages/void/test/integration/mysql-test.test.ts': 11000, 'packages/void/test/integration/named-layouts.test.ts': 9939, 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, From 40dc6e8eb6799800bc0cfb11cec1e9dcf39f10c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 00:02:19 +0800 Subject: [PATCH 02/17] fix(void): prebundle void's CommonJS deps wherever workerd runs them Void picked the workerd prebundle list by framework class, not by where code runs: - Astro's Cloudflare adapter runs its ssr environment in workerd, but got no include. An installed void/email failed with "exports is not defined" (#411) and MySQL void/db with "require is not defined". - Default-mode apps also prebundled into Vite's unused Node ssr environment (#412). - PostgreSQL void/db failed in dev with "require is not defined" when pg was not reachable from the app root (strict pnpm, no migrations). The include now goes to every environment whose resolve conditions contain workerd: Void's worker, class A frameworks and Astro. The Cloudflare plugin sets them from its config hook, so plugin order does not matter. The pg template lets drizzle build the pool, like MySQL, and 'void > pg' serves drizzle's bare import. Fixes #411 Fixes #412 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 6 +- packages/void/src/index.ts | 5 +- packages/void/src/plugin-drizzle.ts | 27 ++--- .../void/src/plugin-worker-environment.ts | 98 ++++++++++++++-- .../void/test/integration/pg-api-test.test.ts | 110 ++++++++++++++++++ .../unit/plugin-dev-asset-routing.test.ts | 98 +++++++++------- .../void/test/unit/plugin-drizzle.test.ts | 96 ++++++++++----- .../test/unit/plugin-vite-environment.test.ts | 57 +++++++++ .../unit/worker-build-node-require.test.ts | 84 +++++++++++++ playground/pg-api-test/db/schema.ts | 7 ++ playground/pg-api-test/package.json | 17 +++ playground/pg-api-test/routes/api/users.ts | 8 ++ .../pg-api-test/routes/api/users/create-db.ts | 8 ++ playground/pg-api-test/tsconfig.json | 15 +++ playground/pg-api-test/vite.config.ts | 6 + playground/pg-api-test/void.config.ts | 8 ++ pnpm-lock.yaml | 15 +++ test/integration-timings.ts | 1 + 18 files changed, 562 insertions(+), 104 deletions(-) create mode 100644 packages/void/test/integration/pg-api-test.test.ts create mode 100644 packages/void/test/unit/worker-build-node-require.test.ts create mode 100644 playground/pg-api-test/db/schema.ts create mode 100644 playground/pg-api-test/package.json create mode 100644 playground/pg-api-test/routes/api/users.ts create mode 100644 playground/pg-api-test/routes/api/users/create-db.ts create mode 100644 playground/pg-api-test/tsconfig.json create mode 100644 playground/pg-api-test/vite.config.ts create mode 100644 playground/pg-api-test/void.config.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index d10bf736c..150e4bf15 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -98,11 +98,11 @@ generated entries must preserve the module-boundary rules below. - **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. - **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. @@ -207,3 +207,5 @@ production functions and real generated entries rather than copied code. - **Sandbox SDK 1.0**: Keep the packaged Dockerfile helper version and the managed controller’s verified helper-layer digest in sync with `@cloudflare/sandbox`. Native execution uses Durable Object scheduling with named images; managed controller protocol 2 preserves admission, metering, and cleanup, including retained protocol 1 deployments. Cancellation crosses Workers RPC as a stream, not an AbortSignal. Run the Sandbox RPC and lifecycle tests when changing this boundary. - **Service usage limits**: AI and Sandbox operations return ordinary promises and reject limits with `UsageLimit` (`SandboxLimitError` extends it). Preserve model/file types, the shared `usage_limit` response, and automatic HTTP 429 handling in Void routes and Pages actions. Streaming clients retain typed limits and retry metadata. Sandbox status and cleanup stay available after limits; process completion retains the termination reason from its own container generation. + +- **The Cloudflare plugin's require conversion needs the Vite copy that runs the build.** pnpm installs Vite once per set of peers, so an app's Vite can differ from the one `@cloudflare/vite-plugin` imports through void, and each copy bundles its own rolldown. Rolldown skips a builtin plugin that another copy made, such as the Cloudflare plugin's `esmExternalRequirePlugin`, so every CommonJS require of a Node built-in stays a `__require()` call that throws in workerd ("Calling `require` for "node:buffer" in an environment that doesn't expose the `require` function"). For PostgreSQL and MySQL apps, the runtime-optimizer plugin serves each built-in that workerd provides for the app's compatibility settings from an import in every workerd build, SvelteKit's included; D1 apps keep the Cloudflare plugin's output. diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 5ccd150a2..95a3fa994 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -208,9 +208,8 @@ export function voidPlugin(options?: { // graph and therefore another envContext, so request bindings disappear for // the prebundled copy. Every return path includes this plugin, including // framework and Node targets that do not all use bindingRuntimePlugin. - const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin( - !isNodeTarget(config.target) && !frameworkOwnsCloudflareRuntime, - dialect, + const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin(dialect, () => + readWranglerCompat(root, config), ); // Scan queues/websocket routes early so both framework-mode and default-mode // branches can safely close over these bindings. diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index ac927ab5b..a36a36975 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -15,7 +15,6 @@ const drizzleOrmResolved = fileURLToPath(import.meta.resolve('drizzle-orm')); const envModuleResolved = fileURLToPath(import.meta.resolve('#self/_env')); const drizzleNodePgResolved = fileURLToPath(import.meta.resolve('drizzle-orm/node-postgres')); const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); -const pgResolved = fileURLToPath(import.meta.resolve('pg')); const VIRTUAL_DB_ID = '\0virtual:void-db'; const VIRTUAL_SCHEMA_DB_ID = '\0virtual:void-db-schema'; @@ -60,14 +59,11 @@ export function drizzlePlugin( generateDbDts(outDir, schema ?? null, dialect); } - let isBuild = false; - return { name: 'void:drizzle', enforce: 'pre', - config(_, env) { - isBuild = env.command === 'build'; + config() { if (!schema) { return; } @@ -82,14 +78,8 @@ export function drizzlePlugin( }, resolveId: { - filter: { id: /^(void\/db|@schema|pg$)/ }, + filter: { id: /^(void\/db|@schema)/ }, handler(id, importer) { - // Resolve pg from void's own deps during build so PG projects - // don't need pg as a direct dependency. - if (id === 'pg' && isBuild && dialect === 'postgresql' && importer === VIRTUAL_DB_ID) { - return pgResolved; - } - // Only intercept void/db in server/worker environments (not client) if (id === 'void/db' && this.environment?.name !== 'client') { if (!hasDbRuntime) { @@ -196,9 +186,9 @@ export function createDb(connectionString) { } if (dialect === 'postgresql') { + // Like MySQL: drizzle builds the pool, so its bare pg import is the only one. return ` import { drizzle } from ${JSON.stringify(drizzleNodePgResolved)}; -import pg from "pg"; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -215,12 +205,13 @@ function getConnectionString() { function getInstance() { return getRuntimeRequestResource(_requestResourceKey, () => { - const pool = new pg.Pool({ - connectionString: getConnectionString(), - max: 1, - idleTimeoutMillis: 1000, + return drizzle({ + connection: { + connectionString: getConnectionString(), + max: 1, + idleTimeoutMillis: 1000, + }${schema ? ', schema' : ''}, }); - return drizzle(pool${drizzleConfig}); }); } diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index acde5aa50..ef98153c5 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -1,6 +1,9 @@ -import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { builtinModules, createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { dirname, join } from 'pathslash'; import { type Plugin } from 'vite'; -import { ensureWranglerCompat } from './cli/wrangler.ts'; +import { ensureWranglerCompat, type WranglerCompat } from './cli/wrangler.ts'; import { readConfig, isNodeTarget, type DatabaseDialect } from './config.ts'; const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; @@ -130,31 +133,106 @@ export function createWranglerCompatPersistPlugin( }; } +// void/db leaves its driver import to drizzle, so these nested prebundles serve it. +const DATABASE_DRIVER_DEPS: Record> = { + sqlite: [], + postgresql: ['void > pg'], + mysql: ['void > mysql2'], +}; + +const NODE_BUILTIN = new RegExp(`^(?:node:)?(?:${builtinModules.join('|')})$`); +const NODE_BUILTIN_REQUIRE_PREFIX = '\0void:node-require:'; + +/** + * The Node built-ins that workerd provides for the given compatibility settings, from the preset + * the Cloudflare plugin builds Workers with (a dependency of `@cloudflare/vite-plugin`). Before + * 2025-09-15 the preset fills in parts of `node:process` instead of listing it, but workerd + * provides that module at every date. + */ +async function readWorkerdNodeBuiltins(compat: WranglerCompat): Promise> { + const vitePlugin = fileURLToPath(import.meta.resolve('@cloudflare/vite-plugin')); + const manifestPath = createRequire(vitePlugin).resolve('@cloudflare/unenv-preset/package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + exports: { '.': { import: string } }; + }; + const { getCloudflarePreset } = (await import( + pathToFileURL(join(dirname(manifestPath), manifest.exports['.'].import)).href + )) as { + getCloudflarePreset(compat: { compatibilityDate: string; compatibilityFlags: Array }): { + external: Array; + }; + }; + const { external } = getCloudflarePreset({ + compatibilityDate: compat.compatibilityDate, + compatibilityFlags: compat.compatibilityFlags ?? [], + }); + return new Set([...external.map((name) => name.replace(/^node:/, '')), 'process']); +} + export function createServerRuntimeOptimizerPlugin( - voidOwnsWorkerRuntime: boolean, dialect: DatabaseDialect, + readCompat: () => WranglerCompat, ): Plugin { + let workerdNodeBuiltins: Promise> | undefined; const serverRuntimeOptimizerPlugin: Plugin = { name: 'void:server-runtime-optimizer', - configEnvironment(name) { + configEnvironment(name, environment) { if (name === 'client') { return; } - if (!voidOwnsWorkerRuntime) { + // The Cloudflare plugin gives every environment it runs in workerd these conditions from + // its config hook, which runs before any configEnvironment hook. That covers Void's own + // worker, class A frameworks and adapters such as @astrojs/cloudflare. Node runtimes load + // CommonJS themselves, and an include there turns on SSR prebundling nobody asked for. + if (!environment.resolve?.conditions?.includes('workerd')) { return { optimizeDeps: { exclude: ['void'] } }; } return { optimizeDeps: { exclude: ['void'], // Excluding void also skips converting its CommonJS dependencies, which workerd cannot - // evaluate: an installed void/email fails with "exports is not defined". Runtimes a - // framework owns stay untouched; Node loads CommonJS itself, and forcing an include - // there turns on SSR prebundling the framework did not ask for. mysql2 is CommonJS too: - // void/db leaves its import to drizzle, so this nested prebundle serves it. - include: ['void > email-validator', ...(dialect === 'mysql' ? ['void > mysql2'] : [])], + // evaluate: an installed void/email fails with "exports is not defined". + include: ['void > email-validator', ...DATABASE_DRIVER_DEPS[dialect]], }, }; }, + // A build bundles void/db's driver, which is CommonJS. The Cloudflare plugin turns its requires + // of Node built-ins that workerd provides into imports with rolldown's esmExternalRequirePlugin, + // but rolldown skips that plugin unless the Vite copy running the build made it. pnpm installs + // Vite once per set of peers, so the app's Vite can differ from the one the Cloudflare plugin + // imports, and each require then throws in workerd. Serve those built-ins from an import here. + // A require of any other built-in stays as it is and throws only if it runs. + resolveId: { + // Before Vite's resolver, which makes a required built-in external. + order: 'pre', + filter: { id: NODE_BUILTIN }, + async handler(id, _importer, options) { + if ( + dialect === 'sqlite' || + options.kind !== 'require-call' || + this.environment?.mode !== 'build' || + !this.environment.config.resolve.conditions.includes('workerd') + ) { + return; + } + const name = id.replace(/^node:/, ''); + workerdNodeBuiltins ??= readWorkerdNodeBuiltins(readCompat()); + if (!(await workerdNodeBuiltins).has(name)) { + return; + } + return `${NODE_BUILTIN_REQUIRE_PREFIX}${name}`; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:node-require:/ }, + handler(id) { + // A built-in's default export is its CommonJS exports, and `require` returns an ES + // module's "module.exports" export. + const name = id.slice(NODE_BUILTIN_REQUIRE_PREFIX.length); + return `import builtin from "node:${name}";\nexport { builtin as "module.exports" };\n`; + }, + }, }; return serverRuntimeOptimizerPlugin; diff --git a/packages/void/test/integration/pg-api-test.test.ts b/packages/void/test/integration/pg-api-test.test.ts new file mode 100644 index 000000000..3f94d647b --- /dev/null +++ b/packages/void/test/integration/pg-api-test.test.ts @@ -0,0 +1,110 @@ +import { execSync, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { rmSync, unlinkSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { join } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { createServer } from 'pglite-server'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + killChild, + spawnPlaygroundServer, + startServer, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = join(ROOT, 'playground/pg-api-test'); +const ENV_PATH = join(PLAYGROUND, '.env'); +const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, +]; + +// No db/migrations and no auth: no other Void module imports pg, so void/db's own import +// must reach workerd prebundled. +describe('playground/pg-api-test', () => { + let pglite: PGlite; + let pgServer: ReturnType; + let child: ChildProcess | undefined; + + beforeAll(async () => { + pglite = new PGlite(); + await pglite.exec( + 'CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE);', + ); + for (const user of USERS) { + await pglite.query('INSERT INTO users (name, email) VALUES ($1, $2)', [ + user.name, + user.email, + ]); + } + pgServer = createServer(pglite); + pgServer.listen(0); + await once(pgServer, 'listening'); + const { port } = pgServer.address() as AddressInfo; + writeFileSync(ENV_PATH, `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + try { + unlinkSync(ENV_PATH); + } catch { + // Ignore missing cleanup files between test runs. + } + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer( + (port) => (child = spawnPlaygroundServer(ROOT, 'pg-api-test', script, port)), + 30_000, + ); + } + + async function stop(): Promise { + if (child) { + await killChild(child); + child = undefined; + } + } + + async function expectUsers({ base, output }: StartedServer): Promise { + for (const path of ['/api/users', '/api/users/create-db']) { + const response = await fetch(base + path); + const result = { path, status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ + path, + status: 200, + body: JSON.stringify(USERS), + }); + } + } + + it('queries PostgreSQL through void/db and createDb in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); + + it('queries PostgreSQL through void/db and createDb in the built Worker', async () => { + rmSync(join(PLAYGROUND, 'dist'), { recursive: true, force: true }); + execSync('vp run --filter ./playground/pg-api-test build', { cwd: ROOT, stdio: 'pipe' }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await stop(); + } + }); +}); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 1dbd6d651..76ee5516f 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -81,6 +81,15 @@ function createFrameworkOwnedRuntimeProjectRoot(): string { return root; } +function createAstroProjectRoot(): string { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '^7.0.0' } }), + ); + return root; +} + function createNodeProjectRoot(): string { const root = createProjectRoot(); writeFileSync( @@ -216,17 +225,23 @@ describe('voidPlugin dev asset routing', () => { config({}, { command: 'build', mode: 'production' }); } + // The Cloudflare plugin gives each environment it runs in workerd these conditions. + const workerd = { resolve: { conditions: ['workerd', 'worker', 'module', 'browser'] } }; + const node = { resolve: { conditions: ['module', 'node'] } }; + async function resolveRuntimeOptimizerConfig( root: string, environmentName: string, + environment: import('vite').EnvironmentOptions = {}, ): Promise { vi.spyOn(process, 'cwd').mockReturnValue(root); const { voidPlugin } = await import('../../src/index.ts'); const plugin = voidPlugin().find((entry) => entry.name === 'void:server-runtime-optimizer'); const configEnvironment = getHookHandler(plugin?.configEnvironment) as ( name: string, + environment: import('vite').EnvironmentOptions, ) => unknown; - return configEnvironment(environmentName); + return configEnvironment(environmentName, environment); } it('runs the env config hook for framework and Node early-return plugin paths', async () => { @@ -235,53 +250,50 @@ describe('voidPlugin dev asset routing', () => { await expect(runEnvConfig(createNodeProjectRoot())).resolves.toBeUndefined(); }); - it('keeps one Void runtime module graph in core, framework, and Node servers', async () => { + it('converts void CommonJS dependencies only in environments that run in workerd', async () => { const workerdOptimizer = { optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, }; - await expect( - resolveRuntimeOptimizerConfig(createApiOnlyProjectRoot(), 'void_worker'), - ).resolves.toEqual(workerdOptimizer); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(createFrameworkProjectRoot(), 'ssr'), - ).resolves.toEqual(workerdOptimizer); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(createFrameworkOwnedRuntimeProjectRoot(), 'ssr'), - ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); - vi.restoreAllMocks(); - await expect(resolveRuntimeOptimizerConfig(createNodeProjectRoot(), 'ssr')).resolves.toEqual({ - optimizeDeps: { exclude: ['void'] }, - }); + const nodeOptimizer = { optimizeDeps: { exclude: ['void'] } }; + const cases = [ + [createApiOnlyProjectRoot, 'void_worker', workerd, workerdOptimizer], + // Default mode never runs Vite's own ssr environment. + [createApiOnlyProjectRoot, 'ssr', node, nodeOptimizer], + [createFrameworkProjectRoot, 'ssr', workerd, workerdOptimizer], + // @astrojs/cloudflare runs Astro's ssr environment in workerd. + [createAstroProjectRoot, 'ssr', workerd, workerdOptimizer], + [createFrameworkOwnedRuntimeProjectRoot, 'ssr', node, nodeOptimizer], + [createNodeProjectRoot, 'ssr', {}, nodeOptimizer], + ] as const; + for (const [createRoot, name, environment, expected] of cases) { + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(createRoot(), name, environment), + `${createRoot.name} ${name}`, + ).resolves.toEqual(expected); + } }); - it('prebundles mysql2 for void/db only in worker runtimes Void owns', async () => { - await expect( - resolveRuntimeOptimizerConfig( - useDatabase(createApiOnlyProjectRoot(), 'mysql'), - 'void_worker', - ), - ).resolves.toEqual({ - optimizeDeps: { exclude: ['void'], include: ['void > email-validator', 'void > mysql2'] }, - }); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(useDatabase(createApiOnlyProjectRoot(), 'pg'), 'void_worker'), - ).resolves.toEqual({ - optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, - }); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig( - useDatabase(createFrameworkOwnedRuntimeProjectRoot(), 'mysql'), - 'ssr', - ), - ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); - vi.restoreAllMocks(); - await expect( - resolveRuntimeOptimizerConfig(useDatabase(createNodeProjectRoot(), 'mysql'), 'ssr'), - ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); + it('prebundles the void/db driver wherever workerd runs it', async () => { + const cases = [ + [createApiOnlyProjectRoot, 'mysql', workerd, ['void > mysql2']], + [createApiOnlyProjectRoot, 'pg', workerd, ['void > pg']], + [createAstroProjectRoot, 'mysql', workerd, ['void > mysql2']], + [createAstroProjectRoot, 'pg', workerd, ['void > pg']], + [createFrameworkOwnedRuntimeProjectRoot, 'mysql', node, undefined], + [createNodeProjectRoot, 'pg', {}, undefined], + ] as const; + for (const [createRoot, database, environment, drivers] of cases) { + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(useDatabase(createRoot(), database), 'ssr', environment), + `${createRoot.name} ${database}`, + ).resolves.toEqual({ + optimizeDeps: drivers + ? { exclude: ['void'], include: ['void > email-validator', ...drivers] } + : { exclude: ['void'] }, + }); + } }); it('leaves client dependency optimization unchanged', async () => { diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 308816a90..c548d8440 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -64,7 +64,6 @@ async function loadExecutableDbModule( fakeStateKeys.push(stateKey); const fakeDriverPath = join(root, 'fake-driver.mjs'); - const fakePgPath = join(root, 'fake-pg.mjs'); const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; const stateExpression = `globalThis[${JSON.stringify(stateKey)}]`; const createDatabaseSource = ` @@ -103,31 +102,26 @@ export function drizzle(client) { 'import { drizzle } from "./fake-driver.mjs";', ); } else if (dialect === 'postgresql') { - writeFileSync( - fakePgPath, - `const state = ${stateExpression}; -export class Pool { - constructor(options) { - this.options = options; - state.pools.push({ dialect: "postgresql", options }); - } -} -export default { Pool }; -`, - ); writeFileSync( fakeDriverPath, `import { getRuntimeBinding as getTestRuntimeBinding } from ${JSON.stringify(envUrl)}; +const state = ${stateExpression}; ${createDatabaseSource} -export function drizzle(pool) { - if (typeof pool === "string") pool = { options: { connectionString: pool } }; - return createDatabase("postgresql", pool); +export function drizzle(config) { + if (typeof config === "string") return createDatabase("postgresql", { options: { connectionString: config } }); + if (typeof config?.connection !== "object" || config.connection === null) { + throw new Error("void/db must let drizzle build the pg pool from a connection object"); + } + const { connection, ...drizzleConfig } = config; + state.pools.push({ dialect: "postgresql", options: connection, drizzleConfig }); + return createDatabase("postgresql", { options: connection }); } `, ); - code = code - .replace(/^import \{ drizzle \} from .*;$/m, `import { drizzle } from "./fake-driver.mjs";`) - .replace('import pg from "pg";', 'import pg from "./fake-pg.mjs";'); + code = code.replace( + /^import \{ drizzle \} from .*;$/m, + `import { drizzle } from "./fake-driver.mjs";`, + ); } else { writeFileSync( fakeDriverPath, @@ -375,6 +369,7 @@ describe('drizzlePlugin db typing generation', () => { for (const pool of requestPools) { if (dialect === 'postgresql') { expect(pool.options).toMatchObject({ max: 1, idleTimeoutMillis: 1000 }); + expect(pool.drizzleConfig).toEqual({ schema: expect.anything() }); } else { expect(pool.options).toMatchObject({ connectionLimit: 1, maxIdle: 1, idleTimeout: 1000 }); } @@ -393,9 +388,12 @@ describe('drizzlePlugin db typing generation', () => { }, ); - it('leaves the MySQL driver import to drizzle', () => { + it.each([ + ['mysql', 'mysql2'], + ['postgresql', 'pg'], + ] as const)('leaves the %s driver import to drizzle', (dialect, driver) => { const root = createProjectRoot(); - const plugin = drizzlePlugin(root, false, 'mysql'); + const plugin = drizzlePlugin(root, false, dialect); const load = getHookHandler(plugin.load) as (this: object, id: string) => string; const code = load.call({}, '\0virtual:void-db'); @@ -404,11 +402,14 @@ describe('drizzlePlugin db typing generation', () => { (match) => JSON.parse(match[1]!) as string, ); - // The worker optimizer serves drizzle's bare mysql2 import from a prebundle; a direct import - // by file path would reach workerd as raw CommonJS. - expect(specifiers.filter((specifier) => specifier.includes('mysql2'))).toEqual([ - expect.stringMatching(/drizzle-orm[\\/]mysql2[\\/]/), - ]); + // The worker optimizer serves drizzle's bare driver import from a prebundle. A bare import + // here resolves from the app root, and an import by file path reaches workerd as CommonJS. + const driverPath = new RegExp(`(^|[\\\\/])${driver}([\\\\/]|$)`); + expect( + specifiers.filter( + (specifier) => driverPath.test(specifier) && !specifier.includes('drizzle-orm'), + ), + ).toEqual([]); }); it.each([true, false])( @@ -457,6 +458,46 @@ export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); }, ); + it.each([true, false])( + 'gives drizzle a pg pool with the request options (schema: %s)', + async (hasSchema) => { + const root = createProjectRoot({ schema: hasSchema }); + const plugin = drizzlePlugin(root, false, 'postgresql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + writeFileSync( + join(root, 'harness.mjs'), + `import { db } from "./virtual-db.mjs"; +import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; +export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); +`, + ); + type PgPool = { options: Record; end(): Promise }; + const harness = (await import( + `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` + )) as { requestClient(env: Record): PgPool }; + + const url = 'postgres://void@127.0.0.1:9/app'; + const pool = harness.requestClient({ DATABASE_URL: url }); + try { + expect(pool.options).toMatchObject({ + connectionString: url, + max: 1, + idleTimeoutMillis: 1000, + }); + } finally { + await pool.end?.(); + } + }, + ); + it('serves schema imports of void/db from an operator-only virtual module', () => { const root = createProjectRoot(); const plugin = drizzlePlugin(root, false, 'sqlite'); @@ -508,8 +549,7 @@ export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); const code = load.call({}, '\0virtual:void-db'); expect(code).toContain('import { drizzle } from "'); - expect(code).not.toContain('import * as schema'); - expect(code).toContain('return drizzle(pool)'); + expect(code).not.toMatch(/\bschema\b/); }); it('uses a custom D1 binding name in the sqlite virtual module', () => { diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 9a5b1301c..c0a448e8a 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { cloudflare } from '@cloudflare/vite-plugin'; import { resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; @@ -145,4 +146,60 @@ describe('voidPlugin Vite environments', () => { expect(config.environments.ssr.resolve.external).toEqual([]); expect(config.environments.ssr.resolve.conditions).toContain('workerd'); }); + + it('prebundles void CommonJS dependencies only in the worker environment', async () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig({ root, plugins: [voidPlugin()] }, 'serve', 'development'); + + expect(config.environments.void_worker.optimizeDeps.include).toEqual( + expect.arrayContaining(['void > email-validator', 'void > pg']), + ); + expect(config.environments.ssr.optimizeDeps.include).toEqual([]); + }); + + it.each(['before', 'after'])( + 'prebundles them in an adapter-owned workerd environment with Void listed %s it', + async (order) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '^7.0.0' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'mysql', worker: { compatibility_date: '2026-02-24' } }), + ); + writeFileSync(join(root, 'worker.ts'), 'export default {};\n'); + writeFileSync( + join(root, 'wrangler.jsonc'), + JSON.stringify({ + name: 'adapter-app', + main: './worker.ts', + compatibility_date: '2026-02-24', + }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + // @astrojs/cloudflare runs Astro's ssr environment in workerd this way. + const adapter = cloudflare({ + configPath: join(root, 'wrangler.jsonc'), + viteEnvironment: { name: 'ssr' }, + }); + + const config = await resolveConfig( + { root, plugins: order === 'before' ? [voidPlugin(), adapter] : [adapter, voidPlugin()] }, + 'serve', + 'development', + ); + + expect(config.environments.ssr.optimizeDeps.include).toEqual( + expect.arrayContaining(['void > email-validator', 'void > mysql2']), + ); + }, + ); }); diff --git a/packages/void/test/unit/worker-build-node-require.test.ts b/packages/void/test/unit/worker-build-node-require.test.ts new file mode 100644 index 000000000..4c7ee37c5 --- /dev/null +++ b/packages/void/test/unit/worker-build-node-require.test.ts @@ -0,0 +1,84 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createBuilder, type Plugin } from 'vite'; +import { afterEach, expect, it, vi } from 'vite-plus/test'; +import { voidPlugin } from '../../src/index.ts'; + +const roots: Array = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +// pnpm installs Vite once per set of peers, so the Vite that runs a build can differ from the one +// the Cloudflare plugin imports. Rolldown then skips the Cloudflare plugin's esmExternalRequirePlugin, +// as this plugin does. +const skipCloudflareRequireConversion: Plugin = { + name: 'test:skip-cloudflare-require-conversion', + configResolved(config) { + for (const environment of Object.values(config.environments)) { + const options = environment.build.rolldownOptions; + if (Array.isArray(options.plugins)) { + options.plugins = options.plugins.filter( + (plugin) => + !(plugin && 'name' in plugin && plugin.name === 'builtin:esm-external-require'), + ); + } + } + }, +}; + +it('serves the Node built-ins that CommonJS requires in a MySQL Worker build from imports', async () => { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-worker-require-'))); + roots.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'mysql', worker: { compatibility_date: '2026-02-24' } }), + ); + // mysql2 requires node:buffer like this. + mkdirSync(join(root, 'lib')); + writeFileSync( + join(root, 'lib/size.cjs'), + "exports.size = (text) => require('node:buffer').Buffer.byteLength(text);\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/size.ts'), + "import { size } from '../../lib/size.cjs';\nexport const GET = () => Response.json({ size: size('void') });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const builder = await createBuilder( + { + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin(), skipCloudflareRequireConversion], + }, + null, + ); + await builder.buildApp(); + + const output = join(root, 'dist/ssr'); + const code = readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(output, file), 'utf8')) + .join('\n'); + // workerd has no `require`, so this call would throw when the route runs. + expect(code).not.toContain('__require("node:buffer")'); + expect(code).toMatch(/^import \w+ from "node:buffer";$/m); +}); diff --git a/playground/pg-api-test/db/schema.ts b/playground/pg-api-test/db/schema.ts new file mode 100644 index 000000000..f6faf8f9d --- /dev/null +++ b/playground/pg-api-test/db/schema.ts @@ -0,0 +1,7 @@ +import { pgTable, serial, text } from 'void/schema-pg'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), +}); diff --git a/playground/pg-api-test/package.json b/playground/pg-api-test/package.json new file mode 100644 index 000000000..b844c5b08 --- /dev/null +++ b/playground/pg-api-test/package.json @@ -0,0 +1,17 @@ +{ + "name": "playground-pg-api-test", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/pg-api-test/routes/api/users.ts b/playground/pg-api-test/routes/api/users.ts new file mode 100644 index 000000000..a4b9fb04d --- /dev/null +++ b/playground/pg-api-test/routes/api/users.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { db } from 'void/db'; + +export const GET = defineHandler(async () => { + const all = await db.select().from(users); + return all; +}); diff --git a/playground/pg-api-test/routes/api/users/create-db.ts b/playground/pg-api-test/routes/api/users/create-db.ts new file mode 100644 index 000000000..c2b25cc9b --- /dev/null +++ b/playground/pg-api-test/routes/api/users/create-db.ts @@ -0,0 +1,8 @@ +import { users } from '@schema'; +import { defineHandler } from 'void'; +import { createDb } from 'void/db'; + +export const GET = defineHandler(async (c) => { + const all = await createDb(String(c.env.DATABASE_URL)).select().from(users); + return all; +}); diff --git a/playground/pg-api-test/tsconfig.json b/playground/pg-api-test/tsconfig.json new file mode 100644 index 000000000..55e4a6313 --- /dev/null +++ b/playground/pg-api-test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["void/env"], + "allowImportingTsExtensions": true + }, + "include": ["routes", "db"] +} diff --git a/playground/pg-api-test/vite.config.ts b/playground/pg-api-test/vite.config.ts new file mode 100644 index 000000000..2bae9de06 --- /dev/null +++ b/playground/pg-api-test/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [voidPlugin()], +}); diff --git a/playground/pg-api-test/void.config.ts b/playground/pg-api-test/void.config.ts new file mode 100644 index 000000000..ff6ebb3c8 --- /dev/null +++ b/playground/pg-api-test/void.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + database: 'pg', + worker: { + compatibility_date: '2026-02-24', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f46cbbcd0..3572c0559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1396,6 +1396,21 @@ importers: specifier: 'catalog:' version: 4.150.0(@cloudflare/workers-types@5.20261009.1)(@types/node@26.6.4) + playground/pg-api-test: + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.1.0(@types/node@26.6.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.1.0(@types/node@26.6.4)(@vitest/ui@5.0.4)(@volar/typescript@2.4.28(typescript@7.0.2))(@vue/language-core@3.3.6)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2(@typescript-eslint/types@8.71.0))(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(vue-tsc@3.3.6(typescript@7.0.2))(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/pg-test: dependencies: hono: diff --git a/test/integration-timings.ts b/test/integration-timings.ts index 959b992f6..c647295bf 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -28,6 +28,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/nuxt.test.ts': 43000, 'packages/void/test/integration/operator-cli.test.ts': 4910, 'packages/void/test/integration/pages-target-assets.test.ts': 10966, + 'packages/void/test/integration/pg-api-test.test.ts': 11000, 'packages/void/test/integration/pg-test.test.ts': 16237, 'packages/void/test/integration/plugin-env-schema.test.ts': 236, 'packages/void/test/integration/prepare-cli.test.ts': 1957, From 5671fad65d63836d1ab1efbb3b30671aa459f201 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 00:02:20 +0800 Subject: [PATCH 03/17] fix(void): bundle the hosted MySQL migration handler for workerd Hosted framework deploys of MySQL apps with migrations could not start. The migration handler bundle kept the bundler's createRequire(import.meta.url), and workerd gives modules no import.meta.url, so the whole Worker failed at startup. With npm, Yarn or Bun, mysql2's dependencies also stayed bare imports. The MySQL bundle now gets the same require shim as PostgreSQL. It uses default imports, because safer-buffer needs the CommonJS shape of node:buffer. It is built with noExternal: true, so hoisted dependencies are bundled too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- ...ansform.ts => worker-require-transform.ts} | 24 +++-- packages/void/src/cli/wrapper.ts | 60 +++++++++-- ...st.ts => worker-require-transform.test.ts} | 13 ++- packages/void/test/unit/wrapper.test.ts | 99 ++++++++++++++++++- 4 files changed, 175 insertions(+), 21 deletions(-) rename packages/void/src/cli/{pg-bundle-transform.ts => worker-require-transform.ts} (82%) rename packages/void/test/unit/{pg-bundle-transform.test.ts => worker-require-transform.test.ts} (69%) diff --git a/packages/void/src/cli/pg-bundle-transform.ts b/packages/void/src/cli/worker-require-transform.ts similarity index 82% rename from packages/void/src/cli/pg-bundle-transform.ts rename to packages/void/src/cli/worker-require-transform.ts index 1237ef117..ae13e8e16 100644 --- a/packages/void/src/cli/pg-bundle-transform.ts +++ b/packages/void/src/cli/worker-require-transform.ts @@ -29,11 +29,17 @@ function nodes(root: AstNode): Array { return found; } -/** Rewrite the two CommonJS constructs emitted by the PG migration bundle. */ -export function transformPgWorkerBundle( +/** + * Replace the `createRequire(import.meta.url)` binding that the bundler emits for CommonJS + * database drivers with `requireShim`, which serves Node built-ins from static imports. + * workerd gives modules no `import.meta.url`, so the original binding fails at startup. + * PostgreSQL bundles also replace `pgpass`, which reads the filesystem. + */ +export function transformWorkerRequireBundle( code: string, filename: string, requireShim: string, + database: 'PostgreSQL' | 'MySQL', ): string { if (!code.includes('createRequire') && !code.includes('pgpass')) { return code; @@ -53,7 +59,9 @@ export function transformPgWorkerBundle( !isIdentifier(specifiers[0]?.imported as AstNode | undefined) || (specifiers[0]!.imported as { name: string }).name !== 'createRequire' ) { - throw new Error(`deploy: Unsupported node:module import in PostgreSQL bundle ${filename}.`); + throw new Error( + `deploy: Unsupported node:module import in ${database} bundle ${filename}.`, + ); } edits.push({ start: statement.start!, end: statement.end!, replacement: '' }); } @@ -67,7 +75,7 @@ export function transformPgWorkerBundle( ) ) { if (declarations.length !== 1 || hasRequireBinding) { - throw new Error(`deploy: Unsupported require binding in PostgreSQL bundle ${filename}.`); + throw new Error(`deploy: Unsupported require binding in ${database} bundle ${filename}.`); } hasRequireBinding = true; edits.push({ start: statement.start!, end: statement.end!, replacement: requireShim }); @@ -91,12 +99,12 @@ export function transformPgWorkerBundle( } if (code.includes('createRequire') && !hasRequireBinding) { throw new Error( - `deploy: Could not locate the generated require binding in PostgreSQL bundle ${filename}.`, + `deploy: Could not locate the generated require binding in ${database} bundle ${filename}.`, ); } if (code.includes('pgpass') && pgpassCalls === 0 && code.includes('__require')) { throw new Error( - `deploy: Could not locate the pgpass require in PostgreSQL bundle ${filename}.`, + `deploy: Could not locate the pgpass require in ${database} bundle ${filename}.`, ); } const ms = new MagicString(code); @@ -105,7 +113,9 @@ export function transformPgWorkerBundle( } const transformed = ms.toString(); if (transformed.includes('createRequire') || transformed.includes('__require("pgpass")')) { - throw new Error(`deploy: PostgreSQL bundle ${filename} still contains an unsupported require.`); + throw new Error( + `deploy: ${database} bundle ${filename} still contains an unsupported require.`, + ); } return pgpassCalls > 0 ? `const __voidPgpassNoop = Object.assign(function pgpass(_connectionInfo, callback) { callback(undefined); }, { warnTo() {} });\n${transformed}` diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 87c9cce71..a4a78ec1d 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -15,7 +15,7 @@ import { build } from 'vite'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { readProjectPaths } from '../project-paths.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; -import { transformPgWorkerBundle } from './pg-bundle-transform.ts'; +import { transformWorkerRequireBundle } from './worker-require-transform.ts'; export type WrapperFeatures = { /** Cron job definitions (from scanJobsSync). */ @@ -279,7 +279,7 @@ async function bundleMigrationHandler( }, } : isMysql - ? { noExternal: ['mysql2'] } + ? { noExternal: true } : undefined, build: { outDir, @@ -298,22 +298,27 @@ async function bundleMigrationHandler( }, }); - if (isPostgresql) { - patchPgWorkerBundle(outDir); + if (isPostgresql || isMysql) { + patchWorkerRequireBundle(outDir, isPostgresql ? 'PostgreSQL' : 'MySQL'); } for (const file of readdirSync(outDir)) { copyFileSync(join(outDir, file), join(workerDir, file)); } } -function patchPgWorkerBundle(outDir: string): void { +function patchWorkerRequireBundle(outDir: string, database: 'PostgreSQL' | 'MySQL'): void { for (const file of readdirSync(outDir)) { if (!file.endsWith('.mjs') && !file.endsWith('.js')) { continue; } const path = join(outDir, file); const code = readFileSync(path, 'utf-8'); - const transformed = transformPgWorkerBundle(code, file, pgWorkerRequireShim()); + const transformed = transformWorkerRequireBundle( + code, + file, + database === 'PostgreSQL' ? pgWorkerRequireShim() : mysqlWorkerRequireShim(), + database, + ); if (transformed !== code) { writeFileSync(path, transformed); } @@ -363,6 +368,49 @@ function pgWorkerRequireShim(): string { ].join('\n'); } +// The Node built-ins that mysql2 and its bundled dependencies require. Each one is the +// module's default export, which matches CommonJS `module.exports`: safer-buffer calls +// `hasOwnProperty` on `require("buffer")`, and an ESM namespace object has no prototype. +// mysql2 wraps its optional `cardinal` require in try/catch, so it stays unsupported. +function mysqlWorkerRequireShim(): string { + return [ + 'import __voidNodeBuffer from "node:buffer";', + 'import __voidNodeCrypto from "node:crypto";', + 'import __voidNodeDiagnosticsChannel from "node:diagnostics_channel";', + 'import __voidNodeEvents from "node:events";', + 'import __voidNodeNet from "node:net";', + 'import __voidNodeProcess from "node:process";', + 'import __voidNodeStream from "node:stream";', + 'import __voidNodeStringDecoder from "node:string_decoder";', + 'import __voidNodeTimers from "node:timers";', + 'import __voidNodeTls from "node:tls";', + 'import __voidNodeUrl from "node:url";', + 'import __voidNodeUtil from "node:util";', + 'import __voidNodeZlib from "node:zlib";', + 'var __voidNodeRequireModules = {', + ' buffer: __voidNodeBuffer,', + ' crypto: __voidNodeCrypto,', + ' diagnostics_channel: __voidNodeDiagnosticsChannel,', + ' events: __voidNodeEvents,', + ' net: __voidNodeNet,', + ' process: __voidNodeProcess,', + ' stream: __voidNodeStream,', + ' string_decoder: __voidNodeStringDecoder,', + ' timers: __voidNodeTimers,', + ' tls: __voidNodeTls,', + ' url: __voidNodeUrl,', + ' util: __voidNodeUtil,', + ' zlib: __voidNodeZlib,', + '};', + 'var __require = (id) => {', + ' if (id.startsWith("node:")) id = id.slice(5);', + ' const mod = __voidNodeRequireModules[id];', + ' if (mod) return mod;', + ' throw new Error(`Unsupported require("${id}") in Void MySQL migration bundle`);', + '};', + ].join('\n'); +} + /** * Bundle a worker entry into a self-contained module. * diff --git a/packages/void/test/unit/pg-bundle-transform.test.ts b/packages/void/test/unit/worker-require-transform.test.ts similarity index 69% rename from packages/void/test/unit/pg-bundle-transform.test.ts rename to packages/void/test/unit/worker-require-transform.test.ts index 916b21bc0..eeb6ae794 100644 --- a/packages/void/test/unit/pg-bundle-transform.test.ts +++ b/packages/void/test/unit/worker-require-transform.test.ts @@ -1,6 +1,6 @@ import { parseSync } from 'vite'; import { describe, expect, it } from 'vite-plus/test'; -import { transformPgWorkerBundle } from '../../src/cli/pg-bundle-transform.ts'; +import { transformWorkerRequireBundle } from '../../src/cli/worker-require-transform.ts'; const shim = 'var __require = (id) => { throw new Error(id); };'; @@ -9,7 +9,7 @@ describe('PostgreSQL migration bundle transform', () => { const source = `import { createRequire } from 'node:module'; var __require = /* bundler annotation */ (() => createRequire(import.meta.url))(); const value = __require ( 'pgpass' );`; - const transformed = transformPgWorkerBundle(source, 'bundle.mjs', shim); + const transformed = transformWorkerRequireBundle(source, 'bundle.mjs', shim, 'PostgreSQL'); expect(transformed).toContain('const value = __voidPgpassNoop;'); expect(transformed).toContain(shim); expect(transformed).not.toContain('createRequire'); @@ -18,12 +18,17 @@ const value = __require ( 'pgpass' );`; it('fails closed when the emitted require shape changes', () => { expect(() => - transformPgWorkerBundle('const x = createRequire(import.meta.url);', 'bundle.mjs', shim), + transformWorkerRequireBundle( + 'const x = createRequire(import.meta.url);', + 'bundle.mjs', + shim, + 'PostgreSQL', + ), ).toThrow('Could not locate the generated require binding'); }); it('leaves unrelated bundles untouched', () => { const source = 'export const value = 1;'; - expect(transformPgWorkerBundle(source, 'bundle.mjs', shim)).toBe(source); + expect(transformWorkerRequireBundle(source, 'bundle.mjs', shim, 'PostgreSQL')).toBe(source); }); }); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 00a41c0e9..6db01a420 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -1,15 +1,20 @@ import { + cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs'; +import { createRequire, isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { parseSync } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { cleanupWrapper, @@ -18,6 +23,7 @@ import { selectBundledEntryFilename, } from '../../src/cli/wrapper.ts'; import type { WrapperFeatures } from '../../src/cli/wrapper.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const base: WrapperFeatures = { crons: [], @@ -585,20 +591,105 @@ describe('generateWrapper with migrations', () => { expect(bundled).toContain('__voidPgpassNoop'); }); - it('uses the MySQL migration handler when dialect is mysql', async () => { + /** Run the wrapped MySQL Worker in workerd and return its `/__void/migrate` response body. */ + async function migrateMySqlInWorkerd(workerDir: string): Promise { + // The applied-migrations SELECT returns a row, so mysql2 also parses a result set. + const mysql = await startFakeMySqlServer([{ name: '000_applied_elsewhere.sql' }]); + // Upload every module, as the platform does; the wrapper imports the handler statically. + const modules = readdirSync(workerDir) + .filter((file) => file.endsWith('.mjs')) + .sort((a, b) => Number(b === 'index.mjs') - Number(a === 'index.mjs')) + .map((file) => ({ type: 'ESModule' as const, path: join(workerDir, file) })); + let miniflare: Miniflare | undefined; + try { + miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: workerDir, + modules, + compatibilityDate: '2026-02-24', + compatibilityFlags: ['nodejs_compat'], + bindings: { + __VOID_PROXY_TOKEN: 'proxy-token', + HYPERDRIVE: { connectionString: `mysql://void@127.0.0.1:${mysql.port}/app` }, + }, + }), + ); + const response = await miniflare.dispatchFetch('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'proxy-token' }, + }); + return await response.json(); + } finally { + await miniflare?.dispose(); + await mysql.close(); + } + } + + it('runs MySQL migrations from the wrapped Worker in workerd', async () => { const { workerDir, projectRoot } = createMigrationFixture({ name: '001_test.sql', sql: 'CREATE TABLE test (id INT);', }); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'mysql', + }); + + expect(await migrateMySqlInWorkerd(workerDir)).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + }); + // npm, Yarn and Bun hoist mysql2's dependencies into the app's node_modules. The build finds + // them there, so anything it does not bundle stays a require that workerd cannot load. + it('bundles the mysql2 dependencies that sit in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_test.sql', + sql: 'CREATE TABLE test (id INT);', + }); + const mysql2PackageJson = createRequire(import.meta.url).resolve('mysql2/package.json'); + const { dependencies } = JSON.parse(readFileSync(mysql2PackageJson, 'utf8')) as { + dependencies: Record; + }; + const installDir = dirname(dirname(realpathSync(mysql2PackageJson))); + for (const name of Object.keys(dependencies)) { + // Copies, not symlinks, so this also runs on Windows. + cpSync(realpathSync(join(installDir, name)), join(projectRoot, 'node_modules', name), { + recursive: true, + }); + } await generateWrapper(workerDir, 'index.mjs', projectRoot, { ...base, hasMigrations: true, migrationDialect: 'mysql', }); - const handler = readFileSync(join(workerDir, '__void_migration_handler.mjs'), 'utf-8'); - expect(handler).toContain('VARCHAR(255)'); + expect(await migrateMySqlInWorkerd(workerDir)).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + // workerd serves only Node built-ins to the require shim. mysql2 requires its optional + // `cardinal` inside try/catch. + for (const file of readdirSync(workerDir).filter((name) => + name.startsWith('__void_migration_handler'), + )) { + const code = readFileSync(join(workerDir, file), 'utf8'); + const { module } = parseSync(file, code); + const imports = [ + ...module.staticImports.map((entry) => entry.moduleRequest.value), + ...module.staticExports.flatMap((entry) => + entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])), + ), + ]; + expect(imports.filter((id) => !/^(node:|cloudflare:|\.\/)/.test(id))).toEqual([]); + const required = Array.from( + code.matchAll(/\b__require\(\s*["']([^"']+)["']\s*\)/g), + (match) => match[1]!, + ); + expect(required.filter((id) => !isBuiltin(id) && id !== 'cardinal')).toEqual([]); + } }); it('does not generate migration handler when hasMigrations is false', async () => { From 02a13a1bf0104c0f79a23bbdf599ec90ec9a16ad Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 00:02:21 +0800 Subject: [PATCH 04/17] fix(void): run MySQL migrations atomically and name failed files Two MySQL runs at the same time could apply a data migration twice, and a failed file left its earlier data changes applied, so a rerun applied them again. Failed migrations also printed only the database error on the CLI paths, for every dialect. The MySQL runner now takes a per-database named lock and runs each file in a transaction with its tracking row. The hosted handler uses the transaction too; the platform already runs one migration per project. MySQL still commits DDL on its own. When that leaves earlier statements applied, the error says so and links the new docs section. Every SQL runner now reports "migration failed: " and keeps the database error as the cause. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/database/mysql.md | 15 +- docs/integrations/cloudflare.md | 2 +- .../void/src/cli/deploy-cloudflare-migrate.ts | 4 +- packages/void/src/migrations/runner-mysql.ts | 116 +++++- packages/void/src/migrations/runner-pg.ts | 8 +- packages/void/src/migrations/runner.ts | 12 +- .../src/runtime/migration-handler-mysql.ts | 8 + packages/void/test/unit/db-commands.test.ts | 3 + packages/void/test/unit/runner-mysql.test.ts | 355 ++++++++++++++++-- packages/void/test/unit/runner-pg.test.ts | 32 ++ packages/void/test/unit/runner-sqlite.test.ts | 13 + 11 files changed, 517 insertions(+), 51 deletions(-) diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index 22b783bef..0f5b98239 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -51,9 +51,18 @@ void deploy For direct Cloudflare deploys, export the production `DATABASE_URL` in the shell running `void deploy --platform cloudflare`. Void uses it to provision Hyperdrive and apply pending checked-in migrations before uploading the Worker; it is not written to the Worker config. -::: warning MySQL DDL is not transactional -MySQL may implicitly commit schema changes. If a multi-statement migration fails partway through, earlier statements can remain applied even though Void does not record the migration as complete. Keep migrations small, review them before deploy, and make failed migrations safe to rerun after repairing the database. -::: +### Failed migrations {#failed-migrations} + +Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed. + +To recover, undo the applied statements, fix the file, then run `void db migrate` or `void deploy` again: + +```sh +void db execute "DROP TABLE comments" +void db execute --remote "DROP TABLE comments" # deployed database +``` + +MySQL rejects `IF NOT EXISTS` on `ADD COLUMN` and `CREATE INDEX`, so undoing a statement is usually simpler than making it safe to rerun. Small migrations leave less to undo. ## Database commands diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 4bf7608c5..a71f5592a 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -154,7 +154,7 @@ Void uses Access credentials for readiness checks without saving them. If checks ### Databases and secrets -For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error. +For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error; see [failed MySQL migrations](../guide/database/mysql.md#failed-migrations). Creating Hyperdrive requires an API token with Hyperdrive edit permission. Alternatively, create it in Cloudflare and add its ID to `cloudflare.hyperdrive`; an existing binding works with browser login. diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts index eb794913e..e61ec1cc8 100644 --- a/packages/void/src/cli/deploy-cloudflare-migrate.ts +++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts @@ -120,8 +120,8 @@ export async function migrateCloudflareWorker(input: { // Cloudflare never returns the Hyperdrive password, so the deploy environment provides the // production URL. A rerun skips migrations already recorded as finished. A failed PostgreSQL - // file rolls back. The MySQL runner uses no transaction and MySQL DDL commits on its own, so a - // failed MySQL file can leave its earlier statements applied. + // file rolls back. A failed MySQL file rolls back its data changes, but MySQL commits DDL on + // its own, so earlier statements of that file can stay applied. if (dialect !== 'sqlite' && migrations.length > 0) { const mysql = dialect === 'mysql'; const label = mysql ? 'MySQL' : 'PostgreSQL'; diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index ca130b02d..b8bd361d0 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { Connection } from 'mysql2/promise'; import { splitMigrationSql } from '../runtime/migration-handler.ts'; import { collectMigrations } from './collect.ts'; @@ -47,6 +48,67 @@ export async function getMySqlAppliedMigrations(url: string): Promise { + const [row] = await client.query>('SELECT DATABASE() AS db'); + const database = row?.db ?? ''; + const name = `void_migrations:${createHash('sha1').update(database).digest('hex')}`; + const [lock] = await client.query>>( + 'SELECT GET_LOCK(?, ?) AS acquired', + [name, MIGRATION_LOCK_TIMEOUT_SECONDS], + ); + // Vitess names the column after the call and ignores the alias. + if (Number(Object.values(lock ?? {})[0]) !== 1) { + throw new Error( + `Another migration run is applying migrations to MySQL database "${database}". Wait for it to finish, then run the command again.`, + ); + } + return name; +} + +/** + * A DDL statement makes MySQL commit and close the open transaction. A savepoint can only be + * rolled back to inside a transaction, so this tells whether the file's changes can still be undone. + */ +async function isTransactionOpen(client: MySqlClient): Promise { + try { + await client.query('SAVEPOINT `void_migration`'); + await client.query('ROLLBACK TO SAVEPOINT `void_migration`'); + return true; + } catch { + return false; + } +} + +// ER_LOCK_DEADLOCK and ER_TRANSACTION_ROLLBACK_DURING_COMMIT: MySQL has rolled back the whole +// transaction itself, which also closes it, so the savepoint probe cannot tell it from a DDL commit. +const ROLLED_BACK_BY_SERVER_ERRNOS = new Set([1213, 3101]); + +function isRolledBackByServer(error: unknown): boolean { + const errno = + typeof error === 'object' && error !== null ? (error as { errno?: unknown }).errno : undefined; + return typeof errno === 'number' && ROLLED_BACK_BY_SERVER_ERRNOS.has(errno); +} + +function mySqlMigrationError(name: string, error: unknown, committed: boolean): Error { + const lines = [ + `migration ${name} failed: ${error instanceof Error ? error.message : String(error)}`, + ]; + if (committed) { + lines.push( + `MySQL commits schema changes immediately, so earlier statements in ${name} may already be applied. Undo them, fix the file, then run the command again.`, + 'See https://void.cloud/guide/database/mysql#failed-migrations', + ); + } + return new Error(lines.join('\n'), { cause: error }); +} + export async function applyMySqlMigrations( client: MySqlClient, migrations: Array<{ name: string; sql: string }>, @@ -55,27 +117,45 @@ export async function applyMySqlMigrations( return []; } - await client.query( - 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', - ); - const existing = await client.query>( - 'SELECT `name` FROM `_void_migrations`', - ); - const appliedSet = new Set(existing.map((row) => String(row.name))); - const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); + const lock = await acquireMySqlMigrationLock(client); + try { + await client.query( + 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', + ); + const existing = await client.query>( + 'SELECT `name` FROM `_void_migrations`', + ); + const appliedSet = new Set(existing.map((row) => String(row.name))); + const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); - for (const migration of pending) { - // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. - for (const statement of splitMigrationSql(migration.sql)) { - await client.query(statement); + for (const migration of pending) { + let ran = 0; + // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, on its own. + await client.query('BEGIN'); + try { + // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. + for (const statement of splitMigrationSql(migration.sql)) { + await client.query(statement); + ran++; + } + await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ + migration.name, + new Date().toISOString(), + ]); + await client.query('COMMIT'); + } catch (error) { + const committed = + ran > 0 && !isRolledBackByServer(error) && !(await isTransactionOpen(client)); + // A failed ROLLBACK means the session is gone, and MySQL rolls back with it. + await client.query('ROLLBACK').catch(() => {}); + throw mySqlMigrationError(migration.name, error, committed); + } } - await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ - migration.name, - new Date().toISOString(), - ]); - } - return pending.map((migration) => migration.name); + return pending.map((migration) => migration.name); + } finally { + await client.query('SELECT RELEASE_LOCK(?)', [lock]).catch(() => {}); + } } export async function runMySqlMigrations( diff --git a/packages/void/src/migrations/runner-pg.ts b/packages/void/src/migrations/runner-pg.ts index 62e8f15f3..75e116695 100644 --- a/packages/void/src/migrations/runner-pg.ts +++ b/packages/void/src/migrations/runner-pg.ts @@ -71,8 +71,12 @@ export async function applyPgMigrations( ]); await client.query('COMMIT'); } catch (err) { - await client.query('ROLLBACK'); - throw err; + // A failed ROLLBACK means the session is gone, and PostgreSQL rolls back with it. + await client.query('ROLLBACK').catch(() => {}); + throw new Error( + `migration ${m.name} failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); } } diff --git a/packages/void/src/migrations/runner.ts b/packages/void/src/migrations/runner.ts index 18a296a36..d7ee3c861 100644 --- a/packages/void/src/migrations/runner.ts +++ b/packages/void/src/migrations/runner.ts @@ -270,7 +270,17 @@ export async function runMigrations(migrationsDir: string, dbPath: string): Prom return true; }); - if (tx.immediate()) { + // A throw inside the transaction rolls the whole file back. + let applied: boolean; + try { + applied = tx.immediate(); + } catch (err) { + throw new Error( + `migration ${migration.name} failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + if (applied) { appliedNames.push(migration.name); } } diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index ef79a7ea8..1cfbff0f1 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -68,6 +68,12 @@ async function runMySqlMigrations( } const start = Date.now(); try { + // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, + // on its own. Hyperdrive keeps one connection for the open transaction. Unlike the CLI + // runner, this takes no named lock: Hyperdrive pools connections per transaction, so a + // session lock could stay on a pooled connection. The platform migrates one deploy per + // project at a time. + await connection.query('BEGIN'); // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. for (const statement of splitMigrationSql(migration.sql)) { await connection.query(statement); @@ -76,8 +82,10 @@ async function runMySqlMigrations( 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [migration.name, new Date().toISOString()], ); + await connection.query('COMMIT'); results.push({ name: migration.name, applied: true, durationMs: Date.now() - start }); } catch (error) { + await connection.query('ROLLBACK').catch(() => {}); results.push({ name: migration.name, applied: false, diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index ffbbb0f8f..e6b6dc175 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -53,6 +53,9 @@ vi.mock('mysql2/promise', () => ({ 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 [[], []]; }, }), diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 0a2e6f29b..faaeac6c1 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vite-plus/test'; import { applyMySqlMigrations, @@ -9,6 +10,7 @@ import { createMigrationHandler } from '../../src/runtime/migration-handler-mysq const mysqlQueries = vi.hoisted(() => [] as Array); const mysqlConnections = vi.hoisted(() => [] as Array); +const mysqlFailures = vi.hoisted(() => new Map()); vi.mock('mysql2/promise', () => ({ createConnection: async (options: unknown) => { @@ -16,6 +18,10 @@ vi.mock('mysql2/promise', () => ({ return { query: async (sql: string) => { mysqlQueries.push(sql); + const failure = mysqlFailures.get(sql); + if (failure) { + throw failure; + } return [[]]; }, end: async () => {}, @@ -23,21 +29,58 @@ vi.mock('mysql2/promise', () => ({ }, })); +const LOCK_NAME = `void_migrations:${createHash('sha1').update('app').digest('hex')}`; +const CONTROL = /^(BEGIN|COMMIT|ROLLBACK)$|SAVEPOINT|GET_LOCK|RELEASE_LOCK|DATABASE\(\)/; + +function mySqlError(message: string, code: string): Error { + return Object.assign(new Error(message), { code }); +} + +/** + * A client for `applyMySqlMigrations`. It owns database `app`, grants the lock unless `locked`, + * and throws for statements in `failures`. With `committed`, MySQL has already committed the + * open transaction (after DDL), so rolling back to a savepoint fails as it does in MySQL. + */ +function fakeClient( + options: { + applied?: Array; + failures?: Record; + locked?: boolean; + committed?: boolean; + } = {}, +) { + const calls: Array<{ sql: string; params?: Array }> = []; + const client: MySqlClient = { + async query> = Array>>( + sql: string, + params?: Array, + ): Promise { + calls.push({ sql, params }); + const failure = options.failures?.[sql]; + if (failure) { + throw failure; + } + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: 'app' }] as unknown as T; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [{ acquired: options.locked ? 0 : 1 }] as unknown as T; + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + return (options.applied ?? []).map((name) => ({ name })) as unknown as T; + } + if (sql.startsWith('ROLLBACK TO SAVEPOINT') && options.committed) { + throw mySqlError('SAVEPOINT void_migration does not exist', 'ER_SP_DOES_NOT_EXIST'); + } + return [] as unknown as T; + }, + }; + return { client, calls, sql: () => calls.map((call) => call.sql) }; +} + describe('MySQL migration runner', () => { it('applies only pending migrations and records them with MySQL placeholders', async () => { - const calls: Array<{ sql: string; params?: Array }> = []; - const client: MySqlClient = { - async query> = Array>>( - sql: string, - params?: Array, - ): Promise { - calls.push({ sql, params }); - if (sql.startsWith('SELECT')) { - return [{ name: '001_initial.sql' }] as unknown as T; - } - return [] as unknown as T; - }, - }; + const { client, calls } = fakeClient({ applied: ['001_initial.sql'] }); const applied = await applyMySqlMigrations(client, [ { name: '001_initial.sql', sql: 'CREATE TABLE existing (id INT)' }, @@ -82,20 +125,13 @@ describe('MySQL migration runner', () => { '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')); + queries.filter((query) => !query.includes('_void_migrations') && !CONTROL.test(query)); - const calls: Array = []; - const client: MySqlClient = { - async query> = Array>>( - query: string, - ): Promise { - calls.push(query); - return [] as unknown as T; - }, - }; + const { client, sql: calls } = fakeClient(); await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); - expect(migrationQueries(calls)).toEqual(statements); + expect(migrationQueries(calls())).toEqual(statements); + mysqlQueries.length = 0; const handler = createMigrationHandler( { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, [{ name: '001_init.sql', sql }], @@ -109,6 +145,277 @@ describe('MySQL migration runner', () => { expect(await response.json()).toMatchObject({ ok: true }); expect(migrationQueries(mysqlQueries)).toEqual(statements); }); + + // Two runs at once would both apply a pending data migration. + it('holds a named lock for the database while it reads and applies migrations', async () => { + const { client, calls, sql } = fakeClient(); + + await applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]); + + expect(calls[1]).toEqual({ sql: 'SELECT GET_LOCK(?, ?) AS acquired', params: [LOCK_NAME, 10] }); + expect(sql().indexOf('SELECT `name` FROM `_void_migrations`')).toBeGreaterThan(1); + expect(calls.at(-1)).toEqual({ sql: 'SELECT RELEASE_LOCK(?)', params: [LOCK_NAME] }); + }); + + // Vitess (PlanetScale) names the GET_LOCK column after the call and ignores the alias. + it('takes the lock when the server ignores the column alias', async () => { + const client: MySqlClient = { + async query> = Array>>( + sql: string, + ): Promise { + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: 'app' }] as unknown as T; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [{ [`get_lock('${LOCK_NAME}', 10)`]: 1 }] as unknown as T; + } + return [] as unknown as T; + }, + }; + + await expect( + applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]), + ).resolves.toEqual(['001_bump.sql']); + }); + + it('stops before reading or applying migrations when another run holds the lock', async () => { + const { client, sql } = fakeClient({ locked: true }); + + await expect( + applyMySqlMigrations(client, [{ name: '001_bump.sql', sql: 'UPDATE c SET n = n + 1' }]), + ).rejects.toThrow( + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then run the command again.', + ); + expect(sql()).toEqual(['SELECT DATABASE() AS db', 'SELECT GET_LOCK(?, ?) AS acquired']); + }); + + it('commits each file with its tracking row and rolls back a failed file', async () => { + const cause = mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'); + const { client, sql } = fakeClient({ failures: { 'INSERT INTO nope VALUES (1);': cause } }); + + const error = await applyMySqlMigrations(client, [ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', + }, + ]).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "migration 002_backfill.sql failed: Table 'app.nope' doesn't exist", + ); + expect((error as Error).cause).toBe(cause); + const migrationQueries = sql().filter( + (query) => !/GET_LOCK|DATABASE\(\)|CREATE TABLE IF NOT EXISTS|SELECT `name`/.test(query), + ); + expect(migrationQueries).toEqual([ + 'BEGIN', + 'CREATE TABLE c (n INT);', + 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', + 'COMMIT', + 'BEGIN', + 'UPDATE c SET n = n + 1;', + 'INSERT INTO nope VALUES (1);', + 'SAVEPOINT `void_migration`', + 'ROLLBACK TO SAVEPOINT `void_migration`', + 'ROLLBACK', + 'SELECT RELEASE_LOCK(?)', + ]); + }); + + it('says how to repair a file whose earlier statements MySQL already committed', async () => { + const cause = mySqlError( + "Key column 'nope' doesn't exist in table", + 'ER_KEY_COLUMN_DOES_NOT_EXITS', + ); + const { client } = fakeClient({ + committed: true, + failures: { 'ALTER TABLE c ADD INDEX i (nope);': cause }, + }); + + await expect( + applyMySqlMigrations(client, [ + { + name: '002_comments.sql', + sql: 'CREATE TABLE comments (id INT);\n--> statement-breakpoint\nALTER TABLE c ADD INDEX i (nope);', + }, + ]), + ).rejects.toThrow( + [ + "migration 002_comments.sql failed: Key column 'nope' doesn't exist in table", + 'MySQL commits schema changes immediately, so earlier statements in 002_comments.sql may already be applied. Undo them, fix the file, then run the command again.', + 'See https://void.cloud/guide/database/mysql#failed-migrations', + ].join('\n'), + ); + }); + + it('does not warn about earlier statements when the first statement fails', async () => { + const cause = mySqlError("Duplicate column name 'n'", 'ER_DUP_FIELDNAME'); + const { client, sql } = fakeClient({ + committed: true, + failures: { 'ALTER TABLE c ADD COLUMN n INT;': cause }, + }); + + await expect( + applyMySqlMigrations(client, [ + { name: '002_dup.sql', sql: 'ALTER TABLE c ADD COLUMN n INT;' }, + ]), + ).rejects.toThrow(/^migration 002_dup\.sql failed: Duplicate column name 'n'$/); + expect(sql()).not.toContain('SAVEPOINT `void_migration`'); + }); + + // MySQL rolls back the whole transaction itself here, so the file left nothing behind. + it.each([ + { + failing: 'UPDATE d SET n = n + 1;', + cause: Object.assign( + mySqlError( + 'Deadlock found when trying to get lock; try restarting transaction', + 'ER_LOCK_DEADLOCK', + ), + { errno: 1213 }, + ), + }, + { + failing: 'COMMIT', + cause: Object.assign( + mySqlError( + 'Plugin instructed the server to rollback the current transaction.', + 'ER_TRANSACTION_ROLLBACK_DURING_COMMIT', + ), + { errno: 3101 }, + ), + }, + ])( + 'does not warn about earlier statements when MySQL rolled back $cause.code', + async ({ failing, cause }) => { + const { client, sql } = fakeClient({ committed: true, failures: { [failing]: cause } }); + + const error = await applyMySqlMigrations(client, [ + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nUPDATE d SET n = n + 1;', + }, + ]).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe(`migration 002_backfill.sql failed: ${cause.message}`); + expect((error as Error).cause).toBe(cause); + expect(sql()).toContain('ROLLBACK'); + }, + ); + + // A lost connection makes ROLLBACK fail too; the error must still name the file. + it('names the failed file when the rollback fails too', async () => { + const cause = mySqlError( + 'Connection lost: The server closed the connection.', + 'PROTOCOL_CONNECTION_LOST', + ); + const { client, sql } = fakeClient({ + failures: { + 'UPDATE c SET n = n + 1;': cause, + ROLLBACK: new Error("Can't add new command when connection is in closed state"), + }, + }); + + const error = await applyMySqlMigrations(client, [ + { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, + ]).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe(`migration 002_backfill.sql failed: ${cause.message}`); + expect((error as Error).cause).toBe(cause); + expect(sql().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + }); +}); + +describe('MySQL hosted migration handler', () => { + it('commits each file with its tracking row and rolls back a failed file', async () => { + mysqlQueries.length = 0; + mysqlFailures.set( + 'INSERT INTO nope VALUES (1);', + mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'), + ); + try { + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + [ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', + }, + ], + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + + expect(await response.json()).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { name: '002_backfill.sql', applied: false, error: "Table 'app.nope' doesn't exist" }, + ], + }); + expect(mysqlQueries.slice(2)).toEqual([ + 'BEGIN', + 'CREATE TABLE c (n INT);', + 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', + 'COMMIT', + 'BEGIN', + 'UPDATE c SET n = n + 1;', + 'INSERT INTO nope VALUES (1);', + 'ROLLBACK', + ]); + } finally { + mysqlFailures.clear(); + } + }); + + it('names the failed file when the rollback fails too', async () => { + mysqlFailures.set( + 'UPDATE c SET n = n + 1;', + mySqlError('Connection lost: The server closed the connection.', 'PROTOCOL_CONNECTION_LOST'), + ); + mysqlFailures.set( + 'ROLLBACK', + new Error("Can't add new command when connection is in closed state"), + ); + try { + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + [ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, + ], + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + + expect(await response.json()).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { + name: '002_backfill.sql', + applied: false, + error: 'Connection lost: The server closed the connection.', + }, + ], + }); + } finally { + mysqlFailures.clear(); + } + }); }); describe('MySQL connections in Workers', () => { diff --git a/packages/void/test/unit/runner-pg.test.ts b/packages/void/test/unit/runner-pg.test.ts index d37196ab9..4d620c925 100644 --- a/packages/void/test/unit/runner-pg.test.ts +++ b/packages/void/test/unit/runner-pg.test.ts @@ -135,6 +135,38 @@ describe('applyPgMigrations', () => { const tracking = await pglite.query('SELECT name FROM _void_migrations'); expect(tracking.rows).toHaveLength(0); }); + + it('names the failed file and keeps the database error as the cause', async () => { + const migrations = [ + { name: '001_create_users.sql', sql: 'CREATE TABLE users (id SERIAL PRIMARY KEY)' }, + { name: '002_bad.sql', sql: 'INVALID SQL THAT WILL FAIL' }, + ]; + + const error = await applyPgMigrations(client, migrations).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe( + 'migration 002_bad.sql failed: syntax error at or near "INVALID"', + ); + expect((error as Error).cause).toMatchObject({ code: '42601' }); + }); + + it('names the failed file when the rollback fails too', async () => { + const failing: PgClient = { + async query = Record>(text: string) { + if (text === 'SELECT 1 FROM missing') { + throw new Error('connection lost'); + } + if (text === 'ROLLBACK') { + throw new Error('Client was closed and is not queryable'); + } + return { rows: [] as Array }; + }, + }; + + await expect( + applyPgMigrations(failing, [{ name: '001_x.sql', sql: 'SELECT 1 FROM missing' }]), + ).rejects.toThrow('migration 001_x.sql failed: connection lost'); + }); }); describe('readPgAppliedMigrations', () => { diff --git a/packages/void/test/unit/runner-sqlite.test.ts b/packages/void/test/unit/runner-sqlite.test.ts index 4fee832f1..51efed08b 100644 --- a/packages/void/test/unit/runner-sqlite.test.ts +++ b/packages/void/test/unit/runner-sqlite.test.ts @@ -240,6 +240,19 @@ describe('runMigrations (local SQLite)', () => { } }); + it('names the failed file and keeps the SQLite error as the cause', async () => { + const root = createDir(); + const migDir = join(root, 'migrations'); + const dbPath = join(root, 'test.sqlite'); + writeMigration(migDir, '001_ok.sql', 'CREATE TABLE a (id TEXT PRIMARY KEY)'); + writeMigration(migDir, '002_bad.sql', 'INSERT INTO nope VALUES (1);'); + + const error = await runMigrations(migDir, dbPath).catch((caught: unknown) => caught); + + expect((error as Error).message).toBe('migration 002_bad.sql failed: no such table: nope'); + expect((error as Error).cause).toMatchObject({ code: 'SQLITE_ERROR' }); + }); + it('upgrades a legacy _migrations table into _void_migrations on first run', async () => { const root = createDir(); const migDir = join(root, 'migrations'); From bb568b42e76a16d2c8c131faebbccd0a10a9965a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 01:52:31 +0800 Subject: [PATCH 05/17] fix(void): give framework requests a pg/MySQL request scope React Router, TanStack Start and vinext with PostgreSQL or MySQL threw "Request-scoped resource is unavailable outside a runtime handler" on the first void/db query, in dev and in built Workers. Astro failed the same way. The class A worker wrappers now run each fetch inside withRuntimeEnv when the app uses PostgreSQL or MySQL, so one request reuses one client. D1 apps and apps without a database keep the same wrapper output. Outside a Void request scope, the pg and MySQL db export now builds a client for each use, like D1 does. That MySQL client closes idle connections, and one query chain uses one client instead of one per property read. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/index.ts | 2 + packages/void/src/plugin-drizzle.ts | 69 ++++--- packages/void/src/plugin-framework-entry.ts | 53 +++--- packages/void/test/integration/astro.test.ts | 120 ++++++++++++- .../test/integration/react-router.test.ts | 122 ++++++++++++- .../test/unit/plugin-class-a-wrapper.test.ts | 169 +++++++++++++++++- .../void/test/unit/plugin-drizzle.test.ts | 105 +++++++++++ test/integration-timings.ts | 4 +- 8 files changed, 592 insertions(+), 52 deletions(-) diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 95a3fa994..515189cf2 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -619,6 +619,7 @@ export function voidPlugin(options?: { framework, root, devTriggerToken, + dialect, config.routing?.notFound, ); // Class A builds write no `assets` config, and `not_found_handling` alone would keep @@ -649,6 +650,7 @@ export function voidPlugin(options?: { framework, getOriginalFrameworkEntry(framework, root), true, + dialect, resolved.build.assetsDir, config.routing?.notFound, ); diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index a36a36975..a382b4efe 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -158,24 +158,39 @@ function getConnectionString() { throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function getInstance() { - return getRuntimeRequestResource(_requestResourceKey, () => { - return drizzle({ - connection: { - uri: getConnectionString(), - connectionLimit: 1, - maxIdle: 1, - idleTimeout: 1000, - // Workers forbid eval, which mysql2 uses to build its row parsers. - disableEval: true, - }${mysqlDrizzleConfig}, - }); +function createInstance(maxIdle) { + return drizzle({ + connection: { + uri: getConnectionString(), + connectionLimit: 1, + maxIdle, + idleTimeout: 1000, + // Workers forbid eval, which mysql2 uses to build its row parsers. + disableEval: true, + }${mysqlDrizzleConfig}, }); } +function getInstance() { + return getRuntimeRequestResource( + _requestResourceKey, + // maxIdle below connectionLimit starts mysql2's idle timer when the pool is built, and + // workerd rejects timers in global scope, where a module-level query builds its pool. + () => createInstance(1), + // Framework entries that Void does not own run outside its request scope: + // they get a client for each use, never one shared across requests. Nothing + // ends that pool, so mysql2 closes its idle connection. + () => createInstance(0), + ); +} + export const db = new Proxy({}, { get(_, prop) { - return Reflect.get(getInstance(), prop); + const instance = getInstance(); + const value = Reflect.get(instance, prop); + // drizzle's methods read this.session. Outside a request scope each lookup builds a + // client, so bind them to this one: a query chain then uses one client. + return typeof value === "function" ? value.bind(instance) : value; }, }); @@ -203,21 +218,29 @@ function getConnectionString() { throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function getInstance() { - return getRuntimeRequestResource(_requestResourceKey, () => { - return drizzle({ - connection: { - connectionString: getConnectionString(), - max: 1, - idleTimeoutMillis: 1000, - }${schema ? ', schema' : ''}, - }); +function createInstance() { + return drizzle({ + connection: { + connectionString: getConnectionString(), + max: 1, + idleTimeoutMillis: 1000, + }${schema ? ', schema' : ''}, }); } +function getInstance() { + // Framework entries that Void does not own run outside its request scope: + // they get a client for each use, never one shared across requests. + return getRuntimeRequestResource(_requestResourceKey, createInstance, createInstance); +} + export const db = new Proxy({}, { get(_, prop) { - return Reflect.get(getInstance(), prop); + const instance = getInstance(); + const value = Reflect.get(instance, prop); + // drizzle's methods read this.session. Outside a request scope each lookup builds a + // client, so bind them to this one: a query chain then uses one client. + return typeof value === "function" ? value.bind(instance) : value; }, }); diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts index 728f484e3..6a098e944 100644 --- a/packages/void/src/plugin-framework-entry.ts +++ b/packages/void/src/plugin-framework-entry.ts @@ -1,7 +1,7 @@ import { mkdirSync } from 'node:fs'; import { join } from 'pathslash'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; -import { type NotFoundHandling } from './config.ts'; +import { type DatabaseDialect, type NotFoundHandling } from './config.ts'; import { isViteBuildOrPreview } from './plugin-dev-config.ts'; import { frameworkTriggersDispatchPath, @@ -22,6 +22,7 @@ export function getFrameworkEntry( framework: Framework, root: string, devTriggerToken: string, + dialect: DatabaseDialect, notFound?: NotFoundHandling, ): string { const isBuild = isViteBuildOrPreview(); @@ -38,6 +39,7 @@ export function getFrameworkEntry( framework, getOriginalFrameworkEntry(framework, root), isBuild, + dialect, undefined, notFound, ); @@ -63,6 +65,11 @@ export function getOriginalFrameworkEntry(framework: Framework, root: string): s * first, dispatches `/__void/{scheduled,queue}` if it matches, and otherwise * delegates to the framework's original entry. * + * For PostgreSQL and MySQL, both shapes run the framework inside Void's request scope + * (`withRuntimeEnv`), as Void's own handlers do, so void/db keeps one client per request. D1 apps + * keep the framework's own fetch: in a request scope, void/db reads D1 through a replica-capable + * session instead of the primary. + * * We emit two shapes of the wrapper depending on the Vite command: * * - Serve: dispatch is dynamically imported and gets a chance to handle @@ -80,6 +87,7 @@ export function generateClassATriggerWrapper( framework: Framework, original: string, isBuild: boolean, + dialect: DatabaseDialect, assetsDir = 'assets', /** * Resolved `assetConfig.not_found_handling`. A Class A app deploys through @@ -92,25 +100,9 @@ export function generateClassATriggerWrapper( const outDir = join(root, '.void'); mkdirSync(outDir, { recursive: true }); const wrapperPath = join(outDir, `${framework}-trigger-wrapper.ts`); - const body = isBuild - ? `// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. -import original from ${JSON.stringify(original)}; - -export default { - ...original, - async fetch(request, env, ctx) { - return serveWithAssets(request, env, await original.fetch(request, env, ctx)); - }, -}; -${compileAssetFallbackHelper(assetsDir, false, notFound)} -` - : `// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. -import original from ${JSON.stringify(original)}; - -export default { - ...original, - async fetch(request, env, ctx) { - if (request.method === "POST") { + const fetchBody = isBuild + ? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));` + : ` if (request.method === "POST") { const pathname = new URL(request.url).pathname; if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); @@ -118,10 +110,25 @@ export default { if (response) return response; } } - return original.fetch(request, env, ctx); - }, + return original.fetch(request, env, ctx);`; + const scoped = dialect !== 'sqlite'; + const fetchMethod = scoped + ? ` fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { +${fetchBody.replace(/^/gm, ' ')} + }); + },` + : ` async fetch(request, env, ctx) { +${fetchBody} + },`; + const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit. +import original from ${JSON.stringify(original)}; +${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''} +export default { + ...original, +${fetchMethod} }; -`; +${isBuild ? `${compileAssetFallbackHelper(assetsDir, false, notFound)}\n` : ''}`; writeFileIfChanged(wrapperPath, body); return wrapperPath; } diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index 0c54758a2..d5b697d8a 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -1,8 +1,17 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { once } from 'node:events'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; import { join } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; import spawn from 'cross-spawn'; +import { createServer } from 'pglite-server'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/astro'; @@ -96,3 +105,110 @@ describe('playground/astro dev', () => { } }); }); + +// Astro owns its Worker entry, so void/db runs without Void's request scope. +describe('playground/astro with PostgreSQL', () => { + const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, + ]; + let pglite: PGlite; + let pgServer: ReturnType; + let playground: IsolatedPlayground; + let child: ReturnType | undefined; + let base: string; + let output = () => ''; + + beforeAll(async () => { + pglite = new PGlite(); + await pglite.exec( + 'CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);', + ); + for (const user of USERS) { + await pglite.query('INSERT INTO users (name, email) VALUES ($1, $2)', [ + user.name, + user.email, + ]); + } + pgServer = createServer(pglite); + pgServer.listen(0, '127.0.0.1'); + await once(pgServer, 'listening'); + const { port } = pgServer.address() as AddressInfo; + + playground = createIsolatedPlayground(ROOT, PLAYGROUND); + const root = playground.root; + const configPath = join(root, 'void.config.ts'); + writeFileSync( + configPath, + readFileSync(configPath, 'utf8') + .replace('defineConfig({', "defineConfig({\n database: 'pg',") + .replace( + "compatibility_flags: ['nodejs_als']", + "compatibility_flags: ['nodejs_compat', 'nodejs_als']", + ), + ); + rmSync(join(root, 'db/migrations'), { recursive: true, force: true }); + writeFileSync( + join(root, 'db/schema.ts'), + `import { pgTable, serial, text } from 'void/schema-pg'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull(), +}); +`, + ); + mkdirSync(join(root, 'src/pages/api'), { recursive: true }); + writeFileSync( + join(root, 'src/pages/api/users.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export const prerender = false; + +export async function GET() { + return Response.json(await db.select().from(users)); +} +`, + ); + writeFileSync( + join(root, '.env'), + `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`, + ); + + const server = await startServer((serverPort) => { + const astro = join(root, 'node_modules/astro/bin/astro.mjs'); + child = spawn( + process.execPath, + [astro, 'dev', '--host', '127.0.0.1', '--port', String(serverPort)], + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ASTRO_DEV_BACKGROUND: 'false', VOID_INSPECTOR_PORT: 'false' }, + }, + ); + return child; + }, 30_000); + base = server.base; + output = server.output; + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + playground?.cleanup(); + }); + + it('queries PostgreSQL through void/db from an API route in dev', async () => { + const response = await fetch(`${base}/api/users`); + const result = { status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ status: 200, body: JSON.stringify(USERS) }); + }); +}); diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index f5402e3b0..3f20e2d77 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -1,7 +1,16 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { killChild, startServer } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/react-router'; @@ -60,3 +69,114 @@ describe('playground/react-router dev', { retry: 2 }, () => { expect(html).toContain('ok'); }); }); + +// React Router owns its request handler; void/db must still reach MySQL from a loader. +describe('playground/react-router with MySQL', () => { + const USERS = [ + { id: 1, name: 'Ada', email: 'ada@example.com' }, + { id: 2, name: 'Linus', email: 'linus@example.com' }, + ]; + let mysqlServer: FakeMySqlServer; + let playground: IsolatedPlayground; + let child: ReturnType | undefined; + + beforeAll(async () => { + mysqlServer = await startFakeMySqlServer(USERS); + playground = createIsolatedPlayground(ROOT, PLAYGROUND); + const root = playground.root; + const configPath = join(root, 'void.config.ts'); + writeFileSync( + configPath, + readFileSync(configPath, 'utf8').replace( + 'defineConfig({', + "defineConfig({\n database: 'mysql',", + ), + ); + rmSync(join(root, 'db/migrations'), { recursive: true, force: true }); + rmSync(join(root, 'crons'), { recursive: true, force: true }); + writeFileSync( + join(root, 'db/schema.ts'), + `import { int, mysqlTable, varchar } from 'void/schema-mysql'; + +export const users = mysqlTable('users', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), +}); +`, + ); + writeFileSync( + join(root, 'app/routes/api.users.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export async function loader() { + return Response.json(await db.select().from(users)); +} +`, + ); + const routesPath = join(root, 'app/routes.ts'); + writeFileSync( + routesPath, + readFileSync(routesPath, 'utf8').replace( + "route('smoke', 'routes/smoke.tsx'),", + "route('smoke', 'routes/smoke.tsx'),\n route('api/users', 'routes/api.users.ts'),", + ), + ); + writeFileSync( + join(root, '.env'), + `DATABASE_URL=mysql://void@127.0.0.1:${mysqlServer.port}/app\n`, + ); + }); + + afterAll(async () => { + if (child) { + await killChild(child); + } + await mysqlServer?.close(); + playground?.cleanup(); + }); + + function start(script: 'dev' | 'preview'): Promise { + return startServer((port) => { + child = spawn('vp', [script, '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); + return child; + }, 30_000); + } + + async function expectUsers({ base, output }: StartedServer): Promise { + const response = await fetch(`${base}/api/users`); + const result = { status: response.status, body: await response.text() }; + expect(result, output().slice(-4000)).toEqual({ status: 200, body: JSON.stringify(USERS) }); + } + + it('queries MySQL through void/db in dev', async () => { + const server = await start('dev'); + try { + await expectUsers(server); + } finally { + await killChild(server.child); + child = undefined; + } + }); + + it('queries MySQL through void/db in the built Worker', async () => { + execFileSync('vp', ['build'], { + cwd: playground.root, + stdio: 'pipe', + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + shell: process.platform === 'win32', + }); + const server = await start('preview'); + try { + await expectUsers(server); + } finally { + await killChild(server.child); + child = undefined; + } + }); +}); diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 17859bbe3..82060f4da 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -1,10 +1,11 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { Plugin, ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { frameworkTriggersDispatchPath } from '../../src/plugin-framework-triggers.ts'; vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); @@ -29,6 +30,16 @@ function createTanStackRoot(voidConfig?: Record): string { return root; } +/** The generated wrappers import Void's runtime env module by package name, as an app does. */ +function linkVoidPackage(root: string): void { + mkdirSync(join(root, 'node_modules'), { recursive: true }); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(root, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); +} + function callConfigResolved(plugin: Plugin, config: Partial) { const hook = plugin.configResolved; const fn = typeof hook === 'function' ? hook : hook?.handler; @@ -120,6 +131,162 @@ describe('Class A framework dev wrapper', () => { }); }); +describe('Class A framework request scope', () => { + /** The dev wrapper, written when the plugin is created, and the build wrapper. */ + async function generateWrappers(root: string): Promise<{ dev: string; build: string }> { + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const wrapperPlugin = voidPlugin().find( + (plugin) => plugin.name === 'void:framework-build-wrapper', + ); + if (!wrapperPlugin) { + throw new Error('Expected Class A wrapper plugin.'); + } + const wrapperPath = join(root, '.void', 'tanstack-start-trigger-wrapper.ts'); + const dev = readFileSync(wrapperPath, 'utf-8'); + callConfigResolved(wrapperPlugin, { + command: 'build', + build: { assetsDir: 'assets' } as ResolvedConfig['build'], + } as Partial); + const build = readFileSync(wrapperPath, 'utf-8'); + // The asset fallback helper that ends the build wrapper is covered above. + return { dev, build: build.slice(0, build.indexOf('import { createAssetFallback')) }; + } + + // A request scope would move D1 reads from the primary to a replica-capable session. + it.each([ + ['without a database', undefined], + ['with D1', 'export {};\n'], + ])('keeps the framework fetch unscoped for an app %s', async (_name, schema) => { + const root = createTanStackRoot(); + if (schema) { + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db/schema.ts'), schema); + } + + const { dev, build } = await generateWrappers(root); + + expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; + +export default { + ...original, + async fetch(request, env, ctx) { + if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx); + }, +}; +`); + expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; + +export default { + ...original, + async fetch(request, env, ctx) { + return serveWithAssets(request, env, await original.fetch(request, env, ctx)); + }, +}; +`); + }); + + it.each(['pg', 'mysql'])( + 'runs the framework fetch in the request scope with %s', + async (database) => { + const root = createTanStackRoot({ database }); + + const { dev, build } = await generateWrappers(root); + + expect(dev).toBe(`// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; +import { withRuntimeEnv } from "void/_env"; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { + if (request.method === "POST") { + const pathname = new URL(request.url).pathname; + if (pathname === "/__void/scheduled" || pathname === "/__void/queue") { + const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))}); + const response = await handleDevTrigger(request, env, ctx); + if (response) return response; + } + } + return original.fetch(request, env, ctx); + }); + }, +}; +`); + expect(build).toBe(`// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit. +import original from "@tanstack/react-start/server-entry"; +import { withRuntimeEnv } from "void/_env"; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, async () => { + return serveWithAssets(request, env, await original.fetch(request, env, ctx)); + }); + }, +}; +`); + }, + ); + + it.each(['pg', 'mysql'])( + 'runs each dev request inside its own Void request scope with %s', + async (database) => { + const root = createTanStackRoot({ database }); + const packageRoot = join(root, 'node_modules/@tanstack/react-start'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ type: 'module', exports: { './server-entry': './server-entry.js' } }), + ); + // void/db keeps its PostgreSQL/MySQL client in this request resource. + writeFileSync( + join(packageRoot, 'server-entry.js'), + 'import { getRuntimeRequestResource } from "void/_env";\n' + + 'const key = {};\n' + + 'let created = 0;\n' + + 'export default { async fetch() {\n' + + ' const first = getRuntimeRequestResource(key, () => ++created);\n' + + ' await new Promise((resolve) => setTimeout(resolve, 0));\n' + + ' const second = getRuntimeRequestResource(key, () => ++created);\n' + + ' return Response.json([first, second]);\n' + + '} };\n', + ); + linkVoidPackage(root); + + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + voidPlugin(); + + const runner = join(root, 'run.mjs'); + writeFileSync( + runner, + `import worker from ${JSON.stringify(pathToFileURL(join(root, '.void/tanstack-start-trigger-wrapper.ts')).href)};\n` + + 'const env = {};\n' + + 'const responses = await Promise.all([1, 2].map(() => worker.fetch(new Request("https://example.test/"), env, {})));\n' + + 'process.stdout.write(JSON.stringify(await Promise.all(responses.map((response) => response.json()))));\n', + ); + expect( + JSON.parse(execFileSync(process.execPath, [runner], { cwd: root, encoding: 'utf8' })), + ).toEqual([ + [1, 1], + [2, 2], + ]); + }, + ); +}); + describe('Class A routing.notFound self-host warning', () => { /** Run a Class A `build` twice and return every line the plugin logged. */ async function buildTwiceWithNotFound(): Promise> { diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index c548d8440..09cbacf38 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -2,6 +2,8 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { int, mysqlTable } from 'drizzle-orm/mysql-core'; +import { pgTable, serial } from 'drizzle-orm/pg-core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { getHookHandler } from '../helpers/vite-hooks.ts'; @@ -164,6 +166,43 @@ export const withEnv = (env, callback) => withRuntimeEnv(env, callback); return { module, state }; } +type DriverQuery = { toSQL(): { sql: string } }; + +/** Load void/db with drizzle's own driver and record each client it builds. */ +async function loadDriverDbModule(dialect: 'postgresql' | 'mysql'): Promise<{ + db: { select(): { from(table: unknown): DriverQuery } }; + clients: Array<{ end(): unknown }>; +}> { + const root = createProjectRoot({ schema: false }); + const plugin = drizzlePlugin(root, false, dialect, { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const clientsKey = `__void_db_clients_${dialect}_${crypto.randomUUID().replaceAll('-', '')}`; + const clients: Array<{ end(): unknown }> = []; + (globalThis as Record)[clientsKey] = clients; + fakeStateKeys.push(clientsKey); + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ drizzle \} from (.*);$/m, + (_match, driver: string) => `import { drizzle as createDrizzle } from ${driver}; +function drizzle(config) { + const database = createDrizzle(config); + globalThis[${JSON.stringify(clientsKey)}].push(database.$client); + return database; +}`, + ) + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + const module = (await import( + `${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}` + )) as { db: Awaited>['db'] }; + return { db: module.db, clients }; +} + afterEach(() => { for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); @@ -238,6 +277,72 @@ describe('drizzlePlugin db typing generation', () => { } }); + it.each(['postgresql', 'mysql'] as const)( + 'builds a %s client for each use when a framework handler has no Void request scope', + async (dialect) => { + const { module, state } = await loadExecutableDbModule(dialect); + const previousEnv = (globalThis as Record).__env__; + (globalThis as Record).__env__ = { + HYPERDRIVE: { connectionString: `${dialect}://hyperdrive` }, + }; + try { + const first = module.db.instanceId; + const second = module.db.instanceId; + expect(second).not.toBe(first); + expect(state.pools).toHaveLength(2); + for (const pool of state.pools) { + // Nothing ends these MySQL pools, so mysql2 must close their idle connections. + expect(pool.options).toMatchObject( + dialect === 'postgresql' + ? { connectionString: 'postgresql://hyperdrive', max: 1, idleTimeoutMillis: 1000 } + : { + uri: 'mysql://hyperdrive', + connectionLimit: 1, + maxIdle: 0, + idleTimeout: 1000, + disableEval: true, + }, + ); + } + } finally { + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + } + }, + ); + + it.each(['postgresql', 'mysql'] as const)( + 'builds one %s client for a query outside a Void request scope', + async (dialect) => { + const { db, clients } = await loadDriverDbModule(dialect); + const users = + dialect === 'postgresql' + ? pgTable('users', { id: serial('id') }) + : mysqlTable('users', { id: int('id') }); + const previousEnv = (globalThis as Record).__env__; + (globalThis as Record).__env__ = { + DATABASE_URL: `${dialect === 'postgresql' ? 'postgres' : 'mysql'}://void@127.0.0.1:9/app`, + }; + try { + // drizzle's select() reads `this.session` and `this.dialect`. + const query = db.select().from(users); + + expect(query.toSQL().sql).toMatch(/^select .id. from .users.$/); + expect(clients).toHaveLength(1); + } finally { + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + await Promise.all(clients.map((client) => client.end())); + } + }, + ); + it('keeps sqlite projects on the D1 internal db entry', () => { const root = createProjectRoot(); diff --git a/test/integration-timings.ts b/test/integration-timings.ts index c647295bf..42026a88b 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -9,7 +9,7 @@ export const integrationTimings: Readonly> = { 'packages/svelte/test/integration/svelte-pages-client.test.ts': 30000, 'packages/void/test/e2e/cloudflare-deploy-live.test.ts': 6, 'packages/void/test/integration/analog.test.ts': 38000, - 'packages/void/test/integration/astro.test.ts': 17000, + 'packages/void/test/integration/astro.test.ts': 19500, 'packages/void/test/integration/cli-help.test.ts': 1718, 'packages/void/test/integration/cloudflare-compatibility-build.test.ts': 5000, 'packages/void/test/integration/cloudflare-deploy-build.test.ts': 6700, @@ -33,7 +33,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/plugin-env-schema.test.ts': 236, 'packages/void/test/integration/prepare-cli.test.ts': 1957, 'packages/void/test/integration/queue-dispatch.test.ts': 208, - 'packages/void/test/integration/react-router.test.ts': 9000, + 'packages/void/test/integration/react-router.test.ts': 21000, 'packages/void/test/integration/sandbox-managed-rpc.test.ts': 722, 'packages/void/test/integration/spa-middleware-auth.test.ts': 26264, 'packages/void/test/integration/ssr-playground.test.ts': 13008, From a30f942becbed306d07746794a5478476ce1d1c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 01:52:40 +0800 Subject: [PATCH 06/17] fix(void): load the real void/db in SvelteKit dev with installed void With void installed from npm, SvelteKit's dev server externalized void before any plugin ran. void/db then loaded the package's D1 runtime, so PostgreSQL and MySQL apps failed and the hooks' env was a second copy. The SvelteKit plugin now keeps void in Vite's SSR graph during serve. Bare 'void' imports in server code go to the handler entry, as they do in Void apps, so the full plugin does not load into Kit's module runner. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-framework.ts | 26 ++++ .../test/unit/plugin-vite-environment.test.ts | 133 +++++++++++++++++- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 150e4bf15..3d060b8a9 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -98,7 +98,7 @@ generated entries must preserve the module-boundary rules below. - **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev, an installed third-party package that imports `void/*` stays external and gets its own env module copy without the request env. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index fa9fba0c4..365435e70 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { type Plugin } from 'vite'; import { type ApplicationAnalysis } from './application-analysis.ts'; import { @@ -29,6 +30,8 @@ import { migrationPlugin } from './plugin-migration.ts'; import { createCloudflareBuiltinsExternalPlugin } from './plugin-worker-environment.ts'; import { injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; +const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); + export function createFrameworkPlugins(input: { readonly localD1: ReturnType; readonly root: string; @@ -180,6 +183,29 @@ export function createFrameworkPlugins(input: { const svelteKitHooksInjectPlugin: Plugin = { name: 'void:sveltekit-hooks-inject', apply: 'serve', + config() { + if (detected.class !== 'b') { + return; + } + // Kit's dev server runs server code in Vite's Node SSR graph. That graph + // externalizes a bare import resolving into node_modules before any plugin + // sees it, so an installed void/db would load the package's D1 runtime + // instead of the virtual module. Inlining keeps every void import, including + // the injected env import below, in the graph that holds void/db's env module. + return { ssr: { noExternal: ['void'] } }; + }, + resolveId: { + order: 'pre', + filter: { id: /^void$/ }, + handler() { + if (detected.class !== 'b' || this.environment?.name === 'client') { + return; + } + // Inlined, the package entry would load the whole Vite plugin into Kit's module + // runner. Server code gets the lightweight handler subpath, as in Void apps. + return handlerFile; + }, + }, transform(code, id) { if (detected.class !== 'b') { return; diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index c0a448e8a..827ef785a 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { cloudflare } from '@cloudflare/vite-plugin'; -import { resolveConfig } from 'vite'; +import { createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; @@ -202,4 +203,132 @@ describe('voidPlugin Vite environments', () => { ); }, ); + + it.each([ + ['sveltekit', '@sveltejs/kit', 'serve', true], + ['sveltekit', '@sveltejs/kit', 'build', false], + ['nuxt', 'nuxt', 'serve', false], + ['analog', '@analogjs/platform', 'serve', false], + ['astro', 'astro', 'serve', false], + ] as const)( + 'keeps void in the Node SSR graph for %s (%s, %s): %s', + async (_name, dependency, command, inlined) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { [dependency]: '*' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig({ root, plugins: [voidPlugin()] }, command, 'development'); + + expect(config.ssr.noExternal).toEqual( + inlined ? expect.arrayContaining(['void']) : expect.not.arrayContaining(['void']), + ); + }, + ); + + it('serves the void/db virtual module to SvelteKit server code from an installed void', async () => { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { '@sveltejs/kit': '*', void: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db/schema.ts'), + "import { pgTable, serial } from 'void/schema-pg';\nexport const users = pgTable('users', { id: serial('id') });\n", + ); + // A packed install: Vite externalizes bare SSR imports that resolve into node_modules. + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ + name: 'void', + type: 'module', + exports: { './db': './db.mjs', './_env': './env.mjs', './schema-pg': './schema-pg.mjs' }, + }), + ); + for (const file of ['db.mjs', 'env.mjs', 'schema-pg.mjs']) { + writeFileSync(join(installed, file), 'export {};\n'); + } + mkdirSync(join(root, 'src')); + writeFileSync( + join(root, 'src/server.ts'), + "import { db } from 'void/db';\nimport { withRuntimeEnv } from 'void/_env';\nexport { db, withRuntimeEnv };\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const result = await server.environments.ssr.transformRequest('/src/server.ts'); + + expect(result?.code).toContain('virtual:void-db'); + expect(result?.code).not.toMatch(/__vite_ssr_import__\("void\/(?:db|_env)"/); + } finally { + await server.close(); + } + }); + + // The package entry is the whole Vite plugin; server code needs only its handler helpers. + it.each([ + ['sveltekit', '@sveltejs/kit', 'ssr', true], + ['sveltekit', '@sveltejs/kit', 'client', false], + ['astro', 'astro', 'ssr', false], + ] as const)( + 'sends bare void from %s %s code in the %s environment to the handler entry: %s', + async (_name, dependency, environment, redirected) => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { [dependency]: '*', void: '*' } }), + ); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { '.': './index.mjs' } }), + ); + writeFileSync(join(installed, 'index.mjs'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const resolved = await server.environments[environment]!.pluginContainer.resolveId( + 'void', + join(root, 'src/hooks.server.ts'), + ); + + expect(resolved?.id).toBe( + normalizePath( + redirected + ? createRequire(import.meta.url).resolve('void/handler') + : join(installed, 'index.mjs'), + ), + ); + } finally { + await server.close(); + } + }, + ); }); From cc87476c421034fc931fdab8c2cce8341f87f731 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 01:52:41 +0800 Subject: [PATCH 07/17] fix(void): bundle every migration handler dependency for hoisted installs A build decides what is external by resolving from the app root. With npm, Yarn or Bun, pg, pg-types and @void/deploy-core are hoisted there, so the hosted pg and sqlite migration handlers kept bare imports that workerd cannot load ("No such module", "Unsupported require"). The migration handler build now bundles every package for each dialect. Node built-ins stay external for the require shims. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/src/cli/wrapper.ts | 21 +-- packages/void/test/unit/wrapper.test.ts | 187 +++++++++++++++++++----- vite.config.ts | 1 + 3 files changed, 159 insertions(+), 50 deletions(-) diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index a4a78ec1d..6c4e1bbd3 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -271,16 +271,17 @@ async function bundleMigrationHandler( conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], } : undefined, - ssr: isPostgresql - ? { - noExternal: ['pg', 'pg-cloudflare'], - resolve: { - conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], - }, - } - : isMysql - ? { noExternal: true } - : undefined, + // A build decides what is external by resolving from `root`, which sits in the app. npm, + // Yarn and Bun hoist dependencies there, and workerd cannot load a bare import, so bundle + // every package. Node built-ins stay external for the require shims below. + ssr: { + noExternal: true, + ...(isPostgresql && { + resolve: { + conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], + }, + }), + }, build: { outDir, emptyOutDir: true, diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 6db01a420..6e10c7e9d 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events'; import { cpSync, existsSync, @@ -10,10 +11,13 @@ import { writeFileSync, } from 'node:fs'; import { createRequire, isBuiltin } from 'node:module'; +import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; import { parseSync } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { @@ -591,10 +595,12 @@ describe('generateWrapper with migrations', () => { expect(bundled).toContain('__voidPgpassNoop'); }); - /** Run the wrapped MySQL Worker in workerd and return its `/__void/migrate` response body. */ - async function migrateMySqlInWorkerd(workerDir: string): Promise { - // The applied-migrations SELECT returns a row, so mysql2 also parses a result set. - const mysql = await startFakeMySqlServer([{ name: '000_applied_elsewhere.sql' }]); + /** Start the wrapped Worker in workerd, send one internal request and return its JSON body. */ + async function fetchWrappedWorker( + workerDir: string, + request: { path: string; body?: unknown }, + options: { bindings?: Record; d1Databases?: Array } = {}, + ): Promise { // Upload every module, as the platform does; the wrapper imports the handler statically. const modules = readdirSync(workerDir) .filter((file) => file.endsWith('.mjs')) @@ -608,23 +614,87 @@ describe('generateWrapper with migrations', () => { modules, compatibilityDate: '2026-02-24', compatibilityFlags: ['nodejs_compat'], - bindings: { - __VOID_PROXY_TOKEN: 'proxy-token', - HYPERDRIVE: { connectionString: `mysql://void@127.0.0.1:${mysql.port}/app` }, - }, + bindings: { __VOID_PROXY_TOKEN: 'proxy-token', ...options.bindings }, + d1Databases: options.d1Databases, }), ); - const response = await miniflare.dispatchFetch('http://localhost/__void/migrate', { + const response = await miniflare.dispatchFetch(`http://localhost${request.path}`, { method: 'POST', - headers: { 'x-void-internal': 'proxy-token' }, + headers: { 'x-void-internal': 'proxy-token', 'content-type': 'application/json' }, + body: request.body === undefined ? undefined : JSON.stringify(request.body), }); return await response.json(); } finally { await miniflare?.dispose(); + } + } + + /** Run the wrapped MySQL Worker in workerd and return its `/__void/migrate` response body. */ + async function migrateMySqlInWorkerd(workerDir: string): Promise { + // The applied-migrations SELECT returns a row, so mysql2 also parses a result set. + const mysql = await startFakeMySqlServer([{ name: '000_applied_elsewhere.sql' }]); + try { + return await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate' }, + { + bindings: { + HYPERDRIVE: { connectionString: `mysql://void@127.0.0.1:${mysql.port}/app` }, + }, + }, + ); + } finally { await mysql.close(); } } + /** + * npm, Yarn and Bun hoist dependencies into the app's node_modules, where the build finds them. + * Copy each package there (copies, not symlinks, so this also runs on Windows). + */ + function hoistIntoApp(projectRoot: string, packages: Record): void { + for (const [name, source] of Object.entries(packages)) { + cpSync(realpathSync(source), join(projectRoot, 'node_modules', name), { + recursive: true, + filter: (path) => !path.endsWith(`${sep}node_modules`), + }); + } + } + + /** The dependencies of `packageName`, as they sit next to its installed copy. */ + function dependencyDirs(packageName: string): Record { + const packageJson = createRequire(import.meta.url).resolve(`${packageName}/package.json`); + const { dependencies } = JSON.parse(readFileSync(packageJson, 'utf8')) as { + dependencies: Record; + }; + const installDir = dirname(dirname(realpathSync(packageJson))); + return Object.fromEntries( + Object.keys(dependencies).map((name) => [name, join(installDir, name)]), + ); + } + + /** workerd loads only relative, `node:` and `cloudflare:` imports, and the shims serve built-ins. */ + function expectSelfContainedHandler(workerDir: string, optionalRequires: Array = []) { + for (const file of readdirSync(workerDir).filter((name) => + name.startsWith('__void_migration_handler'), + )) { + const code = readFileSync(join(workerDir, file), 'utf8'); + const { module } = parseSync(file, code); + const imports = [ + ...module.staticImports.map((entry) => entry.moduleRequest.value), + ...module.staticExports.flatMap((entry) => + entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])), + ), + ]; + expect(imports.filter((id) => !/^(node:|cloudflare:|\.\/)/.test(id))).toEqual([]); + const required = Array.from( + code.matchAll(/\b__require\(\s*["']([^"']+)["']\s*\)/g), + (match) => match[1]!, + ); + expect(required.filter((id) => !isBuiltin(id) && !optionalRequires.includes(id))).toEqual([]); + } + } + it('runs MySQL migrations from the wrapped Worker in workerd', async () => { const { workerDir, projectRoot } = createMigrationFixture({ name: '001_test.sql', @@ -649,17 +719,7 @@ describe('generateWrapper with migrations', () => { name: '001_test.sql', sql: 'CREATE TABLE test (id INT);', }); - const mysql2PackageJson = createRequire(import.meta.url).resolve('mysql2/package.json'); - const { dependencies } = JSON.parse(readFileSync(mysql2PackageJson, 'utf8')) as { - dependencies: Record; - }; - const installDir = dirname(dirname(realpathSync(mysql2PackageJson))); - for (const name of Object.keys(dependencies)) { - // Copies, not symlinks, so this also runs on Windows. - cpSync(realpathSync(join(installDir, name)), join(projectRoot, 'node_modules', name), { - recursive: true, - }); - } + hoistIntoApp(projectRoot, dependencyDirs('mysql2')); await generateWrapper(workerDir, 'index.mjs', projectRoot, { ...base, hasMigrations: true, @@ -670,26 +730,73 @@ describe('generateWrapper with migrations', () => { ok: true, migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], }); - // workerd serves only Node built-ins to the require shim. mysql2 requires its optional - // `cardinal` inside try/catch. - for (const file of readdirSync(workerDir).filter((name) => - name.startsWith('__void_migration_handler'), - )) { - const code = readFileSync(join(workerDir, file), 'utf8'); - const { module } = parseSync(file, code); - const imports = [ - ...module.staticImports.map((entry) => entry.moduleRequest.value), - ...module.staticExports.flatMap((entry) => - entry.entries.flatMap((item) => (item.moduleRequest ? [item.moduleRequest.value] : [])), + // mysql2 requires its optional `cardinal` inside try/catch. + expectSelfContainedHandler(workerDir, ['cardinal']); + }); + + it('bundles the pg dependencies that sit in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_test.sql', + sql: 'CREATE TABLE test (id SERIAL PRIMARY KEY);', + }); + hoistIntoApp(projectRoot, dependencyDirs('pg')); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'postgresql', + }); + + const pglite = new PGlite(); + // pglite-server does not wait for PGlite: a query that arrives during startup spins forever. + await pglite.waitReady; + const server = createPgServer(pglite); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate' }, + { + bindings: { + HYPERDRIVE: { connectionString: `postgresql://postgres@127.0.0.1:${port}/postgres` }, + }, + }, ), - ]; - expect(imports.filter((id) => !/^(node:|cloudflare:|\.\/)/.test(id))).toEqual([]); - const required = Array.from( - code.matchAll(/\b__require\(\s*["']([^"']+)["']\s*\)/g), - (match) => match[1]!, - ); - expect(required.filter((id) => !isBuiltin(id) && id !== 'cardinal')).toEqual([]); + ).toEqual({ + ok: true, + migrations: [{ name: '001_test.sql', applied: true, durationMs: expect.any(Number) }], + }); + } finally { + server.close(); + await pglite.close(); } + expectSelfContainedHandler(workerDir); + }); + + it('bundles @void/deploy-core when it sits in the app node_modules', async () => { + const { workerDir, projectRoot } = createMigrationFixture({ + name: '001_users.sql', + sql: 'CREATE TABLE users (id INTEGER PRIMARY KEY);', + }); + hoistIntoApp(projectRoot, { + '@void/deploy-core': join(import.meta.dirname, '..', '..', '..', 'deploy-core'), + }); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'sqlite', + }); + + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/migrate/app', body: { attemptId: 'attempt-1', deploymentId: 'dep-1' } }, + { d1Databases: ['DB'] }, + ), + ).toMatchObject({ ok: true, confirmedApplied: ['001_users.sql'] }); + expectSelfContainedHandler(workerDir); }); it('does not generate migration handler when hasMigrations is false', async () => { diff --git a/vite.config.ts b/vite.config.ts index ad93d62da..f7f749462 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ const sdkPgliteUnitTests = [ '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', + 'packages/void/test/unit/wrapper.test.ts', ]; // Vitest forwards a FILTERED env to test workers — in CI only VOID_E2E_CF_TOKEN survives, while From 7853e5e2d4e86f8e1cff7351157f6a4973744557 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 02:38:22 +0800 Subject: [PATCH 08/17] fix(void): serialize MySQL migrations per database across deploys and the CLI Two deploys of different projects that use one MySQL database, or a deploy and `void db migrate`, could both read a file as pending and apply it twice. The platform only locks one project's deploys, and the hosted handler took no lock: it reaches MySQL through Hyperdrive, which pools connections per transaction, so it cannot hold the CLI's GET_LOCK, and it never saw that lock either. The hosted handler now holds a lease row in `_void_migration_lock`, written one autocommit statement at a time on its own connection and renewed before each migration statement. After taking it, the handler checks the CLI's named lock with IS_USED_LOCK. After GET_LOCK, the CLI runner waits for a live lease. Each run publishes its own lock before it checks for the other kind, so runs on one database never overlap. Lease times are UTC, so session time zones and DST changes do not move them. A Vitess connection target such as `app@primary` uses the lock of its keyspace. A run releases its lease by expiring the row rather than deleting it, because InnoDB ends one of two waiting inserts with a deadlock when the row is deleted under them. A lease statement that still loses a deadlock polls again. Deploys wait up to about 2 minutes, enough for the lease of a run that stopped early to expire; the CLI waits up to about 20 seconds. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/database/mysql.md | 2 + packages/void/AGENTS.md | 1 + packages/void/src/migrations/runner-mysql.ts | 26 +- .../src/runtime/migration-handler-mysql.ts | 23 +- .../void/src/runtime/migration-lock-mysql.ts | 165 +++++ .../void/test/helpers/fake-mysql-server.ts | 33 +- packages/void/test/unit/runner-mysql.test.ts | 579 ++++++++++++++---- packages/void/test/unit/wrapper.test.ts | 5 +- 8 files changed, 701 insertions(+), 133 deletions(-) create mode 100644 packages/void/src/runtime/migration-lock-mysql.ts diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index 0f5b98239..365ed77e8 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -51,6 +51,8 @@ void deploy For direct Cloudflare deploys, export the production `DATABASE_URL` in the shell running `void deploy --platform cloudflare`. Void uses it to provision Hyperdrive and apply pending checked-in migrations before uploading the Worker; it is not written to the Worker config. +Only one migration run changes a database at a time, whether it comes from `void db migrate`, `void deploy --platform cloudflare`, or a `void deploy` of any project that uses the database. Another run waits for it, up to about 20 seconds for a command that connects with `DATABASE_URL` and about 2 minutes for a `void deploy` to the Void platform, then stops with an error that asks you to try again. + ### Failed migrations {#failed-migrations} Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed. diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 3d060b8a9..c0034e886 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -120,6 +120,7 @@ generated entries must preserve the module-boundary rules below. - **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: when the journal has entries, the first delegates to `drizzle-kit generate --custom --name `, which writes the empty `.sql`, appends the journal entry, and writes a snapshot of the current schema (it refuses if the last entry's snapshot is missing and byte-restores the journal if drizzle-kit fails); with no journal entries it writes a plain timestamped `.sql`. The second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys. - **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock. +- **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. ### Self-Host AI + ISR diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index b8bd361d0..d1497c9f3 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,6 +1,10 @@ -import { createHash } from 'node:crypto'; import type { Connection } from 'mysql2/promise'; import { splitMigrationSql } from '../runtime/migration-handler.ts'; +import { + mySqlMigrationLockError, + mySqlMigrationLockName, + readMySqlMigrationLease, +} from '../runtime/migration-lock-mysql.ts'; import { collectMigrations } from './collect.ts'; export type MySqlClient = { @@ -52,21 +56,33 @@ const MIGRATION_LOCK_TIMEOUT_SECONDS = 10; /** * Take a named lock so two runs on one database cannot both apply a pending file. The lock - * survives the commits MySQL makes for DDL and ends with the session. Lock names are - * server-wide and at most 64 characters, so the name carries a hash of the database name. + * survives the commits MySQL makes for DDL and ends with the session. A deploy cannot hold a + * session lock and writes a lease row instead, so this also waits for that lease. */ async function acquireMySqlMigrationLock(client: MySqlClient): Promise { const [row] = await client.query>('SELECT DATABASE() AS db'); const database = row?.db ?? ''; - const name = `void_migrations:${createHash('sha1').update(database).digest('hex')}`; + const name = mySqlMigrationLockName(database); const [lock] = await client.query>>( 'SELECT GET_LOCK(?, ?) AS acquired', [name, MIGRATION_LOCK_TIMEOUT_SECONDS], ); // Vitess names the column after the call and ignores the alias. if (Number(Object.values(lock ?? {})[0]) !== 1) { + throw mySqlMigrationLockError(database, 'run the command again'); + } + + const readLease = () => readMySqlMigrationLease((sql, params) => client.query(sql, params)); + const deadline = Date.now() + MIGRATION_LOCK_TIMEOUT_SECONDS * 1000; + let remaining = await readLease(); + while (remaining !== undefined && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + remaining = await readLease(); + } + if (remaining !== undefined) { + await client.query('SELECT RELEASE_LOCK(?)', [name]).catch(() => {}); throw new Error( - `Another migration run is applying migrations to MySQL database "${database}". Wait for it to finish, then run the command again.`, + `A deploy is applying migrations to MySQL database "${database}". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in ${remaining} seconds.`, ); } return name; diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index 1cfbff0f1..fe1529877 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -1,9 +1,11 @@ +import type { Connection } from 'mysql2/promise'; import { splitMigrationSql, type MigrationEntry, type MigrationResponse, type MigrationResult, } from './migration-handler.ts'; +import { acquireMySqlMigrationLease, type MySqlMigrationLease } from './migration-lock-mysql.ts'; export function createMigrationHandler( env: Record, @@ -53,7 +55,18 @@ async function runMySqlMigrations( multipleStatements: true, disableEval: true, }); + let leaseConnection: Connection | undefined; + let lease: MySqlMigrationLease | undefined; try { + // Lease statements must stay out of the migration transactions, so they get their own connection. + const leaseClient = await createConnection({ + uri: hyperdrive.connectionString, + disableEval: true, + }); + leaseConnection = leaseClient; + lease = await acquireMySqlMigrationLease( + async (sql, params) => (await leaseClient.query(sql, params))[0], + ); await connection.query( 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', ); @@ -69,15 +82,14 @@ async function runMySqlMigrations( const start = Date.now(); try { // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, - // on its own. Hyperdrive keeps one connection for the open transaction. Unlike the CLI - // runner, this takes no named lock: Hyperdrive pools connections per transaction, so a - // session lock could stay on a pooled connection. The platform migrates one deploy per - // project at a time. + // on its own. Hyperdrive keeps one connection for the open transaction. await connection.query('BEGIN'); // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. for (const statement of splitMigrationSql(migration.sql)) { + await lease.renew(); await connection.query(statement); } + await lease.renew(); await connection.query( 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [migration.name, new Date().toISOString()], @@ -98,6 +110,7 @@ async function runMySqlMigrations( return { ok: true, migrations: results }; } finally { - await connection.end(); + await lease?.release().catch(() => {}); + await Promise.all([connection.end(), leaseConnection?.end()]); } } diff --git a/packages/void/src/runtime/migration-lock-mysql.ts b/packages/void/src/runtime/migration-lock-mysql.ts new file mode 100644 index 000000000..a5837ce95 --- /dev/null +++ b/packages/void/src/runtime/migration-lock-mysql.ts @@ -0,0 +1,165 @@ +import { createHash, randomUUID } from 'node:crypto'; + +/** + * Migration runs on one MySQL database exclude each other with two kinds of lock, and each run + * checks for the other kind after it takes its own: + * + * - The CLI runner holds a named lock (GET_LOCK) for its session, so it ends with the connection. + * - The hosted handler reaches MySQL through Hyperdrive, which pools connections per transaction, + * so a session lock could stay on a pooled connection or be reset away. It writes a lease row + * with an expiry instead, using one statement at a time. Lease times are UTC: NOW() follows + * each session's time zone and its DST changes, and DATETIME stores no zone. + */ + +/** Hyperdrive ends a statement after 60 seconds, and the hosted handler renews before each one. */ +const LEASE_SECONDS = 120; +/** A deploy waits long enough for the lease of a run that stopped early to expire. */ +const DEPLOY_WAIT_MS = (LEASE_SECONDS + 10) * 1000; +const POLL_MS = 1000; + +/** Runs one statement and returns the rows or the result header. */ +export type MySqlLeaseQuery = (sql: string, params?: Array) => Promise; + +export type MySqlMigrationLease = { + /** Extends the lease. Throws when it expired or another run took it. */ + renew(): Promise; + release(): Promise; +}; + +/** + * The CLI runner's named lock. Lock names are server-wide and at most 64 characters, so the name + * carries a hash of the database name. Vitess reports a connection's target (`app@primary`, + * `app:-80@replica`) in DATABASE(), and the lock belongs to the keyspace. + */ +export function mySqlMigrationLockName(database: string): string { + return `void_migrations:${createHash('sha1').update(database.split(/[:@]/)[0]!).digest('hex')}`; +} + +export function mySqlMigrationLockError(database: string, retry: string): Error { + return new Error( + `Another migration run is applying migrations to MySQL database "${database}". Wait for it to finish, then ${retry}.`, + ); +} + +/** + * Seconds left on a deploy's lease, or undefined when no deploy holds one. The CLI runner calls + * this while it holds the named lock. + */ +export async function readMySqlMigrationLease(query: MySqlLeaseQuery): Promise { + try { + const [row] = rowsOf( + await query( + "SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(6), `expires_at`) AS remaining FROM `_void_migration_lock` WHERE `name` = 'app' AND `expires_at` > UTC_TIMESTAMP(6)", + ), + ); + return row ? Number(row.remaining) : undefined; + } catch (error) { + if (isMissingTable(error)) { + return undefined; + } + throw error; + } +} + +/** + * Takes the lease for a hosted run. It waits while another deploy holds the lease or a CLI run + * holds the named lock, and throws when that takes too long. + */ +export async function acquireMySqlMigrationLease( + query: MySqlLeaseQuery, +): Promise { + const [row] = rowsOf(await query('SELECT DATABASE() AS db')); + const database = String(row?.db ?? ''); + const lockName = mySqlMigrationLockName(database); + const owner = randomUUID(); + await query( + 'CREATE TABLE IF NOT EXISTS `_void_migration_lock` (`name` VARCHAR(64) NOT NULL PRIMARY KEY, `owner` VARCHAR(64) NOT NULL, `expires_at` DATETIME(6) NOT NULL)', + ); + + const lease: MySqlMigrationLease = { + async renew() { + const result = await query( + "UPDATE `_void_migration_lock` SET `expires_at` = UTC_TIMESTAMP(6) + INTERVAL ? SECOND WHERE `name` = 'app' AND `owner` = ? AND `expires_at` > UTC_TIMESTAMP(6)", + [LEASE_SECONDS, owner], + ); + if (affectedRows(result) !== 1) { + throw new Error( + `The migration lock on MySQL database "${database}" expired before this run finished. Deploy again.`, + ); + } + }, + // Expires the row instead of deleting it. When a delete meets two waiting inserts of the same + // key, InnoDB ends one of them with a deadlock. + async release() { + await query( + "UPDATE `_void_migration_lock` SET `expires_at` = UTC_TIMESTAMP(6) - INTERVAL 1 SECOND WHERE `name` = 'app' AND `owner` = ?", + [owner], + ); + }, + }; + + const deadline = Date.now() + DEPLOY_WAIT_MS; + for (;;) { + if (await take(query, owner)) { + // Taken before this check, so a CLI run that starts later sees the lease and stops. + const [lock] = rowsOf(await query('SELECT IS_USED_LOCK(?)', [lockName])); + // Vitess names the column after the call and ignores any alias. + if (Object.values(lock ?? {})[0] == null) { + return lease; + } + await lease.release(); + } + if (Date.now() >= deadline) { + throw mySqlMigrationLockError(database, 'deploy again'); + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } +} + +async function take(query: MySqlLeaseQuery, owner: string): Promise { + try { + return await tryTake(query, owner); + } catch (error) { + // MySQL rolled the statement back as a deadlock victim, so the lease is not taken: poll again. + if (errnoOf(error) === 1213) { + return false; + } + throw error; + } +} + +async function tryTake(query: MySqlLeaseQuery, owner: string): Promise { + const inserted = await query( + "INSERT IGNORE INTO `_void_migration_lock` (`name`, `owner`, `expires_at`) VALUES ('app', ?, UTC_TIMESTAMP(6) + INTERVAL ? SECOND)", + [owner, LEASE_SECONDS], + ); + if (affectedRows(inserted) === 1) { + return true; + } + // The lease of a run that stopped early. + const taken = await query( + "UPDATE `_void_migration_lock` SET `owner` = ?, `expires_at` = UTC_TIMESTAMP(6) + INTERVAL ? SECOND WHERE `name` = 'app' AND `expires_at` < UTC_TIMESTAMP(6)", + [owner, LEASE_SECONDS], + ); + return affectedRows(taken) === 1; +} + +function rowsOf(result: unknown): Array> { + return Array.isArray(result) ? (result as Array>) : []; +} + +function affectedRows(result: unknown): number { + return Number((result as { affectedRows?: unknown } | undefined)?.affectedRows ?? 0); +} + +function isMissingTable(error: unknown): boolean { + const value = + typeof error === 'object' && error !== null ? (error as Record) : undefined; + return value?.code === 'ER_NO_SUCH_TABLE' || value?.errno === 1146; +} + +function errnoOf(error: unknown): unknown { + return typeof error === 'object' && error !== null + ? (error as { errno?: unknown }).errno + : undefined; +} diff --git a/packages/void/test/helpers/fake-mysql-server.ts b/packages/void/test/helpers/fake-mysql-server.ts index da4a8bdd5..9030e4881 100644 --- a/packages/void/test/helpers/fake-mysql-server.ts +++ b/packages/void/test/helpers/fake-mysql-server.ts @@ -11,7 +11,7 @@ type ServerConnection = { writeColumns(columns: Array>): void; writeTextRow(values: Array): void; writeEof(): void; - writeOk(): void; + writeOk(packet?: { affectedRows?: number }): void; }; const { Connection, ConnectionConfig } = mysql as unknown as { @@ -23,21 +23,15 @@ const CHARSET = mysql.Charsets.UTF8_GENERAL_CI; // Every basic capability except CLIENT_SSL (0x0800), so clients never ask for TLS. const CAPABILITY_FLAGS = 0xffff & ~0x0800; -export type FakeMySqlRow = Record; +export type FakeMySqlRow = Record; export type FakeMySqlServer = { port: number; close(): Promise; }; -/** - * A MySQL wire-protocol server built on mysql2's own server API. It accepts any user, answers - * every SELECT with `rows` (columns in key order of the first row) and every other statement - * with OK. It speaks the text protocol only, which is what drizzle's mysql2 driver uses. - */ -export async function startFakeMySqlServer(rows: Array): Promise { - const columnNames = Object.keys(rows[0] ?? {}); - const columns = columnNames.map((name) => ({ +function columnsOf(rows: Array) { + return Object.keys(rows[0] ?? {}).map((name) => ({ catalog: 'def', schema: 'app', table: 'fake', @@ -50,6 +44,17 @@ export async function startFakeMySqlServer(rows: Array): Promise | ((sql: string) => Array), +): Promise { const sockets = new Set(); const server = createServer((socket) => { @@ -73,13 +78,15 @@ export async function startFakeMySqlServer(rows: Array): Promise { if (/^\s*select\b/i.test(sql)) { + const answer = typeof rows === 'function' ? rows(sql) : rows; + const columns = columnsOf(answer); connection.writeColumns(columns); - for (const row of rows) { - connection.writeTextRow(columnNames.map((name) => row[name])); + for (const row of answer) { + connection.writeTextRow(columns.map((column) => row[column.name])); } connection.writeEof(); } else { - connection.writeOk(); + connection.writeOk({ affectedRows: 1 }); } connection.sequenceId = 0; }); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index faaeac6c1..0a8bfd431 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it, vi } from 'vite-plus/test'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { applyMySqlMigrations, readMySqlAppliedMigrations, @@ -8,34 +8,243 @@ import { import { runVoidAuthMigrations } from '../../src/runtime/better-auth-mysql.ts'; import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; -const mysqlQueries = vi.hoisted(() => [] as Array); const mysqlConnections = vi.hoisted(() => [] as Array); -const mysqlFailures = vi.hoisted(() => new Map()); +const mysqlServer = vi.hoisted(() => ({ database: undefined as unknown })); +// Every connection the hosted handler opens reaches the test's FakeMySqlDatabase. vi.mock('mysql2/promise', () => ({ createConnection: async (options: unknown) => { mysqlConnections.push(options); + const database = mysqlServer.database as FakeMySqlDatabase; + const session = database.connect({ database: database.handlerDatabase }); return { - query: async (sql: string) => { - mysqlQueries.push(sql); - const failure = mysqlFailures.get(sql); - if (failure) { - throw failure; - } - return [[]]; - }, - end: async () => {}, + query: async (sql: string, params?: Array) => [ + await database.query(session, sql, params), + ], + end: async () => database.disconnect(session), }; }, })); const LOCK_NAME = `void_migrations:${createHash('sha1').update('app').digest('hex')}`; -const CONTROL = /^(BEGIN|COMMIT|ROLLBACK)$|SAVEPOINT|GET_LOCK|RELEASE_LOCK|DATABASE\(\)/; +const CONTROL = + /^(BEGIN|COMMIT|ROLLBACK)$|SAVEPOINT|GET_LOCK|RELEASE_LOCK|IS_USED_LOCK|DATABASE\(\)|_void_migration_lock/; function mySqlError(message: string, code: string): Error { return Object.assign(new Error(message), { code }); } +/** `expiresAt` is the stored DATETIME, which has no time zone, in milliseconds. */ +type Lease = { owner: string; expiresAt: number }; +type Session = { utcOffsetMs?: number; database?: string }; + +/** + * One MySQL database shared by every connection and client of a test. It keeps the tracking rows, + * the deploy lease and named locks, and logs each statement with its session and time. + * `SELECT SLEEP(n)` waits n seconds, so runs can overlap. Each session has a time zone, which + * NOW(6) follows and UTC_TIMESTAMP(6) does not, and the name DATABASE() reports. + */ +class FakeMySqlDatabase { + readonly log: Array<{ session: number; sql: string; at: number }> = []; + readonly failures = new Map(); + /** Statements that fail on their next run only, keyed by their first characters. */ + readonly failOnce = new Map(); + readonly applied: Array = []; + lease: Lease | undefined; + /** DATABASE() for the sessions the hosted handler opens. */ + readonly handlerDatabase: string; + private leaseTable = false; + private readonly locks = new Map(); + private readonly sessions: Array = []; + + constructor(options: { lease?: Lease; handlerDatabase?: string } = {}) { + this.lease = options.lease; + this.leaseTable = options.lease !== undefined; + this.handlerDatabase = options.handlerDatabase ?? 'app'; + } + + connect(session: Session = {}): number { + return this.sessions.push(session); + } + + disconnect(session: number): void { + for (const [name, holder] of this.locks) { + if (holder === session) { + this.locks.delete(name); + } + } + } + + client(options: { utcOffsetMs?: number } = {}): MySqlClient { + const session = this.connect(options); + return { + query: async >>( + sql: string, + params?: Array, + ) => { + const result = await this.query(session, sql, params); + return (Array.isArray(result) ? result : []) as T; + }, + }; + } + + statements(session?: number): Array { + return this.log + .filter((entry) => session === undefined || entry.session === session) + .map((entry) => entry.sql); + } + + async query(session: number, sql: string, params: Array = []): Promise { + this.log.push({ session, sql, at: Date.now() }); + const failure = this.failures.get(sql); + if (failure) { + throw failure; + } + for (const [prefix, error] of this.failOnce) { + if (sql.startsWith(prefix)) { + this.failOnce.delete(prefix); + throw error; + } + } + const sleep = /^SELECT SLEEP\((\d+(?:\.\d+)?)\)/.exec(sql); + if (sleep) { + await new Promise((resolve) => setTimeout(resolve, Number(sleep[1]) * 1000)); + return [{ slept: 0 }]; + } + const name = String(params[0]); + if (sql === 'SELECT DATABASE() AS db') { + return [{ db: this.sessions[session - 1]!.database ?? 'app' }]; + } + if (sql.startsWith('SELECT GET_LOCK')) { + const holder = this.locks.get(name); + if (holder !== undefined && holder !== session) { + return [{ acquired: 0 }]; + } + this.locks.set(name, session); + return [{ acquired: 1 }]; + } + if (sql.startsWith('SELECT RELEASE_LOCK')) { + if (this.locks.get(name) === session) { + this.locks.delete(name); + } + return [{ released: 1 }]; + } + if (sql.startsWith('SELECT IS_USED_LOCK')) { + return [{ [`IS_USED_LOCK('${name}')`]: this.locks.get(name) ?? null }]; + } + if (sql.includes('`_void_migration_lock`')) { + return this.leaseStatement(session, sql, params); + } + if (sql === 'SELECT `name` FROM `_void_migrations`') { + return this.applied.map((applied) => ({ name: applied })); + } + if (sql.startsWith('INSERT INTO `_void_migrations`')) { + if (this.applied.includes(name)) { + throw mySqlError(`Duplicate entry '${name}' for key 'PRIMARY'`, 'ER_DUP_ENTRY'); + } + this.applied.push(name); + } + return /^\s*SELECT\b/i.test(sql) ? [] : { affectedRows: 1 }; + } + + private leaseStatement(session: number, sql: string, params: Array): unknown { + const now = Date.now(); + const clock = /UTC_TIMESTAMP\(6\)/.test(sql) + ? now + : now + (this.sessions[session - 1]!.utcOffsetMs ?? 0); + // The statement's own expiry filter, if it has one. + const filter = /`expires_at` ([<>]) (?:NOW|UTC_TIMESTAMP)\(6\)/.exec(sql)?.[1]; + const matches = (lease: Lease) => + filter === undefined || (filter === '<' ? lease.expiresAt < clock : lease.expiresAt > clock); + if (sql.startsWith('CREATE TABLE')) { + this.leaseTable = true; + return { affectedRows: 0 }; + } + if (!this.leaseTable) { + throw mySqlError("Table 'app._void_migration_lock' doesn't exist", 'ER_NO_SUCH_TABLE'); + } + if (sql.startsWith('SELECT TIMESTAMPDIFF')) { + return this.lease && matches(this.lease) + ? [{ remaining: Math.floor((this.lease.expiresAt - clock) / 1000) }] + : []; + } + if (sql.startsWith('INSERT IGNORE')) { + if (this.lease) { + return { affectedRows: 0 }; + } + this.lease = { owner: String(params[0]), expiresAt: clock + Number(params[1]) * 1000 }; + return { affectedRows: 1 }; + } + if (sql.startsWith('UPDATE') && sql.includes('SET `owner`')) { + if (!this.lease || !matches(this.lease)) { + return { affectedRows: 0 }; + } + this.lease = { owner: String(params[0]), expiresAt: clock + Number(params[1]) * 1000 }; + return { affectedRows: 1 }; + } + // The release keeps the row and moves its expiry into the past. + if (sql.startsWith('UPDATE') && sql.includes('- INTERVAL 1 SECOND')) { + if (!this.lease || this.lease.owner !== params[0]) { + return { affectedRows: 0 }; + } + this.lease.expiresAt = clock - 1000; + return { affectedRows: 1 }; + } + if (sql.startsWith('UPDATE')) { + if (!this.lease || !matches(this.lease) || this.lease.owner !== params[1]) { + return { affectedRows: 0 }; + } + this.lease.expiresAt = clock + Number(params[0]) * 1000; + return { affectedRows: 1 }; + } + throw new Error(`unexpected lease statement: ${sql}`); + } +} + +function useDatabase(database: FakeMySqlDatabase): FakeMySqlDatabase { + mysqlServer.database = database; + return database; +} + +beforeEach(() => { + mysqlConnections.length = 0; + useDatabase(new FakeMySqlDatabase()); +}); + +async function migrate( + migrations: Array<{ name: string; sql: string }>, +): Promise<{ status: number; body: Record }> { + const handler = createMigrationHandler( + { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, + migrations, + ); + const response = await handler( + new Request('http://localhost/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': 'token' }, + }), + ); + return { status: response.status, body: (await response.json()) as Record }; +} + +/** Runs `start` to its end while fake time moves on one second at a time. */ +async function withFakeTime(start: () => Promise): Promise { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + try { + let settled = false; + const promise = start().finally(() => { + settled = true; + }); + while (!settled) { + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(1000); + } + return await promise; + } finally { + vi.useRealTimers(); + } +} + /** * A client for `applyMySqlMigrations`. It owns database `app`, grants the lock unless `locked`, * and throws for statements in `failures`. With `committed`, MySQL has already committed the @@ -131,19 +340,9 @@ describe('MySQL migration runner', () => { await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); expect(migrationQueries(calls())).toEqual(statements); - mysqlQueries.length = 0; - 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); + const database = useDatabase(new FakeMySqlDatabase()); + expect((await migrate([{ name: '001_init.sql', sql }])).body).toMatchObject({ ok: true }); + expect(migrationQueries(database.statements())).toEqual(statements); }); // Two runs at once would both apply a pending data migration. @@ -207,7 +406,10 @@ describe('MySQL migration runner', () => { ); expect((error as Error).cause).toBe(cause); const migrationQueries = sql().filter( - (query) => !/GET_LOCK|DATABASE\(\)|CREATE TABLE IF NOT EXISTS|SELECT `name`/.test(query), + (query) => + !/GET_LOCK|DATABASE\(\)|CREATE TABLE IF NOT EXISTS|SELECT `name`|_void_migration_lock/.test( + query, + ), ); expect(migrationQueries).toEqual([ 'BEGIN', @@ -330,116 +532,275 @@ describe('MySQL migration runner', () => { describe('MySQL hosted migration handler', () => { it('commits each file with its tracking row and rolls back a failed file', async () => { - mysqlQueries.length = 0; - mysqlFailures.set( + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( 'INSERT INTO nope VALUES (1);', mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'), ); - try { - const handler = createMigrationHandler( - { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, - [ - { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, - { - name: '002_backfill.sql', - sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', - }, - ], - ); - const response = await handler( - new Request('http://localhost/__void/migrate', { - method: 'POST', - headers: { 'x-void-internal': 'token' }, - }), - ); - expect(await response.json()).toMatchObject({ - ok: false, - error: 'migration 002_backfill.sql failed', - migrations: [ - { name: '001_init.sql', applied: true }, - { name: '002_backfill.sql', applied: false, error: "Table 'app.nope' doesn't exist" }, - ], - }); - expect(mysqlQueries.slice(2)).toEqual([ - 'BEGIN', - 'CREATE TABLE c (n INT);', - 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', - 'COMMIT', - 'BEGIN', - 'UPDATE c SET n = n + 1;', - 'INSERT INTO nope VALUES (1);', - 'ROLLBACK', - ]); - } finally { - mysqlFailures.clear(); - } + const { body } = await migrate([ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { + name: '002_backfill.sql', + sql: 'UPDATE c SET n = n + 1;\n--> statement-breakpoint\nINSERT INTO nope VALUES (1);', + }, + ]); + + expect(body).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { name: '002_backfill.sql', applied: false, error: "Table 'app.nope' doesn't exist" }, + ], + }); + // Session 1 runs the migrations; the lease uses its own connection. + expect(database.statements(1).slice(2)).toEqual([ + 'BEGIN', + 'CREATE TABLE c (n INT);', + 'INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', + 'COMMIT', + 'BEGIN', + 'UPDATE c SET n = n + 1;', + 'INSERT INTO nope VALUES (1);', + 'ROLLBACK', + ]); }); it('names the failed file when the rollback fails too', async () => { - mysqlFailures.set( + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( 'UPDATE c SET n = n + 1;', mySqlError('Connection lost: The server closed the connection.', 'PROTOCOL_CONNECTION_LOST'), ); - mysqlFailures.set( + database.failures.set( 'ROLLBACK', new Error("Can't add new command when connection is in closed state"), ); - try { - const handler = createMigrationHandler( - { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, - [ - { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, - { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, - ], + + const { body } = await migrate([ + { name: '001_init.sql', sql: 'CREATE TABLE c (n INT);' }, + { name: '002_backfill.sql', sql: 'UPDATE c SET n = n + 1;' }, + ]); + + expect(body).toMatchObject({ + ok: false, + error: 'migration 002_backfill.sql failed', + migrations: [ + { name: '001_init.sql', applied: true }, + { + name: '002_backfill.sql', + applied: false, + error: 'Connection lost: The server closed the connection.', + }, + ], + }); + }); +}); + +// The CLI runner holds a named lock; a deploy, which reaches MySQL through Hyperdrive, holds a +// lease row. Each checks for the other, so runs on one database never apply a file twice. +describe('MySQL migration lock across runners', () => { + const RACE = [ + { + name: '0001_seed.sql', + sql: 'SELECT SLEEP(0.2);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS roles (name TEXT);\n--> statement-breakpoint\nINSERT INTO roles VALUES (1);', + }, + ]; + const seeded = (database: FakeMySqlDatabase) => + database.statements().filter((sql) => sql === 'INSERT INTO roles VALUES (1);').length; + + it('applies a pending file once when two deploys share a database', async () => { + const { database, bodies, finishedAt } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + // The second starts while the first sleeps, before either has recorded the file. + const first = migrate(RACE); + await new Promise((resolve) => setTimeout(resolve, 20)); + const results = await Promise.all([first, migrate(RACE)]); + return { database, bodies: results.map((result) => result.body), finishedAt: Date.now() }; + }); + + expect(seeded(database)).toBe(1); + expect(bodies.map((body) => body.ok)).toEqual([true, true]); + // Released by expiring the row: the next run's INSERT IGNORE finds it and takes it over. + expect(database.lease?.expiresAt).toBeLessThan(finishedAt); + }); + + it('applies a pending file once when a CLI run and a deploy share a database', async () => { + const { database, cli, deploy } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const [cli, deploy] = await Promise.all([ + applyMySqlMigrations(database.client(), RACE), + migrate(RACE), + ]); + return { database, cli, deploy }; + }); + + expect(seeded(database)).toBe(1); + expect(deploy.body.ok).toBe(true); + expect(cli).toEqual(['0001_seed.sql']); + }); + + // Vitess reports a connection's target in DATABASE(): Hyperdrive may connect to `app@primary` + // while the CLI connects to `app`. + it('makes a deploy wait for a CLI run that names a Vitess target', async () => { + const { database, deploy } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase({ handlerDatabase: 'app@primary' })); + const [, deploy] = await Promise.all([ + applyMySqlMigrations(database.client(), RACE), + new Promise((resolve) => setTimeout(resolve, 100)).then(() => migrate(RACE)), + ]); + return { database, deploy }; + }); + + expect(seeded(database)).toBe(1); + expect(deploy.body.ok).toBe(true); + }); + + it('stops a CLI run while a deploy holds the lease', async () => { + const error = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'deploy', expiresAt: Date.now() + 100_000 } }), ); - const response = await handler( - new Request('http://localhost/__void/migrate', { - method: 'POST', - headers: { 'x-void-internal': 'token' }, - }), + const failure = await applyMySqlMigrations(database.client(), RACE).catch( + (caught: unknown) => caught, ); + expect(database.statements()).not.toContain('SELECT `name` FROM `_void_migrations`'); + expect(database.statements().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + return failure; + }); + + expect((error as Error).message).toBe( + 'A deploy is applying migrations to MySQL database "app". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in 90 seconds.', + ); + }); - expect(await response.json()).toMatchObject({ + it('stops a CLI run while a deploy holds the lease in another time zone', async () => { + const error = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'deploy', expiresAt: Date.now() + 100_000 } }), + ); + const failure = await applyMySqlMigrations( + database.client({ utcOffsetMs: 9 * 3_600_000 }), + RACE, + ).catch((caught: unknown) => caught); + expect(database.statements()).not.toContain('SELECT `name` FROM `_void_migrations`'); + return failure; + }); + + expect((error as Error).message).toBe( + 'A deploy is applying migrations to MySQL database "app". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in 90 seconds.', + ); + }); + + it('lets a CLI run apply after a deploy released its lease', async () => { + const applied = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'done', expiresAt: Date.now() - 1000 } }), + ); + return await applyMySqlMigrations(database.client(), [ + { name: '0001_a.sql', sql: 'CREATE TABLE a (id INT)' }, + ]); + }); + + expect(applied).toEqual(['0001_a.sql']); + }); + + it('makes a deploy wait for the lease of a run that stopped early to expire', async () => { + const { database, expiresAt, finishedAt, result } = await withFakeTime(async () => { + const expiresAt = Date.now() + 30_000; + const database = useDatabase(new FakeMySqlDatabase({ lease: { owner: 'gone', expiresAt } })); + const result = await migrate(RACE); + return { database, expiresAt, finishedAt: Date.now(), result }; + }); + + expect(result.body.ok).toBe(true); + const seededAt = database.log.find((entry) => entry.sql === 'INSERT INTO roles VALUES (1);'); + expect(seededAt?.at).toBeGreaterThanOrEqual(expiresAt); + expect(database.lease?.owner).not.toBe('gone'); + expect(database.lease?.expiresAt).toBeLessThan(finishedAt); + }); + + it('keeps waiting when MySQL ends its lease insert as a deadlock victim', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failOnce.set( + 'INSERT IGNORE', + Object.assign( + mySqlError( + 'Deadlock found when trying to get lock; try restarting transaction', + 'ER_LOCK_DEADLOCK', + ), + { errno: 1213 }, + ), + ); + return { database, result: await migrate(RACE) }; + }); + + expect(result.body.ok).toBe(true); + expect(seeded(database)).toBe(1); + }); + + it('fails a deploy that waited too long for another run', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase( + new FakeMySqlDatabase({ lease: { owner: 'other', expiresAt: Date.now() + 3_600_000 } }), + ); + return { database, result: await migrate(RACE) }; + }); + + expect(result).toEqual({ + status: 500, + body: { ok: false, - error: 'migration 002_backfill.sql failed', - migrations: [ - { name: '001_init.sql', applied: true }, - { - name: '002_backfill.sql', - applied: false, - error: 'Connection lost: The server closed the connection.', - }, - ], - }); - } finally { - mysqlFailures.clear(); - } + error: + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then deploy again.', + migrations: [], + }, + }); + expect(seeded(database)).toBe(0); + expect(database.lease?.owner).toBe('other'); + }); + + // Hyperdrive ends statements after 60 seconds, so this only happens if that limit changes. + it('stops a deploy whose lease expired during a statement', async () => { + const { database, result } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const result = await migrate([ + { + name: '0001_slow.sql', + sql: 'SELECT SLEEP(200);\n--> statement-breakpoint\nUPDATE c SET n = n + 1;', + }, + ]); + return { database, result }; + }); + + expect(result.body).toMatchObject({ + ok: false, + migrations: [ + { + name: '0001_slow.sql', + applied: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + }, + ], + }); + expect(database.statements()).not.toContain('UPDATE c SET n = n + 1;'); }); }); describe('MySQL connections in Workers', () => { // Workers forbid eval, which mysql2 uses to build row parsers unless disableEval is set. - it('opens the hosted migration connection with eval disabled', async () => { - mysqlConnections.length = 0; - const handler = createMigrationHandler( - { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, - [], - ); - const response = await handler( - new Request('http://localhost/__void/migrate', { - method: 'POST', - headers: { 'x-void-internal': 'token' }, - }), - ); - expect(await response.json()).toMatchObject({ ok: true }); + it('opens the hosted migration connections with eval disabled', async () => { + expect((await migrate([])).body).toMatchObject({ ok: true }); expect(mysqlConnections).toEqual([ { uri: 'mysql://localhost/app', multipleStatements: true, disableEval: true }, + { uri: 'mysql://localhost/app', disableEval: true }, ]); }); it('opens the Better Auth migration connection with eval disabled', async () => { - mysqlConnections.length = 0; await runVoidAuthMigrations( { DATABASE_URL: 'mysql://localhost/auth' }, { dialect: 'mysql', providers: null }, diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 6e10c7e9d..70e526e29 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -632,7 +632,10 @@ describe('generateWrapper with migrations', () => { /** Run the wrapped MySQL Worker in workerd and return its `/__void/migrate` response body. */ async function migrateMySqlInWorkerd(workerDir: string): Promise { // The applied-migrations SELECT returns a row, so mysql2 also parses a result set. - const mysql = await startFakeMySqlServer([{ name: '000_applied_elsewhere.sql' }]); + // No CLI run holds the named lock, so the deploy's lease is enough. + const mysql = await startFakeMySqlServer((sql) => + sql.includes('IS_USED_LOCK') ? [{ holder: null }] : [{ name: '000_applied_elsewhere.sql' }], + ); try { return await fetchWrappedWorker( workerDir, From d14921957943acd1262b0920932c6cfb7c5ccdb6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 02:46:01 +0800 Subject: [PATCH 09/17] fix(void): share the request env across void copies and guard installed void on Node With an installed void (npm or pnpm, not a workspace link), Vite can load void outside the plugin path, which broke two things in dev: - Node target: void/kv, void/storage and void/db loaded and returned 200 instead of failing with "target: 'void/kv' is not available with target 'node'". Vite's SSR import analysis externalizes a bare import that resolves into node_modules before any plugin resolveId runs, so the guard never saw it. A workspace link resolves outside node_modules and already threw. Builds already enforced the guard. - SvelteKit: an installed package that imports void/* stays external and loads its own copy of void's env modules. Each copy made its own AsyncLocalStorage, so the package saw no request env and failed with "env: Cloudflare env is unavailable". The Node-target plugin now inlines void (ssr.noExternal) outside builds, for the dev server and Vitest, as SvelteKit dev already does, so every void import reaches the guard. The request env and request scope stores now live on globalThis under versioned Symbol.for keys, like authContext, so every copy of the module reads the current request. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 4 +- packages/void/src/plugin-node-target.ts | 6 ++- packages/void/src/runtime/env-raw.ts | 13 ++++++- packages/void/src/runtime/env.ts | 6 ++- packages/void/test/unit/env.test.ts | 27 ++++++++++++- .../test/unit/plugin-vite-environment.test.ts | 38 +++++++++++++++++++ 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index c0034e886..9f05455c4 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -37,7 +37,7 @@ voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBinding voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] ``` -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server and Vitest inline `void` (`ssr.noExternal`), as a workspace link does: Vite externalizes a bare import that resolves into node_modules before any plugin sees it, so without this an installed `void/kv` would skip the guard. Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together. @@ -98,7 +98,7 @@ generated entries must preserve the module-boundary rules below. - **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev, an installed third-party package that imports `void/*` stays external and gets its own env module copy without the request env. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev, an installed third-party package that imports `void/*` stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index b9cadbed6..3006b958c 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -143,7 +143,11 @@ export function nodeTargetPlugin(target: NodeTarget): Array { config(_, { command }) { if (command !== 'build') { - return; + // The dev server (and Vitest) run routes in Vite's Node SSR graph, which + // externalizes a bare import resolving into node_modules before any plugin sees + // it. An installed void/kv would then skip the guard above. Inlining void keeps + // every void import on the plugin path, as with a workspace link. + return { ssr: { noExternal: ['void'] } }; } const root = process.cwd(); diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts index 908763d1d..33a0fe312 100644 --- a/packages/void/src/runtime/env-raw.ts +++ b/packages/void/src/runtime/env-raw.ts @@ -22,7 +22,18 @@ export function isIsrActive(rawEnv: RuntimeEnv): boolean { return rawEnv.__VOID_ISR_DEPLOYMENT_ID != null; } -export const envContext: AsyncLocalStorage = new AsyncLocalStorage(); +/** + * One process or isolate can evaluate this module more than once: Vite's SSR module + * runner keeps its own copy beside a package it leaves external, and a dependency + * optimizer reload re-evaluates it while a request is in flight. Every copy must read + * the env of the current request, so the store lives on `globalThis`, like + * `authContext`. Two Void versions in one process share it: change the key when the + * store shape changes. + */ +const ENV_CONTEXT_SYMBOL: unique symbol = Symbol.for('void.envContext.v1'); +const shared = globalThis as { [ENV_CONTEXT_SYMBOL]?: AsyncLocalStorage }; +shared[ENV_CONTEXT_SYMBOL] ??= new AsyncLocalStorage(); +export const envContext: AsyncLocalStorage = shared[ENV_CONTEXT_SYMBOL]; let cloudflareEnv: RuntimeEnv | undefined; diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts index 2e39cd786..5e553bbca 100644 --- a/packages/void/src/runtime/env.ts +++ b/packages/void/src/runtime/env.ts @@ -22,7 +22,11 @@ type RuntimeRequestScope = { resources: Map; }; -const runtimeRequestContext = new AsyncLocalStorage(); +// Shared across copies of this module for the reason given at `envContext`. +const REQUEST_SCOPE_SYMBOL: unique symbol = Symbol.for('void.runtimeRequestScope.v1'); +const shared = globalThis as { [REQUEST_SCOPE_SYMBOL]?: AsyncLocalStorage }; +shared[REQUEST_SCOPE_SYMBOL] ??= new AsyncLocalStorage(); +const runtimeRequestContext = shared[REQUEST_SCOPE_SYMBOL]; /** @internal Reuse a resource only within the current runtime request. */ export function getRuntimeRequestResource( diff --git a/packages/void/test/unit/env.test.ts b/packages/void/test/unit/env.test.ts index b0aad440c..f4c70851e 100644 --- a/packages/void/test/unit/env.test.ts +++ b/packages/void/test/unit/env.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { getRuntimeBinding, getRuntimeEnv, withRuntimeEnv } from '../../src/runtime/env.ts'; const globalScope = globalThis as { @@ -37,6 +37,31 @@ describe('runtime env resolution', () => { expect(value).toEqual({ tag: 'scoped-db' }); }); + // A package that Vite leaves external loads its own copy of these modules, and a + // dependency optimizer reload evaluates them again while a request is in flight. + it('shares the request env and scope with another copy of the env module', async () => { + vi.resetModules(); + const first = await import('../../src/runtime/env.ts'); + vi.resetModules(); + const second = await import('../../src/runtime/env.ts'); + expect(second.withRuntimeEnv).not.toBe(first.withRuntimeEnv); + + const key = {}; + const seen = await first.withRuntimeEnv({ KV: { tag: 'scoped-kv' } }, async () => { + await Promise.resolve(); + return { + binding: second.getRuntimeBinding('KV'), + resource: second.getRuntimeRequestResource( + key, + () => 'request', + () => 'outside', + ), + }; + }); + + expect(seen).toEqual({ binding: { tag: 'scoped-kv' }, resource: 'request' }); + }); + it('throws when no runtime env is available', () => { expect(() => getRuntimeEnv()).toThrow('env: Cloudflare env is unavailable'); }); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 827ef785a..bd643dca8 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -283,6 +283,44 @@ describe('voidPlugin Vite environments', () => { } }); + it('rejects Cloudflare-only imports from an installed void on the Node target dev server', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', devDependencies: { void: '*' } }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { './kv': './kv.mjs' } }), + ); + writeFileSync(join(installed, 'kv.mjs'), 'export const kv = {};\n'); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/cache.ts'), + "import { kv } from 'void/kv';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + await expect( + server.environments.ssr.transformRequest('/routes/api/cache.ts'), + ).rejects.toThrow("target: 'void/kv' is not available with target 'node'."); + } finally { + await server.close(); + } + }); + // The package entry is the whole Vite plugin; server code needs only its handler helpers. it.each([ ['sveltekit', '@sveltejs/kit', 'ssr', true], From e7c92266994f12e5b811c1b4b47403808c4830fe Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 03:16:19 +0800 Subject: [PATCH 10/17] fix(void): run pg/MySQL queries on the current request's pool, and scope Astro requests A PostgreSQL or MySQL query built or prepared at module scope, such as `const q = db.select().from(users)` or a cached `.prepare()`, worked on the first request and then failed on every later one: pg hung until workerd cancelled the request, MySQL failed with "Cannot perform I/O on behalf of a different request". drizzle keeps the client it was built with, and that client was the first request's pool, whose socket workerd ties to that request. Astro apps also had no Void request scope, so every query built its own client. Twenty concurrent requests with eight queries each exhausted Postgres ("sorry, too many clients already") and failed about half of them, in dev and in the built Worker. void/db for PostgreSQL and MySQL is now one drizzle instance. Its client looks up the current request's pool on each call, so a query runs on the pool of the request that executes it, and building a query creates no pool. drizzle's pool checks still see a pool, so a transaction holds one connection; their answers are kept per isolate, so outside a request scope they build no extra pools. Inside a request, reading `db` builds the request's pool first, so a missing DATABASE_URL or Hyperdrive still reports its own error rather than drizzle's "Failed query". For PostgreSQL and MySQL, the Astro Cloudflare adapter's default entry now runs each request inside Void's request scope, in dev, the build and its prerender worker. D1 Astro apps keep their current entry. The Astro PostgreSQL integration test also failed now and then under parallel load: Astro's update check writes .astro/settings.json in the fresh test copy, the watcher can report that one write twice, and the second report restarts the dev server mid-request. The test turns the update check off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-drizzle.ts | 78 +++++-- packages/void/src/plugin-framework.ts | 44 ++++ packages/void/test/integration/astro.test.ts | 126 +++++++++-- .../test/integration/react-router.test.ts | 23 +- .../void/test/unit/db-request-pool.test.ts | 212 ++++++++++++++++++ .../void/test/unit/plugin-drizzle.test.ts | 176 +++++++++------ .../test/unit/plugin-vite-environment.test.ts | 90 ++++++++ test/integration-timings.ts | 2 +- vite.config.ts | 1 + 10 files changed, 643 insertions(+), 111 deletions(-) create mode 100644 packages/void/test/unit/db-request-pool.test.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 9f05455c4..49ffb0aa0 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -111,7 +111,7 @@ generated entries must preserve the module-boundary rules below. - **Direct Sandbox entitlement invariant**: `void/sandbox` is opt-in and is the only public Void feature that currently requires Workers Paid on the native Cloudflare path. When source inference or `void.config.ts` enables it, deploy must verify Containers with a read-only account probe after authentication and before provisioning/building, then explain both the Workers Paid signup and Containers/Cloudchamber token permissions on failure. A non-Sandbox app must emit no Container and perform no Containers probe. - **Local D1 identity**: `local-d1.ts` selects the configured D1 binding from root JSON/JSONC config, preferring `preview_database_id` over `database_id` for local development. Development, schema checks, auth migrations, and every local database command use this same identity and persistence directory. `voidPlugin({ persistTo })` records its canonical path in `.void/local-d1.json`; CLI commands reread binding IDs and reject stale Vite-config state until `void dev` refreshes it. - **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). -- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. +- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope forbids the timer mysql2 may start). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper. - **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. - **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. The server runtime reads the same embedded public values for boot validation and SSR. Client folding must observe the final shared `config.env`; Vite runs `configResolved` hooks concurrently. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys. - **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build. diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index a382b4efe..1e767bc68 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -18,6 +18,33 @@ const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mys const VIRTUAL_DB_ID = '\0virtual:void-db'; const VIRTUAL_SCHEMA_DB_ID = '\0virtual:void-db-schema'; + +// Workers tie a connection to the request that opened it. void/db's PostgreSQL and MySQL +// templates share one drizzle instance whose client looks up the current request's pool on +// every call, so a query built or prepared in one request runs on the pool of the request that +// executes it. `has` and `getPrototypeOf` answer drizzle's pool checks before a transaction. +// Every pool has the same shape, so those answers are kept: outside a request scope, where each +// lookup builds a pool, a check would otherwise build one that never runs a query. +const REQUEST_POOL_PROXY = ` +function requestPool(resolve, own = {}) { + const has = new Map(); + let prototype; + return new Proxy({}, { + get(_, prop) { + if (Object.hasOwn(own, prop)) return own[prop]; + const pool = resolve(); + const value = Reflect.get(pool, prop); + return typeof value === "function" ? value.bind(pool) : value; + }, + has(_, prop) { + if (Object.hasOwn(own, prop)) return true; + if (!has.has(prop)) has.set(prop, Reflect.has(resolve(), prop)); + return has.get(prop); + }, + getPrototypeOf: () => (prototype ??= Object.getPrototypeOf(resolve())), + }); +} +`; type DrizzlePluginOptions = { d1BindingName?: string; forceVirtualDb?: boolean; @@ -158,8 +185,8 @@ function getConnectionString() { throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function createInstance(maxIdle) { - return drizzle({ +function createPools(maxIdle) { + const pool = drizzle({ connection: { uri: getConnectionString(), connectionLimit: 1, @@ -167,29 +194,37 @@ function createInstance(maxIdle) { idleTimeout: 1000, // Workers forbid eval, which mysql2 uses to build its row parsers. disableEval: true, - }${mysqlDrizzleConfig}, - }); + }, + }).$client; + return { pool, promisePool: pool.promise() }; } -function getInstance() { +function getPools() { return getRuntimeRequestResource( _requestResourceKey, - // maxIdle below connectionLimit starts mysql2's idle timer when the pool is built, and - // workerd rejects timers in global scope, where a module-level query builds its pool. - () => createInstance(1), + // maxIdle below connectionLimit starts mysql2's idle timer when the pool is built. + () => createPools(1), // Framework entries that Void does not own run outside its request scope: // they get a client for each use, never one shared across requests. Nothing // ends that pool, so mysql2 closes its idle connection. - () => createInstance(0), + () => createPools(0), ); } +${REQUEST_POOL_PROXY} +const promiseClient = requestPool(() => getPools().promisePool); +// drizzle asks for the promise API while it builds the database, which can run in global +// scope. workerd rejects the timer a mysql2 pool may start there, so answer without one. +const client = requestPool(() => getPools().pool, { promise: () => promiseClient }); + +let instance; export const db = new Proxy({}, { get(_, prop) { - const instance = getInstance(); + // drizzle reports a query's errors as "Failed query", so build the request's pool here: + // a missing connection string then throws its own message. Outside a request, build none. + getRuntimeRequestResource(_requestResourceKey, () => createPools(1), () => undefined); + instance ??= drizzle({ client${mysqlDrizzleConfig} }); const value = Reflect.get(instance, prop); - // drizzle's methods read this.session. Outside a request scope each lookup builds a - // client, so bind them to this one: a query chain then uses one client. return typeof value === "function" ? value.bind(instance) : value; }, }); @@ -218,28 +253,31 @@ function getConnectionString() { throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function createInstance() { +function createPool() { return drizzle({ connection: { connectionString: getConnectionString(), max: 1, idleTimeoutMillis: 1000, - }${schema ? ', schema' : ''}, - }); + }, + }).$client; } -function getInstance() { +function getPool() { // Framework entries that Void does not own run outside its request scope: // they get a client for each use, never one shared across requests. - return getRuntimeRequestResource(_requestResourceKey, createInstance, createInstance); + return getRuntimeRequestResource(_requestResourceKey, createPool, createPool); } +${REQUEST_POOL_PROXY} +let instance; export const db = new Proxy({}, { get(_, prop) { - const instance = getInstance(); + // drizzle reports a query's errors as "Failed query", so build the request's pool here: + // a missing connection string then throws its own message. Outside a request, build none. + getRuntimeRequestResource(_requestResourceKey, createPool, () => undefined); + instance ??= drizzle({ client: requestPool(getPool)${schema ? ', schema' : ''} }); const value = Reflect.get(instance, prop); - // drizzle's methods read this.session. Outside a request scope each lookup builds a - // client, so bind them to this one: a query chain then uses one client. return typeof value === "function" ? value.bind(instance) : value; }, }); diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 365435e70..f93e4e1bf 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -31,6 +31,10 @@ import { createCloudflareBuiltinsExternalPlugin } from './plugin-worker-environm import { injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); + +const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; +const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; export function createFrameworkPlugins(input: { readonly localD1: ReturnType; @@ -220,12 +224,52 @@ export function createFrameworkPlugins(input: { }, }; + // Astro's Cloudflare adapter owns the Worker entry. For PostgreSQL and MySQL, its default + // entry runs each request inside Void's request scope (`withRuntimeEnv`), as the Class A + // wrapper does, so void/db keeps one client per request instead of one per query. D1 apps + // keep Astro's own entry: in a request scope, void/db reads D1 through a replica-capable + // session instead of the primary. + const astroRequestScopePlugin: Plugin | undefined = + detected.name === 'astro' && dialect !== 'sqlite' + ? { + name: 'void:astro-request-scope', + enforce: 'pre', + resolveId: { + filter: { id: /^@astrojs\/cloudflare\/entrypoints\/server(?:\.js)?$/ }, + handler(_id, importer) { + if (importer === ASTRO_SCOPED_SERVER_ENTRY || this.environment?.name === 'client') { + return; + } + return ASTRO_SCOPED_SERVER_ENTRY; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:astro-server-entry$/ }, + handler() { + return `import original from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; +import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)}; +export * from ${JSON.stringify(ASTRO_SERVER_ENTRY)}; + +export default { + ...original, + fetch(request, env, ctx) { + return withRuntimeEnv(env, () => original.fetch(request, env, ctx)); + }, +}; +`; + }, + }, + } + : undefined; + return [ devTriggerSecretGuardPlugin(), createCloudflareBuiltinsExternalPlugin(), wranglerCompatPlugin, wranglerSyncPlugin, ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), + ...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []), svelteKitHooksInjectPlugin, clientStubsPlugin, serverRuntimeOptimizerPlugin, diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index d5b697d8a..eeb2cc6b4 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { once } from 'node:events'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import type { AddressInfo } from 'node:net'; @@ -11,6 +12,7 @@ import { killChild, startServer, type IsolatedPlayground, + type StartedServer, } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); @@ -106,7 +108,7 @@ describe('playground/astro dev', () => { }); }); -// Astro owns its Worker entry, so void/db runs without Void's request scope. +// Astro owns its Worker entry; Void runs it in a request scope for PostgreSQL. describe('playground/astro with PostgreSQL', () => { const USERS = [ { id: 1, name: 'Ada', email: 'ada@example.com' }, @@ -116,8 +118,55 @@ describe('playground/astro with PostgreSQL', () => { let pgServer: ReturnType; let playground: IsolatedPlayground; let child: ReturnType | undefined; - let base: string; - let output = () => ''; + let dev: StartedServer; + let connections = 0; + + function start(script: 'dev' | 'preview'): Promise { + const root = playground.root; + return startServer((port) => { + child = spawn( + process.execPath, + [ + join(root, 'node_modules/astro/bin/astro.mjs'), + script, + '--host', + '127.0.0.1', + '--port', + String(port), + ], + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ASTRO_DEV_BACKGROUND: 'false', + ASTRO_PREVIEW_BACKGROUND: 'false', + // Astro's update check writes .astro/settings.json in a fresh project, and the watcher + // can report that one write twice; the second restarts the dev server mid-request. + ASTRO_DISABLE_UPDATE_CHECK: 'true', + VOID_INSPECTOR_PORT: 'false', + }, + }, + ); + return child; + }, 30_000); + } + + // Runs every query of a request on one connection, a query built at module load included. + async function expectOneConnectionPerRequest({ base, output }: StartedServer): Promise { + const before = connections; + const response = await fetch(`${base}/api/user-counts`); + const result = { + status: response.status, + body: await response.text(), + connections: connections - before, + }; + expect(result, output().slice(-4000)).toEqual({ + status: 200, + body: '[2,2,2,2]', + connections: 1, + }); + } beforeAll(async () => { pglite = new PGlite(); @@ -131,6 +180,7 @@ describe('playground/astro with PostgreSQL', () => { ]); } pgServer = createServer(pglite); + pgServer.on('connection', () => connections++); pgServer.listen(0, '127.0.0.1'); await once(pgServer, 'listening'); const { port } = pgServer.address() as AddressInfo; @@ -170,6 +220,22 @@ export const prerender = false; export async function GET() { return Response.json(await db.select().from(users)); } +`, + ); + writeFileSync( + join(root, 'src/pages/api/user-counts.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +export const prerender = false; + +// Built when the module loads, run by every request. +const allUsers = db.select().from(users); + +export async function GET() { + const results = await Promise.all([allUsers, allUsers, allUsers, allUsers]); + return Response.json(results.map((rows) => rows.length)); +} `, ); writeFileSync( @@ -177,21 +243,7 @@ export async function GET() { `DATABASE_URL=postgresql://postgres@127.0.0.1:${port}/postgres\n`, ); - const server = await startServer((serverPort) => { - const astro = join(root, 'node_modules/astro/bin/astro.mjs'); - child = spawn( - process.execPath, - [astro, 'dev', '--host', '127.0.0.1', '--port', String(serverPort)], - { - cwd: root, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, ASTRO_DEV_BACKGROUND: 'false', VOID_INSPECTOR_PORT: 'false' }, - }, - ); - return child; - }, 30_000); - base = server.base; - output = server.output; + dev = await start('dev'); }); afterAll(async () => { @@ -207,8 +259,42 @@ export async function GET() { }); it('queries PostgreSQL through void/db from an API route in dev', async () => { - const response = await fetch(`${base}/api/users`); + const response = await fetch(`${dev.base}/api/users`); const result = { status: response.status, body: await response.text() }; - expect(result, output().slice(-4000)).toEqual({ status: 200, body: JSON.stringify(USERS) }); + expect(result, dev.output().slice(-4000)).toEqual({ + status: 200, + body: JSON.stringify(USERS), + }); + }); + + it('runs every query of a request on one connection in dev', async () => { + for (const _request of [1, 2]) { + await expectOneConnectionPerRequest(dev); + } + }); + + it('runs every query of a request on one connection in the built Worker', async () => { + // The dev server writes Void's dev-only middleware into src/ until it stops. + await killChild(dev.child); + child = undefined; + execFileSync( + process.execPath, + [join(playground.root, 'node_modules/astro/bin/astro.mjs'), 'build'], + { + cwd: playground.root, + stdio: 'pipe', + // Build as `void deploy` does: it keeps the Wrangler deploy config `astro preview` reads. + env: { ...process.env, VOID_CLOUDFLARE_DEPLOY_BUILD: '1', VOID_INSPECTOR_PORT: 'false' }, + }, + ); + const server = await start('preview'); + try { + for (const _request of [1, 2]) { + await expectOneConnectionPerRequest(server); + } + } finally { + await killChild(server.child); + child = undefined; + } }); }); diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index 3f20e2d77..90380a68e 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -113,6 +113,19 @@ import { db } from 'void/db'; export async function loader() { return Response.json(await db.select().from(users)); } +`, + ); + writeFileSync( + join(root, 'app/routes/api.user-count.ts'), + `import { users } from '@schema'; +import { db } from 'void/db'; + +// Built when the module loads, run by every request. +const allUsers = db.select().from(users); + +export async function loader() { + return Response.json((await allUsers).length); +} `, ); const routesPath = join(root, 'app/routes.ts'); @@ -120,7 +133,7 @@ export async function loader() { routesPath, readFileSync(routesPath, 'utf8').replace( "route('smoke', 'routes/smoke.tsx'),", - "route('smoke', 'routes/smoke.tsx'),\n route('api/users', 'routes/api.users.ts'),", + "route('smoke', 'routes/smoke.tsx'),\n route('api/users', 'routes/api.users.ts'),\n route('api/user-count', 'routes/api.user-count.ts'),", ), ); writeFileSync( @@ -152,6 +165,14 @@ export async function loader() { const response = await fetch(`${base}/api/users`); const result = { status: response.status, body: await response.text() }; expect(result, output().slice(-4000)).toEqual({ status: 200, body: JSON.stringify(USERS) }); + // Each request runs the module-level query on its own connection. + for (const _request of [1, 2]) { + const count = await fetch(`${base}/api/user-count`); + expect({ status: count.status, body: await count.text() }, output().slice(-4000)).toEqual({ + status: 200, + body: String(USERS.length), + }); + } } it('queries MySQL through void/db in dev', async () => { diff --git a/packages/void/test/unit/db-request-pool.test.ts b/packages/void/test/unit/db-request-pool.test.ts new file mode 100644 index 000000000..0567622ea --- /dev/null +++ b/packages/void/test/unit/db-request-pool.test.ts @@ -0,0 +1,212 @@ +import { once } from 'node:events'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { createServer } from 'pglite-server'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { startFakeMySqlServer, type FakeMySqlServer } from '../helpers/fake-mysql-server.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; + +const USERS = [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Linus' }, +]; + +type Dialect = 'postgresql' | 'mysql'; +type Pool = { end(callback?: () => void): unknown }; +type Query = PromiseLike; +type Db = { + select(): { from(table: unknown): Query & { prepare(name?: string): { execute(): Query } } }; + query: { users: { findMany(): Query } }; + transaction(callback: (tx: Db) => Promise): Promise; +}; + +const roots: Array = []; +const stateKeys: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + for (const key of stateKeys.splice(0)) { + delete (globalThis as Record)[key]; + } +}); + +/** + * Load void/db with drizzle's own driver. Each request ends the pools built during it, as + * workerd does when a request finishes: nothing may reuse a request's connection afterwards. + */ +async function loadDb(dialect: Dialect, url: string) { + const root = mkdtempSync(join(tmpdir(), 'void-db-request-')); + roots.push(root); + const core = dialect === 'postgresql' ? 'pg-core' : 'mysql-core'; + const coreUrl = import.meta.resolve(`drizzle-orm/${core}`); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db', 'schema.ts'), + dialect === 'postgresql' + ? `import { integer, pgTable, text } from ${JSON.stringify(coreUrl)}; +export const users = pgTable('users', { id: integer('id').primaryKey(), name: text('name').notNull() }); +` + : `import { int, mysqlTable, varchar } from ${JSON.stringify(coreUrl)}; +export const users = mysqlTable('users', { id: int('id').primaryKey(), name: varchar('name', { length: 255 }).notNull() }); +`, + ); + + const plugin = drizzlePlugin(root, false, dialect); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const stateKey = `__void_db_request_pools_${crypto.randomUUID().replaceAll('-', '')}`; + const state = { pools: [] as Array, checkouts: 0 }; + (globalThis as Record)[stateKey] = state; + stateKeys.push(stateKey); + // Both drivers check a connection out of the pool for each query and each transaction. + const checkout = dialect === 'postgresql' ? 'connect' : 'getConnection'; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ drizzle \} from (.*);$/m, + (_match, driver: string) => `import { drizzle as createDrizzle } from ${driver}; +function drizzle(config) { + const database = createDrizzle(config); + const state = globalThis[${JSON.stringify(stateKey)}]; + const pool = database.$client; + if (config.connection) { + state.pools.push(pool); + const checkout = pool.${checkout}; + pool.${checkout} = (...args) => { + state.checkouts++; + return checkout.apply(pool, args); + }; + } + return database; +}`, + ) + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + const module = (await import( + `${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}` + )) as { db: Db }; + const { users } = (await import(pathToFileURL(join(root, 'db', 'schema.ts')).href)) as { + users: unknown; + }; + + async function endPools(first = 0): Promise { + await Promise.all( + state.pools + .slice(first) + .map((pool) => + dialect === 'postgresql' + ? pool.end() + : new Promise((resolve) => pool.end(() => resolve())), + ), + ); + } + + async function request(callback: () => Promise): Promise { + const first = state.pools.length; + try { + return await withRuntimeEnv({ DATABASE_URL: url }, callback); + } finally { + await endPools(first); + } + } + + return { db: module.db, users, state, request, endPools }; +} + +describe.each(['postgresql', 'mysql'] as const)('void/db %s across requests', (dialect) => { + let url: string; + let pglite: PGlite | undefined; + let pgServer: ReturnType | undefined; + let mysqlServer: FakeMySqlServer | undefined; + + beforeAll(async () => { + if (dialect === 'postgresql') { + pglite = new PGlite(); + await pglite.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);'); + await pglite.exec("INSERT INTO users VALUES (1, 'Ada'), (2, 'Linus');"); + pgServer = createServer(pglite); + pgServer.listen(0, '127.0.0.1'); + await once(pgServer, 'listening'); + url = `postgresql://postgres@127.0.0.1:${(pgServer.address() as AddressInfo).port}/postgres`; + } else { + mysqlServer = await startFakeMySqlServer(USERS); + url = `mysql://void@127.0.0.1:${mysqlServer.port}/app`; + } + }); + + afterAll(async () => { + if (pgServer) { + pgServer.close(); + await once(pgServer, 'close'); + } + await pglite?.close(); + await mysqlServer?.close(); + }); + + it('runs a query built or prepared in one request on the pool of a later request', async () => { + const { db, users, state, request } = await loadDb(dialect, url); + + // Module-level queries: built once, run by every request. + const { built, prepared, relational } = await request(async () => { + const built = db.select().from(users); + const prepared = db.select().from(users).prepare('void_users'); + const relational = db.query.users.findMany(); + expect(await built).toEqual(USERS); + return { built, prepared, relational }; + }); + const later = await request(async () => ({ + built: await built, + prepared: await prepared.execute(), + relational: await relational, + })); + + expect(later).toEqual({ built: USERS, prepared: USERS, relational: USERS }); + expect(state.pools).toHaveLength(2); + }); + + it('runs a transaction on one connection of the request pool', async () => { + const { db, users, state, request } = await loadDb(dialect, url); + + const rows = await request(() => db.transaction(async (tx) => tx.select().from(users))); + + expect(rows).toEqual(USERS); + // One checkout holds the connection for the whole transaction; drizzle only does that for + // a pool, so a stand-in client must still look like one. + expect(state.checkouts).toBe(1); + }); + + it('builds one pool for a transaction outside a request scope', async () => { + const { db, users, state, endPools } = await loadDb(dialect, url); + // A framework entry that Void does not wrap runs without its request scope. + const previousEnv = (globalThis as Record).__env__; + (globalThis as Record).__env__ = { DATABASE_URL: url }; + try { + await db.transaction(async (tx) => tx.select().from(users)); + const before = state.pools.length; + + const rows = await db.transaction(async (tx) => tx.select().from(users)); + + expect(rows).toEqual(USERS); + // Each use builds a pool here, so drizzle's pool checks must not build more. + expect(state.pools.length - before).toBe(1); + } finally { + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + await endPools(); + } + }); +}); diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 09cbacf38..174aba4d9 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -16,6 +16,7 @@ vi.mock('../../src/log', () => ({ import { voidWarn } from '../../src/log.ts'; import { getMiniflareDatabasePath, openSqliteDatabase } from '../../src/migrations/runner.ts'; import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; const roots: Array = []; const fakeStateKeys: Array = []; @@ -40,6 +41,8 @@ type FakePoolRecord = { type FakeDatabaseState = { nextId: number; pools: Array; + // drizzle configs of the databases built on a client that void/db passes in. + instances: Array>; clients: Array; }; @@ -61,7 +64,7 @@ async function loadExecutableDbModule( const load = getHookHandler(plugin.load) as (this: object, id: string) => string; let code = load.call({}, '\0virtual:void-db'); const stateKey = `__void_db_scope_${dialect}_${crypto.randomUUID().replaceAll('-', '')}`; - const state: FakeDatabaseState = { nextId: 0, pools: [], clients: [] }; + const state: FakeDatabaseState = { nextId: 0, pools: [], instances: [], clients: [] }; (globalThis as Record)[stateKey] = state; fakeStateKeys.push(stateKey); @@ -69,21 +72,36 @@ async function loadExecutableDbModule( const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; const stateExpression = `globalThis[${JSON.stringify(stateKey)}]`; const createDatabaseSource = ` -function createDatabase(dialect, pool) { +function createPool(dialect, options) { const state = ${stateExpression}; - const instanceId = ++state.nextId; - const connectionOwner = dialect === "postgresql" ? pool.options.connectionString : pool.options.uri; + const id = ++state.nextId; + const connectionOwner = dialect === "postgresql" ? options.connectionString : options.uri; let owner = connectionOwner; try { owner = getTestRuntimeBinding("REQUEST_ID") ?? connectionOwner; } catch {} - const database = { - instanceId, + const pool = { + id, + options, async query(expectedOwner) { - if (owner !== expectedOwner) throw new Error("database instance crossed request ownership"); - return instanceId; + if (owner !== expectedOwner) throw new Error("database pool crossed request ownership"); + return id; }, + promise: () => pool, + }; + return pool; +} + +// Like drizzle, the database runs every query on its client; instanceId names the pool that +// serves the current call. +function createDatabase(client) { + const database = { + get instanceId() { + return client.id; + }, + query: (expectedOwner) => client.query(expectedOwner), async transaction(callback) { return callback(database); }, + $client: client, }; return database; } @@ -110,13 +128,17 @@ export function drizzle(client) { const state = ${stateExpression}; ${createDatabaseSource} export function drizzle(config) { - if (typeof config === "string") return createDatabase("postgresql", { options: { connectionString: config } }); - if (typeof config?.connection !== "object" || config.connection === null) { + if (typeof config === "string") return createDatabase(createPool("postgresql", { connectionString: config })); + const { connection, client, ...drizzleConfig } = config; + if (client) { + state.instances.push(drizzleConfig); + return createDatabase(client); + } + if (typeof connection !== "object" || connection === null) { throw new Error("void/db must let drizzle build the pg pool from a connection object"); } - const { connection, ...drizzleConfig } = config; state.pools.push({ dialect: "postgresql", options: connection, drizzleConfig }); - return createDatabase("postgresql", { options: connection }); + return createDatabase(createPool("postgresql", connection)); } `, ); @@ -131,12 +153,17 @@ export function drizzle(config) { const state = ${stateExpression}; ${createDatabaseSource} export function drizzle(config) { - if (typeof config?.connection !== "object" || config.connection === null) { + const { connection, client, ...drizzleConfig } = config; + if (client) { + state.instances.push(drizzleConfig); + // Like drizzle, query through the promise API of a callback pool. + return createDatabase(typeof client.promise === "function" ? client.promise() : client); + } + if (typeof connection !== "object" || connection === null) { throw new Error("void/db must let drizzle build the mysql2 pool from a connection object"); } - const { connection, ...drizzleConfig } = config; state.pools.push({ dialect: "mysql", options: connection, drizzleConfig }); - return createDatabase("mysql", { options: connection }); + return createDatabase(createPool("mysql", connection)); } `, ); @@ -168,7 +195,7 @@ export const withEnv = (env, callback) => withRuntimeEnv(env, callback); type DriverQuery = { toSQL(): { sql: string } }; -/** Load void/db with drizzle's own driver and record each client it builds. */ +/** Load void/db with drizzle's own driver and record each pool it builds. */ async function loadDriverDbModule(dialect: 'postgresql' | 'mysql'): Promise<{ db: { select(): { from(table: unknown): DriverQuery } }; clients: Array<{ end(): unknown }>; @@ -188,7 +215,7 @@ async function loadDriverDbModule(dialect: 'postgresql' | 'mysql'): Promise<{ (_match, driver: string) => `import { drizzle as createDrizzle } from ${driver}; function drizzle(config) { const database = createDrizzle(config); - globalThis[${JSON.stringify(clientsKey)}].push(database.$client); + if (config.connection) globalThis[${JSON.stringify(clientsKey)}].push(database.$client); return database; }`, ) @@ -315,34 +342,40 @@ describe('drizzlePlugin db typing generation', () => { ); it.each(['postgresql', 'mysql'] as const)( - 'builds one %s client for a query outside a Void request scope', + 'builds no %s client for a query until it runs', async (dialect) => { const { db, clients } = await loadDriverDbModule(dialect); const users = dialect === 'postgresql' ? pgTable('users', { id: serial('id') }) : mysqlTable('users', { id: int('id') }); - const previousEnv = (globalThis as Record).__env__; - (globalThis as Record).__env__ = { - DATABASE_URL: `${dialect === 'postgresql' ? 'postgres' : 'mysql'}://void@127.0.0.1:9/app`, - }; - try { - // drizzle's select() reads `this.session` and `this.dialect`. - const query = db.select().from(users); - expect(query.toSQL().sql).toMatch(/^select .id. from .users.$/); - expect(clients).toHaveLength(1); - } finally { - if (previousEnv === undefined) { - delete (globalThis as Record).__env__; - } else { - (globalThis as Record).__env__ = previousEnv; - } - await Promise.all(clients.map((client) => client.end())); - } + // A module-level query is built in global scope: no env exists there, and workerd + // rejects the timer a mysql2 pool may start. + const query = db.select().from(users); + + expect(query.toSQL().sql).toMatch(/^select .id. from .users.$/); + expect(clients).toEqual([]); }, ); + it.each([ + ['postgresql', 'PostgreSQL'], + ['mysql', 'MySQL'], + ] as const)('reports a missing %s connection string in a request', async (dialect, name) => { + const { db, clients } = await loadDriverDbModule(dialect); + const users = + dialect === 'postgresql' + ? pgTable('users', { id: serial('id') }) + : mysqlTable('users', { id: int('id') }); + + // drizzle reports an error from a running query as "Failed query: ...". + await expect(withRuntimeEnv({}, async () => await db.select().from(users))).rejects.toThrow( + `db: ${name} is not configured.`, + ); + expect(clients).toEqual([]); + }); + it('keeps sqlite projects on the D1 internal db entry', () => { const root = createProjectRoot(); @@ -472,22 +505,26 @@ describe('drizzlePlugin db typing generation', () => { const requestPools = state.pools.filter((pool) => pool.options.uri !== explicitOwner); expect(requestPools).toHaveLength(6); for (const pool of requestPools) { + expect(pool.drizzleConfig).toEqual({}); if (dialect === 'postgresql') { expect(pool.options).toMatchObject({ max: 1, idleTimeoutMillis: 1000 }); - expect(pool.drizzleConfig).toEqual({ schema: expect.anything() }); } else { expect(pool.options).toMatchObject({ connectionLimit: 1, maxIdle: 1, idleTimeout: 1000 }); } } + // Every request shares one drizzle database, which carries the schema. + expect(state.instances).toEqual([ + dialect === 'postgresql' + ? { schema: expect.anything() } + : { schema: expect.anything(), mode: 'default' }, + ]); if (dialect === 'mysql') { expect(state.pools).toHaveLength(7); - expect(state.pools.find((pool) => pool.options.uri === explicitOwner)?.options).toEqual({ - uri: explicitOwner, - disableEval: true, - }); + const explicitPool = state.pools.find((pool) => pool.options.uri === explicitOwner); + expect(explicitPool?.options).toEqual({ uri: explicitOwner, disableEval: true }); + expect(explicitPool?.drizzleConfig).toEqual({ schema: expect.anything(), mode: 'default' }); for (const pool of state.pools) { expect(pool.options).toMatchObject({ disableEval: true }); - expect(pool.drizzleConfig).toEqual({ schema: expect.anything(), mode: 'default' }); } } }, @@ -536,30 +573,31 @@ describe('drizzlePlugin db typing generation', () => { `import { db, createDb } from "./virtual-db.mjs"; import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; export { createDb }; -export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); +export const requestPoolConfig = (env) => + withRuntimeEnv(env, () => { + const pool = db.$client; + const config = pool.config; + pool.end(); + return config; + }); `, ); - type MySqlPool = { config: { connectionConfig: { disableEval: boolean } }; end(): void }; + type MySqlPoolConfig = { connectionConfig: { disableEval: boolean } }; const harness = (await import( `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` )) as { - createDb(url: string): { $client: MySqlPool }; - requestClient(env: Record): MySqlPool; + createDb(url: string): { $client: { config: MySqlPoolConfig; end(): void } }; + requestPoolConfig(env: Record): MySqlPoolConfig; }; - const pools = [ - harness.createDb('mysql://void@127.0.0.1:9/app').$client, - harness.requestClient({ DATABASE_URL: 'mysql://void@127.0.0.1:9/app' }), - ]; + const explicit = harness.createDb('mysql://void@127.0.0.1:9/app').$client; try { - for (const pool of pools) { - expect(pool.config.connectionConfig.disableEval).toBe(true); - } + expect(explicit.config.connectionConfig.disableEval).toBe(true); } finally { - for (const pool of pools) { - pool.end?.(); - } + explicit.end(); } + const request = harness.requestPoolConfig({ DATABASE_URL: 'mysql://void@127.0.0.1:9/app' }); + expect(request.connectionConfig.disableEval).toBe(true); }, ); @@ -581,25 +619,27 @@ export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); join(root, 'harness.mjs'), `import { db } from "./virtual-db.mjs"; import { withRuntimeEnv } from ${JSON.stringify(envUrl)}; -export const requestClient = (env) => withRuntimeEnv(env, () => db.$client); +export const requestPoolOptions = (env) => + withRuntimeEnv(env, async () => { + const pool = db.$client; + const options = pool.options; + await pool.end(); + return options; + }); `, ); - type PgPool = { options: Record; end(): Promise }; const harness = (await import( `${pathToFileURL(join(root, 'harness.mjs')).href}?test=${crypto.randomUUID()}` - )) as { requestClient(env: Record): PgPool }; + )) as { + requestPoolOptions(env: Record): Promise>; + }; const url = 'postgres://void@127.0.0.1:9/app'; - const pool = harness.requestClient({ DATABASE_URL: url }); - try { - expect(pool.options).toMatchObject({ - connectionString: url, - max: 1, - idleTimeoutMillis: 1000, - }); - } finally { - await pool.end?.(); - } + await expect(harness.requestPoolOptions({ DATABASE_URL: url })).resolves.toMatchObject({ + connectionString: url, + max: 1, + idleTimeoutMillis: 1000, + }); }, ); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index bd643dca8..ab32924bb 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -370,3 +371,92 @@ describe('voidPlugin Vite environments', () => { }, ); }); + +describe('Astro request scope', () => { + const entry = '@astrojs/cloudflare/entrypoints/server'; + const scopedEntry = '\0void:astro-server-entry'; + + it.each([ + ['pg', true], + ['mysql', true], + [undefined, false], + ] as const)( + 'runs the Cloudflare adapter entry in a Void request scope (database: %s): %s', + async (database, scoped) => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database, worker: { compatibility_date: '2026-02-24' } }), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const container = server.environments.ssr!.pluginContainer; + const resolved = await container.resolveId(entry); + expect(resolved?.id === scopedEntry).toBe(scoped); + if (!scoped) { + return; + } + // The wrapper imports the adapter's own entry. + expect((await container.resolveId(entry, scopedEntry))?.id).not.toBe(scopedEntry); + + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + writeFileSync( + join(root, 'adapter-entry.mjs'), + `import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envUrl)}; +export const named = "kept"; +export default { + marker: "kept", + fetch(request, env, ctx) { + // Throws outside a request scope. + getRuntimeRequestResource({}, () => ({})); + return Response.json({ url: request.url, database: getRuntimeBinding("DATABASE_URL"), ctx }); + }, +}; +`, + ); + const loaded = await container.load(scopedEntry); + const code = (typeof loaded === 'string' ? loaded : (loaded?.code ?? '')) + .replaceAll(JSON.stringify(entry), '"./adapter-entry.mjs"') + .replace( + /^import \{ withRuntimeEnv \} from .*;$/m, + `import { withRuntimeEnv } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'entry.mjs'), code); + const wrapper = (await import(pathToFileURL(join(root, 'entry.mjs')).href)) as { + named: string; + default: { + marker: string; + fetch(request: Request, env: unknown, ctx: unknown): Promise; + }; + }; + + expect([wrapper.named, wrapper.default.marker]).toEqual(['kept', 'kept']); + const response = await wrapper.default.fetch( + new Request('http://localhost/api'), + { DATABASE_URL: 'postgres://request' }, + { id: 'ctx' }, + ); + expect(await response.json()).toEqual({ + url: 'http://localhost/api', + database: 'postgres://request', + ctx: { id: 'ctx' }, + }); + } finally { + await server.close(); + } + }, + ); +}); diff --git a/test/integration-timings.ts b/test/integration-timings.ts index 42026a88b..d1c293f9e 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -9,7 +9,7 @@ export const integrationTimings: Readonly> = { 'packages/svelte/test/integration/svelte-pages-client.test.ts': 30000, 'packages/void/test/e2e/cloudflare-deploy-live.test.ts': 6, 'packages/void/test/integration/analog.test.ts': 38000, - 'packages/void/test/integration/astro.test.ts': 19500, + 'packages/void/test/integration/astro.test.ts': 26000, 'packages/void/test/integration/cli-help.test.ts': 1718, 'packages/void/test/integration/cloudflare-compatibility-build.test.ts': 5000, 'packages/void/test/integration/cloudflare-deploy-build.test.ts': 6700, diff --git a/vite.config.ts b/vite.config.ts index f7f749462..08f6a971c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,7 @@ const cliUnitTests = [ // SDK unit files that start PGlite. Add new PGlite unit files here. const sdkPgliteUnitTests = [ 'packages/void/test/unit/db-export-roundtrip.test.ts', + 'packages/void/test/unit/db-request-pool.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', From 439d2c45c8f9fce32c48198bd16ad0ff2256c755 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 03:30:19 +0800 Subject: [PATCH 11/17] fix(void): close idle MySQL connections without a pool timer void/db's MySQL pools never closed an idle connection. mysql2 closes idle connections only from a timer that it starts when it builds a pool with maxIdle below connectionLimit. The request pools set both to 1 and createDb kept mysql2's defaults (both 10), so no timer ran: on Node, as in SvelteKit dev, every request left its connection open, and in workerd a WebSocket left one open per message. The pool built outside a request scope used maxIdle 0, which starts the timer when the pool is built: workerd rejects a timer in global scope, and on Node it fires every second until the pool ends, which nothing does, so the process stays alive. The pools now set no maxIdle, so mysql2 starts no timer. void/db closes a connection once it has been idle, timed from the pool's release event, which runs in the request that ran the query, and stops the timer when the connection is acquired again. Request and outside-scope pools close after 1 second, createDb pools after 60 seconds (mysql2's default). The timer is unref'd, so a Node script that ends its pool exits at once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-drizzle.ts | 60 +++++++---- .../void/test/helpers/fake-mysql-server.ts | 3 + .../void/test/unit/plugin-drizzle.test.ts | 100 ++++++++++++++++-- 4 files changed, 132 insertions(+), 33 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 49ffb0aa0..cfe418618 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -111,7 +111,7 @@ generated entries must preserve the module-boundary rules below. - **Direct Sandbox entitlement invariant**: `void/sandbox` is opt-in and is the only public Void feature that currently requires Workers Paid on the native Cloudflare path. When source inference or `void.config.ts` enables it, deploy must verify Containers with a read-only account probe after authentication and before provisioning/building, then explain both the Workers Paid signup and Containers/Cloudchamber token permissions on failure. A non-Sandbox app must emit no Container and perform no Containers probe. - **Local D1 identity**: `local-d1.ts` selects the configured D1 binding from root JSON/JSONC config, preferring `preview_database_id` over `database_id` for local development. Development, schema checks, auth migrations, and every local database command use this same identity and persistence directory. `voidPlugin({ persistTo })` records its canonical path in `.void/local-d1.json`; CLI commands reread binding IDs and reject stale Vite-config state until `void dev` refreshes it. - **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode). -- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope forbids the timer mysql2 may start). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper. +- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope has no request to own it). mysql2 starts its idle timer only when it builds a pool with `maxIdle` below `connectionLimit`, and that timer runs until the pool ends, so the MySQL pools set neither: each connection closes from its pool `release` event after 1 second idle (60 seconds for `createDb`). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper. - **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral. - **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. The server runtime reads the same embedded public values for boot validation and SSR. Client folding must observe the final shared `config.env`; Vite runs `configResolved` hooks concurrently. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys. - **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build. diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 1e767bc68..7f7e69975 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -185,35 +185,46 @@ function getConnectionString() { throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production."); } -function createPools(maxIdle) { - const pool = drizzle({ - connection: { - uri: getConnectionString(), - connectionLimit: 1, - maxIdle, - idleTimeout: 1000, - // Workers forbid eval, which mysql2 uses to build its row parsers. - disableEval: true, - }, - }).$client; +// mysql2 closes idle connections only from a timer that it starts when it builds a pool with +// maxIdle below connectionLimit, and that timer runs until the pool ends: workerd rejects it in +// global scope, and nothing ends Void's pools. Time each connection from its release instead, +// which runs in the request that ran the query. +function closeIdleConnections(pool, idleTimeout) { + const timers = new WeakMap(); + pool.on("release", (connection) => { + const timer = setTimeout(() => connection.destroy(), idleTimeout); + // A Node script that ends its pool exits without waiting for this timer. + timer.unref?.(); + timers.set(connection, timer); + }); + pool.on("acquire", (connection) => clearTimeout(timers.get(connection))); + return pool; +} + +function createPools() { + const pool = closeIdleConnections( + drizzle({ + connection: { + uri: getConnectionString(), + connectionLimit: 1, + // Workers forbid eval, which mysql2 uses to build its row parsers. + disableEval: true, + }, + }).$client, + 1000, + ); return { pool, promisePool: pool.promise() }; } function getPools() { - return getRuntimeRequestResource( - _requestResourceKey, - // maxIdle below connectionLimit starts mysql2's idle timer when the pool is built. - () => createPools(1), - // Framework entries that Void does not own run outside its request scope: - // they get a client for each use, never one shared across requests. Nothing - // ends that pool, so mysql2 closes its idle connection. - () => createPools(0), - ); + // Framework entries that Void does not own run outside its request scope: + // they get a client for each use, never one shared across requests. + return getRuntimeRequestResource(_requestResourceKey, createPools, createPools); } ${REQUEST_POOL_PROXY} const promiseClient = requestPool(() => getPools().promisePool); // drizzle asks for the promise API while it builds the database, which can run in global -// scope. workerd rejects the timer a mysql2 pool may start there, so answer without one. +// scope. No request exists there to own a pool, so answer without one. const client = requestPool(() => getPools().pool, { promise: () => promiseClient }); let instance; @@ -222,7 +233,7 @@ export const db = new Proxy({}, { get(_, prop) { // drizzle reports a query's errors as "Failed query", so build the request's pool here: // a missing connection string then throws its own message. Outside a request, build none. - getRuntimeRequestResource(_requestResourceKey, () => createPools(1), () => undefined); + getRuntimeRequestResource(_requestResourceKey, createPools, () => undefined); instance ??= drizzle({ client${mysqlDrizzleConfig} }); const value = Reflect.get(instance, prop); return typeof value === "function" ? value.bind(instance) : value; @@ -230,7 +241,10 @@ export const db = new Proxy({}, { }); export function createDb(connectionString) { - return drizzle({ connection: { uri: connectionString, disableEval: true }${mysqlDrizzleConfig} }); + const db = drizzle({ connection: { uri: connectionString, disableEval: true }${mysqlDrizzleConfig} }); + // 60 seconds is mysql2's own idleTimeout default. + closeIdleConnections(db.$client, 60000); + return db; } `; } diff --git a/packages/void/test/helpers/fake-mysql-server.ts b/packages/void/test/helpers/fake-mysql-server.ts index 9030e4881..c3ae94428 100644 --- a/packages/void/test/helpers/fake-mysql-server.ts +++ b/packages/void/test/helpers/fake-mysql-server.ts @@ -27,6 +27,8 @@ export type FakeMySqlRow = Record; export type FakeMySqlServer = { port: number; + /** The client connections the server holds open. */ + openConnections(): number; close(): Promise; }; @@ -98,6 +100,7 @@ export async function startFakeMySqlServer( return { port: (server.address() as AddressInfo).port, + openConnections: () => sockets.size, async close() { for (const socket of sockets) { socket.destroy(); diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 174aba4d9..15d857ae1 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -17,6 +17,7 @@ import { voidWarn } from '../../src/log.ts'; import { getMiniflareDatabasePath, openSqliteDatabase } from '../../src/migrations/runner.ts'; import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; const roots: Array = []; const fakeStateKeys: Array = []; @@ -86,6 +87,8 @@ function createPool(dialect, options) { return id; }, promise: () => pool, + // void/db listens to a MySQL pool to close idle connections. + on() {}, }; return pool; } @@ -230,6 +233,31 @@ function drizzle(config) { return { db: module.db, clients }; } +type MySqlDb = { + $client: { config: unknown; end(callback: () => void): void }; + execute(query: unknown): Promise; +}; + +/** Load the MySQL void/db module with drizzle's own driver and mysql2. */ +async function loadMySqlDbModule(): Promise<{ + db: MySqlDb; + createDb(connectionString: string): MySqlDb; + sql(strings: TemplateStringsArray): unknown; +}> { + const root = createProjectRoot(); + const plugin = drizzlePlugin(root, false, 'mysql', { forceVirtualDb: true }); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + const envUrl = new URL('../../src/runtime/env.ts', import.meta.url).href; + const code = load + .call({}, '\0virtual:void-db') + .replace( + /^import \{ (getRuntimeBinding, getRuntimeRequestResource) \} from .*;$/m, + (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, + ); + writeFileSync(join(root, 'virtual-db.mjs'), code); + return import(`${pathToFileURL(join(root, 'virtual-db.mjs')).href}?test=${crypto.randomUUID()}`); +} + afterEach(() => { for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); @@ -318,18 +346,14 @@ describe('drizzlePlugin db typing generation', () => { expect(second).not.toBe(first); expect(state.pools).toHaveLength(2); for (const pool of state.pools) { - // Nothing ends these MySQL pools, so mysql2 must close their idle connections. expect(pool.options).toMatchObject( dialect === 'postgresql' ? { connectionString: 'postgresql://hyperdrive', max: 1, idleTimeoutMillis: 1000 } - : { - uri: 'mysql://hyperdrive', - connectionLimit: 1, - maxIdle: 0, - idleTimeout: 1000, - disableEval: true, - }, + : { uri: 'mysql://hyperdrive', connectionLimit: 1, disableEval: true }, ); + // mysql2 would start a timer when it builds a pool with these. + expect(pool.options).not.toHaveProperty('maxIdle'); + expect(pool.options).not.toHaveProperty('idleTimeout'); } } finally { if (previousEnv === undefined) { @@ -509,7 +533,9 @@ describe('drizzlePlugin db typing generation', () => { if (dialect === 'postgresql') { expect(pool.options).toMatchObject({ max: 1, idleTimeoutMillis: 1000 }); } else { - expect(pool.options).toMatchObject({ connectionLimit: 1, maxIdle: 1, idleTimeout: 1000 }); + expect(pool.options).toMatchObject({ connectionLimit: 1, disableEval: true }); + expect(pool.options).not.toHaveProperty('maxIdle'); + expect(pool.options).not.toHaveProperty('idleTimeout'); } } // Every request shares one drizzle database, which carries the schema. @@ -601,6 +627,62 @@ export const requestPoolConfig = (env) => }, ); + it('closes idle MySQL connections without a timer when it builds a pool', async () => { + const server = await startFakeMySqlServer([{ one: 1 }]); + const url = `mysql://void@127.0.0.1:${server.port}/app`; + const { db, createDb, sql } = await loadMySqlDbModule(); + const previousEnv = (globalThis as Record).__env__; + /** Run queries, then expect their connection to close once idle for `idleTimeout`. */ + const expectClosedWhenIdle = async (queries: () => Promise, idleTimeout: number) => { + await queries(); + expect(server.openConnections()).toBe(1); + // The pool releases a connection before the query resolves. + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(idleTimeout - 1); + expect(server.openConnections()).toBe(1); + vi.advanceTimersByTime(1); + await vi.waitFor(() => expect(server.openConnections()).toBe(0)); + }; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + // workerd rejects a timer started outside a request, and a pool can be built there. + await withRuntimeEnv({ DATABASE_URL: url }, async () => { + void db.$client; + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(async () => { + await db.execute(sql`select 1`); + await db.execute(sql`select 1`); + }, 1000); + }); + + // A framework entry that Void does not wrap runs outside its request scope, where each + // use builds a pool. + (globalThis as Record).__env__ = { DATABASE_URL: url }; + void db.$client.config; + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(() => db.execute(sql`select 1`), 1000); + + const own = createDb(url); + try { + expect(vi.getTimerCount()).toBe(0); + await expectClosedWhenIdle(() => own.execute(sql`select 1`), 60_000); + // The pool connects again for the next query. + await own.execute(sql`select 1`); + expect(server.openConnections()).toBe(1); + } finally { + await new Promise((resolve) => own.$client.end(() => resolve())); + } + } finally { + vi.useRealTimers(); + if (previousEnv === undefined) { + delete (globalThis as Record).__env__; + } else { + (globalThis as Record).__env__ = previousEnv; + } + await server.close(); + } + }); + it.each([true, false])( 'gives drizzle a pg pool with the request options (schema: %s)', async (hasSchema) => { From f59bcc81c217007b6295853dbfac402d7be3f0e8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 03:44:37 +0800 Subject: [PATCH 12/17] fix(void): build SvelteKit pg/MySQL apps for workerd with a request scope A built SvelteKit app with a PostgreSQL or MySQL database failed every database request in workerd. Kit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs that output: the CommonJS requires of Node built-ins in pg and mysql2 became `createRequire(import.meta.url)`, which throws in workerd, and pg loaded pg-cloudflare's empty Node stub. When the driver resolves from the app root (npm, Yarn and Bun installs, or an app that lists pg or mysql2), Kit left it external, and Void's browser-target packaging bundled it with browser stubs instead. The built app also ran outside Void's request scope, so each query opened its own connection. For SvelteKit apps on PostgreSQL or MySQL, Void now builds that environment the way the Cloudflare plugin builds Workers: `workerd` conditions, every dependency bundled, and neutral output. A required Node built-in that workerd provides for the app's compatibility date and flags, as Cloudflare's own preset lists them, is served from a `node:` import. Any other require stays as it is and throws only if it runs, so a module that requires `tty` without calling it still loads. Each request runs in Void's request scope with Kit 2's `platform.env` or Kit 3's `cloudflare:workers` env; Kit's prerender in Node runs outside it. D1 apps keep Kit's output. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-framework.ts | 113 ++++++- .../void/src/sveltekit-hooks-transform.ts | 67 +++- .../unit/sveltekit-hooks-transform.test.ts | 77 ++++- .../test/unit/sveltekit-worker-build.test.ts | 314 ++++++++++++++++++ vite.config.ts | 1 + 6 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 packages/void/test/unit/sveltekit-worker-build.test.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index cfe418618..5cab43c4f 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -186,7 +186,7 @@ Agent instructions use versioned markers (`` / `> { + const vitePlugin = fileURLToPath(import.meta.resolve('@cloudflare/vite-plugin')); + const manifestPath = createRequire(vitePlugin).resolve('@cloudflare/unenv-preset/package.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + exports: { '.': { import: string } }; + }; + const { getCloudflarePreset } = (await import( + pathToFileURL(join(dirname(manifestPath), manifest.exports['.'].import)).href + )) as { + getCloudflarePreset(compat: { compatibilityDate: string; compatibilityFlags: Array }): { + external: Array; + }; + }; + const { external } = getCloudflarePreset({ + compatibilityDate: compat.compatibilityDate, + compatibilityFlags: compat.compatibilityFlags ?? [], + }); + return new Set([...external.map((name) => name.replace(/^node:/, '')), 'process']); +} + export function createFrameworkPlugins(input: { readonly localD1: ReturnType; readonly root: string; @@ -263,6 +296,79 @@ export default { } : undefined; + // SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs + // that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg + // loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become + // `createRequire(import.meta.url)`. A driver that Vite can resolve from the app root (npm, Yarn + // and Bun installs, or an app that lists it) even stays external, and Void's browser-target + // packaging bundles it with browser stubs. Build that environment as the Cloudflare plugin + // builds Workers, and run each request in Void's request scope, as in dev, so void/db keeps one + // client per request. D1 apps keep Kit's output: in a request scope, void/db reads D1 through a + // replica-capable session instead of the primary. + let workerdNodeBuiltins: Promise> | undefined; + const svelteKitWorkerBuildPlugin: Plugin | undefined = + detected.class === 'b' && dialect !== 'sqlite' + ? { + name: 'void:sveltekit-worker-build', + apply: 'build', + // Before Vite's resolver, which makes a required built-in external. + enforce: 'pre', + configEnvironment(name, environment) { + if (name !== 'ssr') { + return; + } + return { + resolve: { + conditions: environment.resolve?.conditions + ? ['workerd', 'worker'] + : ['workerd', 'worker', ...defaultServerConditions], + noExternal: true, + }, + // Node output binds `require` at load with createRequire(import.meta.url). Neutral + // output throws only when a require runs, as for mysql2's optional `cardinal`. + build: { rolldownOptions: { platform: 'neutral' } }, + }; + }, + // Serve each required Node built-in that workerd provides from an import, as the + // Cloudflare plugin does with rolldown's esmExternalRequirePlugin. That plugin works only + // with the rolldown copy running the build, which need not be Void's. A require of any + // other built-in stays as it is and throws only if it runs. + resolveId: { + filter: { id: NODE_BUILTIN }, + async handler(id, _importer, options) { + if (this.environment?.name !== 'ssr' || options.kind !== 'require-call') { + return; + } + const name = id.replace(/^node:/, ''); + workerdNodeBuiltins ??= readWorkerdNodeBuiltins(readWranglerCompat(root, config)); + if (!(await workerdNodeBuiltins).has(name)) { + return; + } + return `${NODE_BUILTIN_REQUIRE_PREFIX}${name}`; + }, + }, + load: { + // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention + filter: { id: /^\0void:node-require:/ }, + handler(id) { + // A built-in's default export is its CommonJS exports, and `require` returns an ES + // module's "module.exports" export. + const name = id.slice(NODE_BUILTIN_REQUIRE_PREFIX.length); + return `import builtin from "node:${name}";\nexport { builtin as "module.exports" };\n`; + }, + }, + transform(code, id) { + if ( + !id.includes('.svelte-kit/generated/server/internal') && + !id.includes('.svelte-kit/generated/build/server.js') + ) { + return; + } + return injectSvelteKitBuildHooks(code, id, envModuleFile); + }, + } + : undefined; + return [ devTriggerSecretGuardPlugin(), createCloudflareBuiltinsExternalPlugin(), @@ -271,6 +377,7 @@ export default { ...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []), ...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []), svelteKitHooksInjectPlugin, + ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, serverRuntimeOptimizerPlugin, bindingRuntimePlugin(bindingNames), diff --git a/packages/void/src/sveltekit-hooks-transform.ts b/packages/void/src/sveltekit-hooks-transform.ts index d6f8b81ae..7e5aed7a1 100644 --- a/packages/void/src/sveltekit-hooks-transform.ts +++ b/packages/void/src/sveltekit-hooks-transform.ts @@ -7,12 +7,60 @@ import { type AstProgram, } from './shared/ast.ts'; +type HooksTransform = { code: string; map: ReturnType }; + /** Add Void's dev bindings to SvelteKit's generated get_hooks function. */ export function injectSvelteKitHooks( code: string, id: string, dispatchPath: string, -): { code: string; map: ReturnType } | undefined { +): HooksTransform | undefined { + return editGetHooks( + code, + id, + `\n const { withRuntimeEnv } = await import("void/_env");\n const { handleDevTrigger } = await import(${JSON.stringify(dispatchPath)});`, + `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + + ` handle = async ({ event, resolve }) => {\n` + + ` const __voidEnv = event.platform?.env ?? (await import('cloudflare:workers')).env;\n` + + ` return withRuntimeEnv(__voidEnv, async () => {\n` + + ` const __triggerResp = await handleDevTrigger(event.request, __voidEnv, undefined);\n` + + ` if (__triggerResp) return __triggerResp;\n` + + ` return __origHandle({ event, resolve });\n` + + ` });\n };\n `, + ); +} + +/** + * Run each request of a SvelteKit build in Void's request scope. `envModule` is the env module + * void/db imports. Kit prerenders in Node, where `cloudflare:workers` does not load and Kit 3 + * passes no platform, so those requests run outside the scope. + */ +export function injectSvelteKitBuildHooks( + code: string, + id: string, + envModule: string, +): HooksTransform | undefined { + return editGetHooks( + code, + id, + `\n const { withRuntimeEnv } = await import(${JSON.stringify(envModule)});`, + `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + + ` handle = async ({ event, resolve }) => {\n` + + ` const __voidEnv = event.platform?.env ??\n` + + ` (typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'\n` + + ` ? (await import('cloudflare:workers')).env\n` + + ` : undefined);\n` + + ` return withRuntimeEnv(__voidEnv, () => __origHandle({ event, resolve }));\n` + + ` };\n `, + ); +} + +function editGetHooks( + code: string, + id: string, + prologue: string, + handleWrapper: string, +): HooksTransform | undefined { if (!code.includes('get_hooks')) { return undefined; } @@ -45,20 +93,7 @@ export function injectSvelteKitHooks( throw new Error('sveltekit: Expected one object return in generated get_hooks.'); } const ms = new MagicString(code); - ms.appendLeft( - body.start + 1, - `\n const { withRuntimeEnv } = await import("void/_env");\n const { handleDevTrigger } = await import(${JSON.stringify(dispatchPath)});`, - ); - ms.appendLeft( - returns[0].start, - `const __origHandle = handle ?? (({ event, resolve }) => resolve(event));\n` + - ` handle = async ({ event, resolve }) => {\n` + - ` const __voidEnv = event.platform?.env ?? (await import('cloudflare:workers')).env;\n` + - ` return withRuntimeEnv(__voidEnv, async () => {\n` + - ` const __triggerResp = await handleDevTrigger(event.request, __voidEnv, undefined);\n` + - ` if (__triggerResp) return __triggerResp;\n` + - ` return __origHandle({ event, resolve });\n` + - ` });\n };\n `, - ); + ms.appendLeft(body.start + 1, prologue); + ms.appendLeft(returns[0].start, handleWrapper); return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; } diff --git a/packages/void/test/unit/sveltekit-hooks-transform.test.ts b/packages/void/test/unit/sveltekit-hooks-transform.test.ts index d46f7743d..bbb950319 100644 --- a/packages/void/test/unit/sveltekit-hooks-transform.test.ts +++ b/packages/void/test/unit/sveltekit-hooks-transform.test.ts @@ -1,8 +1,11 @@ import { parseSync } from 'vite'; -import { describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getRawRuntimeEnv } from '../../src/runtime/env-raw.ts'; -import { withRuntimeEnv } from '../../src/runtime/env.ts'; -import { injectSvelteKitHooks } from '../../src/sveltekit-hooks-transform.ts'; +import { getRuntimeRequestResource, withRuntimeEnv } from '../../src/runtime/env.ts'; +import { + injectSvelteKitBuildHooks, + injectSvelteKitHooks, +} from '../../src/sveltekit-hooks-transform.ts'; describe('SvelteKit generated hook injection', () => { it('targets get_hooks even when another function returns an object first', () => { @@ -74,4 +77,72 @@ export async function get_hooks ( ) { expect(originalHandle).toHaveBeenCalledTimes(1); expect(load.mock.calls.some(([id]) => id === 'cloudflare:workers')).toBe(major === 3); }); + + describe('in a production build', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ['Kit 2 in workerd', { platform: true, workerd: true, scoped: true }], + ['Kit 3 in workerd', { platform: false, workerd: true, scoped: true }], + // Kit prerenders in Node, which cannot load cloudflare:workers. + ['Kit 3 prerendering in Node', { platform: false, workerd: false, scoped: false }], + ])('runs requests of %s in the request scope it can reach', async (_name, kit) => { + if (kit.workerd) { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + } + const env = { name: 'bindings' }; + // void/db keeps its pool in a request resource like this one. + const resource = {}; + const requestResource = () => + getRuntimeRequestResource( + resource, + () => ({}), + () => null, + ); + const appHandle = vi.fn(() => { + const first = requestResource(); + return Response.json({ scoped: first !== null && first === requestResource() }); + }); + const load = vi.fn(async (id: string) => { + if (id === '/void/env.mjs') { + return { withRuntimeEnv }; + } + if (id === 'cloudflare:workers') { + return { env }; + } + throw new Error(`Unexpected import: ${id}`); + }); + const source = `export async function get_hooks() { + let handle = originalHandle; + return { handle }; + }`; + const result = injectSvelteKitBuildHooks(source, 'server.js', '/void/env.mjs')!; + expect(() => parseSync('server.js', result.code)).not.toThrow(); + const getHooks = new Function( + 'load', + 'originalHandle', + `${result.code.replace(/^export /gm, '').replaceAll('import(', 'load(')}; return get_hooks;`, + )(load, appHandle) as () => Promise<{ + handle: (options: { + event: { request: Request; platform?: { env: typeof env } }; + }) => Promise; + }>; + const { handle } = await getHooks(); + const response = await handle({ + event: { + request: new Request('https://example.com/__void/scheduled'), + ...(kit.platform ? { platform: { env } } : {}), + }, + }); + + // A build has no dev triggers: every request reaches the app's handle. + expect(appHandle).toHaveBeenCalledTimes(1); + expect(await response.json()).toEqual({ scoped: kit.scoped }); + expect(load.mock.calls.some(([id]) => id === 'cloudflare:workers')).toBe( + kit.workerd && !kit.platform, + ); + }); + }); }); diff --git a/packages/void/test/unit/sveltekit-worker-build.test.ts b/packages/void/test/unit/sveltekit-worker-build.test.ts new file mode 100644 index 000000000..eed828d29 --- /dev/null +++ b/packages/void/test/unit/sveltekit-worker-build.test.ts @@ -0,0 +1,314 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { getWorkerBundleDir, readBuildOutput } from '@cloudflare/build-output-utils'; +import { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; +import { build } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { prepareCfFrameworkBuildOutput } from '../../src/cli/cf-build-output.ts'; +import { bundleEntry } from '../../src/cli/wrapper.ts'; +import { voidPlugin } from '../../src/index.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +// Each case runs two Vite builds and starts workerd. +const TIMEOUT_MS = (process.platform === 'win32' ? 3 : 1) * 60_000; +const COMPATIBILITY_DATE = '2026-02-24'; + +const roots: Array = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +type KitApp = { root: string; compatibilityDate: string }; + +/** + * A SvelteKit server build reduced to what Void sees: Kit's generated get_hooks module, a route + * that queries void/db, and an entry that calls `handle` as the adapter's Worker does. + */ +async function buildKitServer( + database: 'pg' | 'mysql', + { + hoisted = false, + compatibilityDate = COMPATIBILITY_DATE, + kit = 3, + }: { hoisted?: boolean; compatibilityDate?: string; kit?: 2 | 3 } = {}, +): Promise { + // Windows runners report a short-name temp path, which Vite refuses to serve. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-kit-worker-'))); + roots.push(root); + const write = (path: string, content: string) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), content); + }; + write('package.json', JSON.stringify({ type: 'module', dependencies: { '@sveltejs/kit': '*' } })); + if (hoisted) { + // npm, Yarn and Bun install void's drivers next to the app, as pnpm does when the app lists + // them, so Vite can resolve them from the app root. + const voidRequire = createRequire(new URL('../../package.json', import.meta.url)); + mkdirSync(join(root, 'node_modules')); + for (const name of ['pg', 'mysql2']) { + symlinkSync( + dirname(voidRequire.resolve(`${name}/package.json`)), + join(root, 'node_modules', name), + 'junction', + ); + } + } + write( + 'void.json', + JSON.stringify({ + database, + worker: { compatibility_date: compatibilityDate, compatibility_flags: ['nodejs_compat'] }, + }), + ); + write('db/schema.ts', 'export {};\n'); + // Kit 2 writes get_hooks to server/internal.js, Kit 3 to build/server.js. + const hooksModule = + kit === 2 + ? '.svelte-kit/generated/server/internal.js' + : '.svelte-kit/generated/build/server.js'; + write( + hooksModule, + 'export async function get_hooks() {\n let handle;\n return { handle };\n}\n', + ); + // workerd has no node:tty at this compatibility date, and a Worker never calls this. + write('src/terminal.cjs', "exports.isTerminal = () => require('tty').isatty(1);\n"); + write( + 'src/route.js', + `import { db, sql } from 'void/db'; +import { isTerminal } from './terminal.cjs'; + +export async function GET(request) { + if (new URL(request.url).searchParams.has('terminal')) { + return Response.json({ terminal: isTerminal() }); + } + const result = await db.execute(sql\`select 1 as one\`); + // Outside Void's request scope, each use of db builds its own pool. + const pool = () => db.$client.options ?? db.$client.config; + return Response.json({ + rows: Array.isArray(result) ? result[0] : result.rows, + samePool: pool() === pool(), + }); +} +`, + ); + write( + 'src/entry.js', + `import { get_hooks } from '../${hooksModule}'; + +export default { + async fetch(request, env) { + const { handle = ({ event, resolve }) => resolve(event) } = await get_hooks(); + // Kit 2's adapter passes the bindings as platform.env. Kit 3 passes no platform. + const platform = new URL(request.url).searchParams.has('platform') ? { env } : undefined; + return handle({ + event: { request, platform }, + resolve: async () => (await import('./route.js')).GET(request), + }); + }, +}; +`, + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + build: { ssr: 'src/entry.js', outDir: 'build/server', emptyOutDir: true }, + }); + return { root, compatibilityDate }; +} + +/** Package the server build as the given deploy target does; return its modules. */ +async function packageWorker( + { root, compatibilityDate }: KitApp, + target: 'hosted' | 'native', +): Promise<{ directory: string; mainModule: string; dispose(): Promise }> { + const entry = join(root, 'build/server/entry.js'); + if (target === 'hosted') { + const { outputDir, entryFilename } = await bundleEntry(root, entry); + return { directory: outputDir, mainModule: entryFilename, async dispose() {} }; + } + mkdirSync(join(root, 'public')); + writeFileSync( + join(root, 'wrangler.json'), + JSON.stringify({ + name: 'kit-worker', + main: 'build/server/entry.js', + compatibility_date: compatibilityDate, + assets: { directory: 'public' }, + }), + ); + const prepared = await prepareCfFrameworkBuildOutput({ + projectRoot: root, + configPath: join(root, 'wrangler.json'), + accountId: 'account-id', + }); + if (!prepared) { + throw new Error('Expected native build output.'); + } + const built = await readBuildOutput(prepared.directory); + return { + directory: getWorkerBundleDir(prepared.directory), + mainModule: built.workers.default.config.manifest!.mainModule, + dispose: () => prepared.dispose(), + }; +} + +/** Serve the packaged Worker in workerd and request the route as Kit 3 and as Kit 2. */ +async function fetchRoute( + worker: { directory: string; mainModule: string }, + compatibilityDate: string, + connectionString: string, +): Promise> { + const modules = readdirSync(worker.directory, { recursive: true }) + .map(String) + .filter((file) => /\.m?js$/.test(file)) + .sort((a, b) => Number(b === worker.mainModule) - Number(a === worker.mainModule)) + .map((file) => ({ type: 'ESModule' as const, path: join(worker.directory, file) })); + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: worker.directory, + modules, + compatibilityDate, + compatibilityFlags: ['nodejs_compat'], + bindings: { HYPERDRIVE: { connectionString } }, + }), + ); + try { + const bodies: Array = []; + for (const path of ['/', '/?platform']) { + const response = await miniflare.dispatchFetch(`http://localhost${path}`); + const body = await response.text(); + expect(response.status, body).toBe(200); + bodies.push(JSON.parse(body)); + } + return bodies; + } finally { + await miniflare.dispose(); + } +} + +describe.each([ + ['only under void', false], + ['next to the app', true], +])('SvelteKit production builds with a Hyperdrive driver installed %s', (_layout, hoisted) => { + it.each(['hosted', 'native'] as const)( + 'serves PostgreSQL queries from the %s Worker in one pool per request', + async (target) => { + const app = await buildKitServer('pg', { hoisted }); + const worker = await packageWorker(app, target); + const pglite = new PGlite(); + // pglite-server does not wait for PGlite: a query that arrives during startup spins forever. + await pglite.waitReady; + const server = createPgServer(pglite); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `postgresql://postgres@127.0.0.1:${port}/postgres`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + server.close(); + await pglite.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, + ); + + it.each(['hosted', 'native'] as const)( + 'serves MySQL queries from the %s Worker in one pool per request', + async (target) => { + const app = await buildKitServer('mysql', { hoisted }); + const worker = await packageWorker(app, target); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, + ); +}); + +it( + "serves MySQL queries in one pool per request through Kit 2's generated hooks module", + async () => { + const app = await buildKitServer('mysql', { kit: 2 }); + const worker = await packageWorker(app, 'hosted'); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const [, withPlatform] = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(withPlatform).toEqual({ rows: [{ one: 1 }], samePool: true }); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, +); + +it( + 'serves MySQL queries from a Worker whose compatibility date predates the native node:process', + async () => { + // The Cloudflare preset lists node:process only from 2025-09-15, and mysql2 requires it + // when it loads. + const app = await buildKitServer('mysql', { compatibilityDate: '2025-06-01' }); + const worker = await packageWorker(app, 'hosted'); + const mysql = await startFakeMySqlServer([{ one: 1 }]); + try { + const bodies = await fetchRoute( + worker, + app.compatibilityDate, + `mysql://void@127.0.0.1:${mysql.port}/app`, + ); + expect(bodies).toEqual([ + { rows: [{ one: 1 }], samePool: true }, + { rows: [{ one: 1 }], samePool: true }, + ]); + } finally { + await mysql.close(); + await worker.dispose(); + } + }, + TIMEOUT_MS, +); diff --git a/vite.config.ts b/vite.config.ts index 08f6a971c..2eba2563a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,6 +28,7 @@ const sdkPgliteUnitTests = [ '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', + 'packages/void/test/unit/sveltekit-worker-build.test.ts', 'packages/void/test/unit/wrapper.test.ts', ]; From c75284a7d9e2d78891b4aaf9a83e90093d7acf02 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 04:16:58 +0800 Subject: [PATCH 13/17] fix(void): give Nuxt, Analog and deploy cron bundles the app's void/db Nitro builds a Nuxt or Analog server without Void's Vite plugins, so every server route got the static D1 client from `void/db`. A PostgreSQL or MySQL app failed each database request with a missing `DB` binding, and `db.query` was undefined even on D1 because the client had no schema. The deploy build of hosted framework cron and queue handlers had the same gap: it built `void/db` as the D1 client and could not resolve `@schema`. Nuxt and Analog apps now add `void/nitro` to their Nitro modules. The module and the cron/queue handler build share the plugin that serves the app's own `void/db`, with its dialect, schema and driver, and resolve `@schema` through the bundler so a schema folder works. Their Worker builds import a workerd build of pg or mysql2, made the same way as the migration handler, because Nitro's CommonJS handling and Node stubs break both drivers in workerd. The pg build loads no `node:fs`, which workerd provides only from the 2025-09-15 compatibility date. For PostgreSQL and MySQL the module also runs each request in Void's request scope and keeps Void's `node:async_hooks` import native, so a request reuses one pool across its queries instead of opening a connection per query. The cron/queue handler build does the same for each cron event and queue batch, as Void apps do. D1 apps keep their current request and event handling. A PostgreSQL or MySQL app with no schema file now gets its dialect's `void/db` in Vite builds too. An app whose bundle still loads the D1 client while it has HYPERDRIVE or DATABASE_URL gets an error that says to add `void/nitro`. `void init` prints the module step for Nuxt and Analog. Nuxt apps keep the Nitro `@schema` alias, because Nuxt types server routes from Nitro's aliases. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/database.md | 4 - docs/integrations/frameworks/analog.md | 14 +- docs/integrations/frameworks/nuxt.md | 10 +- docs/reference/api.md | 1 + packages/void/AGENTS.md | 2 +- packages/void/package.json | 5 + packages/void/src/cli/init.ts | 20 +- packages/void/src/cli/wrapper.ts | 159 +++++++++-- packages/void/src/nitro.ts | 104 +++++++ packages/void/src/plugin-drizzle.ts | 264 ++++++++++++------ packages/void/src/runtime/db.ts | 20 +- .../void/test/helpers/fake-mysql-server.ts | 5 + packages/void/test/integration/analog.test.ts | 6 + packages/void/test/integration/nuxt.test.ts | 6 + packages/void/test/unit/init.test.ts | 36 +++ packages/void/test/unit/nitro.test.ts | 168 +++++++++++ .../void/test/unit/plugin-drizzle.test.ts | 17 ++ packages/void/test/unit/wrapper.test.ts | 209 +++++++++++++- packages/void/vite.config.ts | 1 + playground/analog/package.json | 1 - .../analog/src/server/routes/api/smoke.ts | 4 +- playground/analog/vite.config.ts | 8 +- playground/nuxt/nuxt.config.ts | 4 +- playground/nuxt/server/api/smoke.get.ts | 4 +- pnpm-lock.yaml | 3 - 25 files changed, 922 insertions(+), 153 deletions(-) create mode 100644 packages/void/src/nitro.ts create mode 100644 packages/void/test/unit/nitro.test.ts diff --git a/docs/guide/database.md b/docs/guide/database.md index 2e94f11f0..cd51b5fcb 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -234,10 +234,6 @@ const usersWithPosts = await db.query.users.findMany({ }); ``` -::: warning ⚠️ Nuxt and Analog limitations -In Nuxt and Analog, use the query builder (`db.select().from(table)`). The `db.query.*` relational API is not available in these frameworks. -::: - ## Seeding Use `void db seed` to reset your local database, re-apply migrations, and then run a seed file. diff --git a/docs/integrations/frameworks/analog.md b/docs/integrations/frameworks/analog.md index 742a9398e..1ee8dfa39 100644 --- a/docs/integrations/frameworks/analog.md +++ b/docs/integrations/frameworks/analog.md @@ -26,7 +26,6 @@ Void provides the local Cloudflare binding runtime. `nitro-cloudflare-dev` creat ### 3. Configure `vite.config.ts` ```ts -import { resolve } from 'node:path'; import analog from '@analogjs/platform'; import { defineConfig } from 'vite'; import { voidPlugin } from 'void'; @@ -41,12 +40,7 @@ export default defineConfig({ nitro: { preset: 'cloudflare-module', cloudflareDev: { configPath: './.void-wrangler.jsonc' }, - modules: ['nitro-cloudflare-dev'], - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(__dirname, 'db/schema.ts'), - }, + modules: ['nitro-cloudflare-dev', 'void/nitro'], }, }), voidPlugin(), @@ -54,6 +48,8 @@ export default defineConfig({ }); ``` +The `void/nitro` module gives Analog API routes and page server loads your app's `void/db` and `@schema`. + ### 4. Create `void.config.ts` Void generates the Cloudflare config used by Analog's development runtime: @@ -98,10 +94,6 @@ export const load = async ({ event }: PageServerLoad) => { }; ``` -::: warning ⚠️ Analog limitation -Use `db.select().from(table)` for database queries in Analog. The `db.query.*` relational API is not available. -::: - ### Other resources Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`. diff --git a/docs/integrations/frameworks/nuxt.md b/docs/integrations/frameworks/nuxt.md index ba8641af5..1248c707f 100644 --- a/docs/integrations/frameworks/nuxt.md +++ b/docs/integrations/frameworks/nuxt.md @@ -32,14 +32,14 @@ import { voidPlugin } from 'void'; export default defineNuxtConfig({ nitro: { preset: 'cloudflare-module', + modules: ['void/nitro'], cloudflareDev: { configPath: './.void-wrangler.jsonc' }, // Required for Nuxt 4.6.0 on Windows. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. + // Nuxt types server routes from Nitro's aliases. '@schema': resolve(import.meta.dirname, 'db/schema.ts'), }, }, @@ -47,6 +47,8 @@ export default defineNuxtConfig({ }); ``` +The `void/nitro` module gives Nuxt server routes your app's `void/db` and `@schema`. + ### 4. Create `void.config.ts` Void generates the Cloudflare config used by Nuxt's development runtime: @@ -88,10 +90,6 @@ export default defineEventHandler(async () => { }); ``` -::: warning ⚠️ Nuxt limitation -Use `db.select().from(table)` for database queries in Nuxt. The `db.query.*` relational API is not available. -::: - ### Other resources Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`. diff --git a/docs/reference/api.md b/docs/reference/api.md index dd56075a5..a3899c162 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -68,4 +68,5 @@ Import Void's app-facing APIs from these paths: | `void/queues` | `queues` proxy for sending messages to typed queues | | `void/sandbox` | `getSandbox`, `sandbox`, `SandboxLimitError`, `SandboxUnavailableError`, and the SDK 1.0 file, backup, and mount helpers. `getSandbox(id)` resolves a client. Commands, files, port requests, and process completion return promises; limits reject with `SandboxLimitError`, which extends `UsageLimit`. Container state lasts until inactivity (default ten minutes), a crash, or a restart. See [Sandboxes](../guide/sandboxes.md) for the API and deployment requirements. | | `void/env` | `defineEnv`, `env`, schema helpers (`string`, `number`, `boolean`, `url`, `email`, `oneOf`, `json`), and global Cloudflare env types | +| `void/nitro` | Nitro module for Nuxt and Analog. Add `'void/nitro'` to the Nitro `modules` so server routes get the app's `void/db` and `@schema` | | `void/sveltekit` | SvelteKit integration helpers: `withVoidTSConfig()` and `mergeVoidSvelteKitTsconfig()` | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 5cab43c4f..de1d271f4 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -102,7 +102,7 @@ generated entries must preserve the module-boundary rules below. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. Bundles that never run Void's Vite plugins, Nitro (through `void/nitro`) and the deploy cron/queue handler build, get the same module from `voidDbBundlerPlugin`, which resolves `@schema` through the bundler so a schema folder works. Their Worker builds import the driver that `bundleWorkerSqlDriver` builds like the migration handler: Nitro's CommonJS handling and Node stubs break pg and mysql2 in workerd. For pg and MySQL, `void/nitro` also runs each request in `withRuntimeEnv` (as the Class A wrapper does) and keeps Void's `node:async_hooks` native in workerd builds, because Nitro's unenv AsyncLocalStorage drops the store after the first await. The deploy handler build likewise runs each cron event and queue batch in `withRuntimeEnv`. - **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. diff --git a/packages/void/package.json b/packages/void/package.json index e37444d10..97aa52a04 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -90,6 +90,11 @@ "./routes": { "types": "./dist/routes-stub.d.mts" }, + "./nitro": { + "types": "./dist/nitro.d.mts", + "import": "./dist/nitro.mjs", + "require": "./dist/nitro.mjs" + }, "./sveltekit": { "types": "./dist/sveltekit.d.mts", "import": "./dist/sveltekit.mjs", diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 56c638545..9a890120c 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2373,9 +2373,10 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { if (framework.name === 'nuxt') { note( `Detected Nuxt. Void init does not patch nuxt.config.* automatically.\n\n` + - `Add \`voidPlugin()\` to the Nuxt Vite plugins option:\n\n` + + `Add \`voidPlugin()\` to the Nuxt Vite plugins option and \`void/nitro\` to the Nitro modules:\n\n` + `import { voidPlugin } from "void";\n\n` + `export default defineNuxtConfig({\n` + + ` nitro: { modules: ["void/nitro"] },\n` + ` vite: { plugins: [voidPlugin()] },\n` + `});`, 'Manual Nuxt config step', @@ -2383,6 +2384,23 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { return; } + if (framework.name === 'analog') { + note( + `Detected Analog. Void init does not patch vite.config.* automatically.\n\n` + + `Add \`voidPlugin()\` to the Vite plugins and \`void/nitro\` to Analog's Nitro modules:\n\n` + + `import analog from "@analogjs/platform";\n` + + `import { voidPlugin } from "void";\n\n` + + `export default defineConfig({\n` + + ` plugins: [\n` + + ` analog({ nitro: { modules: ["nitro-cloudflare-dev", "void/nitro"] } }),\n` + + ` voidPlugin(),\n` + + ` ],\n` + + `});`, + 'Manual Analog config step', + ); + return; + } + noteManualViteConfigSetup(); } diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 6c4e1bbd3..a670ac6bb 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -10,9 +10,11 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import { dirname, join, extname } from 'pathslash'; +import { dirname, join, extname, toSlash } from 'pathslash'; import { build } from 'vite'; +import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; +import { voidDbBundlerPlugin } from '../plugin-drizzle.ts'; import { readProjectPaths } from '../project-paths.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; import { transformWorkerRequireBundle } from './worker-require-transform.ts'; @@ -45,6 +47,8 @@ export type WrapperResult = { }; const BUNDLED_ENTRY_RE = /^index\.(?:m?js|cjs)$/; +// The env module that the app's `void/db` imports, so the jobs' request scope is the one it reads. +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); const WRAPPER_MARKER = '// Generated by Void: deployment wrapper.'; function isGeneratedWrapper(source: string, originalName: string): boolean { @@ -113,7 +117,8 @@ export async function generateWrapper( if (features.crons.length > 0 || features.queues.length > 0) { const tmpDir = join(root, '.void', 'handlers'); mkdirSync(tmpDir, { recursive: true }); - const handlersSource = generateHandlersSource(root, features); + const config = readConfig(root); + const handlersSource = generateHandlersSource(root, features, getDatabaseDialect(config)); const handlersEntry = join(tmpDir, 'handlers.ts'); writeFileSync(handlersEntry, handlersSource); @@ -121,6 +126,14 @@ export async function generateWrapper( await build({ root: tmpDir, logLevel: 'silent', + // This focused build does not run voidPlugin(), so give it the app's `void/db` and + // `@schema`. A PostgreSQL or MySQL driver comes prebuilt for workerd. + plugins: [ + voidDbBundlerPlugin(root, { + config, + sqlDriver: (dialect) => bundleWorkerSqlDriver(dialect, join(tmpDir, 'sql-driver')), + }), + ], resolve: { // Cron/queue scaffolds intentionally import their lightweight runtime helpers from the // public `void` entry. This focused build does not run voidPlugin(), so reproduce its @@ -263,24 +276,14 @@ async function bundleMigrationHandler( root: tmpDir, logLevel: 'silent', resolve: isPostgresql - ? { - alias: { - 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(handlerPath), - 'pg-pool': resolvePgCjsDependency(handlerPath, 'pg-pool'), - }, - conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], - } + ? { alias: pgWorkerAliases(handlerPath), conditions: WORKER_CONDITIONS } : undefined, // A build decides what is external by resolving from `root`, which sits in the app. npm, // Yarn and Bun hoist dependencies there, and workerd cannot load a bare import, so bundle // every package. Node built-ins stay external for the require shims below. ssr: { noExternal: true, - ...(isPostgresql && { - resolve: { - conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'], - }, - }), + ...(isPostgresql && { resolve: { conditions: WORKER_CONDITIONS } }), }, build: { outDir, @@ -290,7 +293,7 @@ async function bundleMigrationHandler( target: 'esnext', rollupOptions: { input: handlerPath, - external: [/^cloudflare:/, /^node:/, /^pgpass$/], + external: SQL_WORKER_EXTERNAL, output: { entryFileNames: '__void_migration_handler.mjs', chunkFileNames: '__void_migration_handler-[hash].mjs', @@ -300,14 +303,85 @@ async function bundleMigrationHandler( }); if (isPostgresql || isMysql) { - patchWorkerRequireBundle(outDir, isPostgresql ? 'PostgreSQL' : 'MySQL'); + patchWorkerRequireBundle(outDir, isPostgresql ? 'PostgreSQL' : 'MySQL', 'migration bundle'); } for (const file of readdirSync(outDir)) { copyFileSync(join(outDir, file), join(workerDir, file)); } } -function patchWorkerRequireBundle(outDir: string, database: 'PostgreSQL' | 'MySQL'): void { +// workerd's conditions, then a browser build's, so packages pick their Worker builds. +const WORKER_CONDITIONS = ['workerd', 'worker', 'browser', 'module', 'import', 'default']; +const SQL_WORKER_EXTERNAL = [/^cloudflare:/, /^node:/, /^pgpass$/]; + +/** pg's workerd socket and its CommonJS pool, resolved from the pg that `fromPath` loads. */ +function pgWorkerAliases(fromPath: string): Record { + return { + 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(fromPath), + 'pg-pool': resolvePgCjsDependency(fromPath, 'pg-pool'), + }; +} + +/** + * Build Void's PostgreSQL or MySQL driver as one ES module for workerd, the way the migration + * handler bundles it, and return its path. Bundlers that do not run voidPlugin() import this + * module for `pg` or `mysql2`: their CommonJS handling breaks the drivers in workerd. + */ +export async function bundleWorkerSqlDriver( + dialect: 'postgresql' | 'mysql', + outDir: string, +): Promise { + const packageName = dialect === 'postgresql' ? 'pg' : 'mysql2'; + const require = createRequire(import.meta.url); + const main = require.resolve(packageName); + const names = Object.keys(require(packageName) as object).filter( + (name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name), + ); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, 'entry.mjs'); + writeFileSync( + entry, + `import driver from ${JSON.stringify(toSlash(main))};\nexport default driver;\nexport const { ${names.join(', ')} } = driver;\n`, + ); + const bundleDir = join(outDir, 'out'); + await build({ + root: outDir, + logLevel: 'silent', + resolve: { + alias: dialect === 'postgresql' ? pgWorkerAliases(main) : {}, + conditions: WORKER_CONDITIONS, + }, + ssr: { noExternal: true, resolve: { conditions: WORKER_CONDITIONS } }, + build: { + outDir: bundleDir, + emptyOutDir: true, + minify: false, + ssr: entry, + target: 'esnext', + rollupOptions: { + input: entry, + external: SQL_WORKER_EXTERNAL, + output: { + entryFileNames: `${packageName}.mjs`, + chunkFileNames: `${packageName}-[hash].mjs`, + }, + }, + }, + }); + patchWorkerRequireBundle( + bundleDir, + dialect === 'postgresql' ? 'PostgreSQL' : 'MySQL', + 'driver bundle', + ); + return join(bundleDir, `${packageName}.mjs`); +} + +/** `bundle` names the bundle in the error a failed require throws. */ +function patchWorkerRequireBundle( + outDir: string, + database: 'PostgreSQL' | 'MySQL', + bundle: string, +): void { for (const file of readdirSync(outDir)) { if (!file.endsWith('.mjs') && !file.endsWith('.js')) { continue; @@ -317,7 +391,7 @@ function patchWorkerRequireBundle(outDir: string, database: 'PostgreSQL' | 'MySQ const transformed = transformWorkerRequireBundle( code, file, - database === 'PostgreSQL' ? pgWorkerRequireShim() : mysqlWorkerRequireShim(), + database === 'PostgreSQL' ? pgWorkerRequireShim(bundle) : mysqlWorkerRequireShim(bundle), database, ); if (transformed !== code) { @@ -340,12 +414,14 @@ function resolvePgCloudflareWorkerdEntry(fromPath: string): string { return join(dirname(packageJsonPath), 'dist', 'index.js'); } -function pgWorkerRequireShim(): string { +// pg requires `fs` only to read sslcert/sslkey/sslrootcert files, which a Worker cannot read. +// workerd has node:fs only from compatibility date 2025-09-15, and this module loads at Worker +// start, so that require stays unsupported and throws only if it runs. +function pgWorkerRequireShim(bundle: string): string { return [ 'import * as __voidNodeCrypto from "node:crypto";', 'import * as __voidNodeDns from "node:dns";', 'import * as __voidNodeEvents from "node:events";', - 'import * as __voidNodeFs from "node:fs";', 'import * as __voidNodeNet from "node:net";', 'import * as __voidNodeTls from "node:tls";', 'import * as __voidNodeUtil from "node:util";', @@ -354,7 +430,6 @@ function pgWorkerRequireShim(): string { ' crypto: __voidNodeCrypto,', ' dns: __voidNodeDns,', ' events: __voidNodeEvents,', - ' fs: __voidNodeFs,', ' net: __voidNodeNet,', ' tls: __voidNodeTls,', ' util: __voidNodeUtil,', @@ -364,7 +439,7 @@ function pgWorkerRequireShim(): string { ' if (id.startsWith("node:")) id = id.slice(5);', ' const mod = __voidNodeRequireModules[id];', ' if (mod) return mod;', - ' throw new Error(`Unsupported require("${id}") in Void PG migration bundle`);', + ` throw new Error(\`Unsupported require("\${id}") in Void PG ${bundle}\`);`, '};', ].join('\n'); } @@ -373,7 +448,7 @@ function pgWorkerRequireShim(): string { // module's default export, which matches CommonJS `module.exports`: safer-buffer calls // `hasOwnProperty` on `require("buffer")`, and an ESM namespace object has no prototype. // mysql2 wraps its optional `cardinal` require in try/catch, so it stays unsupported. -function mysqlWorkerRequireShim(): string { +function mysqlWorkerRequireShim(bundle: string): string { return [ 'import __voidNodeBuffer from "node:buffer";', 'import __voidNodeCrypto from "node:crypto";', @@ -407,7 +482,7 @@ function mysqlWorkerRequireShim(): string { ' if (id.startsWith("node:")) id = id.slice(5);', ' const mod = __voidNodeRequireModules[id];', ' if (mod) return mod;', - ' throw new Error(`Unsupported require("${id}") in Void MySQL migration bundle`);', + ` throw new Error(\`Unsupported require("\${id}") in Void MySQL ${bundle}\`);`, '};', ].join('\n'); } @@ -668,9 +743,21 @@ function generateWrapperSource( return lines.join('\n'); } -function generateHandlersSource(root: string, features: WrapperFeatures): string { +function generateHandlersSource( + root: string, + features: WrapperFeatures, + dialect: DatabaseDialect, +): string { const lines: Array = []; const paths = readProjectPaths(root); + // PostgreSQL and MySQL jobs run each event in Void's request scope, as a Void app's do, so + // void/db keeps one pool for the event instead of opening one for each query. D1 jobs keep + // their reads on the primary: in a request scope, void/db reads D1 through a replica-capable + // session. + const scoped = dialect !== 'sqlite'; + if (scoped) { + lines.push(`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`); + } // Scheduled handlers if (features.crons.length > 0) { @@ -680,7 +767,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string } lines.push(''); - lines.push('export async function handleScheduled(controller, env, ctx) {'); + lines.push( + scoped + ? 'async function __runScheduled(controller, env, ctx) {' + : 'export async function handleScheduled(controller, env, ctx) {', + ); lines.push(' const tasks = [];'); for (let i = 0; i < features.crons.length; i++) { lines.push(` if (${JSON.stringify(features.crons[i].crons)}.includes(controller.cron)) {`); @@ -701,6 +792,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string ); lines.push(' if (errors.length) throw new AggregateError(errors, "Scheduled jobs failed");'); lines.push('}'); + if (scoped) { + lines.push('export function handleScheduled(controller, env, ctx) {'); + lines.push(' return withRuntimeEnv(env, () => __runScheduled(controller, env, ctx));'); + lines.push('}'); + } } // Queue handlers @@ -714,7 +810,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string lines.push(''); lines.push(generateQueueResolverSource(features.queues.map((queue) => queue.name))); lines.push(''); - lines.push('export async function handleQueue(batch, env, ctx) {'); + lines.push( + scoped + ? 'async function __runQueue(batch, env, ctx) {' + : 'export async function handleQueue(batch, env, ctx) {', + ); lines.push(' const queueSuffix = batch.queue;'); lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);'); lines.push(' if (__queueIndex === -1) {'); @@ -737,6 +837,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string } lines.push(' }'); lines.push('}'); + if (scoped) { + lines.push('export function handleQueue(batch, env, ctx) {'); + lines.push(' return withRuntimeEnv(env, () => __runQueue(batch, env, ctx));'); + lines.push('}'); + } lines.push(` export async function handleQueueRelay(payload, env, ctx) { if (!payload || typeof payload.queue !== "string" || !Array.isArray(payload.messages) || diff --git a/packages/void/src/nitro.ts b/packages/void/src/nitro.ts new file mode 100644 index 000000000..f42e2de3f --- /dev/null +++ b/packages/void/src/nitro.ts @@ -0,0 +1,104 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'pathslash'; +import { normalizePath, type Plugin } from 'vite'; +import { bundleWorkerSqlDriver } from './cli/wrapper.ts'; +import { getDatabaseDialect, readConfig } from './config.ts'; +import { voidDbBundlerPlugin } from './plugin-drizzle.ts'; + +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// The package's built modules, where the env runtime and its AsyncLocalStorage live. +const packageDistDir = `${normalizePath(dirname(dirname(envModuleFile)))}/`; +const REQUEST_SCOPE_PLUGIN = '#void/nitro-request-scope'; + +/** The parts of a Nitro instance that the module uses. */ +type NitroInstance = { + options: { + dev: boolean; + rootDir: string; + buildDir: string; + exportConditions?: Array; + plugins: Array; + virtual: Record string | Promise)>; + rollupConfig?: { plugins?: unknown }; + }; +}; + +/** + * A Nitro runtime plugin that runs each request in Void's request scope, as the Class A wrapper + * does, so void/db keeps one PostgreSQL or MySQL client per request instead of one per query. + */ +function requestScopePluginSource(): string { + return `import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)}; + +export default function voidRequestScope(nitroApp) { + const handler = nitroApp.h3App.handler; + // Nitro's Cloudflare entry and dev plugins keep the env here; a dev plugin may first set a + // promise of it. + nitroApp.h3App.handler = async (event) => + withRuntimeEnv(await globalThis.__env__, () => handler(event)); +} +`; +} + +/** + * Keep Void's `node:async_hooks` imports for workerd's own module. Without nodejs_compat in a + * Wrangler config that it reads, Nitro bundles a stub whose AsyncLocalStorage loses the request + * scope at the first await. + */ +const nativeAsyncHooksPlugin: Plugin = { + name: 'void:nitro-async-hooks', + resolveId(id, importer) { + if ( + id === 'node:async_hooks' && + importer !== undefined && + normalizePath(importer).startsWith(packageDistDir) + ) { + return { id, external: true }; + } + return null; + }, +}; + +/** + * Nitro module for apps whose server routes Nitro bundles (Nuxt and Analog). Nitro's bundler + * does not run Vite plugins, so without this module `void/db` in a server route, page server + * load or cron job is the package's D1 client without the app's schema. Register it in the + * Nitro config: `modules: ['void/nitro']`. + */ +const voidNitroModule = { + name: 'void', + setup(nitro: NitroInstance): void { + const { options } = nitro; + const config = readConfig(options.rootDir); + const dialect = getDatabaseDialect(config); + // Development runs Nitro in Node, which loads the drivers itself. + const workerd = !options.dev && options.exportConditions?.includes('workerd') === true; + const plugins: Array = [ + voidDbBundlerPlugin(options.rootDir, { + config, + ...(workerd && { + sqlDriver: (sqlDialect) => + bundleWorkerSqlDriver(sqlDialect, join(options.buildDir, 'void')), + }), + }), + ]; + // D1 apps keep Nitro's requests as they are: in a request scope, void/db reads D1 through + // a replica-capable session instead of the primary. + if (dialect !== 'sqlite') { + options.virtual[REQUEST_SCOPE_PLUGIN] = requestScopePluginSource(); + options.plugins.push(REQUEST_SCOPE_PLUGIN); + if (workerd) { + plugins.push(nativeAsyncHooksPlugin); + } + } + const rollupConfig = (options.rollupConfig ??= {}); + const existing = rollupConfig.plugins; + // Nitro adds its own resolvers after these, so `void/db` never reaches them. + rollupConfig.plugins = [ + ...plugins, + ...(Array.isArray(existing) ? existing : existing ? [existing] : []), + ]; + }, +}; + +export default voidNitroModule; diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 7f7e69975..e7d50589c 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -1,12 +1,13 @@ import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { join } from 'pathslash'; +import { dirname, join } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; import { DRIZZLE_OPERATORS, generateDbDts } from './codegen/db-types.ts'; import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts'; +import { getDatabaseDialect, readConfig, resolveBindingNames, type VoidConfig } from './config.ts'; import { discoverSchema } from './drizzle/discover.ts'; import { voidWarn } from './log.ts'; -import { readProjectPaths } from './project-paths.ts'; +import { readProjectPaths, resolveProjectPaths, type ProjectPaths } from './project-paths.ts'; // Resolve drizzle-orm paths from void's own deps so they work in any project // (including metaframeworks where drizzle-orm isn't a direct dependency). @@ -45,110 +46,73 @@ function requestPool(resolve, own = {}) { }); } `; -type DrizzlePluginOptions = { +type VoidDbModuleOptions = { d1BindingName?: string; forceVirtualDb?: boolean; +}; +type DrizzlePluginOptions = VoidDbModuleOptions & { localD1DatabaseId?: string; localD1PersistRoot?: string; redirectDbTypePaths?: boolean; }; /** - * Vite plugin for Drizzle integration. - * - * - Intercepts `void/db` imports in SSR/worker environments and serves - * a virtual module that creates a Drizzle DB instance, using the user's - * schema when present. - * - Generates `.void/db.d.ts` with proper Drizzle types. - * - Sets up `@schema` Vite alias for convenient imports when a schema exists. - * - Updates `.void/tsconfig.json` with TypeScript paths. + * The app's `void/db` module for its dialect, schema and D1 binding. Resolving and loading + * have no side effects, so the Vite plugin and `voidDbBundlerPlugin` share them. */ -export function drizzlePlugin( +function createVoidDbModule( root: string, - hasRouteTypes: boolean, - dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite', - options: DrizzlePluginOptions = {}, -): Plugin { - const outDir = join(root, '.void'); - const paths = readProjectPaths(root); + dialect: 'sqlite' | 'postgresql' | 'mysql', + options: VoidDbModuleOptions, + paths: ProjectPaths = readProjectPaths(root), +) { const schema = discoverSchema(root, paths); - const hasDbRuntime = schema != null || options.forceVirtualDb === true; + // The package's own `void/db` is a D1 client, so PostgreSQL and MySQL always need this one. + const hasDbRuntime = schema != null || options.forceVirtualDb === true || dialect !== 'sqlite'; const d1BindingName = options.d1BindingName ?? 'DB'; - // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.) - ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { - redirectDbModule: options.redirectDbTypePaths, - paths, - }); - - // Generate db.d.ts when void/db is available, even without a user schema. - if (hasDbRuntime) { - generateDbDts(outDir, schema ?? null, dialect); - } - return { - name: 'void:drizzle', - enforce: 'pre', - - config() { - if (!schema) { - return; - } - - return { - resolve: { - alias: { - '@schema': schema.path, - }, - }, - }; - }, - - resolveId: { - filter: { id: /^(void\/db|@schema)/ }, - handler(id, importer) { - // Only intercept void/db in server/worker environments (not client) - if (id === 'void/db' && this.environment?.name !== 'client') { - if (!hasDbRuntime) { - return; - } // Fall through to the real module (stub) - // Don't redirect schema files to avoid circular dependency - // (virtual:void-db imports the schema, which imports void/db). - // Schema files only need SQL helpers, not the runtime DB instance. - if (schema && importer) { - // Normalize separators for Windows — Vite uses forward slashes, - // but schema.path from Node's path.join uses backslashes on Windows. - const normalizedImporter = normalizePath(importer); - const normalizedSchemaPath = normalizePath(schema.path); - if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) { - return VIRTUAL_SCHEMA_DB_ID; - } - if ( - schema.type === 'directory' && - normalizedImporter.startsWith(normalizedSchemaPath) - ) { - return VIRTUAL_SCHEMA_DB_ID; - } + schema, + hasDbRuntime, + + resolveId(id: string, importer: string | undefined): string | undefined { + if (id === 'void/db') { + if (!hasDbRuntime) { + return; + } // Fall through to the real module (stub) + // Don't redirect schema files to avoid circular dependency + // (virtual:void-db imports the schema, which imports void/db). + // Schema files only need SQL helpers, not the runtime DB instance. + if (schema && importer) { + // Normalize separators for Windows — Vite uses forward slashes, + // but schema.path from Node's path.join uses backslashes on Windows. + const normalizedImporter = normalizePath(importer); + const normalizedSchemaPath = normalizePath(schema.path); + if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) { + return VIRTUAL_SCHEMA_DB_ID; + } + if (schema.type === 'directory' && normalizedImporter.startsWith(normalizedSchemaPath)) { + return VIRTUAL_SCHEMA_DB_ID; } - return VIRTUAL_DB_ID; } + return VIRTUAL_DB_ID; + } - // Resolve @schema alias explicitly via resolveId so it works in - // meta-framework SSR environments (Astro's workerd runner, etc.) - // where Vite's config-level resolve.alias may not be applied. - if (schema && (id === '@schema' || id.startsWith('@schema/'))) { - if (id === '@schema') { - return schema.path; - } - return join(schema.path, id.slice('@schema/'.length)); + // Resolve @schema alias explicitly via resolveId so it works in + // meta-framework SSR environments (Astro's workerd runner, etc.) + // where Vite's config-level resolve.alias may not be applied. + if (schema && (id === '@schema' || id.startsWith('@schema/'))) { + if (id === '@schema') { + return schema.path; } - }, + return join(schema.path, id.slice('@schema/'.length)); + } }, load: { // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention filter: { id: /^\0virtual:void-db(?:-schema)?$/ }, - handler(id) { + handler(id: string): string { if (id === VIRTUAL_SCHEMA_DB_ID) { return ` export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; @@ -332,6 +296,71 @@ function createPrimaryDb(d1) { `; }, }, + }; +} + +/** + * Vite plugin for Drizzle integration. + * + * - Intercepts `void/db` imports in SSR/worker environments and serves + * a virtual module that creates a Drizzle DB instance, using the user's + * schema when present. + * - Generates `.void/db.d.ts` with proper Drizzle types. + * - Sets up `@schema` Vite alias for convenient imports when a schema exists. + * - Updates `.void/tsconfig.json` with TypeScript paths. + */ +export function drizzlePlugin( + root: string, + hasRouteTypes: boolean, + dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite', + options: DrizzlePluginOptions = {}, +): Plugin { + const outDir = join(root, '.void'); + const paths = readProjectPaths(root); + const dbModule = createVoidDbModule(root, dialect, options, paths); + const { schema, hasDbRuntime } = dbModule; + + // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.) + ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, { + redirectDbModule: options.redirectDbTypePaths, + paths, + }); + + // Generate db.d.ts when void/db is available, even without a user schema. + if (hasDbRuntime) { + generateDbDts(outDir, schema ?? null, dialect); + } + + return { + name: 'void:drizzle', + enforce: 'pre', + + config() { + if (!schema) { + return; + } + + return { + resolve: { + alias: { + '@schema': schema.path, + }, + }, + }; + }, + + resolveId: { + filter: { id: /^(void\/db|@schema)/ }, + handler(id, importer) { + // Only intercept void/db in server/worker environments (not client) + if (id === 'void/db' && this.environment?.name === 'client') { + return; + } + return dbModule.resolveId(id, importer); + }, + }, + + load: dbModule.load, async configureServer() { if (!schema) { @@ -366,3 +395,72 @@ function createPrimaryDb(d1) { }, }; } + +/** + * `void/db` and `@schema` for bundles that do not run Void's Vite plugins: Nitro's (Nuxt and + * Analog server routes, through `void/nitro`) and the cron and queue handler bundle that deploys + * build. Without it they load the package's static `void/db`, a D1 client without the app's + * schema. It reads the dialect, schema and D1 binding from the project, as the Vite plugin does. + * Its hooks are plain Rollup hooks; Vite also needs `enforce` to run them before its resolver. + * + * For a bundle that runs in workerd, `sqlDriver` builds the PostgreSQL or MySQL driver, and the + * plugin serves that module for `pg` or `mysql2`. + */ +export function voidDbBundlerPlugin( + root: string, + options: { + config?: VoidConfig; + sqlDriver?: (dialect: 'postgresql' | 'mysql') => Promise; + } = {}, +): Plugin { + const config = options.config ?? readConfig(root); + const dialect = getDatabaseDialect(config); + const d1BindingName = resolveBindingNames(config.inference?.bindings)?.d1; + const dbModule = createVoidDbModule( + root, + dialect, + // The package's own `void/db` reads D1 from the `DB` binding. + { d1BindingName, forceVirtualDb: d1BindingName !== undefined }, + resolveProjectPaths(root, config), + ); + const driverPackage = + dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql2' : undefined; + let driver: Promise | undefined; + let driverDir: string | undefined; + return { + name: 'void:db', + enforce: 'pre', + async resolveId(id, importer) { + if (id === driverPackage && options.sqlDriver && dialect !== 'sqlite') { + driver ??= options.sqlDriver(dialect).then((path) => { + driverDir = `${normalizePath(dirname(path))}/`; + return path; + }); + return driver; + } + // The driver needs workerd's own Node modules: Nitro replaces them with stubs unless it + // finds nodejs_compat in a Wrangler config, and Void's is not one it reads. + if ( + driverDir && + id.startsWith('node:') && + importer && + normalizePath(importer).startsWith(driverDir) + ) { + return { id, external: true }; + } + const resolved = dbModule.resolveId(id, importer); + if (resolved !== undefined && id !== 'void/db') { + // `@schema` can name a schema folder or a file without its extension. Only Vite's + // config alias hands those to a resolver, so apply the bundler's own rules here. + return this.resolve(resolved, importer, { skipSelf: true }); + } + return resolved ?? null; + }, + load(id) { + if (id !== VIRTUAL_DB_ID && id !== VIRTUAL_SCHEMA_DB_ID) { + return null; + } + return dbModule.load.handler(id); + }, + }; +} diff --git a/packages/void/src/runtime/db.ts b/packages/void/src/runtime/db.ts index 12cc91cc5..1f0591b2e 100644 --- a/packages/void/src/runtime/db.ts +++ b/packages/void/src/runtime/db.ts @@ -6,7 +6,7 @@ * This stub exists for the published npm package and type declarations. */ import { drizzle as _drizzle, type DrizzleD1Database } from 'drizzle-orm/d1'; -import { getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; +import { getRuntimeBinding, getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; // Re-export commonly used Drizzle utilities so consumers never need // to depend on drizzle-orm directly. @@ -56,6 +56,20 @@ function createPrimaryDb(d1: D1Database): DrizzleD1Database { const requestDbKey = {}; +function requireD1(): D1Database { + // Bundlers that skip Void's plugins load this D1 client for every app. Nitro needs the + // `void/nitro` module to serve a PostgreSQL or MySQL app's own `void/db`. + if ( + getRuntimeBinding('DB') == null && + (getRuntimeBinding('HYPERDRIVE') != null || getRuntimeBinding('DATABASE_URL') != null) + ) { + throw new Error( + "db: Missing Cloudflare binding 'DB'. This app connects through HYPERDRIVE or DATABASE_URL, but its server bundle loaded the D1 client. In Nuxt or Analog, add 'void/nitro' to the Nitro modules.", + ); + } + return requireRuntimeBinding('DB'); +} + /** * Default Drizzle D1 instance — resolves the `DB` binding from the runtime * environment. When the Void plugin is active, this is replaced with a @@ -65,10 +79,10 @@ export const db: DrizzleD1Database = new Proxy({} as DrizzleD1Database, { get(_, prop) { const instance = getRuntimeRequestResource( requestDbKey, - () => createDb(requireRuntimeBinding('DB')), + () => createDb(requireD1()), // Framework adapters do not always enter Void's request scope. Without a // shared session, each operation starts on primary to preserve fresh reads. - () => createPrimaryDb(requireRuntimeBinding('DB')), + () => createPrimaryDb(requireD1()), ); return Reflect.get(instance, prop); }, diff --git a/packages/void/test/helpers/fake-mysql-server.ts b/packages/void/test/helpers/fake-mysql-server.ts index c3ae94428..054e99b46 100644 --- a/packages/void/test/helpers/fake-mysql-server.ts +++ b/packages/void/test/helpers/fake-mysql-server.ts @@ -29,6 +29,8 @@ export type FakeMySqlServer = { port: number; /** The client connections the server holds open. */ openConnections(): number; + /** Every client connection the server has accepted. */ + acceptedConnections(): number; close(): Promise; }; @@ -58,8 +60,10 @@ export async function startFakeMySqlServer( rows: Array | ((sql: string) => Array), ): Promise { const sockets = new Set(); + let accepted = 0; const server = createServer((socket) => { + accepted++; sockets.add(socket); socket.once('close', () => sockets.delete(socket)); const connection = new Connection({ @@ -101,6 +105,7 @@ export async function startFakeMySqlServer( return { port: (server.address() as AddressInfo).port, openConnections: () => sockets.size, + acceptedConnections: () => accepted, async close() { for (const socket of sockets) { socket.destroy(); diff --git a/packages/void/test/integration/analog.test.ts b/packages/void/test/integration/analog.test.ts index 3857cb09b..2c106d052 100644 --- a/packages/void/test/integration/analog.test.ts +++ b/packages/void/test/integration/analog.test.ts @@ -104,6 +104,12 @@ describe('playground/analog dev', () => { expect(existsSync(join(ROOT, `${PLAYGROUND}/wrangler.jsonc`))).toBe(false); }); + it("serves void/db with the app's schema to server routes", async () => { + const response = await fetch(`${DEV_BASE}/api/smoke`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ firstUser: { name: 'Alice' } }); + }); + it('does not serve the dev-trigger token', async () => { const token = readFileSync(join(PLAYGROUND_ROOT, '.void/dev-trigger-token'), 'utf8').trim(); // Nitro's dev bundle inlines the trigger dispatch module. Analog builds it before listening. diff --git a/packages/void/test/integration/nuxt.test.ts b/packages/void/test/integration/nuxt.test.ts index 5a41d8cb5..6c5b55693 100644 --- a/packages/void/test/integration/nuxt.test.ts +++ b/packages/void/test/integration/nuxt.test.ts @@ -105,6 +105,12 @@ describe( expect(await bundle.text()).not.toContain(token); }); + it("serves void/db with the app's schema to server routes", async () => { + const response = await fetch(`${DEV_BASE}/api/smoke`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ firstUser: { name: 'Alice' } }); + }); + it('navigates to smoke route via SPA from home page', async () => { const { chromium } = await import('playwright'); const browser = await chromium.launch(); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 20760122b..8d86a8dce 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1868,12 +1868,48 @@ jobs: expect.stringContaining('export default defineNuxtConfig'), 'Manual Nuxt config step', ); + expect(mockNote).toHaveBeenCalledWith( + expect.stringContaining('nitro: { modules: ["void/nitro"] }'), + 'Manual Nuxt config step', + ); } finally { rmSync(root, { recursive: true, force: true }); } }, ); + it('prints an Analog-specific manual config step with the Nitro module', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'existing-analog-app', + private: true, + devDependencies: { '@analogjs/platform': '^2.8.0' }, + }, + null, + 2, + ) + '\n', + ); + mockSelect.mockResolvedValueOnce('none'); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + + await runInitCommand(root); + + expect(existsSync(join(root, 'vite.config.ts'))).toBe(false); + expect(mockNote).toHaveBeenCalledWith( + expect.stringContaining( + 'analog({ nitro: { modules: ["nitro-cloudflare-dev", "void/nitro"] } })', + ), + 'Manual Analog config step', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('adds voidPlugin to an existing Vite plugins array without replacing scripts', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { diff --git a/packages/void/test/unit/nitro.test.ts b/packages/void/test/unit/nitro.test.ts new file mode 100644 index 000000000..761bcc2eb --- /dev/null +++ b/packages/void/test/unit/nitro.test.ts @@ -0,0 +1,168 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { toSlash } from 'pathslash'; +import type { Plugin } from 'vite'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import voidNitroModule from '../../src/nitro.ts'; +import { db } from '../../src/runtime/db.ts'; +import { getRuntimeRequestResource, withRuntimeEnv } from '../../src/runtime/env.ts'; + +const roots: Array = []; +const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + delete (globalThis as { __env__?: unknown }).__env__; +}); + +function createApp(database: 'pg' | 'mysql' | 'd1'): string { + const root = mkdtempSync(join(tmpdir(), 'void-nitro-')); + roots.push(root); + writeFileSync(join(root, 'void.json'), JSON.stringify(database === 'd1' ? {} : { database })); + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db', 'schema.ts'), 'export const users = {};\n'); + return root; +} + +type NitroOptions = { + dev: boolean; + rootDir: string; + buildDir: string; + exportConditions: Array; + plugins: Array; + virtual: Record string | Promise)>; + rollupConfig: { plugins?: unknown }; +}; + +/** Run the module against the options of a Nitro Worker build. */ +function setUp(root: string, options: { dev: boolean; plugins?: unknown }): NitroOptions { + const nitro = { + options: { + dev: options.dev, + rootDir: root, + buildDir: join(root, '.nitro'), + exportConditions: ['workerd'], + plugins: [], + virtual: {}, + rollupConfig: { plugins: options.plugins }, + } as NitroOptions, + }; + voidNitroModule.setup(nitro); + return nitro.options; +} + +type ResolveResult = string | { id: string; external?: boolean } | null | undefined; + +// Nitro's Rollup calls the hooks as plain functions, with its plugin context as `this`. +const context = { + async resolve(source: string): Promise<{ id: string }> { + return { id: source }; + }, +}; + +function resolve(plugin: Plugin, id: string, importer: string): Promise { + return (plugin.resolveId as (id: string, importer: string) => Promise).call( + context, + id, + importer, + ); +} + +describe('void/nitro', () => { + it("serves the app's void/db and @schema before Nitro's own plugins", async () => { + const root = createApp('pg'); + const existing = { name: 'existing' }; + const options = setUp(root, { dev: true, plugins: existing }); + const [plugin, ...rest] = options.rollupConfig.plugins as Array; + expect(rest).toEqual([existing]); + + const route = join(root, 'server', 'api', 'users.ts'); + const schema = toSlash(join(root, 'db', 'schema.ts')); + const id = await resolve(plugin!, 'void/db', route); + const code = (plugin!.load as (id: string) => string | null)(id as string); + expect(code).toMatch(/drizzle-orm[\\/]+node-postgres/); + expect(code).toContain(JSON.stringify(schema)); + expect(await resolve(plugin!, '@schema', route)).toEqual({ id: schema }); + // Development runs in Node, which loads the driver itself. + expect(await resolve(plugin!, 'pg', route)).toBeNull(); + }); + + it('serves a workerd build of the driver to a Worker build', async () => { + const root = createApp('mysql'); + const [plugin, asyncHooks] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + const route = join(root, 'server', 'api', 'users.ts'); + + const driver = await resolve(plugin!, 'mysql2', route); + expect(typeof driver).toBe('string'); + expect(existsSync(driver as string)).toBe(true); + expect(readFileSync(driver as string, 'utf8')).toContain('export {'); + // The driver and Void's request scope keep workerd's Node modules instead of Nitro's stubs. + expect(await resolve(plugin!, 'node:net', driver as string)).toEqual({ + id: 'node:net', + external: true, + }); + expect(await resolve(asyncHooks!, 'node:async_hooks', envModuleFile)).toEqual({ + id: 'node:async_hooks', + external: true, + }); + expect(await resolve(asyncHooks!, 'node:async_hooks', route)).toBeNull(); + }); + + it('runs each PostgreSQL or MySQL request in one Void request scope', async () => { + const options = setUp(createApp('pg'), { dev: true }); + expect(options.plugins).toHaveLength(1); + const source = options.virtual[options.plugins[0]!] as string; + const pluginFile = join(roots[0]!, 'request-scope.mjs'); + writeFileSync( + pluginFile, + source.replace( + JSON.stringify(envModuleFile), + JSON.stringify(pathToFileURL(envModuleFile).href), + ), + ); + const { default: requestScope } = (await import(pathToFileURL(pluginFile).href)) as { + default: (nitroApp: { h3App: { handler: (event: string) => Promise } }) => void; + }; + + const key = {}; + const nitroApp = { + h3App: { + // A route that reads its request's resource before and after an await. + async handler(event: string) { + const first = getRuntimeRequestResource(key, () => ({ event })); + await new Promise((resolve) => setTimeout(resolve, 5)); + return [first, getRuntimeRequestResource(key, () => ({ event }))]; + }, + }, + }; + requestScope(nitroApp); + // A dev plugin can set a promise of the env before the env itself. + (globalThis as { __env__?: unknown }).__env__ = Promise.resolve({ DATABASE_URL: 'x' }); + + const [a, b] = (await Promise.all([ + nitroApp.h3App.handler('a'), + nitroApp.h3App.handler('b'), + ])) as Array<[object, object]>; + expect(a[1]).toBe(a[0]); + expect(b[1]).toBe(b[0]); + expect(a[0]).toEqual({ event: 'a' }); + expect(b[0]).toEqual({ event: 'b' }); + }); + + it('keeps D1 requests and Node modules as Nitro has them', () => { + const options = setUp(createApp('d1'), { dev: false }); + expect(options.plugins).toEqual([]); + expect(options.virtual).toEqual({}); + expect(options.rollupConfig.plugins).toHaveLength(1); + }); + + it('tells an app whose bundle skipped the module to add it', () => { + expect(() => + withRuntimeEnv({ HYPERDRIVE: { connectionString: 'postgresql://db/app' } }, () => db.select), + ).toThrow("add 'void/nitro' to the Nitro modules"); + }); +}); diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 15d857ae1..5790fb2a9 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -779,6 +779,23 @@ export const requestPoolOptions = (env) => expect(code).not.toMatch(/\bschema\b/); }); + // The package's own void/db is a D1 client, which never fits these dialects. + it.each(['postgresql', 'mysql'] as const)('serves the %s void/db without a schema', (dialect) => { + const root = createProjectRoot({ schema: false }); + const plugin = drizzlePlugin(root, false, dialect); + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string } }, + source: string, + importer: string | undefined, + options: { isEntry: boolean }, + ) => string | undefined; + + expect( + resolveId.call({ environment: { name: 'ssr' } }, 'void/db', undefined, { isEntry: false }), + ).toBe('\0virtual:void-db'); + expect(readFileSync(join(root, '.void', 'db.d.ts'), 'utf8')).toContain('Record'); + }); + it('uses a custom D1 binding name in the sqlite virtual module', () => { const root = createProjectRoot({ schema: false }); const plugin = drizzlePlugin(root, false, 'sqlite', { diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 70e526e29..45cbeb8eb 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -8,6 +8,7 @@ import { readFileSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { createRequire, isBuiltin } from 'node:module'; @@ -599,7 +600,11 @@ describe('generateWrapper with migrations', () => { async function fetchWrappedWorker( workerDir: string, request: { path: string; body?: unknown }, - options: { bindings?: Record; d1Databases?: Array } = {}, + options: { + bindings?: Record; + d1Databases?: Array; + compatibilityDate?: string; + } = {}, ): Promise { // Upload every module, as the platform does; the wrapper imports the handler statically. const modules = readdirSync(workerDir) @@ -612,7 +617,7 @@ describe('generateWrapper with migrations', () => { convertV4MiniflareOptions({ modulesRoot: workerDir, modules, - compatibilityDate: '2026-02-24', + compatibilityDate: options.compatibilityDate ?? '2026-02-24', compatibilityFlags: ['nodejs_compat'], bindings: { __VOID_PROXY_TOKEN: 'proxy-token', ...options.bindings }, d1Databases: options.d1Databases, @@ -802,6 +807,206 @@ describe('generateWrapper with migrations', () => { expectSelfContainedHandler(workerDir); }); + /** + * An app whose cron, and with `queue` a queue consumer, reads its users through `void/db`. + * The job throws unless each select and relational query, which needs the app's schema, returns + * `users`. The last two run in parallel. + */ + async function wrapUsersCron( + database: 'pg' | 'mysql', + users: Array<{ id: number; name: string }>, + options: { schemaImport: string; schemaFolder?: boolean; queue?: boolean }, + ): Promise { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'void.json'), JSON.stringify({ database })); + const [table, id] = database === 'pg' ? ['pgTable', 'serial'] : ['mysqlTable', 'int']; + const schema = `import { ${table}, ${id}, text } from 'void/schema-${database}'; +export const users = ${table}('users', { id: ${id}('id').primaryKey(), name: text('name').notNull() }); +`; + // A schema folder's files are also importable one by one, as `@schema/`. + let fileImport = ''; + if (options.schemaFolder) { + mkdirSync(join(projectRoot, 'db', 'schema')); + writeFileSync(join(projectRoot, 'db', 'schema', 'users.ts'), schema); + writeFileSync(join(projectRoot, 'db', 'schema', 'index.ts'), "export * from './users';\n"); + fileImport = `import { users as usersFile } from '@schema/users'; +if (usersFile !== users) throw new Error('@schema/users is another module'); +`; + } else { + writeFileSync(join(projectRoot, 'db', 'schema.ts'), schema); + } + const job = `import { users } from ${JSON.stringify(options.schemaImport)}; +import { db } from 'void/db'; +${fileImport} +export default async function () { + const found = JSON.stringify([ + await db.select().from(users), + ...(await Promise.all([db.select().from(users), db.query.users.findMany()])), + ]); + if (found !== ${JSON.stringify(JSON.stringify([users, users, users]))}) throw new Error(found); +} +`; + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'users.ts'), job); + if (options.queue) { + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'users.ts'), job); + } + // The schema imports Void's SQL helpers by package name, as an app does. + mkdirSync(join(projectRoot, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(projectRoot, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + crons: [{ name: 'users', filePath: 'users.ts', crons: ['0 * * * *'] }], + ...(options.queue && { + queues: [{ name: 'users', filePath: 'users.ts', bindingName: 'QUEUE_USERS' }], + }), + }); + return workerDir; + } + + const runCron = (workerDir: string, connectionString: string, compatibilityDate?: string) => + fetchWrappedWorker( + workerDir, + { path: '/__void/scheduled', body: { cron: '0 * * * *', scheduledTime: Date.now() } }, + { bindings: { HYPERDRIVE: { connectionString } }, compatibilityDate }, + ); + + /** Deliver one message to the wrapped `users` queue consumer through the relay endpoint. */ + const runQueue = (workerDir: string, connectionString: string) => + fetchWrappedWorker( + workerDir, + { + path: '/__void/queue', + body: { + queue: 'users', + messages: [{ id: 'one', timestamp: new Date().toISOString(), body: {}, attempts: 1 }], + }, + }, + { bindings: { HYPERDRIVE: { connectionString } } }, + ); + + /** Serve PGlite with one user to `run`, which can read how many connections the server took. */ + async function withPgUsers( + run: (connectionString: string, connections: () => number) => Promise, + ): Promise { + const pglite = new PGlite(); + await pglite.waitReady; + await pglite.exec( + "CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL); INSERT INTO users (name) VALUES ('alice');", + ); + const server = createPgServer(pglite); + let connections = 0; + server.on('connection', () => connections++); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + return await run(`postgresql://postgres@127.0.0.1:${port}/postgres`, () => connections); + } finally { + server.close(); + await pglite.close(); + } + } + + /** Run the wrapped PostgreSQL cron in workerd against PGlite with one user. */ + const runPgUsersCron = (workerDir: string, compatibilityDate?: string) => + withPgUsers((connectionString) => runCron(workerDir, connectionString, compatibilityDate)); + + it('gives a PostgreSQL cron the app database and schema in workerd', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + }); + expect(await runPgUsersCron(workerDir)).toEqual({ ok: true }); + }); + + // workerd provides node:fs only from compatibility date 2025-09-15, and apps keep older dates. + it('starts a PostgreSQL cron Worker at a compatibility date without node:fs', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + }); + expect(await runPgUsersCron(workerDir, '2024-09-23')).toEqual({ ok: true }); + }); + + it('gives a cron a schema folder through @schema', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + schemaFolder: true, + }); + expect(await runPgUsersCron(workerDir)).toEqual({ ok: true }); + }); + + it('gives a MySQL cron the app database and schema in workerd', async () => { + const users = [{ id: 1, name: 'alice' }]; + // A relative import: the cron must get the app's database even without @schema. + const workerDir = await wrapUsersCron('mysql', users, { schemaImport: '../db/schema.ts' }); + + const mysql = await startFakeMySqlServer(users); + try { + expect(await runCron(workerDir, `mysql://void@127.0.0.1:${mysql.port}/app`)).toEqual({ + ok: true, + }); + } finally { + await mysql.close(); + } + }); + + // As in a Void app, each event runs in one request scope, so void/db opens one connection for + // all of its queries, parallel ones included, instead of a pool for each query. + it('runs a PostgreSQL cron event and queue batch on one connection each', async () => { + const workerDir = await wrapUsersCron('pg', [{ id: 1, name: 'alice' }], { + schemaImport: '@schema', + queue: true, + }); + await withPgUsers(async (connectionString, connections) => { + expect(await runCron(workerDir, connectionString)).toEqual({ ok: true }); + expect(connections()).toBe(1); + expect(await runQueue(workerDir, connectionString)).toEqual({ ok: true }); + expect(connections()).toBe(2); + }); + }); + + it('runs a MySQL cron event and queue batch on one connection each', async () => { + const users = [{ id: 1, name: 'alice' }]; + const workerDir = await wrapUsersCron('mysql', users, { schemaImport: '@schema', queue: true }); + + const mysql = await startFakeMySqlServer(users); + try { + const connectionString = `mysql://void@127.0.0.1:${mysql.port}/app`; + expect(await runCron(workerDir, connectionString)).toEqual({ ok: true }); + expect(mysql.acceptedConnections()).toBe(1); + expect(await runQueue(workerDir, connectionString)).toEqual({ ok: true }); + expect(mysql.acceptedConnections()).toBe(2); + } finally { + await mysql.close(); + } + }); + + // In a request scope, void/db reads D1 through a replica-capable session instead of the primary. + it('keeps D1 cron and queue jobs outside the request scope', async () => { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'db', 'schema.ts'), 'export {};\n'); + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'daily.ts'), 'export default async () => {};'); + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'emails.ts'), 'export default async () => {};'); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + crons: [{ name: 'daily', filePath: 'daily.ts', crons: ['0 0 * * *'] }], + queues: [{ name: 'emails', filePath: 'emails.ts', bindingName: 'QUEUE_EMAILS' }], + }); + + const handlers = readFileSync(join(projectRoot, '.void', 'handlers', 'handlers.ts'), 'utf8'); + expect(handlers).toContain('export async function handleScheduled(controller, env, ctx) {'); + expect(handlers).toContain('export async function handleQueue(batch, env, ctx) {'); + expect(handlers).not.toContain('withRuntimeEnv'); + }); + it('does not generate migration handler when hasMigrations is false', async () => { const { workerDir, projectRoot } = createMigrationFixture(); mkdirSync(join(projectRoot, 'crons'), { recursive: true }); diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index faca20611..d38a383ba 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ 'src/runtime/ws-server.ts', 'src/runtime/live-server.ts', 'src/sveltekit.ts', + 'src/nitro.ts', 'src/pages/index.ts', 'src/pages/protocol.ts', 'src/pages/head.ts', diff --git a/playground/analog/package.json b/playground/analog/package.json index bd435314b..1f386eb8a 100644 --- a/playground/analog/package.json +++ b/playground/analog/package.json @@ -24,7 +24,6 @@ "@angular/build": "catalog:", "@angular/compiler-cli": "^22.2.2", "nitro-cloudflare-dev": "^0.2.2", - "pathslash": "catalog:", "typescript": "catalog:angular", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/playground/analog/src/server/routes/api/smoke.ts b/playground/analog/src/server/routes/api/smoke.ts index 2ea821b81..6359a04f1 100644 --- a/playground/analog/src/server/routes/api/smoke.ts +++ b/playground/analog/src/server/routes/api/smoke.ts @@ -6,9 +6,11 @@ import { kv } from 'void/kv'; export default defineEventHandler(async () => { const rows = await db.select().from(users).limit(1); const result = rows[0] ?? null; + // The relational API needs the app's schema in `void/db`. + const first = await db.query.users.findFirst({ columns: { name: true } }); await kv.put('smoke', 'ok'); const kvValue = await kv.get('smoke'); - return { user: result, kvValue }; + return { user: result, firstUser: first ?? null, kvValue }; }); diff --git a/playground/analog/vite.config.ts b/playground/analog/vite.config.ts index 13a71afb5..f7bee9c86 100644 --- a/playground/analog/vite.config.ts +++ b/playground/analog/vite.config.ts @@ -1,5 +1,4 @@ import analog from '@analogjs/platform'; -import { resolve } from 'pathslash'; import { defineConfig } from 'vite-plus'; import { voidPlugin } from 'void'; @@ -12,13 +11,8 @@ export default defineConfig({ ssr: true, nitro: { preset: 'cloudflare-module', - modules: ['nitro-cloudflare-dev'], + modules: ['nitro-cloudflare-dev', 'void/nitro'], cloudflareDev: { configPath: './.void-wrangler.jsonc' }, - alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. - '@schema': resolve(__dirname, 'db/schema.ts'), - }, }, }), voidPlugin(), diff --git a/playground/nuxt/nuxt.config.ts b/playground/nuxt/nuxt.config.ts index 1a957a7cb..bc43ec205 100644 --- a/playground/nuxt/nuxt.config.ts +++ b/playground/nuxt/nuxt.config.ts @@ -6,14 +6,14 @@ export default defineNuxtConfig({ compatibilityDate: '2026-02-24', nitro: { preset: 'cloudflare-module', + modules: ['void/nitro'], cloudflareDev: { configPath: './.void-wrangler.jsonc' }, // Remove when Nuxt ships https://github.com/nuxt/nuxt/commit/21657c6fcf. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, alias: { - // Nitro has its own bundler that doesn't use Vite aliases. - // Duplicate @schema here so Nitro can resolve it. + // Nuxt types server routes from Nitro's aliases. '@schema': resolve(import.meta.dirname, 'db/schema.ts'), }, }, diff --git a/playground/nuxt/server/api/smoke.get.ts b/playground/nuxt/server/api/smoke.get.ts index 89b3fb938..3ad0eeed4 100644 --- a/playground/nuxt/server/api/smoke.get.ts +++ b/playground/nuxt/server/api/smoke.get.ts @@ -5,9 +5,11 @@ import { kv } from 'void/kv'; export default defineEventHandler(async () => { const rows = await db.select().from(users).limit(1); const result = rows[0] ?? null; + // The relational API needs the app's schema in `void/db`. + const first = await db.query.users.findFirst({ columns: { name: true } }); await kv.put('smoke', 'ok'); const kvValue = await kv.get('smoke'); - return { user: result, kvValue }; + return { user: result, firstUser: first ?? null, kvValue }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3572c0559..ee33a5d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1157,9 +1157,6 @@ importers: nitro-cloudflare-dev: specifier: ^0.2.2 version: 0.2.2 - pathslash: - specifier: 'catalog:' - version: 0.1.0 typescript: specifier: catalog:angular version: 6.0.3 From 84c814058b83a33ae87a71b68c5562c5bab854d9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 13:37:47 +0800 Subject: [PATCH 14/17] fix(void): split MySQL migration files and hold the migration lock through auth and reset A MySQL migration file without statement breakpoints ran as one query. Void platform deploys sent it to Hyperdrive as a multi-statement query, which Hyperdrive does not support. The CLI could not tell that earlier statements of a failed file had run, so the error had no partial-apply note. The migration lock also ended too early. The hosted handler released its lease before Better Auth's migrations ran, and `vp dev` ran them after the lock was released (auth-only apps took no lock at all). Two runs could then add the same column and fail. `void db reset` and `void db seed` dropped every table, including the deploy lease table, without the lock, so a deploy in progress failed. Both MySQL runners now split each file into single statements, at breakpoints and then at semicolons outside strings, quoted names and comments. A statement that starts a trigger, procedure, function or event keeps the rest of its part with it. Words such as `event` or a `BEGIN;` transaction still split. The hosted connection no longer sets multipleStatements. Better Auth's MySQL migrations now run inside the lock. The hosted handler renews its lease on a timer while they run. `vp dev` holds the lock of the database each step changes: the files use .env's DATABASE_URL, and Better Auth uses the shell's before .env. When both name one database, auth runs inside the same locked run, also for an app without migration files. `void db reset` and `seed` hold the lock from the first drop until the files are applied again, and keep the lease table. The docs name reset and seed in the lock note, say that a sharded Vitess commit is not atomic across shards, and ask for breakpoints around MySQL stored programs. Fixes #414 Fixes #415 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/database.md | 2 +- docs/guide/database/mysql.md | 4 +- packages/void/AGENTS.md | 3 +- packages/void/src/auth/node.ts | 7 +- packages/void/src/cli/db.ts | 43 ++- packages/void/src/migrations/runner-mysql.ts | 127 ++++--- packages/void/src/plugin-migration.ts | 116 ++++-- .../src/runtime/migration-handler-mysql.ts | 53 ++- .../void/src/runtime/migration-lock-mysql.ts | 34 ++ .../void/src/runtime/mysql-migration-sql.ts | 149 ++++++++ packages/void/test/unit/db-commands.test.ts | 15 +- .../test/unit/mysql-migration-sql.test.ts | 113 ++++++ .../void/test/unit/plugin-migration.test.ts | 181 ++++++++- packages/void/test/unit/runner-mysql.test.ts | 344 ++++++++++++++++-- 14 files changed, 1047 insertions(+), 144 deletions(-) create mode 100644 packages/void/src/runtime/mysql-migration-sql.ts create mode 100644 packages/void/test/unit/mysql-migration-sql.test.ts diff --git a/docs/guide/database.md b/docs/guide/database.md index cd51b5fcb..9384fbc05 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -372,7 +372,7 @@ To keep an existing migration history: Review and commit the metadata, then run `void deploy` again. If no database has applied the SQL yet, you can instead replace it with migrations from `void db generate`. -For D1 migrations containing triggers, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together. +For D1 migrations containing triggers, and MySQL migrations containing triggers, procedures, functions or events, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together. ## Scaffolding diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md index 365ed77e8..b75dc3dcb 100644 --- a/docs/guide/database/mysql.md +++ b/docs/guide/database/mysql.md @@ -51,11 +51,11 @@ void deploy For direct Cloudflare deploys, export the production `DATABASE_URL` in the shell running `void deploy --platform cloudflare`. Void uses it to provision Hyperdrive and apply pending checked-in migrations before uploading the Worker; it is not written to the Worker config. -Only one migration run changes a database at a time, whether it comes from `void db migrate`, `void deploy --platform cloudflare`, or a `void deploy` of any project that uses the database. Another run waits for it, up to about 20 seconds for a command that connects with `DATABASE_URL` and about 2 minutes for a `void deploy` to the Void platform, then stops with an error that asks you to try again. +Only one migration run changes a database at a time, whether it comes from `void db migrate`, `void db reset`, the reset that `void db seed` does before it loads data, `void deploy --platform cloudflare`, or a `void deploy` of any project that uses the database. Better Auth's table changes run inside the same run. Another run waits for it, up to about 20 seconds for a command that connects with `DATABASE_URL` and about 2 minutes for a `void deploy` to the Void platform, then stops with an error that asks you to try again. ### Failed migrations {#failed-migrations} -Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed. +Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. On a sharded Vitess keyspace, such as a sharded PlanetScale database, a commit that fails on one shard does not undo the shards that already committed. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed. To recover, undo the applied statements, fix the file, then run `void db migrate` or `void deploy` again: diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index de1d271f4..474f76f8a 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -120,7 +120,8 @@ generated entries must preserve the module-boundary rules below. - **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: when the journal has entries, the first delegates to `drizzle-kit generate --custom --name `, which writes the empty `.sql`, appends the journal entry, and writes a snapshot of the current schema (it refuses if the last entry's snapshot is missing and byte-restores the journal if drizzle-kit fails); with no journal entries it writes a plain timestamped `.sql`. The second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys. - **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock. -- **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. +- **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. Better Auth's MySQL migrations change the schema too, so each runner keeps its lock through them: the hosted handler renews the lease on a 30-second timer while they run (their statements cannot renew it), and `vp dev` holds the lock of the database each step changes: migration files go to `.env`'s `DATABASE_URL`, and Better Auth to the shell's `DATABASE_URL` before `.env`'s, a URL it resolves once and passes to `runLocalVoidAuthMigrations`. When both name one database, Better Auth runs as `runMySqlMigrations`'s `afterApply` under the same lock. `void db reset`, and the reset that `void db seed` does first, hold `GET_LOCK` from the first `DROP` until the files are applied again, and keep `_void_migration_lock`; seed data loads after the lock, like app writes. +- **MySQL statements**: Cloudflare documents multi-statement queries as unsupported by Hyperdrive (not tried on a live Hyperdrive), so both MySQL runners send one statement per query (`splitMySqlMigrationSql`) and the hosted connection does not set `multipleStatements`. Each breakpoint part splits again at semicolons outside strings, quoted names and comments; a statement that opens a stored program (`CREATE` or `ALTER` of a `TRIGGER`, `PROCEDURE`, `FUNCTION` or `EVENT`) keeps the rest of its part with it, since its body's semicolons look like statement ends. Words such as `event` or a `BEGIN;` transaction do not count, so hand-written files that use them still split. Vitess (PlanetScale) also rejects a comment after a statement's semicolon unless the client sets `multipleStatements`, so the splitter drops comments after a part's last statement. The CLI connections keep `multipleStatements`: seed SQL needs it, and a whole part with several statements keeps running there as before. ### Self-Host AI + ISR diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index 85d43009d..fa30fa3a7 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -18,6 +18,8 @@ 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`. */ + databaseUrl?: string, ): Promise { const authConfig = await loadVoidAuthConfig(configPath); const runtime = { @@ -51,9 +53,10 @@ export async function runLocalVoidAuthMigrations( } const connectionString = - typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0 + databaseUrl ?? + (typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0 ? env.DATABASE_URL - : undefined; + : undefined); if (!connectionString) { return; } diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 487884c71..426567948 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -596,25 +596,32 @@ async function resetDatabase( const { hostname, host, pathname } = new URL(getLocalDatabaseUrl(root, dialect)); 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 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 { - for (const row of tables) { - const table = String(row.table_name).replaceAll('`', '``'); - await client.query(`DROP TABLE IF EXISTS \`${table}\``); + 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`. + const tables = await client.query( + "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 { + for (const row of tables) { + // Deploys of any project on this database keep their lease rows in this table. + if (row.table_name === '_void_migration_lock') { + continue; + } + const table = String(row.table_name).replaceAll('`', '``'); + await client.query(`DROP TABLE IF EXISTS \`${table}\``); + } + } finally { + await client.query('SET FOREIGN_KEY_CHECKS = 1'); } - } finally { - await client.query('SET FOREIGN_KEY_CHECKS = 1'); - } - }); - if (migrations.length > 0) { - const { applyMySqlMigrations } = await import('../migrations/runner-mysql.ts'); - await withMySqlClient(root, (client) => applyMySqlMigrations(client, migrations)); - } + await applyPendingMySqlMigrations(client, migrations); + }), + ); return migrations.length; } diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts index d1497c9f3..de6a82356 100644 --- a/packages/void/src/migrations/runner-mysql.ts +++ b/packages/void/src/migrations/runner-mysql.ts @@ -1,10 +1,10 @@ import type { Connection } from 'mysql2/promise'; -import { splitMigrationSql } from '../runtime/migration-handler.ts'; import { mySqlMigrationLockError, mySqlMigrationLockName, readMySqlMigrationLease, } from '../runtime/migration-lock-mysql.ts'; +import { splitMySqlMigrationSql } from '../runtime/mysql-migration-sql.ts'; import { collectMigrations } from './collect.ts'; export type MySqlClient = { @@ -125,6 +125,22 @@ function mySqlMigrationError(name: string, error: unknown, committed: boolean): return new Error(lines.join('\n'), { cause: error }); } +/** + * Runs `work` while this session holds the database's migration lock, so no other migration run, + * deploy or reset changes the schema meanwhile. + */ +export async function withMySqlMigrationLock( + client: MySqlClient, + work: () => Promise, +): Promise { + const lock = await acquireMySqlMigrationLock(client); + try { + return await work(); + } finally { + await client.query('SELECT RELEASE_LOCK(?)', [lock]).catch(() => {}); + } +} + export async function applyMySqlMigrations( client: MySqlClient, migrations: Array<{ name: string; sql: string }>, @@ -132,62 +148,87 @@ export async function applyMySqlMigrations( if (migrations.length === 0) { return []; } + return withMySqlMigrationLock(client, () => applyPendingMySqlMigrations(client, migrations)); +} - const lock = await acquireMySqlMigrationLock(client); - try { - await client.query( - 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', - ); - const existing = await client.query>( - 'SELECT `name` FROM `_void_migrations`', - ); - const appliedSet = new Set(existing.map((row) => String(row.name))); - const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); - - for (const migration of pending) { - let ran = 0; - // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, on its own. - await client.query('BEGIN'); - try { - // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. - for (const statement of splitMigrationSql(migration.sql)) { - await client.query(statement); - ran++; - } - await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ - migration.name, - new Date().toISOString(), - ]); - await client.query('COMMIT'); - } catch (error) { - const committed = - ran > 0 && !isRolledBackByServer(error) && !(await isTransactionOpen(client)); - // A failed ROLLBACK means the session is gone, and MySQL rolls back with it. - await client.query('ROLLBACK').catch(() => {}); - throw mySqlMigrationError(migration.name, error, committed); +/** Applies the files not yet recorded. The caller holds the migration lock. */ +export async function applyPendingMySqlMigrations( + client: MySqlClient, + migrations: Array<{ name: string; sql: string }>, +): Promise> { + if (migrations.length === 0) { + return []; + } + + await client.query( + 'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)', + ); + const existing = await client.query>( + 'SELECT `name` FROM `_void_migrations`', + ); + const appliedSet = new Set(existing.map((row) => String(row.name))); + const pending = migrations.filter((migration) => !appliedSet.has(migration.name)); + + for (const migration of pending) { + let ran = 0; + // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, on its own. + await client.query('BEGIN'); + try { + // One statement per query, so `ran` tells whether earlier statements of the file ran. + for (const statement of splitMySqlMigrationSql(migration.sql)) { + await client.query(statement); + ran++; } + await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [ + migration.name, + new Date().toISOString(), + ]); + await client.query('COMMIT'); + } catch (error) { + const committed = + ran > 0 && !isRolledBackByServer(error) && !(await isTransactionOpen(client)); + // A failed ROLLBACK means the session is gone, and MySQL rolls back with it. + await client.query('ROLLBACK').catch(() => {}); + throw mySqlMigrationError(migration.name, error, committed); } + } + + return pending.map((migration) => migration.name); +} - return pending.map((migration) => migration.name); +/** Connects to `url` and runs `work` while holding that database's migration lock. */ +export async function withMySqlMigrationLockAt( + url: string, + work: (client: MySqlClient) => Promise, +): Promise { + const { createConnection } = await import('mysql2/promise'); + const connection = await createConnection({ uri: url, multipleStatements: true }); + try { + const client = toMySqlClient(connection); + return await withMySqlMigrationLock(client, () => work(client)); } finally { - await client.query('SELECT RELEASE_LOCK(?)', [lock]).catch(() => {}); + await connection.end(); } } +/** + * Applies the pending files in `migrationsDir`. `afterApply` gets the applied names and runs before + * the lock is released, so the schema changes it makes (Better Auth's tables) do not overlap + * another run either. + */ export async function runMySqlMigrations( migrationsDir: string, url: string, + afterApply?: (applied: Array) => Promise, ): Promise> { const migrations = collectMigrations(migrationsDir); - if (migrations.length === 0) { + if (migrations.length === 0 && !afterApply) { return []; } - const { createConnection } = await import('mysql2/promise'); - const connection = await createConnection({ uri: url, multipleStatements: true }); - try { - return await applyMySqlMigrations(toMySqlClient(connection), migrations); - } finally { - await connection.end(); - } + return withMySqlMigrationLockAt(url, async (client) => { + const applied = await applyPendingMySqlMigrations(client, migrations); + await afterApply?.(applied); + return applied; + }); } diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 1940b1a07..5c34d33d1 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -11,6 +11,11 @@ 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`. */ +function readAuthDatabaseUrl(root: string): string | undefined { + return (process.env.DATABASE_URL ?? readDatabaseUrl(root)) || undefined; +} + /** * Vite plugin that runs local migrations on dev server startup. * For SQLite/D1: applies migrations to the local miniflare database. @@ -35,10 +40,37 @@ export function migrationPlugin( return; } + const migrateAuth = async (databaseUrl?: string) => { + const { runLocalVoidAuthMigrations } = await import('./auth/node.ts'); + await runLocalVoidAuthMigrations( + root, + dialect, + persistRoot, + databaseId, + auth?.configPath, + databaseUrl, + ); + }; + // MySQL runs Better Auth's migrations under the migration lock of the database they change. + const mysqlAuth = dialect === 'mysql' && auth?.enabled === true; + const logApplied = (applied: Array) => { + if (applied.length > 0) { + voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); + } + }; + try { - if (hasSqlMigrations && dialect !== 'sqlite') { - const url = readDatabaseUrl(root); - if (!url) { + if (dialect !== 'sqlite') { + const { collectMigrations } = await import('./migrations/collect.ts'); + const allMigrations = collectMigrations(migrationsDir); + // For MySQL an empty migrations folder has nothing to apply. + const runFiles = dialect === 'mysql' ? allMigrations.length > 0 : hasSqlMigrations; + // Migration files apply to the database in .env, as `void db migrate` does. Better + // Auth's migrations read DATABASE_URL from the shell before .env, and each step holds + // the lock of the database it changes. + const url = runFiles ? readDatabaseUrl(root) : undefined; + const authUrl = mysqlAuth ? readAuthDatabaseUrl(root) : undefined; + if ((runFiles && !url) || (mysqlAuth && !authUrl)) { afterServerReady(server, () => { console.log(); voidError( @@ -47,38 +79,55 @@ export function migrationPlugin( }); return; } - const { collectMigrations } = await import('./migrations/collect.ts'); - const { validateMigrations } = await import('./migrations/validate.ts'); + // One lock covers both steps when they change the same database. + const authWithFiles = authUrl !== undefined && authUrl === url; - const allMigrations = collectMigrations(migrationsDir); - const appliedNames = - dialect === 'mysql' - ? await import('./migrations/runner-mysql.ts').then((runner) => - runner.getMySqlAppliedMigrations(url), - ) - : await import('./migrations/runner-pg.ts').then((runner) => - runner.getPgAppliedMigrations(url), - ); - const appliedSet = new Set(appliedNames); - const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name)); - if (pending.length > 0) { - try { - validateMigrations(pending); - } catch (err) { - voidWarn(err instanceof Error ? err.message : String(err)); + if (url) { + const { validateMigrations } = await import('./migrations/validate.ts'); + const appliedNames = + dialect === 'mysql' + ? await import('./migrations/runner-mysql.ts').then((runner) => + runner.getMySqlAppliedMigrations(url), + ) + : await import('./migrations/runner-pg.ts').then((runner) => + runner.getPgAppliedMigrations(url), + ); + const appliedSet = new Set(appliedNames); + const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name)); + if (pending.length > 0) { + try { + validateMigrations(pending); + } catch (err) { + voidWarn(err instanceof Error ? err.message : String(err)); + } + } + + const applied = + dialect === 'mysql' + ? await import('./migrations/runner-mysql.ts').then((runner) => + runner.runMySqlMigrations( + migrationsDir, + url, + // Report the applied files before Better Auth's step, which can still fail. + authWithFiles + ? async (names) => { + logApplied(names); + await migrateAuth(url); + } + : undefined, + ), + ) + : await import('./migrations/runner-pg.ts').then((runner) => + runner.runPgMigrations(migrationsDir, url), + ); + if (!authWithFiles) { + logApplied(applied); } } - const applied = - dialect === 'mysql' - ? await import('./migrations/runner-mysql.ts').then((runner) => - runner.runMySqlMigrations(migrationsDir, url), - ) - : await import('./migrations/runner-pg.ts').then((runner) => - runner.runPgMigrations(migrationsDir, url), - ); - if (applied.length > 0) { - voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`); + if (authUrl && !authWithFiles) { + const { withMySqlMigrationLockAt } = await import('./migrations/runner-mysql.ts'); + await withMySqlMigrationLockAt(authUrl, () => migrateAuth(authUrl)); } } else if (hasSqlMigrations) { const { runMigrations, getMiniflareDatabasePath, getAppliedMigrations } = @@ -106,8 +155,9 @@ export function migrationPlugin( } if (auth?.enabled) { - const { runLocalVoidAuthMigrations } = await import('./auth/node.ts'); - await runLocalVoidAuthMigrations(root, dialect, persistRoot, databaseId, auth.configPath); + if (!mysqlAuth) { + await migrateAuth(); + } voidLog('Applied Better Auth migrations'); } } catch (err) { diff --git a/packages/void/src/runtime/migration-handler-mysql.ts b/packages/void/src/runtime/migration-handler-mysql.ts index fe1529877..2ad581ced 100644 --- a/packages/void/src/runtime/migration-handler-mysql.ts +++ b/packages/void/src/runtime/migration-handler-mysql.ts @@ -1,11 +1,11 @@ import type { Connection } from 'mysql2/promise'; +import type { MigrationEntry, MigrationResponse, MigrationResult } from './migration-handler.ts'; import { - splitMigrationSql, - type MigrationEntry, - type MigrationResponse, - type MigrationResult, -} from './migration-handler.ts'; -import { acquireMySqlMigrationLease, type MySqlMigrationLease } from './migration-lock-mysql.ts'; + acquireMySqlMigrationLease, + holdMySqlMigrationLease, + type MySqlMigrationLease, +} from './migration-lock-mysql.ts'; +import { splitMySqlMigrationStatements } from './mysql-migration-sql.ts'; export function createMigrationHandler( env: Record, @@ -22,11 +22,7 @@ export function createMigrationHandler( } try { - const result = await runMySqlMigrations(env, migrations); - if (result.ok && runAuthMigrations) { - await runAuthMigrations(); - } - return Response.json(result); + return Response.json(await runMySqlMigrations(env, migrations, runAuthMigrations)); } catch (error) { return Response.json( { @@ -43,6 +39,7 @@ export function createMigrationHandler( async function runMySqlMigrations( env: Record, migrations: Array, + runAuthMigrations?: () => Promise, ): Promise { const hyperdrive = env.HYPERDRIVE as { connectionString?: string } | undefined; if (!hyperdrive?.connectionString) { @@ -50,9 +47,9 @@ async function runMySqlMigrations( } const { createConnection } = await import('mysql2/promise'); + // Hyperdrive runs one statement per query, so a file is sent statement by statement. const connection = await createConnection({ uri: hyperdrive.connectionString, - multipleStatements: true, disableEval: true, }); let leaseConnection: Connection | undefined; @@ -84,10 +81,11 @@ async function runMySqlMigrations( // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, // on its own. Hyperdrive keeps one connection for the open transaction. await connection.query('BEGIN'); - // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments. - for (const statement of splitMigrationSql(migration.sql)) { + for (const statement of splitMySqlMigrationStatements(migration.sql)) { await lease.renew(); - await connection.query(statement); + await connection.query(statement.sql).catch((error: unknown) => { + throw statement.program ? withBreakpointNote(error, migration.name) : error; + }); } await lease.renew(); await connection.query( @@ -108,9 +106,34 @@ async function runMySqlMigrations( } } + // Better Auth's migrations change the schema too, so they run before the lease is released. + if (runAuthMigrations) { + await holdMySqlMigrationLease(lease, runAuthMigrations); + } return { ok: true, migrations: results }; } finally { await lease?.release().catch(() => {}); await Promise.all([connection.end(), leaseConnection?.end()]); } } + +/** + * The splitter keeps the SQL after a trigger, routine or event with it. The CLI runs that with + * `multipleStatements`; here one query takes one statement, so the server rejects it as a syntax + * error before running any of it. MySQL reports 1064; Vitess (PlanetScale) reports "syntax error + * at position", or 1149 for several statements it can parse. A syntax error inside the body looks + * the same, so the note says "if". + */ +function withBreakpointNote(error: unknown, name: string): unknown { + if (!(error instanceof Error)) { + return error; + } + const errno = (error as { errno?: unknown }).errno; + if (errno !== 1064 && errno !== 1149 && !/syntax error at position/.test(error.message)) { + return error; + } + return new Error( + `${error.message}\nThis deploy runs one statement per query. If ${name} has more SQL after a trigger, procedure, function or event, add a \`--> statement-breakpoint\` line after it.`, + { cause: error }, + ); +} diff --git a/packages/void/src/runtime/migration-lock-mysql.ts b/packages/void/src/runtime/migration-lock-mysql.ts index a5837ce95..213c9c39b 100644 --- a/packages/void/src/runtime/migration-lock-mysql.ts +++ b/packages/void/src/runtime/migration-lock-mysql.ts @@ -16,6 +16,8 @@ const LEASE_SECONDS = 120; /** A deploy waits long enough for the lease of a run that stopped early to expire. */ const DEPLOY_WAIT_MS = (LEASE_SECONDS + 10) * 1000; const POLL_MS = 1000; +/** How often the lease is renewed while statements that cannot renew it themselves run. */ +const KEEP_ALIVE_MS = 30_000; /** Runs one statement and returns the rows or the result header. */ export type MySqlLeaseQuery = (sql: string, params?: Array) => Promise; @@ -116,6 +118,38 @@ export async function acquireMySqlMigrationLease( } } +/** + * Runs `work`, whose statements cannot renew the lease before each one (Better Auth's migrations), + * and renews the lease in the background meanwhile. Throws after `work` when a renewal failed, + * because another run may have taken the lease. + */ +export async function holdMySqlMigrationLease( + lease: MySqlMigrationLease, + work: () => Promise, +): Promise { + await lease.renew(); + let failure: { error: unknown } | undefined; + let renewal = Promise.resolve(); + const timer = setInterval(() => { + renewal = renewal + .then(() => lease.renew()) + .catch((error: unknown) => { + failure ??= { error }; + }); + }, KEEP_ALIVE_MS); + let result: T; + try { + result = await work(); + } finally { + clearInterval(timer); + await renewal; + } + if (failure) { + throw failure.error; + } + return result; +} + async function take(query: MySqlLeaseQuery, owner: string): Promise { try { return await tryTake(query, owner); diff --git a/packages/void/src/runtime/mysql-migration-sql.ts b/packages/void/src/runtime/mysql-migration-sql.ts new file mode 100644 index 000000000..369f4f01a --- /dev/null +++ b/packages/void/src/runtime/mysql-migration-sql.ts @@ -0,0 +1,149 @@ +import { splitMigrationSql } from './migration-handler.ts'; + +/** + * Splits a MySQL migration file into the statements Void runs one query at a time. Hyperdrive + * does not accept several statements in one query, and a file that runs statement by statement + * can tell whether a failure left earlier statements committed. + * + * The file first splits at drizzle-kit `--> statement-breakpoint` lines. Each part then splits at + * semicolons outside strings, quoted names and comments, and drops comments after its last + * statement. A statement that defines a trigger, routine or event keeps the rest of its part with + * it: the semicolons in its BEGIN ... END body cannot be told apart from the ones between + * statements without a parser. + */ +export function splitMySqlMigrationSql(sql: string): Array { + return splitMySqlMigrationStatements(sql).map((statement) => statement.sql); +} + +/** + * A statement from {@link splitMySqlMigrationSql}. `program` marks one that opens a trigger, + * routine, event or other compound statement, and so also holds the rest of its part. + */ +export type MySqlMigrationStatement = { sql: string; program: boolean }; + +/** {@link splitMySqlMigrationSql}, with each statement marked. */ +export function splitMySqlMigrationStatements(sql: string): Array { + return splitMigrationSql(sql).flatMap((part) => { + const statements = splitMySqlStatements(part); + return statements.length > 0 ? statements : [{ sql: part, program: false }]; + }); +} + +/** Words after CREATE or ALTER that name an object without a BEGIN ... END body. */ +const PLAIN_OBJECTS = + 'TABLE|INDEX|VIEW|DATABASE|SCHEMA|USER|ROLE|TABLESPACE|SERVER|UNIQUE|FULLTEXT|SPATIAL|TEMPORARY'; + +/** + * The start of a statement whose body can hold semicolons: a stored program, after options such + * as DEFINER, or a compound statement that MariaDB also runs outside one. It is matched against + * the statement with quoted text and comments blanked out, so a column named `event` or a plain + * `BEGIN` transaction does not match. + */ +const COMPOUND_START = new RegExp( + [ + `^\\s*(?:CREATE|ALTER)\\s+(?:(?!(?:${PLAIN_OBJECTS})\\b)\\S+\\s+){0,8}?(?:TRIGGER|PROCEDURE|FUNCTION|EVENT|PACKAGE)\\b`, + '^\\s*BEGIN\\s+NOT\\s+ATOMIC\\b', + '^\\s*(?:\\w+\\s*:\\s*)?(?:BEGIN|IF|CASE|LOOP|REPEAT|WHILE)\\b(?!\\s*(?:WORK\\s*)?$)', + ].join('|'), + 'i', +); + +/** The statements in `sql`, without the comments around them. */ +function splitMySqlStatements(sql: string): Array { + const statements: Array = []; + // The current statement with quoted text and comments blanked out. + let bare = ''; + // Where the current statement starts and ends, or -1 before its first character. + let start = -1; + let end = -1; + // Set once a statement opens a BEGIN ... END body: the rest of the part stays with it. + let compound = false; + let index = 0; + const take = (from: number, to: number) => { + if (start === -1) { + start = from; + } + end = to; + }; + + while (index < sql.length) { + const character = sql[index]!; + const next = sql[index + 1]; + + if (character === "'" || character === '"' || character === '`') { + const close = skipQuoted(sql, index, character); + take(index, close); + bare += ' '; + index = close; + continue; + } + // MySQL reads `--` as a comment only when whitespace or a control character follows it. + if ( + character === '#' || + (character === '-' && next === '-' && !/\S/.test(sql[index + 2] ?? ' ')) + ) { + const newline = sql.indexOf('\n', index); + index = newline === -1 ? sql.length : newline; + bare += ' '; + continue; + } + if (character === '/' && next === '*') { + const close = sql.indexOf('*/', index + 2); + const commentEnd = close === -1 ? sql.length : close + 2; + // `/*! ... */` is SQL that MySQL runs, and `/*+ ... */` holds optimizer hints. + if (sql[index + 2] === '!' || sql[index + 2] === '+') { + take(index, commentEnd); + } + bare += ' '; + index = commentEnd; + continue; + } + if (character === ';' && !compound) { + if (start !== -1 && COMPOUND_START.test(bare)) { + compound = true; + } else { + if (start !== -1) { + statements.push({ sql: sql.slice(start, index + 1), program: false }); + } + start = -1; + end = -1; + bare = ''; + index++; + continue; + } + } + + bare += character; + if (!/\s/.test(character)) { + take(index, index + 1); + } + index++; + } + + if (start !== -1) { + statements.push({ sql: sql.slice(start, end), program: compound }); + } + return statements; +} + +/** The index after the quoted string or name that opens at `open`. */ +function skipQuoted(sql: string, open: number, quote: string): number { + let index = open + 1; + while (index < sql.length) { + const character = sql[index]; + // Backslash escapes apply in strings, not in backtick-quoted names. + if (character === '\\' && quote !== '`') { + index += 2; + continue; + } + if (character === quote) { + if (sql[index + 1] === quote) { + index += 2; + continue; + } + return index + 1; + } + index++; + } + return sql.length; +} diff --git a/packages/void/test/unit/db-commands.test.ts b/packages/void/test/unit/db-commands.test.ts index e6b6dc175..5b6d5b411 100644 --- a/packages/void/test/unit/db-commands.test.ts +++ b/packages/void/test/unit/db-commands.test.ts @@ -47,7 +47,10 @@ vi.mock('mysql2/promise', () => ({ 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' }], []]; + 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`'); @@ -451,6 +454,16 @@ describe('void db 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);'); + // 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')); + const firstDrop = mysqlQueries.findIndex((sql) => sql.startsWith('DROP TABLE')); + expect(lock).toBeGreaterThanOrEqual(0); + expect(lock).toBeLessThan(firstDrop); + const leaseCheck = mysqlQueries.findIndex((sql) => sql.includes('TIMESTAMPDIFF')); + expect(leaseCheck).toBeGreaterThan(lock); + expect(leaseCheck).toBeLessThan(firstDrop); + expect(mysqlQueries.at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + expect(mysqlQueries).not.toContain('DROP TABLE IF EXISTS `_void_migration_lock`'); }); }); diff --git a/packages/void/test/unit/mysql-migration-sql.test.ts b/packages/void/test/unit/mysql-migration-sql.test.ts new file mode 100644 index 000000000..b92d6db3b --- /dev/null +++ b/packages/void/test/unit/mysql-migration-sql.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { splitMySqlMigrationSql } from '../../src/runtime/mysql-migration-sql.ts'; + +describe('splitMySqlMigrationSql', () => { + it('splits a file without breakpoints at its semicolons', () => { + expect( + splitMySqlMigrationSql( + '-- comments\nCREATE TABLE comments (id int primary key);\nALTER TABLE c ADD COLUMN n int;\n', + ), + ).toEqual(['CREATE TABLE comments (id int primary key);', 'ALTER TABLE c ADD COLUMN n int;']); + }); + + it('splits each breakpoint part, and keeps a statement without a semicolon', () => { + expect( + splitMySqlMigrationSql( + 'CREATE TABLE `a` (`id` int);--> statement-breakpoint\nINSERT INTO a VALUES (1); INSERT INTO a VALUES (2)\n--> statement-breakpoint\nCREATE INDEX `a_id` ON `a` (`id`)', + ), + ).toEqual([ + 'CREATE TABLE `a` (`id` int);', + 'INSERT INTO a VALUES (1);', + 'INSERT INTO a VALUES (2)', + 'CREATE INDEX `a_id` ON `a` (`id`)', + ]); + }); + + it('keeps semicolons inside strings, quoted names and comments', () => { + const statements = [ + "INSERT INTO t (s) VALUES ('a;b', 'it''s;', 'it\\'s; \\\\', \"x;y\");", + // A backslash does not escape in a quoted name. + 'CREATE TABLE `dir\\` (id int);', + 'CREATE TABLE `odd;name` (`a``;b` int); # trailing; comment', + 'UPDATE t SET n = 5--3; -- 5 minus -3; then a comment', + 'SELECT 1 /* a; b */ + 1;', + ]; + expect(splitMySqlMigrationSql(statements.join('\n'))).toEqual([ + "INSERT INTO t (s) VALUES ('a;b', 'it''s;', 'it\\'s; \\\\', \"x;y\");", + 'CREATE TABLE `dir\\` (id int);', + 'CREATE TABLE `odd;name` (`a``;b` int);', + 'UPDATE t SET n = 5--3;', + 'SELECT 1 /* a; b */ + 1;', + ]); + }); + + // MySQL runs `/*! ... */`, so it is a statement, not a comment to drop. + it('keeps an executable comment as a statement', () => { + expect(splitMySqlMigrationSql('SELECT 1;\n/*!40101 SET @x = 1 */;\n-- end')).toEqual([ + 'SELECT 1;', + '/*!40101 SET @x = 1 */;', + ]); + }); + + it('keeps a trigger, routine or event together with the rest of its part', () => { + const trigger = + 'CREATE TRIGGER c_bump BEFORE INSERT ON c FOR EACH ROW BEGIN SET NEW.v = NEW.v + 1; END;'; + expect(splitMySqlMigrationSql(`CREATE TABLE c (v int);\n${trigger}`)).toEqual([ + 'CREATE TABLE c (v int);', + trigger, + ]); + // Without a parser, the end of the body is unknown, so what follows in the part stays with it. + expect( + splitMySqlMigrationSql( + `${trigger}\nINSERT INTO c VALUES (1);\n--> statement-breakpoint\nSELECT 1;`, + ), + ).toEqual([`${trigger}\nINSERT INTO c VALUES (1);`, 'SELECT 1;']); + const procedure = + "create definer = `root`@'%' /* x; */ procedure p() begin select 1; select 2; end"; + expect(splitMySqlMigrationSql(`-- p\n${procedure} -- done`)).toEqual([procedure]); + const event = + 'ALTER EVENT e DO BEGIN DELETE FROM a; DELETE FROM b; END;\nCREATE FUNCTION f() RETURNS int RETURN 1;'; + expect(splitMySqlMigrationSql(event)).toEqual([event]); + }); + + // MariaDB also runs compound statements outside a stored program. + it('keeps a compound statement outside a stored program together with the rest of its part', () => { + for (const statement of [ + 'BEGIN NOT ATOMIC INSERT INTO a VALUES (1); INSERT INTO a VALUES (2); END;', + 'fill: LOOP INSERT INTO a VALUES (1); LEAVE fill; END LOOP fill;', + 'IF (SELECT COUNT(*) FROM a) = 0 THEN INSERT INTO a VALUES (1); END IF;', + ]) { + const part = `${statement}\nSELECT 1;`; + expect(splitMySqlMigrationSql(part)).toEqual([part]); + } + }); + + // A whole part with several statements cannot run where one query takes one statement. + it('splits statements that only use those keywords as names or transactions', () => { + expect( + splitMySqlMigrationSql( + "CREATE TABLE logs (id int, event varchar(20), begin int);\nINSERT INTO `begin` VALUES ('BEGIN');\nCREATE INDEX trigger ON event (id);", + ), + ).toEqual([ + 'CREATE TABLE logs (id int, event varchar(20), begin int);', + "INSERT INTO `begin` VALUES ('BEGIN');", + 'CREATE INDEX trigger ON event (id);', + ]); + expect( + splitMySqlMigrationSql( + 'BEGIN;\nINSERT INTO a VALUES (1);\nCOMMIT;\nDROP PROCEDURE IF EXISTS p;', + ), + ).toEqual(['BEGIN;', 'INSERT INTO a VALUES (1);', 'COMMIT;', 'DROP PROCEDURE IF EXISTS p;']); + expect(splitMySqlMigrationSql('BEGIN WORK;\nINSERT INTO a VALUES (1);\nCOMMIT WORK;')).toEqual([ + 'BEGIN WORK;', + 'INSERT INTO a VALUES (1);', + 'COMMIT WORK;', + ]); + }); + + it('sends a part with only comments as it is', () => { + expect(splitMySqlMigrationSql('-- Custom SQL migration file, put your code below! --')).toEqual( + ['-- Custom SQL migration file, put your code below! --'], + ); + }); +}); diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index d04a49692..c414a1128 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -1,10 +1,58 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { getMiniflareDatabasePath } from '../../src/migrations/runner.ts'; import { migrationPlugin } from '../../src/plugin-migration.ts'; +const mysqlQueries = vi.hoisted((): Array => []); +// Each lock, release, recorded file and Better Auth run, with the database it went to. +const mysqlSteps = vi.hoisted((): Array => []); + +vi.mock('mysql2/promise', () => ({ + createConnection: async (options: string | { uri: string }) => { + const database = new URL(typeof options === 'string' ? options : options.uri).pathname.slice(1); + return { + async query(sql: string) { + mysqlQueries.push(sql); + const step = sql.startsWith('SELECT GET_LOCK') + ? 'lock' + : sql.startsWith('SELECT RELEASE_LOCK') + ? 'release' + : sql.startsWith('INSERT INTO `_void_migrations`') + ? 'record' + : undefined; + if (step) { + mysqlSteps.push(`${step} ${database}`); + } + if (sql === 'SELECT DATABASE() AS db') { + return [[{ db: 'app' }], []]; + } + if (sql.startsWith('SELECT GET_LOCK')) { + return [[{ acquired: 1 }], []]; + } + return [[], []]; + }, + async end() {}, + }; + }, +})); + +const authFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); + +vi.mock('../../src/auth/node.ts', () => ({ + runLocalVoidAuthMigrations: async (...args: Array) => { + mysqlQueries.push('Better Auth migrations'); + const databaseUrl = args[5]; + mysqlSteps.push( + `auth ${typeof databaseUrl === 'string' ? new URL(databaseUrl).pathname.slice(1) : '(no URL)'}`, + ); + if (authFailure.error) { + throw authFailure.error; + } + }, +})); + const roots: Array = []; type HookWithHandler = { handler: T }; type ConfigureServerHook = @@ -63,3 +111,134 @@ describe('migrationPlugin persistence roots', () => { expect(existsSync(dbPath)).toBe(true); }); }); + +describe('migrationPlugin with MySQL and Better Auth', () => { + // Better Auth reads the schema, then adds what is missing, so two runs at once both add it. + // An app without migration files reads DATABASE_URL like Better Auth: the shell before .env. + it.each([ + { files: true, url: '.env' }, + { files: false, url: '.env' }, + { files: false, url: 'shell' }, + ])( + 'runs Better Auth migrations under the migration lock (migration files: $files, DATABASE_URL from $url)', + async ({ files, url }) => { + const root = createProjectRoot(); + if (!files) { + rmSync(join(root, 'db'), { recursive: true }); + } + if (url === 'shell') { + vi.stubEnv('DATABASE_URL', 'mysql://root@127.0.0.1:3306/app'); + } else { + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://root@127.0.0.1:3306/app\n'); + } + mysqlQueries.length = 0; + + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + } finally { + vi.unstubAllEnvs(); + } + + const lock = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT GET_LOCK')); + const auth = mysqlQueries.indexOf('Better Auth migrations'); + const release = mysqlQueries.findIndex((sql) => sql.startsWith('SELECT RELEASE_LOCK')); + expect(lock).toBeGreaterThanOrEqual(0); + expect(auth).toBeGreaterThan(lock); + expect(release).toBeGreaterThan(auth); + expect(mysqlQueries.filter((sql) => sql === 'Better Auth migrations')).toHaveLength(1); + }, + ); + + // Migration files apply to the database in .env, as `void db migrate` does. Better Auth's + // migrations change the one DATABASE_URL names in the shell, else in .env. + it.each([ + { + name: 'the shell and .env name different databases', + files: 'one', + shell: 'shell', + env: 'env', + steps: ['lock env', 'record env', 'release env', 'lock shell', 'auth shell', 'release shell'], + }, + { + name: 'no migration files, the shell and .env name different databases', + files: 'none', + shell: 'shell', + env: 'env', + steps: ['lock shell', 'auth shell', 'release shell'], + }, + { + name: 'an empty migrations folder, DATABASE_URL only in the shell', + files: 'empty', + shell: 'shell', + env: undefined, + steps: ['lock shell', 'auth shell', 'release shell'], + }, + { + name: 'the shell and .env name the same database', + files: 'one', + shell: 'app', + env: 'app', + steps: ['lock app', 'record app', 'auth app', 'release app'], + }, + ])( + 'holds the lock of the database each step changes ($name)', + async ({ files, shell, env, steps }) => { + const root = createProjectRoot(); + if (files === 'none') { + rmSync(join(root, 'db'), { recursive: true }); + } else if (files === 'empty') { + rmSync(join(root, 'db', 'migrations', '001_create_users.sql')); + } + if (env) { + writeFileSync(join(root, '.env'), `DATABASE_URL=mysql://root@127.0.0.1:3306/${env}\n`); + } + vi.stubEnv('DATABASE_URL', `mysql://root@127.0.0.1:3306/${shell}`); + mysqlSteps.length = 0; + + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + } finally { + vi.unstubAllEnvs(); + } + + expect(mysqlSteps).toEqual(steps); + }, + ); + + it('reports the applied files when Better Auth migrations fail afterwards', async () => { + const root = createProjectRoot(); + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://root@127.0.0.1:3306/app\n'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + authFailure.error = new Error('Better Auth migration failed'); + let lines: Array; + try { + await runConfigureServer( + migrationPlugin(root, 'mysql', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + ); + lines = log.mock.calls.map(([line]) => String(line)); + } finally { + authFailure.error = undefined; + log.mockRestore(); + } + + const applied = lines.findIndex((line) => + line.includes('Applied 1 migration(s): 001_create_users.sql'), + ); + const failed = lines.findIndex((line) => line.includes('Better Auth migration failed')); + expect(applied).toBeGreaterThanOrEqual(0); + expect(failed).toBeGreaterThan(applied); + }); +}); diff --git a/packages/void/test/unit/runner-mysql.test.ts b/packages/void/test/unit/runner-mysql.test.ts index 0a8bfd431..185e71e45 100644 --- a/packages/void/test/unit/runner-mysql.test.ts +++ b/packages/void/test/unit/runner-mysql.test.ts @@ -1,12 +1,18 @@ import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { runLocalVoidAuthMigrations } from '../../src/auth/node.ts'; import { applyMySqlMigrations, readMySqlAppliedMigrations, + runMySqlMigrations, type MySqlClient, } from '../../src/migrations/runner-mysql.ts'; import { runVoidAuthMigrations } from '../../src/runtime/better-auth-mysql.ts'; import { createMigrationHandler } from '../../src/runtime/migration-handler-mysql.ts'; +import { holdMySqlMigrationLease } from '../../src/runtime/migration-lock-mysql.ts'; const mysqlConnections = vi.hoisted(() => [] as Array); const mysqlServer = vi.hoisted(() => ({ database: undefined as unknown })); @@ -213,10 +219,12 @@ beforeEach(() => { async function migrate( migrations: Array<{ name: string; sql: string }>, + runAuthMigrations?: () => Promise, ): Promise<{ status: number; body: Record }> { const handler = createMigrationHandler( { HYPERDRIVE: { connectionString: 'mysql://localhost/app' }, __VOID_PROXY_TOKEN: 'token' }, migrations, + runAuthMigrations, ); const response = await handler( new Request('http://localhost/__void/migrate', { @@ -229,7 +237,9 @@ async function migrate( /** Runs `start` to its end while fake time moves on one second at a time. */ async function withFakeTime(start: () => Promise): Promise { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'], + }); try { let settled = false; const promise = start().finally(() => { @@ -345,6 +355,22 @@ describe('MySQL migration runner', () => { expect(migrationQueries(database.statements())).toEqual(statements); }); + // Hyperdrive takes one statement per query, and the repair note counts the statements that ran. + it('runs each statement of a file without breakpoints as its own query', async () => { + const sql = '-- hand-written\nCREATE TABLE a (id int);\nCREATE TABLE b (id int); -- done\n'; + const statements = ['CREATE TABLE a (id int);', 'CREATE TABLE b (id int);']; + const migrationQueries = (queries: Array) => + queries.filter((query) => !query.includes('_void_migrations') && !CONTROL.test(query)); + + const { client, sql: calls } = fakeClient(); + await applyMySqlMigrations(client, [{ name: '001_init.sql', sql }]); + expect(migrationQueries(calls())).toEqual(statements); + + const database = useDatabase(new FakeMySqlDatabase()); + expect((await migrate([{ name: '001_init.sql', sql }])).body).toMatchObject({ ok: true }); + expect(migrationQueries(database.statements())).toEqual(statements); + }); + // Two runs at once would both apply a pending data migration. it('holds a named lock for the database while it reads and applies migrations', async () => { const { client, calls, sql } = fakeClient(); @@ -426,31 +452,32 @@ describe('MySQL migration runner', () => { ]); }); - it('says how to repair a file whose earlier statements MySQL already committed', async () => { - const cause = mySqlError( - "Key column 'nope' doesn't exist in table", - 'ER_KEY_COLUMN_DOES_NOT_EXITS', - ); - const { client } = fakeClient({ - committed: true, - failures: { 'ALTER TABLE c ADD INDEX i (nope);': cause }, - }); - - await expect( - applyMySqlMigrations(client, [ - { - name: '002_comments.sql', - sql: 'CREATE TABLE comments (id INT);\n--> statement-breakpoint\nALTER TABLE c ADD INDEX i (nope);', - }, - ]), - ).rejects.toThrow( - [ - "migration 002_comments.sql failed: Key column 'nope' doesn't exist in table", - 'MySQL commits schema changes immediately, so earlier statements in 002_comments.sql may already be applied. Undo them, fix the file, then run the command again.', - 'See https://void.cloud/guide/database/mysql#failed-migrations', - ].join('\n'), - ); - }); + it.each([ + 'CREATE TABLE comments (id INT);\n--> statement-breakpoint\nALTER TABLE c ADD INDEX i (nope);', + 'CREATE TABLE comments (id INT);\nALTER TABLE c ADD INDEX i (nope);', + ])( + 'says how to repair a file whose earlier statements MySQL already committed: %j', + async (sql) => { + const cause = mySqlError( + "Key column 'nope' doesn't exist in table", + 'ER_KEY_COLUMN_DOES_NOT_EXITS', + ); + const { client } = fakeClient({ + committed: true, + failures: { 'ALTER TABLE c ADD INDEX i (nope);': cause }, + }); + + await expect( + applyMySqlMigrations(client, [{ name: '002_comments.sql', sql }]), + ).rejects.toThrow( + [ + "migration 002_comments.sql failed: Key column 'nope' doesn't exist in table", + 'MySQL commits schema changes immediately, so earlier statements in 002_comments.sql may already be applied. Undo them, fix the file, then run the command again.', + 'See https://void.cloud/guide/database/mysql#failed-migrations', + ].join('\n'), + ); + }, + ); it('does not warn about earlier statements when the first statement fails', async () => { const cause = mySqlError("Duplicate column name 'n'", 'ER_DUP_FIELDNAME'); @@ -596,6 +623,62 @@ describe('MySQL hosted migration handler', () => { ], }); }); + + // A stored program keeps the rest of its part, and one query here takes one statement, so the + // server rejects SQL after the program as a syntax error. + const programThenInsert = 'CREATE PROCEDURE p() BEGIN SELECT 1; END;\nINSERT INTO c VALUES (1);'; + const breakpointNote = + 'This deploy runs one statement per query. If 002_p.sql has more SQL after a trigger, procedure, function or event, add a `--> statement-breakpoint` line after it.'; + it.each([ + { + server: 'MySQL', + cause: Object.assign( + mySqlError( + "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'INSERT INTO c VALUES (1)' at line 2", + 'ER_PARSE_ERROR', + ), + { errno: 1064 }, + ), + }, + { + server: 'Vitess', + cause: Object.assign( + mySqlError("syntax error at position 50 near 'INSERT'", 'ER_UNKNOWN_ERROR'), + { errno: 1105 }, + ), + }, + ])('asks for a breakpoint after a stored program on $server', async ({ cause }) => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set(programThenInsert, cause); + + const { body } = await migrate([ + { name: '002_p.sql', sql: `CREATE TABLE c (n INT);\n${programThenInsert}` }, + ]); + + expect(body).toMatchObject({ + ok: false, + migrations: [{ name: '002_p.sql', error: `${cause.message}\n${breakpointNote}` }], + }); + }); + + it('does not ask for a breakpoint for other failures', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const syntax = Object.assign( + mySqlError("You have an error in your SQL syntax near 'TABL c' at line 1", 'ER_PARSE_ERROR'), + { errno: 1064 }, + ); + const missing = Object.assign(mySqlError("Table 'app.c' doesn't exist", 'ER_NO_SUCH_TABLE'), { + errno: 1146, + }); + database.failures.set('CREATE TABL c (n INT);', syntax); + database.failures.set(programThenInsert, missing); + + const plain = await migrate([{ name: '001_c.sql', sql: 'CREATE TABL c (n INT);' }]); + const program = await migrate([{ name: '002_p.sql', sql: programThenInsert }]); + + expect(plain.body).toMatchObject({ migrations: [{ error: syntax.message }] }); + expect(program.body).toMatchObject({ migrations: [{ error: missing.message }] }); + }); }); // The CLI runner holds a named lock; a deploy, which reaches MySQL through Hyperdrive, holds a @@ -790,12 +873,219 @@ describe('MySQL migration lock across runners', () => { }); }); +// Better Auth reads the schema, then adds what is missing, so two runs at once both add it. +describe('MySQL Better Auth migrations under the lock', () => { + it("runs a deploy's Better Auth migrations before another deploy takes the lease", async () => { + const { bodies, runs } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + const runs: Array<{ start: number; end: number }> = []; + const auth = async () => { + const start = Date.now(); + await database.query(database.connect(), 'SELECT SLEEP(5)'); + runs.push({ start, end: Date.now() }); + }; + // An app without migration files: the lease covers only the auth migrations. + const first = migrate([], auth); + await new Promise((resolve) => setTimeout(resolve, 20)); + const results = await Promise.all([first, migrate([], auth)]); + return { bodies: results.map((result) => result.body), runs }; + }); + + expect(bodies.map((body) => body.ok)).toEqual([true, true]); + expect(runs).toHaveLength(2); + expect(runs[1]!.start).toBeGreaterThanOrEqual(runs[0]!.end); + }); + + it('keeps the lease while Better Auth migrations outlast it', async () => { + const { first, second, runs } = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + let runs = 0; + const auth = async () => { + runs++; + await database.query(database.connect(), 'SELECT SLEEP(300)'); + }; + const pending = migrate([], auth); + await new Promise((resolve) => setTimeout(resolve, 20)); + const [first, second] = await Promise.all([pending, migrate([], auth)]); + return { first, second, runs }; + }); + + expect(first.body.ok).toBe(true); + expect(second.body).toEqual({ + ok: false, + error: + 'Another migration run is applying migrations to MySQL database "app". Wait for it to finish, then deploy again.', + migrations: [], + }); + expect(runs).toBe(1); + }); + + it('fails a deploy whose lease another run took during Better Auth migrations', async () => { + const result = await withFakeTime(async () => { + const database = useDatabase(new FakeMySqlDatabase()); + return migrate([], async () => { + database.lease = { owner: 'other', expiresAt: Date.now() + 3_600_000 }; + await database.query(database.connect(), 'SELECT SLEEP(40)'); + }); + }); + + expect(result).toEqual({ + status: 500, + body: { + ok: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + migrations: [], + }, + }); + }); + + it('skips Better Auth migrations after a failed file and releases the lease', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + database.failures.set( + 'INSERT INTO nope VALUES (1);', + mySqlError("Table 'app.nope' doesn't exist", 'ER_NO_SUCH_TABLE'), + ); + let authRuns = 0; + + const { status, body } = await migrate( + [{ name: '001_backfill.sql', sql: 'INSERT INTO nope VALUES (1);' }], + async () => { + authRuns++; + }, + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ ok: false, error: 'migration 001_backfill.sql failed' }); + expect(authRuns).toBe(0); + expect(database.lease!.expiresAt).toBeLessThan(Date.now()); + }); + + it('releases the lease when Better Auth migrations fail', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + + const result = await migrate([], async () => { + throw new Error('Better Auth migration failed'); + }); + + expect(result).toEqual({ + status: 500, + body: { ok: false, error: 'Better Auth migration failed', migrations: [] }, + }); + expect(database.lease!.expiresAt).toBeLessThan(Date.now()); + }); + + 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); + // Another run takes the lease while this one reads its applied files. + vi.spyOn(database, 'query').mockImplementation(async (session, sql, params) => { + if (sql === 'SELECT `name` FROM `_void_migrations`') { + database.lease = { owner: 'other', expiresAt: Date.now() + 3_600_000 }; + } + return query(session, sql, params); + }); + let ran = false; + + const result = await migrate([], async () => { + ran = true; + }); + + expect(ran).toBe(false); + expect(result.body).toMatchObject({ + ok: false, + error: + 'The migration lock on MySQL database "app" expired before this run finished. Deploy again.', + }); + }); + + it('fails when a renewal still in flight at the end of Better Auth migrations fails', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + let renewals = 0; + let failRenewal: ((error: Error) => void) | undefined; + const lease = { + // The first renewal runs before the work; the timer's renewal is still waiting on MySQL. + renew: () => + renewals++ === 0 + ? Promise.resolve() + : new Promise((_resolve, reject) => { + failRenewal = reject; + }), + release: async () => {}, + }; + let finish: (() => void) | undefined; + const outcome = holdMySqlMigrationLease( + lease, + () => + new Promise((resolve) => { + finish = resolve; + }), + ).then( + () => 'done', + (error: Error) => error.message, + ); + + await vi.advanceTimersByTimeAsync(30_000); + finish!(); + await new Promise((resolve) => setImmediate(resolve)); + failRenewal!(new Error('lease taken')); + + expect(await outcome).toBe('lease taken'); + } finally { + vi.useRealTimers(); + } + }); + + // `vp dev` runs Better Auth's migrations this way when they change the database of the files. + it('runs follow-up work in a CLI run before it releases the named lock', async () => { + const database = useDatabase(new FakeMySqlDatabase()); + let holder: unknown; + await runMySqlMigrations( + join(tmpdir(), 'void-no-migrations'), + 'mysql://localhost/app', + async () => { + const [row] = (await database.query(database.connect(), 'SELECT IS_USED_LOCK(?)', [ + LOCK_NAME, + ])) as Array>; + holder = Object.values(row ?? {})[0]; + }, + ); + + expect(holder).toEqual(expect.any(Number)); + expect(database.statements().at(-1)).toBe('SELECT RELEASE_LOCK(?)'); + }); + + // `vp dev` passes the database it holds the lock of. + it('runs local Better Auth migrations on the database it is given', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-url-')); + writeFileSync(join(root, '.env'), 'DATABASE_URL=mysql://localhost/env\n'); + vi.stubEnv('DATABASE_URL', 'mysql://localhost/shell'); + try { + await runLocalVoidAuthMigrations( + root, + 'mysql', + join(root, '.void'), + 'local', + null, + 'mysql://localhost/locked', + ); + } finally { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + + expect(mysqlConnections).toEqual(['mysql://localhost/locked']); + }); +}); + describe('MySQL connections in Workers', () => { // Workers forbid eval, which mysql2 uses to build row parsers unless disableEval is set. it('opens the hosted migration connections with eval disabled', async () => { expect((await migrate([])).body).toMatchObject({ ok: true }); + // Hyperdrive does not take several statements in one query. expect(mysqlConnections).toEqual([ - { uri: 'mysql://localhost/app', multipleStatements: true, disableEval: true }, + { uri: 'mysql://localhost/app', disableEval: true }, { uri: 'mysql://localhost/app', disableEval: true }, ]); }); From afe14709ccd012e73f743b577f058f0ac7ec4f57 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 13:42:11 +0800 Subject: [PATCH 15/17] fix(void): bundle installed void packages for the Node target and SvelteKit An installed void (npm or pnpm, not a workspace link) broke the Node target. The build left the app's void/handler to Node, while the generated routes bundle their own copy of Void's runtime. A ValidationError from a page action then failed the routes' instanceof check, so the request returned 500. An API-only Node build crashed in buildApp: Vite 8 runs that hook for the single-environment builder too, which has no client environment. An installed package that imports void/kv skipped the Cloudflare-only guard, because Vite leaves a package in node_modules to Node before any plugin sees its imports. The maintainer notes listed void/ws as blocked and left out void/sandbox. SvelteKit had the same gap in dev: in a PostgreSQL or MySQL app, an installed package's void/db got the D1 client. The PostgreSQL and MySQL build inlined every dependency with workerd conditions, so a package whose workerd build imports cloudflare:workers (capnweb) broke Kit's analyse step, which loads that output in Node. A new helper finds installed ES module packages that depend on void or @void/*. It reads the app's dependencies and recurses only into those packages. CommonJS packages, including one whose import entry wraps its require build, stay with Node. The Node target now inlines void, @void/* and those packages in dev, Vitest and the build. The build keeps a dynamic cloudflare: import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. The guard error names the importing file. buildApp builds the client only when it exists. In PostgreSQL and MySQL apps, SvelteKit dev inlines the same packages, so they get the app's void/db. The PostgreSQL and MySQL build inlines only what void/db needs: void, drizzle-orm, the driver with its dependencies, and those packages. Other dependencies follow Kit's own rules, as in D1 builds. The D1 client error now says when a package gets the app's void/db. Fixes #416 Fixes #420 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- .github/scripts/packed-consumer.mjs | 54 ++- docs/integrations/nodejs-bun-deno.md | 2 + packages/void/AGENTS.md | 6 +- packages/void/src/installed-packages.ts | 273 +++++++++++ packages/void/src/plugin-framework.ts | 103 ++--- packages/void/src/plugin-node-target.ts | 64 ++- packages/void/src/runtime/db.ts | 6 +- .../test/unit/plugin-vite-environment.test.ts | 434 +++++++++++++++++- .../test/unit/sveltekit-worker-build.test.ts | 145 +++++- 9 files changed, 974 insertions(+), 113 deletions(-) create mode 100644 packages/void/src/installed-packages.ts diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 2cc4d2310..d390b9724 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -7,6 +7,7 @@ import { mkdtempSync, readFileSync, readdirSync, + rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -389,8 +390,59 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E await task.stop(); } } + if (framework === 'react') { + // The Node target bundles the installed void into its build, so a ValidationError from + // void/handler is the class that page actions check, as in dev. + await run( + manager, + manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'], + fixture, + ); + writeFileSync( + join(fixture, 'void.config.ts'), + "import { defineConfig } from 'void/config';\nexport default defineConfig({ target: 'node' });\n", + ); + // KV and email are Cloudflare bindings. + rmSync(join(fixture, 'routes'), { recursive: true }); + writeFileSync( + join(fixture, 'pages/form.tsx'), + 'export default function Form() {\n return

Form

;\n}\n', + ); + writeFileSync( + join(fixture, 'pages/form.server.ts'), + "import { defineHandler, ValidationError } from 'void/handler';\nexport const action = defineHandler(() => {\n throw new ValidationError({ email: 'Email already taken' });\n});\n", + ); + await run(process.execPath, [vite, 'build'], fixture); + const task = launch( + process.execPath, + [vite, 'preview', '--host', '127.0.0.1', '--port', '0', '--strictPort'], + fixture, + ); + try { + const deadline = Date.now() + 60_000; + let base; + while (!base && Date.now() < deadline) { + if (task.child.exitCode !== null) { + throw new Error(task.failure()); + } + base = packedServerUrl(task.output()); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + assert(base, task.failure()); + const response = await fetch(`${base}/form`, { + method: 'POST', + headers: { accept: 'text/html' }, + body: new URLSearchParams({ email: 'taken@example.com' }), + }); + const body = await response.text(); + assert.equal(response.status, 200, `${body}\n${task.failure()}`); + assert(body.includes('Email already taken'), body); + } finally { + await task.stop(); + } + } console.log( - `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}`, + `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 44b30e743..0d992023f 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -80,6 +80,8 @@ dist/ `vite preview` also uses `app.js` to serve your built app locally. +Installed packages that depend on `void` are bundled into `dist/ssr`. If one of their dependencies uses a native addon or reads files next to its own code, add that dependency, in the version the package uses, to your app's `dependencies`. The build then leaves it to Node. + ## What Works These Void features work identically across all targets: diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 474f76f8a..11abcb448 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -37,7 +37,7 @@ voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBinding voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] ``` -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server and Vitest inline `void` (`ssr.noExternal`), as a workspace link does: Vite externalizes a bare import that resolves into node_modules before any plugin sees it, so without this an installed `void/kv` would skip the guard. +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`). Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together. @@ -98,7 +98,7 @@ generated entries must preserve the module-boundary rules below. - **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev, an installed third-party package that imports `void/*` stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev for PostgreSQL and MySQL apps, the plugin also inlines installed ES module packages that depend on void, so their `void/db` is the app's. In a D1 app, and for a CommonJS package or one that does not list void, the package stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. @@ -187,7 +187,7 @@ Agent instructions use versioned markers (`` / `; + readonly devDependencies?: Record; + readonly optionalDependencies?: Record; + readonly peerDependencies?: Record; +}; + +function readManifest(dir: string): PackageManifest | undefined { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest; + } catch { + return undefined; + } +} + +type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null }; + +/** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */ +function findInstalledPackage(name: string, fromDir: string): string | undefined { + // A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds + // `findPnpApi` to `node:module` and serves the packages' files from its archives. + const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir); + if (pnp) { + try { + const manifest = pnp.resolveToUnqualified(`${name}/package.json`, `${fromDir}/`); + return manifest ? dirname(manifest) : undefined; + } catch { + return undefined; + } + } + for (let dir = fromDir; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', name); + if (existsSync(join(candidate, 'package.json'))) { + return realpathSync(candidate); + } + if (dirname(dir) === dir) { + return undefined; + } + } +} + +function isVoidPackage(name: string): boolean { + return name === 'void' || name.startsWith('@void/'); +} + +/** The `exports` conditions that an import matches in Vite's SSR graph. */ +const IMPORT_CONDITIONS = new Set(['node', 'import', 'module', 'default']); + +/** + * Vite's SSR graph also matches `production` or `development`, as NODE_ENV says. Vite sets + * NODE_ENV before it runs the plugin hooks that call this. + */ +function matchesImport(condition: string): boolean { + const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; + return IMPORT_CONDITIONS.has(condition) || condition === mode; +} + +/** The target an import picks from an `exports` value: its first matching key, as in Node. */ +function importTarget(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + for (const item of value) { + const target = importTarget(item); + if (target) { + return target; + } + } + return undefined; + } + if (!value || typeof value !== 'object') { + return undefined; + } + for (const [key, item] of Object.entries(value)) { + const target = matchesImport(key) ? importTarget(item) : undefined; + if (target) { + return target; + } + } + return undefined; +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** The file an import of the package loads: `exports` ".", then `module`, then `main`. */ +function findImportEntry(dir: string, manifest: PackageManifest): string | undefined { + const exports = manifest.exports; + const root = + exports && + typeof exports === 'object' && + Object.keys(exports).some((key) => key.startsWith('.')) + ? (exports as Record)['.'] + : exports; + const entry = + importTarget(root) ?? + (typeof manifest.module === 'string' ? manifest.module : undefined) ?? + (typeof manifest.main === 'string' ? manifest.main : 'index.js'); + // A `main` may leave out the extension or name a directory. + for (const candidate of [entry, `${entry}.js`, `${entry}/index.js`]) { + const file = join(dir, candidate); + if (isFile(file)) { + return file; + } + } + return undefined; +} + +/** Whether Node loads a `.mjs` or `.js` file in the package as an ES module. */ +function isEsmFile(file: string, dir: string, manifest: PackageManifest): boolean { + if (file.endsWith('.mjs')) { + return true; + } + if (!file.endsWith('.js')) { + return false; + } + // The nearest package.json sets the type, and a package can nest one in a subdirectory. + for (let scope = dirname(file); scope.startsWith(`${dir}/`); scope = dirname(scope)) { + const nested = readManifest(scope); + if (nested) { + return nested.type === 'module'; + } + } + return manifest.type === 'module'; +} + +/** A dynamic `import()` argument that is a single string literal naming a relative file. */ +const RELATIVE_LITERAL = /^(["'])(\.{1,2}\/[^"']*)\1$/; + +/** The relative files that a module's imports, re-exports and `import()` calls name. */ +function relativeImports(code: string, parsed: ReturnType): Array { + const specifiers = staticModuleReferences(parsed, code).map((reference) => reference.source); + for (const { moduleRequest } of parsed.module.dynamicImports) { + const literal = RELATIVE_LITERAL.exec(code.slice(moduleRequest.start, moduleRequest.end)); + if (literal) { + specifiers.push(literal[2]!); + } + } + return specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier)); +} + +/** The most files of one package that the check reads. A package with more stays with Node. */ +const MAX_PACKAGE_MODULES = 2000; + +/** + * Vite's SSR module runner evaluates an inlined package as ES modules. The package's import + * entry and every file it reaches through relative imports must be one. So the ES module + * wrapper in Node's dual-package guide, or any ES module of the package that imports a + * CommonJS file, keeps the package with Node. + */ +function shipsEsm(packageDir: string, manifest: PackageManifest): boolean { + const dir = normalize(packageDir); + const entry = findImportEntry(dir, manifest); + if (!entry) { + return false; + } + const files = [entry]; + const seen = new Set(files); + for (const file of files) { + if (!isEsmFile(file, dir, manifest)) { + return false; + } + let code: string; + try { + code = readFileSync(file, 'utf8'); + } catch { + return false; + } + // Vite's SSR transform parses with the same parser and fails on any error, so the module + // runner could not load a file that does not parse. + const parsed = parseSync(file, code, { lang: 'js', sourceType: 'module' }); + if (parsed.errors.length > 0) { + return false; + } + for (const specifier of relativeImports(code, parsed)) { + const next = join(dirname(file), specifier); + if (/\.[cm]?js$/.test(next) && !seen.has(next)) { + if (seen.size >= MAX_PACKAGE_MODULES) { + return false; + } + seen.add(next); + files.push(next); + } + } + } + return true; +} + +/** + * Installed ES module packages that list `void` or an `@void/` package as a dependency, among + * the app's own dependencies and, through them, the packages they depend on (the rule + * vite-plugin-svelte applies to Svelte libraries). Vite leaves a package in node_modules to Node, + * whose `void/*` imports then skip Void's plugins: the generated `void/db` and the Node target's + * guard. Listing these packages in `ssr.noExternal` keeps them in Vite's graph. + */ +export function findVoidDependentPackages(root: string): Array { + const app = readManifest(root); + if (!app) { + return []; + } + const found = new Set(); + const seen = new Set(); + const visit = (names: Iterable, fromDir: string) => { + for (const name of names) { + if (isVoidPackage(name)) { + continue; + } + const dir = findInstalledPackage(name, fromDir); + if (!dir || seen.has(dir)) { + continue; + } + seen.add(dir); + const manifest = readManifest(dir); + const dependencies = { + ...manifest?.dependencies, + ...manifest?.optionalDependencies, + }; + if ( + !manifest || + !Object.keys({ ...dependencies, ...manifest.peerDependencies }).some(isVoidPackage) || + !shipsEsm(dir, manifest) + ) { + continue; + } + found.add(name); + visit(Object.keys(dependencies), dir); + } + }; + visit( + Object.keys({ ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies }), + root, + ); + return [...found].sort(); +} + +/** `name` and every package it depends on, as installed for code in `fromDir`. */ +export function findDependencyClosure(name: string, fromDir: string): Array { + const found = new Set(); + const seen = new Set(); + const visit = (dependency: string, from: string) => { + const dir = findInstalledPackage(dependency, from); + if (!dir || seen.has(dir)) { + return; + } + seen.add(dir); + found.add(dependency); + const manifest = readManifest(dir); + for (const next of Object.keys({ + ...manifest?.dependencies, + ...manifest?.optionalDependencies, + })) { + visit(next, dir); + } + }; + visit(name, fromDir); + return [...found].sort(); +} diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 700640703..a404fdde6 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -1,7 +1,5 @@ -import { readFileSync } from 'node:fs'; -import { builtinModules, createRequire } from 'node:module'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, join } from 'pathslash'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'pathslash'; import { defaultServerConditions, type Plugin } from 'vite'; import { type ApplicationAnalysis } from './application-analysis.ts'; import { @@ -11,9 +9,9 @@ import { readWranglerCompat, resolveLiveBindingConfig, syncWranglerBindings, - type WranglerCompat, } from './cli/wrangler.ts'; import { readConfig } from './config.ts'; +import { findDependencyClosure, findVoidDependentPackages } from './installed-packages.ts'; import { resolveLocalD1Target } from './local-d1.ts'; import { voidLog } from './log.ts'; import { bindingRuntimePlugin } from './plugin-binding-runtime.ts'; @@ -36,39 +34,12 @@ import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hoo const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// Packages Void depends on are installed for code in this directory. +const voidDir = dirname(fileURLToPath(import.meta.url)); const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server'; const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry'; -const NODE_BUILTIN = new RegExp(`^(?:node:)?(?:${builtinModules.join('|')})$`); -const NODE_BUILTIN_REQUIRE_PREFIX = '\0void:node-require:'; - -/** - * The Node built-ins that workerd provides for the given compatibility settings, from the preset - * the Cloudflare plugin builds Workers with (a dependency of `@cloudflare/vite-plugin`). Before - * 2025-09-15 the preset fills in parts of `node:process` instead of listing it, but workerd - * provides that module at every date. - */ -async function readWorkerdNodeBuiltins(compat: WranglerCompat): Promise> { - const vitePlugin = fileURLToPath(import.meta.resolve('@cloudflare/vite-plugin')); - const manifestPath = createRequire(vitePlugin).resolve('@cloudflare/unenv-preset/package.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - exports: { '.': { import: string } }; - }; - const { getCloudflarePreset } = (await import( - pathToFileURL(join(dirname(manifestPath), manifest.exports['.'].import)).href - )) as { - getCloudflarePreset(compat: { compatibilityDate: string; compatibilityFlags: Array }): { - external: Array; - }; - }; - const { external } = getCloudflarePreset({ - compatibilityDate: compat.compatibilityDate, - compatibilityFlags: compat.compatibilityFlags ?? [], - }); - return new Set([...external.map((name) => name.replace(/^node:/, '')), 'process']); -} - export function createFrameworkPlugins(input: { readonly localD1: ReturnType; readonly root: string; @@ -228,8 +199,15 @@ export function createFrameworkPlugins(input: { // externalizes a bare import resolving into node_modules before any plugin // sees it, so an installed void/db would load the package's D1 runtime // instead of the virtual module. Inlining keeps every void import, including - // the injected env import below, in the graph that holds void/db's env module. - return { ssr: { noExternal: ['void'] } }; + // the injected env import below and those of PostgreSQL and MySQL apps' + // installed packages that import void, in the graph that holds void/db's env + // module. In a D1 app, a package's own void/db reads the same D1 binding, so + // those packages stay with Node. + return { + ssr: { + noExternal: ['void', ...(dialect === 'sqlite' ? [] : findVoidDependentPackages(root))], + }, + }; }, resolveId: { order: 'pre', @@ -301,17 +279,18 @@ export default { // loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become // `createRequire(import.meta.url)`. A driver that Vite can resolve from the app root (npm, Yarn // and Bun installs, or an app that lists it) even stays external, and Void's browser-target - // packaging bundles it with browser stubs. Build that environment as the Cloudflare plugin - // builds Workers, and run each request in Void's request scope, as in dev, so void/db keeps one - // client per request. D1 apps keep Kit's output: in a request scope, void/db reads D1 through a - // replica-capable session instead of the primary. - let workerdNodeBuiltins: Promise> | undefined; + // packaging bundles it with browser stubs. Build void/db as the Cloudflare plugin builds + // Workers: void, drizzle-orm, the driver with every package it depends on, and installed + // packages that import void. Other dependencies stay external, as in Kit's own build: Kit's + // analyse step loads this output in Node, and a package's `workerd` build may import + // `cloudflare:` modules (capnweb does). Run each request in Void's request scope, as in dev, so + // void/db keeps one client per request. D1 apps keep Kit's output: in a request scope, void/db + // reads D1 through a replica-capable session instead of the primary. const svelteKitWorkerBuildPlugin: Plugin | undefined = detected.class === 'b' && dialect !== 'sqlite' ? { name: 'void:sveltekit-worker-build', apply: 'build', - // Before Vite's resolver, which makes a required built-in external. enforce: 'pre', configEnvironment(name, environment) { if (name !== 'ssr') { @@ -322,41 +301,22 @@ export default { conditions: environment.resolve?.conditions ? ['workerd', 'worker'] : ['workerd', 'worker', ...defaultServerConditions], - noExternal: true, + noExternal: [ + 'void', + // By name as well: a Yarn PnP install has no node_modules to read. The + // driver's own dependencies then resolve only through it, so they are bundled. + 'drizzle-orm', + dialect === 'mysql' ? 'mysql2' : 'pg', + ...findDependencyClosure('drizzle-orm', voidDir), + ...findDependencyClosure(dialect === 'mysql' ? 'mysql2' : 'pg', voidDir), + ...findVoidDependentPackages(root), + ], }, // Node output binds `require` at load with createRequire(import.meta.url). Neutral // output throws only when a require runs, as for mysql2's optional `cardinal`. build: { rolldownOptions: { platform: 'neutral' } }, }; }, - // Serve each required Node built-in that workerd provides from an import, as the - // Cloudflare plugin does with rolldown's esmExternalRequirePlugin. That plugin works only - // with the rolldown copy running the build, which need not be Void's. A require of any - // other built-in stays as it is and throws only if it runs. - resolveId: { - filter: { id: NODE_BUILTIN }, - async handler(id, _importer, options) { - if (this.environment?.name !== 'ssr' || options.kind !== 'require-call') { - return; - } - const name = id.replace(/^node:/, ''); - workerdNodeBuiltins ??= readWorkerdNodeBuiltins(readWranglerCompat(root, config)); - if (!(await workerdNodeBuiltins).has(name)) { - return; - } - return `${NODE_BUILTIN_REQUIRE_PREFIX}${name}`; - }, - }, - load: { - // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention - filter: { id: /^\0void:node-require:/ }, - handler(id) { - // A built-in's default export is its CommonJS exports, and `require` returns an ES - // module's "module.exports" export. - const name = id.slice(NODE_BUILTIN_REQUIRE_PREFIX.length); - return `import builtin from "node:${name}";\nexport { builtin as "module.exports" };\n`; - }, - }, transform(code, id) { if ( !id.includes('.svelte-kit/generated/server/internal') && @@ -379,6 +339,7 @@ export default { svelteKitHooksInjectPlugin, ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []), clientStubsPlugin, + // Also serves the Node built-ins that the driver requires in SvelteKit's workerd build. serverRuntimeOptimizerPlugin, bindingRuntimePlugin(bindingNames), // Framework-agnostic env DX: typegen, client leak guard, and build-time diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts index 3006b958c..eefdcf6ac 100644 --- a/packages/void/src/plugin-node-target.ts +++ b/packages/void/src/plugin-node-target.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'pathslash'; import type { Plugin } from 'vite'; +import { findVoidDependentPackages } from './installed-packages.ts'; import { readProjectPaths } from './project-paths.ts'; /** Imports that require CF bindings and are not available in Node.js mode. */ @@ -111,17 +112,23 @@ function honoMiddleware( * single SSR build for API-only apps */ export function nodeTargetPlugin(target: NodeTarget): Array { + const unavailable = (id: string, root: string, importer: string | undefined) => { + // The import can come from an installed package, so name the file. + const source = importer ? ` Imported by ${relative(root, importer)}.` : ''; + return new Error( + `target: '${id}' is not available with target '${target}'. ` + + `CF bindings require target: "cloudflare".${source}`, + ); + }; + const guardPlugin: Plugin = { name: 'void:node-target', enforce: 'pre', resolveId: { filter: { id: CF_ONLY_REGEX }, - handler(id) { - throw new Error( - `target: '${id}' is not available with target '${target}'. ` + - `CF bindings require target: "cloudflare".`, - ); + handler(id, importer) { + throw unavailable(id, this.environment.config.root, importer); }, }, @@ -142,15 +149,20 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, config(_, { command }) { + const root = process.cwd(); + // Vite's SSR graph leaves a bare import that resolves into node_modules to Node before + // any plugin sees it. An installed void would then skip the guard above, and in a build + // the app's `void/handler` would load beside the copy that the generated routes bundle, + // so its ValidationError is not the class the routes check. Inline void, its adapters + // and installed packages that import void in the dev server, Vitest and the build alike, + // as with a workspace link. + const ssr = { + noExternal: ['void', /^@void\//, ...findVoidDependentPackages(root)], + }; if (command !== 'build') { - // The dev server (and Vitest) run routes in Vite's Node SSR graph, which - // externalizes a bare import resolving into node_modules before any plugin sees - // it. An installed void/kv would then skip the guard above. Inlining void keeps - // every void import on the plugin path, as with a workspace link. - return { ssr: { noExternal: ['void'] } }; + return { ssr }; } - const root = process.cwd(); const voidDir = join(root, '.void'); const ssrBuildOptions = { outDir: 'dist/ssr', @@ -167,6 +179,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { // API-only apps need a single SSR build. if (existsSync(readProjectPaths(root).pagesDir)) { return { + ssr, build: { outDir: 'dist/client' }, environments: { ssr: { build: ssrBuildOptions }, @@ -178,6 +191,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array { } return { + ssr, build: { ssr: true, ...ssrBuildOptions, @@ -185,14 +199,15 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }; }, - // Orchestrate dual-environment build for pages apps. - // For API-only apps (no `environments.ssr`), this hook is not triggered - // because Vite's builder is only created when extra environments exist. + // Orchestrate the builds. Vite runs this hook with either builder. API-only apps use + // the legacy single-environment builder, which creates only the `ssr` environment. async buildApp(builder) { // SSR must build first so that dist/ssr/app.js exists when the // prerender plugin runs in the client environment's closeBundle hook. await builder.build(builder.environments.ssr); - await builder.build(builder.environments.client); + if (builder.environments.client) { + await builder.build(builder.environments.client); + } }, configureServer(server) { @@ -228,5 +243,22 @@ export function nodeTargetPlugin(target: NodeTarget): Array { }, }; - return [guardPlugin]; + // The build bundles void, whose runtime loads `cloudflare:` modules with import() only when it + // runs in workerd. Keep those external. Node cannot load a static one, so fail the build. + const cloudflareModulesPlugin: Plugin = { + name: 'void:node-target-cloudflare-modules', + apply: 'build', + enforce: 'pre', + resolveId: { + filter: { id: /^cloudflare:/ }, + handler(id, importer, options) { + if (options.kind === 'dynamic-import') { + return { id, external: true }; + } + throw unavailable(id, this.environment.config.root, importer); + }, + }, + }; + + return [guardPlugin, cloudflareModulesPlugin]; } diff --git a/packages/void/src/runtime/db.ts b/packages/void/src/runtime/db.ts index 1f0591b2e..8d6a74939 100644 --- a/packages/void/src/runtime/db.ts +++ b/packages/void/src/runtime/db.ts @@ -58,13 +58,15 @@ const requestDbKey = {}; function requireD1(): D1Database { // Bundlers that skip Void's plugins load this D1 client for every app. Nitro needs the - // `void/nitro` module to serve a PostgreSQL or MySQL app's own `void/db`. + // `void/nitro` module to serve a PostgreSQL or MySQL app's own `void/db`. Vite serves it to + // an installed package only when the package is an ES module that depends on void and the + // app lists it or reaches it through such packages. if ( getRuntimeBinding('DB') == null && (getRuntimeBinding('HYPERDRIVE') != null || getRuntimeBinding('DATABASE_URL') != null) ) { throw new Error( - "db: Missing Cloudflare binding 'DB'. This app connects through HYPERDRIVE or DATABASE_URL, but its server bundle loaded the D1 client. In Nuxt or Analog, add 'void/nitro' to the Nitro modules.", + "db: Missing Cloudflare binding 'DB'. This app connects through HYPERDRIVE or DATABASE_URL, but its server bundle loaded the D1 client. In Nuxt or Analog, add 'void/nitro' to the Nitro modules. An installed package that imports void/db must be an ES module that lists void in its dependencies or peerDependencies, and the app must list it or reach it through such packages.", ); } return requireRuntimeBinding('DB'); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index ab32924bb..3ad078efa 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -1,12 +1,22 @@ -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { cloudflare } from '@cloudflare/vite-plugin'; -import { createServer, normalizePath, resolveConfig } from 'vite'; +import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; +import { findVoidDependentPackages } from '../../src/installed-packages.ts'; const roots: Array = []; @@ -322,6 +332,426 @@ describe('voidPlugin Vite environments', () => { } }); + /** An installed package with the given manifest and `index.js`. */ + function installPackage(root: string, name: string, manifest: object, index: string): void { + const dir = join(root, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, main: 'index.js', ...manifest }), + ); + writeFileSync(join(dir, 'index.js'), index); + } + + it('serves the app void/db to installed ES module packages that depend on void in SvelteKit dev', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { + 'db-lib': '*', + 'cjs-db-lib': '*', + 'wrapped-db-lib': '*', + 'esm-wrapped-db-lib': '*', + }, + devDependencies: { '@sveltejs/kit': '*', void: '*' }, + }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db/schema.ts'), + "import { pgTable, serial } from 'void/schema-pg';\nexport const users = pgTable('users', { id: serial('id') });\n", + ); + installPackage( + root, + 'void', + { type: 'module', exports: { './db': './db.mjs', './schema-pg': './schema-pg.mjs' } }, + '', + ); + for (const file of ['db.mjs', 'schema-pg.mjs']) { + writeFileSync(join(root, 'node_modules/void', file), 'export {};\n'); + } + installPackage( + root, + 'db-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { db } from 'void/db';\n", + ); + // Vite's module runner cannot evaluate CommonJS, so these stay with Node: one CommonJS + // package, and one with an ES module wrapper that imports its CommonJS build. + installPackage( + root, + 'cjs-db-lib', + { peerDependencies: { void: '*' } }, + "exports.db = require('void/db').db;\n", + ); + installPackage( + root, + 'wrapped-db-lib', + { + exports: { import: './wrapper.mjs', require: './index.js' }, + peerDependencies: { void: '*' }, + }, + "exports.db = require('void/db').db;\n", + ); + writeFileSync( + join(root, 'node_modules/wrapped-db-lib/wrapper.mjs'), + "import lib from './index.js';\nexport const db = lib.db;\n", + ); + // The same wrapper in an ES module package, as in Node's dual-package guide. + installPackage( + root, + 'esm-wrapped-db-lib', + { + type: 'module', + exports: { import: './wrapper.mjs', require: './index.cjs' }, + peerDependencies: { void: '*' }, + }, + '', + ); + writeFileSync( + join(root, 'node_modules/esm-wrapped-db-lib/index.cjs'), + "exports.db = require('void/db').db;\n", + ); + writeFileSync( + join(root, 'node_modules/esm-wrapped-db-lib/wrapper.mjs'), + "import lib from './index.cjs';\nexport const db = lib.db;\n", + ); + mkdirSync(join(root, 'src')); + writeFileSync( + join(root, 'src/server.ts'), + "import { db } from 'db-lib';\nimport { db as cjsDb } from 'cjs-db-lib';\nimport { db as wrappedDb } from 'wrapped-db-lib';\nimport { db as esmWrappedDb } from 'esm-wrapped-db-lib';\nexport { db, cjsDb, wrappedDb, esmWrappedDb };\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const app = await server.environments.ssr.transformRequest('/src/server.ts'); + const lib = await server.environments.ssr.transformRequest('/node_modules/db-lib/index.js'); + + expect(app?.code).not.toContain('__vite_ssr_import__("db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("cjs-db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("wrapped-db-lib"'); + expect(app?.code).toContain('__vite_ssr_import__("esm-wrapped-db-lib"'); + expect(lib?.code).toContain('virtual:void-db'); + } finally { + await server.close(); + } + }); + + it('leaves installed packages that depend on void to Node in SvelteKit D1 dev', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'db-lib': '*' }, + devDependencies: { '@sveltejs/kit': '*', void: '*' }, + }), + ); + installPackage(root, 'void', { type: 'module', exports: { './db': './db.mjs' } }, ''); + writeFileSync(join(root, 'node_modules/void/db.mjs'), 'export {};\n'); + installPackage( + root, + 'db-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { db } from 'void/db';\n", + ); + mkdirSync(join(root, 'src')); + writeFileSync(join(root, 'src/server.ts'), "export { db } from 'db-lib';\n"); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const app = await server.environments.ssr.transformRequest('/src/server.ts'); + + expect(app?.code).toContain('__vite_ssr_import__("db-lib"'); + } finally { + await server.close(); + } + }); + + // The rule vite-plugin-svelte applies to Svelte libraries. + it("finds installed ES module packages that depend on void among the app's dependencies and theirs", () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { + 'db-lib': '*', + 'plain-lib': '*', + 'module-db-lib': '*', + 'mjs-db-lib': '*', + }, + devDependencies: { 'dev-db-lib': '*' }, + }), + ); + const usesVoid = { peerDependencies: { void: '*' } }; + installPackage( + root, + 'db-lib', + { type: 'module', dependencies: { 'inner-db-lib': '*' }, ...usesVoid }, + '', + ); + installPackage(root, 'inner-db-lib', { type: 'module', ...usesVoid }, ''); + installPackage(root, 'dev-db-lib', { type: 'module', ...usesVoid }, ''); + // Packages that a package without void depends on stay with it. + installPackage( + root, + 'plain-lib', + { type: 'module', dependencies: { 'hidden-db-lib': '*' } }, + '', + ); + installPackage(root, 'hidden-db-lib', { type: 'module', ...usesVoid }, ''); + // ES module entries outside a `type: "module"` package. + installPackage(root, 'module-db-lib', { module: './index.mjs', ...usesVoid }, ''); + installPackage(root, 'mjs-db-lib', { exports: './index.mjs', ...usesVoid }, ''); + for (const name of ['module-db-lib', 'mjs-db-lib']) { + writeFileSync(join(root, 'node_modules', name, 'index.mjs'), ''); + } + + expect(findVoidDependentPackages(root)).toEqual([ + 'db-lib', + 'dev-db-lib', + 'inner-db-lib', + 'mjs-db-lib', + 'module-db-lib', + ]); + }); + + // Vite's module runner evaluates an inlined package as ES modules, so a package whose import + // entry loads CommonJS stays with Node. + const wrapper = { + 'wrapper.mjs': "import lib from './index.cjs';\nexport const db = lib.db;\n", + 'index.cjs': 'exports.db = 1;\n', + }; + it.each([ + { + shape: 'separate ES module and CommonJS entries', + manifest: { exports: { '.': { import: './index.mjs', require: './index.cjs' } } }, + files: { 'index.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: true, + }, + { + shape: 'a nested type: module folder', + manifest: { exports: { import: './esm/index.js', require: './index.js' } }, + files: { 'esm/package.json': '{"type":"module"}', 'esm/index.js': 'export const db = 1;\n' }, + found: true, + }, + { + shape: "Node's dual-package wrapper", + manifest: { exports: { import: './wrapper.mjs', require: './index.cjs' } }, + files: wrapper, + found: false, + }, + { + shape: 'that wrapper under type: module', + manifest: { type: 'module', exports: { import: './wrapper.mjs', require: './index.cjs' } }, + files: wrapper, + found: false, + }, + { + shape: 'an ES module entry that imports .cjs', + manifest: { type: 'module' }, + files: { + 'index.js': "import vendor from './vendor.cjs';\nexport const db = vendor.db;\n", + 'vendor.cjs': 'module.exports = { db: 1 };\n', + }, + found: false, + }, + ])('finds an installed package with $shape: $found', ({ manifest, files, found }) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { 'db-lib': '*' } }), + ); + installPackage( + root, + 'db-lib', + { ...manifest, peerDependencies: { void: '*' } }, + 'exports.db = 1;\n', + ); + for (const [path, content] of Object.entries(files)) { + mkdirSync(join(root, 'node_modules/db-lib', path, '..'), { recursive: true }); + writeFileSync(join(root, 'node_modules/db-lib', path), content); + } + + expect(findVoidDependentPackages(root)).toEqual(found ? ['db-lib'] : []); + }); + + it('rejects Cloudflare-only imports from installed packages on the Node target dev server', async () => { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'kv-lib': '*' }, + devDependencies: { void: '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + installPackage(root, 'void', { type: 'module', exports: { './kv': './kv.mjs' } }, ''); + writeFileSync(join(root, 'node_modules/void/kv.mjs'), 'export const kv = {};\n'); + installPackage( + root, + 'kv-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { kv } from 'void/kv';\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/cache.ts'), + "import { kv } from 'kv-lib';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + await expect(server.ssrLoadModule('/routes/api/cache.ts')).rejects.toThrow( + "target: 'void/kv' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by node_modules/kv-lib/index.js.", + ); + } finally { + await server.close(); + } + }); + + describe('Node target builds with an installed void', () => { + /** An API-only Node-target app whose route imports void and an adapter from node_modules. */ + function createNodeApp(route: string): string { + const root = realpathSync.native(createProjectRoot()); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + type: 'module', + dependencies: { 'kv-lib': '*' }, + devDependencies: { void: '*', '@void/adapter': '*' }, + }), + ); + writeFileSync(join(root, 'void.json'), JSON.stringify({ target: 'node' })); + installPackage( + root, + 'void', + { type: 'module', exports: { './handler': './handler.mjs', './_env': './env.mjs' } }, + '', + ); + writeFileSync( + join(root, 'node_modules/void/handler.mjs'), + 'export class ValidationError extends Error {}\n', + ); + // Void's runtime imports cloudflare:workers only when it runs in workerd. + writeFileSync( + join(root, 'node_modules/void/env.mjs'), + "export const workers = () => typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers' ? import('cloudflare:workers') : null;\n", + ); + installPackage( + root, + '@void/adapter', + { type: 'module' }, + "export { ValidationError } from 'void/handler';\n", + ); + installPackage( + root, + 'kv-lib', + { type: 'module', peerDependencies: { void: '*' } }, + "export { kv } from 'void/kv';\n", + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync(join(root, 'routes/api/ping.ts'), route); + vi.spyOn(process, 'cwd').mockReturnValue(root); + return root; + } + + async function buildApp(root: string): Promise { + // The CLI's builder, which runs the plugins' buildApp hooks. + const builder = await createBuilder( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + null, + ); + await builder.buildApp(); + const output = join(root, 'dist/ssr'); + return readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(output, file), 'utf8')) + .join('\n'); + } + + it('bundles void and its adapters, as the dev server inlines them', async () => { + const root = createNodeApp( + "import { ValidationError } from 'void/handler';\nimport { ValidationError as AdapterError } from '@void/adapter';\nimport { workers } from 'void/_env';\nexport const GET = () => Response.json({ same: ValidationError === AdapterError, workers: typeof workers });\n", + ); + + const code = await buildApp(root); + + expect(code).toContain('ValidationError = class extends Error'); + expect(code).not.toMatch(/from\s*"(?:void|@void)\//); + expect(code).toContain('import("cloudflare:workers")'); + }); + + // A Pages app's build has a client environment too. + it('inlines void, its adapters and the packages that depend on it in a Pages app build', async () => { + const root = createNodeApp('export const GET = () => Response.json({});\n'); + mkdirSync(join(root, 'pages')); + + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + + expect(config.environments.ssr!.resolve.noExternal).toEqual( + expect.arrayContaining(['void', /^@void\//, 'kv-lib']), + ); + }); + + it('rejects Cloudflare-only imports from installed packages', async () => { + const root = createNodeApp( + "import { kv } from 'kv-lib';\nexport const GET = () => Response.json({ kv: typeof kv });\n", + ); + + await expect(buildApp(root)).rejects.toThrow( + "target: 'void/kv' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by node_modules/kv-lib/index.js.", + ); + }); + + it('rejects a static cloudflare: import, which Node cannot load', async () => { + const root = createNodeApp( + "import { env } from 'cloudflare:workers';\nexport const GET = () => Response.json({ env: typeof env });\n", + ); + + await expect(buildApp(root)).rejects.toThrow( + "target: 'cloudflare:workers' is not available with target 'node'. CF bindings require target: \"cloudflare\". Imported by routes/api/ping.ts.", + ); + }); + }); + // The package entry is the whole Vite plugin; server code needs only its handler helpers. it.each([ ['sveltekit', '@sveltejs/kit', 'ssr', true], diff --git a/packages/void/test/unit/sveltekit-worker-build.test.ts b/packages/void/test/unit/sveltekit-worker-build.test.ts index eed828d29..53afcfd3e 100644 --- a/packages/void/test/unit/sveltekit-worker-build.test.ts +++ b/packages/void/test/unit/sveltekit-worker-build.test.ts @@ -1,17 +1,19 @@ +import { spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { mkdirSync, mkdtempSync, + readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; -import { createRequire } from 'node:module'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { getWorkerBundleDir, readBuildOutput } from '@cloudflare/build-output-utils'; import { PGlite } from '@electric-sql/pglite'; import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; @@ -21,8 +23,11 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { prepareCfFrameworkBuildOutput } from '../../src/cli/cf-build-output.ts'; import { bundleEntry } from '../../src/cli/wrapper.ts'; import { voidPlugin } from '../../src/index.ts'; +import { findDependencyClosure } from '../../src/installed-packages.ts'; import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; +vi.mock('../../src/installed-packages.ts', { spy: true }); + // Each case runs two Vite builds and starts workerd. const TIMEOUT_MS = (process.platform === 'win32' ? 3 : 1) * 60_000; const COMPATIBILITY_DATE = '2026-02-24'; @@ -39,7 +44,8 @@ type KitApp = { root: string; compatibilityDate: string }; /** * A SvelteKit server build reduced to what Void sees: Kit's generated get_hooks module, a route - * that queries void/db, and an entry that calls `handle` as the adapter's Worker does. + * that queries void/db, and an entry that calls `handle` as the adapter's Worker does. Like Kit's + * analyse step, it then loads the output in Node. */ async function buildKitServer( database: 'pg' | 'mysql', @@ -47,29 +53,87 @@ async function buildKitServer( hoisted = false, compatibilityDate = COMPATIBILITY_DATE, kit = 3, - }: { hoisted?: boolean; compatibilityDate?: string; kit?: 2 | 3 } = {}, + listsDriver = false, + }: { hoisted?: boolean; compatibilityDate?: string; kit?: 2 | 3; listsDriver?: boolean } = {}, ): Promise { // Windows runners report a short-name temp path, which Vite refuses to serve. - const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-kit-worker-'))); - roots.push(root); + const parent = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-kit-worker-'))); + roots.push(parent); + const root = join(parent, 'app'); const write = (path: string, content: string) => { mkdirSync(join(root, path, '..'), { recursive: true }); writeFileSync(join(root, path), content); }; - write('package.json', JSON.stringify({ type: 'module', dependencies: { '@sveltejs/kit': '*' } })); + write( + 'package.json', + JSON.stringify({ type: 'module', dependencies: { '@sveltejs/kit': '*', 'db-dep': '*' } }), + ); if (hoisted) { - // npm, Yarn and Bun install void's drivers next to the app, as pnpm does when the app lists - // them, so Vite can resolve them from the app root. - const voidRequire = createRequire(new URL('../../package.json', import.meta.url)); - mkdirSync(join(root, 'node_modules')); - for (const name of ['pg', 'mysql2']) { + // npm, Yarn and Bun install every package at the top, so Vite resolves the driver and each + // package it depends on from the app root. pnpm's hidden hoisted directory holds them all. + // Link each package to its real folder: behind a Windows junction to the whole directory, + // pnpm's relative package links resolve at the wrong depth. + const hoistedDir = fileURLToPath( + new URL('../../../../node_modules/.pnpm/node_modules', import.meta.url), + ); + const link = (name: string) => symlinkSync( - dirname(voidRequire.resolve(`${name}/package.json`)), - join(root, 'node_modules', name), + realpathSync.native(join(hoistedDir, name)), + join(parent, 'node_modules', name), 'junction', ); + mkdirSync(join(parent, 'node_modules')); + for (const name of readdirSync(hoistedDir)) { + if (name.startsWith('@')) { + mkdirSync(join(parent, 'node_modules', name)); + for (const scoped of readdirSync(join(hoistedDir, name))) { + link(join(name, scoped)); + } + } else if (!name.startsWith('.')) { + link(name); + } + } + } else { + // pnpm links the app's own dependencies, and keeps theirs, such as void's driver, hidden. + mkdirSync(join(root, 'node_modules'), { recursive: true }); + symlinkSync( + fileURLToPath(new URL('../..', import.meta.url)), + join(root, 'node_modules/void'), + 'junction', + ); + if (listsDriver) { + // Vite then resolves them from the app root. + for (const name of ['drizzle-orm', database === 'mysql' ? 'mysql2' : 'pg']) { + symlinkSync( + realpathSync.native( + fileURLToPath(new URL(`../../node_modules/${name}`, import.meta.url)), + ), + join(root, 'node_modules', name), + 'junction', + ); + } } } + // An app dependency whose workerd build imports a `cloudflare:` module, as capnweb's does. + write( + 'node_modules/cf-dep/package.json', + JSON.stringify({ + name: 'cf-dep', + type: 'module', + exports: { workerd: './worker.js', default: './node.js' }, + }), + ); + write( + 'node_modules/cf-dep/worker.js', + "import { connect } from 'cloudflare:sockets';\nexport const runtime = typeof connect;\n", + ); + write('node_modules/cf-dep/node.js', "export const runtime = 'node';\n"); + // An installed package that queries the app's database through void/db. + write( + 'node_modules/db-dep/package.json', + JSON.stringify({ name: 'db-dep', type: 'module', peerDependencies: { void: '*' } }), + ); + write('node_modules/db-dep/index.js', "export { db as packageDb } from 'void/db';\n"); write( 'void.json', JSON.stringify({ @@ -77,7 +141,13 @@ async function buildKitServer( worker: { compatibility_date: compatibilityDate, compatibility_flags: ['nodejs_compat'] }, }), ); - write('db/schema.ts', 'export {};\n'); + // An app's schema imports drizzle's table builders through void. + write( + 'db/schema.ts', + database === 'pg' + ? "import { pgTable, integer } from 'void/schema-pg';\nexport const users = pgTable('users', { id: integer('id') });\n" + : "import { mysqlTable, int } from 'void/schema-mysql';\nexport const users = mysqlTable('users', { id: int('id') });\n", + ); // Kit 2 writes get_hooks to server/internal.js, Kit 3 to build/server.js. const hooksModule = kit === 2 @@ -91,14 +161,16 @@ async function buildKitServer( write('src/terminal.cjs', "exports.isTerminal = () => require('tty').isatty(1);\n"); write( 'src/route.js', - `import { db, sql } from 'void/db'; + `import { runtime } from 'cf-dep'; +import { packageDb } from 'db-dep'; +import { db, sql } from 'void/db'; import { isTerminal } from './terminal.cjs'; export async function GET(request) { if (new URL(request.url).searchParams.has('terminal')) { - return Response.json({ terminal: isTerminal() }); + return Response.json({ terminal: isTerminal(), runtime }); } - const result = await db.execute(sql\`select 1 as one\`); + const result = await packageDb.execute(sql\`select 1 as one\`); // Outside Void's request scope, each use of db builds its own pool. const pool = () => db.$client.options ?? db.$client.config; return Response.json({ @@ -133,6 +205,29 @@ export default { plugins: [voidPlugin()], build: { ssr: 'src/entry.js', outDir: 'build/server', emptyOutDir: true }, }); + // Kit's analyse step imports the server nodes in a Node worker. + const output = join(root, 'build/server'); + const modules = readdirSync(output, { recursive: true }) + .map(String) + .filter((file) => file.endsWith('.js')) + .map((file) => pathToFileURL(join(output, file)).href); + const analyse = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + 'for (const url of JSON.parse(process.argv[1])) await import(url);', + JSON.stringify(modules), + ], + { encoding: 'utf8' }, + ); + expect(analyse.status, analyse.stderr).toBe(0); + // The output holds void/db's drizzle-orm and driver, even where Node could load the app's. + for (const file of modules) { + expect(readFileSync(new URL(file), 'utf8')).not.toMatch( + /^(?:import|export)\b[^;]*?\bfrom\s*["'](?:drizzle-orm|pg|mysql2)(?:\/[^"']*)?["']/m, + ); + } return { root, compatibilityDate }; } @@ -312,3 +407,17 @@ it( }, TIMEOUT_MS, ); + +// A Yarn PnP install has no node_modules folder in which to find void's dependencies. +it( + 'bundles drizzle-orm and the driver that the app lists when their dependencies cannot be read', + async () => { + vi.mocked(findDependencyClosure).mockReturnValue([]); + try { + await buildKitServer('pg', { listsDriver: true }); + } finally { + vi.mocked(findDependencyClosure).mockRestore(); + } + }, + TIMEOUT_MS, +); From ee0267bc8515461d0208ce4bd4555f91a06b543f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 13:45:24 +0800 Subject: [PATCH 16/17] fix(void): give framework dev crons the handler exports of bare void, and fix Astro preview and restarts React Router, TanStack Start, vinext and Astro dev servers import every cron and queue file into workerd for the dev triggers. A cron that imports defineScheduled from 'void' loaded the package entry, which is the Vite plugin. The dependency optimizer then tried to bundle Wrangler, so every dev trigger returned 500. In Astro every request failed. `astro preview` after `astro build` failed with "No build output found". The build moves Wrangler's deploy config pointer aside, and only a Vite preview put it back. `astro preview` loads the config but runs none of Void's Vite hooks. When Astro restarts the dev server, the new server starts before the old one closes. The new plugin saw Void's middleware file and left it alone, and the old one then deleted it, so /__void/scheduled returned 404. The fix: - A shared plugin sends bare 'void' to void/handler in the server environments of class A frameworks and Astro, in dev and build. Client code keeps the package entry. - voidPlugin() puts the deploy config pointer back when it runs for `astro preview`, and removes it when the process exits, as a Vite preview does. - The Astro dev middleware keeps a per-process count of the dev servers that use it. A new server joins the count, and the last one to close removes the file and its .gitignore line. Astro's own restart right after "ready" comes from its update check, not from Void code. Fixes #417 Fixes #419 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- packages/void/AGENTS.md | 2 +- packages/void/src/index.ts | 29 +++++-- .../void/src/plugin-framework-triggers.ts | 78 ++++++++++++++----- packages/void/src/plugin-framework.ts | 10 ++- .../void/src/plugin-worker-environment.ts | 23 ++++++ packages/void/test/integration/astro.test.ts | 48 +++++++++++- .../test/integration/react-router.test.ts | 24 +++++- .../test/unit/plugin-class-a-wrapper.test.ts | 60 +++++++++++++- .../unit/plugin-framework-triggers.test.ts | 66 ++++++++++++++++ .../test/unit/plugin-vite-environment.test.ts | 47 ++++++++++- 10 files changed, 352 insertions(+), 35 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 11abcb448..e854fe261 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -96,7 +96,7 @@ generated entries must preserve the module-boundary rules below. - **One-time deployment targets**: Scope saved project IDs and implicit OIDC slugs to their recorded platform origin. A different platform needs `--project` or `VOID_PROJECT`. Managed deployment may establish a first project link, but must never overwrite an existing link, another selected platform URL, or a Cloudflare/Skip deployment preference. Apply this to both prompted and explicit creation/selection, including failed deployments. Re-read the link after reauthentication so a first project created earlier in the attempt is reused. -- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. +- **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. Class A frameworks and Astro get the same redirect for their server environments from `createBareVoidRedirectPlugin`, in dev and build: their dev trigger dispatch imports every cron and queue file into workerd, so one `import { defineScheduled } from 'void'` would otherwise break every trigger (and, in Astro, every request). - **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev for PostgreSQL and MySQL apps, the plugin also inlines installed ES module packages that depend on void, so their `void/db` is the app's. In a D1 app, and for a CommonJS package or one that does not list void, the package stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 515189cf2..f2d1b7d01 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -63,6 +63,7 @@ import { restartPlugin } from './plugin-restart.ts'; import { routingPlugin } from './plugin-routing.ts'; import { mergeWorkerBindings } from './plugin-worker-bindings.ts'; import { + createBareVoidRedirectPlugin, createServerRuntimeOptimizerPlugin, DEV_ASSET_ROUTING_EXCLUSIONS, createCloudflareBuiltinsExternalPlugin, @@ -108,18 +109,26 @@ export function voidPlugin(options?: { ? projectCloudflareConfigPath : undefined; let previewing = false; + // A preview reads the build's Wrangler deploy config, which the build moved aside. Put it back + // until the preview exits. + const restorePreviewPointer = () => { + if (!existsSync(join(root, 'void.config.ts'))) { + return; + } + if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) { + scheduledPreviewPointerCleanups.add(root); + process.once('exit', () => { + rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true }); + }); + } + }; const implicitDeployGuard: Plugin = { name: 'void:remove-implicit-cloudflare-deploy-pointer', enforce: 'pre', config(_config, environment) { previewing = environment.isPreview === true; - if (previewing && existsSync(join(root, 'void.config.ts'))) { - if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) { - scheduledPreviewPointerCleanups.add(root); - process.once('exit', () => { - rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true }); - }); - } + if (previewing) { + restorePreviewPointer(); } }, closeBundle() { @@ -157,6 +166,11 @@ export function voidPlugin(options?: { assertNoRootSourceConventionConflicts(paths); const detected = application.framework; const framework = detected?.class === 'a' ? (detected.name as Framework) : undefined; + if (detected?.name === 'astro' && process.argv[2] === 'preview') { + // `astro preview` loads this config but starts the adapter's preview server without its + // Vite plugins, after the adapter checks for the deploy config. + restorePreviewPointer(); + } const scanDirs = config.inference?.scanDirs ?? (detected ? FRAMEWORK_SCAN_DIRS : undefined); const frameworkOwnsCloudflareRuntime = @@ -728,6 +742,7 @@ export function voidPlugin(options?: { return [ createCloudflareBuiltinsExternalPlugin(), + createBareVoidRedirectPlugin(), createWorkerEnvironmentSanitizerPlugin([ viteEnvironment.name, ...(viteEnvironment.childEnvironments ?? []), diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index 9f66ab397..65d7205ec 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -554,6 +554,23 @@ export const onRequest = defineMiddleware(async (context, next) => { `; } +type AstroMiddlewareClaim = { servers: number; gitignoreLines: ReadonlyArray }; + +const ASTRO_MIDDLEWARE_CLAIMS: unique symbol = Symbol.for('void.astroDevMiddleware.v1'); + +/** + * The dev servers in this process that use the middleware Void wrote, by file path. Astro restarts + * the dev server in the same process with a fresh config, so a new plugin instance (from a new + * copy of this module, too) is set up before the old server closes. Kept on `globalThis` so both + * see the same claim. + */ +function astroMiddlewareClaims(): Map { + const store = globalThis as typeof globalThis & { + [ASTRO_MIDDLEWARE_CLAIMS]?: Map; + }; + return (store[ASTRO_MIDDLEWARE_CLAIMS] ??= new Map()); +} + /** * Writes a tiny Astro middleware to the user's project so * `/__void/scheduled` and `/__void/queue` are intercepted inside Astro's @@ -563,47 +580,72 @@ export const onRequest = defineMiddleware(async (context, next) => { * already has `src/middleware.ts` we don't overwrite it — instead we log a * 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. `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. + * If we wrote the file we remove it when the last dev server using it closes, + * so the user's 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 = () => { + const claims = astroMiddlewareClaims(); + + /** Whether the dev server being set up now holds the middleware Void wrote. */ + const ensureFile = (): boolean => { + const claim = claims.get(middlewarePath); + if (claim && existsSync(middlewarePath)) { + // A restart: the server being replaced wrote this file and still holds it. + claim.servers++; + return true; + } if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) { voidWarn( 'Astro middleware already exists. To enable /__void/scheduled and /__void/queue,\n' + ' add the Void dev-trigger middleware to your existing onRequest sequence.\n' + ' See https://void.cloud/guide/jobs#local-development.', ); - return; + return false; } mkdirSync(dirname(middlewarePath), { recursive: true }); writeFileSync(middlewarePath, astroMiddlewareSource(frameworkTriggersDispatchPath(root))); - wroteFile = true; + if (claim) { + // Removed while the server being replaced still holds it. + claim.servers++; + } else { + claims.set(middlewarePath, { + servers: 1, + gitignoreLines: ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]), + }); + } + return true; }; - const cleanup = () => { - if (wroteFile) { - rmSync(middlewarePath, { force: true }); + const release = () => { + const claim = claims.get(middlewarePath); + if (!claim || --claim.servers > 0) { + return; } - removeGitignoreLines(root, addedGitignoreLines); - addedGitignoreLines = []; + claims.delete(middlewarePath); + rmSync(middlewarePath, { force: true }); + removeGitignoreLines(root, claim.gitignoreLines); }; return { name: 'void:astro-dev-trigger-inject', apply: 'serve', configureServer(server) { - ensureFile(); - if (wroteFile) { - addedGitignoreLines = ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]); + if (!ensureFile()) { + return; } + // One release per dev server. Vite's own restart (after an .env edit) sets up this same + // plugin for the new server before it closes the old one. + let held = true; + const cleanup = () => { + if (held) { + held = false; + release(); + } + }; server.httpServer?.once('close', cleanup); process.once('exit', cleanup); }, diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index a404fdde6..484dd9aa8 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -29,7 +29,10 @@ import { nitroDevTriggerInjectPlugin, } from './plugin-framework-triggers.ts'; import { migrationPlugin } from './plugin-migration.ts'; -import { createCloudflareBuiltinsExternalPlugin } from './plugin-worker-environment.ts'; +import { + createBareVoidRedirectPlugin, + createCloudflareBuiltinsExternalPlugin, +} from './plugin-worker-environment.ts'; import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; const handlerFile = fileURLToPath(import.meta.resolve('#self/handler')); @@ -365,7 +368,10 @@ export default { ? [nitroDevTriggerInjectPlugin(root, { serverDir: 'server' })] : []), ...(detected.name === 'analog' ? [analogDevTriggerInjectPlugin(root)] : []), - ...(detected.name === 'astro' ? [astroDevTriggerInjectPlugin(root)] : []), + // Astro runs its server code, the dev cron and queue dispatch included, in workerd. + ...(detected.name === 'astro' + ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)] + : []), implicitDeployGuard, ]; } diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index ef98153c5..5bb0396ae 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -49,6 +49,29 @@ export function createCloudflareBuiltinsExternalPlugin(): Plugin { }; } +/** + * Send a bare `void` import in server code to the handler helpers (`defineScheduled`, + * `defineQueue`, ...), as Void apps and the cron/queue deploy bundles do. The package entry is the + * Vite plugin: loaded in a worker environment, it pulls Wrangler into the dependency optimizer. + */ +export function createBareVoidRedirectPlugin(): Plugin { + let handlerFile: string | undefined; + return { + name: 'void:bare-void-redirect', + resolveId: { + order: 'pre', + filter: { id: /^void$/ }, + handler() { + if (this.environment?.config.consumer === 'client') { + return; + } + handlerFile ??= fileURLToPath(import.meta.resolve('#self/handler')); + return handlerFile; + }, + }, + }; +} + export function createDefaultWorkerEnvironmentPlugin(): Plugin { return { name: 'void:default-worker-environment', diff --git a/packages/void/test/integration/astro.test.ts b/packages/void/test/integration/astro.test.ts index eeb2cc6b4..449e94be0 100644 --- a/packages/void/test/integration/astro.test.ts +++ b/packages/void/test/integration/astro.test.ts @@ -30,6 +30,26 @@ function readWranglerConfig(root: string): { describe('playground/astro dev', () => { let child: ReturnType; + // Dev writes Void's trigger middleware into src/ and removes it when the server stops. taskkill + // on Windows skips the exit hooks, and a later copy of this playground would keep a middleware + // that checks this server's trigger token. + const removeLeftoverMiddleware = () => { + const root = join(ROOT, PLAYGROUND); + const middleware = join(root, 'src/middleware.ts'); + if ( + !existsSync(middleware) || + !readFileSync(middleware, 'utf8').includes('Auto-generated by Void in dev') + ) { + return; + } + rmSync(middleware); + const gitignore = join(root, '.gitignore'); + writeFileSync( + gitignore, + readFileSync(gitignore, 'utf8').replace(/^src\/middleware\.ts\r?\n/m, ''), + ); + }; + const startDev = async () => { const server = await startServer((port) => { child = spawn( @@ -72,6 +92,7 @@ describe('playground/astro dev', () => { if (child) { await killChild(child); } + removeLeftoverMiddleware(); }); it('syncs wrangler config with compat flag and inferred bindings', () => { @@ -236,6 +257,17 @@ export async function GET() { const results = await Promise.all([allUsers, allUsers, allUsers, allUsers]); return Response.json(results.map((rows) => rows.length)); } +`, + ); + // The import the jobs guide shows. The package entry is Void's Vite plugin, which workerd + // cannot load; the dev middleware imports every cron on each request. + writeFileSync( + join(root, 'crons/tick.ts'), + `import { defineScheduled } from 'void'; + +export const cron = '*/5 * * * *'; + +export default defineScheduled(async () => {}); `, ); writeFileSync( @@ -273,6 +305,17 @@ export async function GET() { } }); + it('runs a cron that imports defineScheduled from void in dev', async () => { + const token = readFileSync(join(playground.root, '.void/dev-trigger-token'), 'utf8').trim(); + const response = await fetch(`${dev.base}/__void/scheduled`, { + method: 'POST', + headers: { 'x-void-dev-trigger': token }, + body: JSON.stringify({ cron: '*/5 * * * *', scheduledTime: Date.now() }), + }); + const result = { status: response.status, body: await response.text() }; + expect(result, dev.output().slice(-4000)).toEqual({ status: 200, body: '{"ok":true}' }); + }); + it('runs every query of a request on one connection in the built Worker', async () => { // The dev server writes Void's dev-only middleware into src/ until it stops. await killChild(dev.child); @@ -283,10 +326,11 @@ export async function GET() { { cwd: playground.root, stdio: 'pipe', - // Build as `void deploy` does: it keeps the Wrangler deploy config `astro preview` reads. - env: { ...process.env, VOID_CLOUDFLARE_DEPLOY_BUILD: '1', VOID_INSPECTOR_PORT: 'false' }, + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, }, ); + // A plain build moves Wrangler's deploy config aside; `astro preview` puts it back. + expect(existsSync(join(playground.root, '.wrangler/deploy/config.json'))).toBe(false); const server = await start('preview'); try { for (const _request of [1, 2]) { diff --git a/packages/void/test/integration/react-router.test.ts b/packages/void/test/integration/react-router.test.ts index 90380a68e..7d26f7d1b 100644 --- a/packages/void/test/integration/react-router.test.ts +++ b/packages/void/test/integration/react-router.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; @@ -94,6 +94,18 @@ describe('playground/react-router with MySQL', () => { ); rmSync(join(root, 'db/migrations'), { recursive: true, force: true }); rmSync(join(root, 'crons'), { recursive: true, force: true }); + // The import the jobs guide shows. The package entry is Void's Vite plugin, which workerd + // cannot load. + mkdirSync(join(root, 'crons')); + writeFileSync( + join(root, 'crons/tick.ts'), + `import { defineScheduled } from 'void'; + +export const cron = '*/5 * * * *'; + +export default defineScheduled(async () => {}); +`, + ); writeFileSync( join(root, 'db/schema.ts'), `import { int, mysqlTable, varchar } from 'void/schema-mysql'; @@ -175,10 +187,18 @@ export async function loader() { } } - it('queries MySQL through void/db in dev', async () => { + it('queries MySQL through void/db and runs a cron in dev', async () => { const server = await start('dev'); try { await expectUsers(server); + const token = readFileSync(join(playground.root, '.void/dev-trigger-token'), 'utf8').trim(); + const response = await fetch(`${server.base}/__void/scheduled`, { + method: 'POST', + headers: { 'x-void-dev-trigger': token }, + body: JSON.stringify({ cron: '*/5 * * * *', scheduledTime: Date.now() }), + }); + const result = { status: response.status, body: await response.text() }; + expect(result, server.output().slice(-4000)).toEqual({ status: 200, body: '{"ok":true}' }); } finally { await killChild(server.child); child = undefined; diff --git a/packages/void/test/unit/plugin-class-a-wrapper.test.ts b/packages/void/test/unit/plugin-class-a-wrapper.test.ts index 82060f4da..1a71c9a45 100644 --- a/packages/void/test/unit/plugin-class-a-wrapper.test.ts +++ b/packages/void/test/unit/plugin-class-a-wrapper.test.ts @@ -1,9 +1,18 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { Plugin, ResolvedConfig } from 'vite'; +import { createServer, normalizePath, type Plugin, type ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { frameworkTriggersDispatchPath } from '../../src/plugin-framework-triggers.ts'; @@ -131,6 +140,53 @@ describe('Class A framework dev wrapper', () => { }); }); +describe('Class A bare void imports', () => { + // The package entry is the whole Vite plugin; a cron needs only `defineScheduled` and friends. + it.each([ + ['ssr', true], + ['client', false], + ] as const)( + 'sends bare void in the %s environment to the handler entry: %s', + async (environment, redirected) => { + const root = realpathSync.native(createTanStackRoot()); + const installed = join(root, 'node_modules/void'); + mkdirSync(installed, { recursive: true }); + writeFileSync( + join(installed, 'package.json'), + JSON.stringify({ name: 'void', type: 'module', exports: { '.': './index.mjs' } }), + ); + writeFileSync(join(installed, 'index.mjs'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [voidPlugin()], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, preTransformRequests: false }, + }); + try { + const resolved = await server.environments[environment]!.pluginContainer.resolveId( + 'void', + join(root, 'crons/tick.ts'), + ); + + expect(resolved?.id).toBe( + normalizePath( + redirected + ? createRequire(import.meta.url).resolve('void/handler') + : join(installed, 'index.mjs'), + ), + ); + } finally { + await server.close(); + } + }, + ); +}); + describe('Class A framework request scope', () => { /** The dev wrapper, written when the plugin is created, and the build wrapper. */ async function generateWrappers(root: string): Promise<{ dev: string; build: string }> { diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index 2ec2b9822..adade2714 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -577,6 +577,72 @@ describe('astroDevTriggerInjectPlugin', () => { expect(existsSync(join(root, 'src', 'middleware.ts'))).toBe(false); expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); }); + + // A restart sets up the new dev server, with a new plugin, before it closes the old one. + it.each([ + ['', false], + [' after it was deleted', true], + ])( + 'keeps its middleware for the dev server that replaces it on a restart%s', + (_name, deleted) => { + const root = createTempRoot(); + const middlewarePath = join(root, 'src', 'middleware.ts'); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'once').mockReturnValue(process); + const closeServer: Array<() => void> = []; + const startServer = () => + callConfigureServer(astroDevTriggerInjectPlugin(root), { + httpServer: { + once(_event: string, cb: () => void) { + closeServer.push(cb); + }, + }, + }); + + startServer(); + if (deleted) { + rmSync(middlewarePath); + } + startServer(); + closeServer[0]!(); + expect(existsSync(middlewarePath)).toBe(true); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toContain('src/middleware.ts'); + expect(log).not.toHaveBeenCalled(); + + closeServer[1]!(); + expect(existsSync(middlewarePath)).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }, + ); + + // Vite restarts on an .env edit with the same plugins: it sets up this plugin for the new + // server, then closes the old one. + it('removes its middleware when the last server closes after Vite restarts the same plugin', () => { + const root = createTempRoot(); + const middlewarePath = join(root, 'src', 'middleware.ts'); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + vi.spyOn(process, 'once').mockReturnValue(process); + const plugin = astroDevTriggerInjectPlugin(root); + const closeServer: Array<() => void> = []; + const startServer = () => + callConfigureServer(plugin, { + httpServer: { + once(_event: string, cb: () => void) { + closeServer.push(cb); + }, + }, + }); + + startServer(); + startServer(); + closeServer[0]!(); + expect(existsSync(middlewarePath)).toBe(true); + + closeServer[1]!(); + expect(existsSync(middlewarePath)).toBe(false); + expect(readFileSync(join(root, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + }); }); describe('analogDevTriggerInjectPlugin', () => { diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 3ad078efa..d35a74dc2 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -756,7 +756,8 @@ describe('voidPlugin Vite environments', () => { it.each([ ['sveltekit', '@sveltejs/kit', 'ssr', true], ['sveltekit', '@sveltejs/kit', 'client', false], - ['astro', 'astro', 'ssr', false], + ['astro', 'astro', 'ssr', true], + ['astro', 'astro', 'client', false], ] as const)( 'sends bare void from %s %s code in the %s environment to the handler entry: %s', async (_name, dependency, environment, redirected) => { @@ -802,6 +803,50 @@ describe('voidPlugin Vite environments', () => { ); }); +describe('Astro preview', () => { + // `astro preview` loads the Astro config, which calls voidPlugin(), but runs none of its hooks. + it.each([ + ['preview', true], + ['dev', false], + ] as const)( + "puts back the build's Wrangler deploy config for `astro %s`: %s", + (command, restored) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { astro: '*' } }), + ); + rmSync(join(root, 'void.json')); + writeFileSync(join(root, 'void.config.ts'), 'export default {};\n'); + const pointer = '{"configPath":"../../dist/server/wrangler.json"}'; + mkdirSync(join(root, '.void')); + writeFileSync(join(root, '.void/preview-deploy-config.json'), pointer); + vi.spyOn(process, 'cwd').mockReturnValue(root); + // Run the process exit cleanup below, before the temp root is removed. + const once = vi.spyOn(process, 'once').mockReturnValue(process); + const argv = process.argv; + process.argv = [argv[0]!, 'astro.mjs', command]; + try { + voidPlugin(); + } finally { + process.argv = argv; + } + + const deployConfig = join(root, '.wrangler/deploy/config.json'); + expect(existsSync(deployConfig) ? readFileSync(deployConfig, 'utf8') : undefined).toBe( + restored ? pointer : undefined, + ); + // As after a Vite preview, a deploy then finds no implicit Wrangler config. + for (const [event, listener] of once.mock.calls) { + if (event === 'exit') { + (listener as () => void)(); + } + } + expect(existsSync(deployConfig)).toBe(false); + }, + ); +}); + describe('Astro request scope', () => { const entry = '@astrojs/cloudflare/entrypoints/server'; const scopedEntry = '\0void:astro-server-entry'; From 0f436597e5d131fdc4ca2674bca5ac2d0e56fca4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 14 Oct 2026 13:51:13 +0800 Subject: [PATCH 17/17] fix(void): build Nitro pg/MySQL Workers with Node compatibility and each import's own driver Nuxt and Analog apps with a PostgreSQL or MySQL database did not deploy as working Workers. A native Cloudflare deploy did not add nodejs_compat. Nitro reads that flag only from a wrangler.* file, so it bundled Node stubs, and node:crypto calls failed in the Worker. The init notes for Nuxt and Analog left out the Cloudflare preset and the dev config, so Nitro built a Node server and dev had no bindings. Analog's Vite server build compiled its own void/db, drizzle and driver into a page chunk that Nitro then bundled. With a hoisted pg this failed on pg-native; otherwise it added unused driver copies. nuxi typecheck could not find @schema and rejected cloudflareDev. Nitro and the deploy cron and queue bundles also gave every pg or mysql2 import the driver from Void's own dependencies, even an app's own import of another version. No test ran a Nitro-built pg or MySQL Worker in workerd. Native deploys now add nodejs_compat for pg and MySQL, as managed deploys do. void/nitro turns on Nitro's cloudflare.nodeCompat for pg and MySQL Worker builds. For D1 it does so only when the flags both deploy paths give the Worker list nodejs_compat. The Nuxt and Analog notes print a config that builds for Cloudflare and runs dev with bindings. In a Nuxt or Analog build, Void's Vite plugin leaves a pg or MySQL void/db import to Nitro, so the app has one client and one driver copy, or the void/nitro hint when the module is missing. The Nuxt docs and playground use a top-level alias and cloudflare.dev. Each pg or mysql2 import now gets a workerd build of the copy the bundler resolves for it. The lookup runs as a require, so Rollup's CommonJS plugin does not wrap the raw driver. Workers need pg 8.11 or later (for its Cloudflare socket) and mysql2 3.13 or later (for disableEval). The build now fails on an older copy with an error that names the driver version and the importer. A new test builds real Nitro Workers for pg and MySQL and runs them in Miniflare. Fixes #418 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/integrations/frameworks/nuxt.md | 11 +- package.json | 2 +- packages/void/AGENTS.md | 6 +- .../void/src/cli/deploy-cloudflare-static.ts | 6 +- packages/void/src/cli/init.ts | 23 +- packages/void/src/cli/wrapper.ts | 60 ++- packages/void/src/nitro.ts | 44 +- packages/void/src/plugin-drizzle.ts | 133 ++++- packages/void/src/plugin-framework.ts | 1 + .../test/integration/nitro-worker.test.ts | 454 ++++++++++++++++++ .../void/test/unit/cloudflare-deploy.test.ts | 35 ++ packages/void/test/unit/init.test.ts | 33 +- packages/void/test/unit/nitro.test.ts | 197 +++++++- .../void/test/unit/plugin-drizzle.test.ts | 31 ++ .../test/unit/plugin-vite-environment.test.ts | 200 +++++++- playground/nuxt/nuxt.config.ts | 10 +- test/integration-timings.ts | 1 + 17 files changed, 1166 insertions(+), 81 deletions(-) create mode 100644 packages/void/test/integration/nitro-worker.test.ts diff --git a/docs/integrations/frameworks/nuxt.md b/docs/integrations/frameworks/nuxt.md index 1248c707f..25065a748 100644 --- a/docs/integrations/frameworks/nuxt.md +++ b/docs/integrations/frameworks/nuxt.md @@ -30,24 +30,23 @@ import { resolve } from 'node:path'; import { voidPlugin } from 'void'; export default defineNuxtConfig({ + alias: { + '@schema': resolve(import.meta.dirname, 'db/schema.ts'), + }, nitro: { preset: 'cloudflare-module', modules: ['void/nitro'], - cloudflareDev: { configPath: './.void-wrangler.jsonc' }, + cloudflare: { dev: { configPath: './.void-wrangler.jsonc' } }, // Required for Nuxt 4.6.0 on Windows. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, - alias: { - // Nuxt types server routes from Nitro's aliases. - '@schema': resolve(import.meta.dirname, 'db/schema.ts'), - }, }, vite: { plugins: [voidPlugin()] }, }); ``` -The `void/nitro` module gives Nuxt server routes your app's `void/db` and `@schema`. +The `void/nitro` module gives Nuxt server routes your app's `void/db` and `@schema`. The top-level `alias` lets `nuxi typecheck` resolve `@schema`, because Nuxt also checks server routes with the app's types. `cloudflare.dev` gives the development server the bindings from Void's generated Cloudflare config; Nuxt runs it in development when `compatibilityDate` is `2025-07-15` or later. ### 4. Create `void.config.ts` diff --git a/package.json b/package.json index 2f9d8f03c..596587b0e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test": "node test/affected.mjs", "test:full": "vp test run", "test:integration": "vp test run --project parallel-integration", - "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts", + "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nitro-worker.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts", "test:pages:react-vue": "vp test run --project parallel-integration packages/react packages/vue", "test:pages:svelte-solid": "vp test run --project parallel-integration packages/svelte packages/solid", "test:platform": "vp test run --project 'platform-*'", diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e854fe261..ac73f0c56 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -37,7 +37,7 @@ voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBinding voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()] ``` -Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`). +Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. A Yarn Plug'n'Play install has no node_modules, so there it asks the PnP runtime that `yarn` loads (`findPnpApi` on `node:module`) for each package directory. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", where Vite also matches `development` or, when NODE_ENV is production, `production`; then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`). Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together. @@ -102,7 +102,7 @@ generated entries must preserve the module-boundary rules below. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. - **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. -- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. Bundles that never run Void's Vite plugins, Nitro (through `void/nitro`) and the deploy cron/queue handler build, get the same module from `voidDbBundlerPlugin`, which resolves `@schema` through the bundler so a schema folder works. Their Worker builds import the driver that `bundleWorkerSqlDriver` builds like the migration handler: Nitro's CommonJS handling and Node stubs break pg and mysql2 in workerd. For pg and MySQL, `void/nitro` also runs each request in `withRuntimeEnv` (as the Class A wrapper does) and keeps Void's `node:async_hooks` native in workerd builds, because Nitro's unenv AsyncLocalStorage drops the store after the first await. The deploy handler build likewise runs each cron event and queue batch in `withRuntimeEnv`. +- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. Bundles that never run Void's Vite plugins, Nitro (through `void/nitro`) and the deploy cron/queue handler build, get the same module from `voidDbBundlerPlugin`, which resolves `@schema` through the bundler so a schema folder works. Their Worker builds import the driver that `bundleWorkerSqlDriver` builds like the migration handler: Nitro's CommonJS handling and Node stubs break pg and mysql2 in workerd. Each `pg`/`mysql2` import gets its own build of the copy the bundler resolves for it, looked up as a `require` so Rollup's CommonJS plugin neither wraps nor loads the raw driver, so Void's drizzle-orm and an app's own driver import keep their own versions. An import of `mysql2/promise` gets that copy's promise API from the same build, so it shares the driver's modules as in Node. A pg older than 8.11 has no `pg-cloudflare` socket and a mysql2 older than 3.13 has no `disableEval` option, so the lookup fails the build with an error that names the driver version and the importer. In an npm, Yarn or Bun install, Void's hoisted drizzle-orm loads the hoisted driver, which is the app's own copy when the app lists one. For pg and MySQL, `void/nitro` also runs each request in `withRuntimeEnv` (as the Class A wrapper does) and keeps Void's `node:async_hooks` native in workerd builds, because Nitro's unenv AsyncLocalStorage drops the store after the first await. Nitro turns on `cloudflare.nodeCompat` only from a `wrangler.*` file, so `void/nitro` sets it for pg/MySQL Worker builds (every deploy adds `nodejs_compat` for them) and for D1 only when the flags a deploy gives the Worker list `nodejs_compat` (the Cloudflare config's, which a managed deploy replaces with `worker.compatibility_flags` when set): a Worker built that way does not start without the flag. Nuxt and Analog also build server code with Vite (Analog's page server loads, Nuxt's app renderer), and Nitro bundles that output: Void's Vite plugin leaves a pg/MySQL `void/db` import there external, so Nitro serves one module and one driver copy, or the package's D1 client and its `void/nitro` hint when the module is missing. The deploy handler build likewise runs each cron event and queue batch in `withRuntimeEnv`. - **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. @@ -121,7 +121,7 @@ generated entries must preserve the module-boundary rules below. - **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock. - **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. Better Auth's MySQL migrations change the schema too, so each runner keeps its lock through them: the hosted handler renews the lease on a 30-second timer while they run (their statements cannot renew it), and `vp dev` holds the lock of the database each step changes: migration files go to `.env`'s `DATABASE_URL`, and Better Auth to the shell's `DATABASE_URL` before `.env`'s, a URL it resolves once and passes to `runLocalVoidAuthMigrations`. When both name one database, Better Auth runs as `runMySqlMigrations`'s `afterApply` under the same lock. `void db reset`, and the reset that `void db seed` does first, hold `GET_LOCK` from the first `DROP` until the files are applied again, and keep `_void_migration_lock`; seed data loads after the lock, like app writes. -- **MySQL statements**: Cloudflare documents multi-statement queries as unsupported by Hyperdrive (not tried on a live Hyperdrive), so both MySQL runners send one statement per query (`splitMySqlMigrationSql`) and the hosted connection does not set `multipleStatements`. Each breakpoint part splits again at semicolons outside strings, quoted names and comments; a statement that opens a stored program (`CREATE` or `ALTER` of a `TRIGGER`, `PROCEDURE`, `FUNCTION` or `EVENT`) keeps the rest of its part with it, since its body's semicolons look like statement ends. Words such as `event` or a `BEGIN;` transaction do not count, so hand-written files that use them still split. Vitess (PlanetScale) also rejects a comment after a statement's semicolon unless the client sets `multipleStatements`, so the splitter drops comments after a part's last statement. The CLI connections keep `multipleStatements`: seed SQL needs it, and a whole part with several statements keeps running there as before. +- **MySQL statements**: Cloudflare documents multi-statement queries as unsupported by Hyperdrive (not tried on a live Hyperdrive), so both MySQL runners send one statement per query (`splitMySqlMigrationSql`) and the hosted connection does not set `multipleStatements`. Each breakpoint part splits again at semicolons outside strings, quoted names and comments; a statement that opens a stored program (`CREATE` or `ALTER` of a `TRIGGER`, `PROCEDURE`, `FUNCTION` or `EVENT`) keeps the rest of its part with it, since its body's semicolons look like statement ends. Without `multipleStatements` the server rejects SQL after the program as a syntax error before it runs any of the query, so the hosted runner adds a note that asks for a `--> statement-breakpoint` after the program. Words such as `event` or a `BEGIN;` transaction do not count, so hand-written files that use them still split. Vitess (PlanetScale) also rejects a comment after a statement's semicolon unless the client sets `multipleStatements`, so the splitter drops comments after a part's last statement. The CLI connections keep `multipleStatements`: seed SQL needs it, and a whole part with several statements keeps running there as before. ### Self-Host AI + ISR diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index 0ebcf10ac..d6bd48da1 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -133,7 +133,11 @@ export async function prepareCloudflareFrameworkArtifact(options: { voidConfig.worker?.compatibility_date ?? readWranglerTopLevelString(rootConfig, 'compatibility_date') ?? DEFAULT_COMPATIBILITY_DATE, - compatibilityFlags: preset.cloudflareCompatibilityFlags, + // The PostgreSQL and MySQL drivers need workerd's Node modules, as in a managed deploy. + compatibilityFlags: + analysis.dialect === 'sqlite' + ? preset.cloudflareCompatibilityFlags + : [...(preset.cloudflareCompatibilityFlags ?? []), 'nodejs_compat'], crons: [...new Set(crons.flatMap((job) => job.crons))], requiredSecrets: requiredSecretNames, versionMetadataBinding, diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 9a890120c..51b767690 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -2373,10 +2373,18 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { if (framework.name === 'nuxt') { note( `Detected Nuxt. Void init does not patch nuxt.config.* automatically.\n\n` + - `Add \`voidPlugin()\` to the Nuxt Vite plugins option and \`void/nitro\` to the Nitro modules:\n\n` + + `Add \`voidPlugin()\` to the Nuxt Vite plugins option, the \`@schema\` alias that \`nuxi typecheck\` reads, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to the Nitro options:\n\n` + + `import { resolve } from "node:path";\n` + `import { voidPlugin } from "void";\n\n` + `export default defineNuxtConfig({\n` + - ` nitro: { modules: ["void/nitro"] },\n` + + ` alias: {\n` + + ` "@schema": resolve(import.meta.dirname, "db/schema.ts"),\n` + + ` },\n` + + ` nitro: {\n` + + ` preset: "cloudflare-module",\n` + + ` modules: ["void/nitro"],\n` + + ` cloudflare: { dev: { configPath: "./.void-wrangler.jsonc" } },\n` + + ` },\n` + ` vite: { plugins: [voidPlugin()] },\n` + `});`, 'Manual Nuxt config step', @@ -2387,12 +2395,19 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void { if (framework.name === 'analog') { note( `Detected Analog. Void init does not patch vite.config.* automatically.\n\n` + - `Add \`voidPlugin()\` to the Vite plugins and \`void/nitro\` to Analog's Nitro modules:\n\n` + + `Install \`nitro-cloudflare-dev\` as a dev dependency. Add \`voidPlugin()\` to the Vite plugins, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to Analog's Nitro options:\n\n` + `import analog from "@analogjs/platform";\n` + `import { voidPlugin } from "void";\n\n` + `export default defineConfig({\n` + ` plugins: [\n` + - ` analog({ nitro: { modules: ["nitro-cloudflare-dev", "void/nitro"] } }),\n` + + ` analog({\n` + + ` ssr: true,\n` + + ` nitro: {\n` + + ` preset: "cloudflare-module",\n` + + ` modules: ["nitro-cloudflare-dev", "void/nitro"],\n` + + ` cloudflareDev: { configPath: "./.void-wrangler.jsonc" },\n` + + ` },\n` + + ` }),\n` + ` voidPlugin(),\n` + ` ],\n` + `});`, diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index a670ac6bb..cb22a7d31 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, @@ -131,7 +132,8 @@ export async function generateWrapper( plugins: [ voidDbBundlerPlugin(root, { config, - sqlDriver: (dialect) => bundleWorkerSqlDriver(dialect, join(tmpDir, 'sql-driver')), + sqlDriver: (dialect, entry) => + bundleWorkerSqlDriver(dialect, entry, join(tmpDir, 'sql-driver')), }), ], resolve: { @@ -323,26 +325,27 @@ function pgWorkerAliases(fromPath: string): Record { } /** - * Build Void's PostgreSQL or MySQL driver as one ES module for workerd, the way the migration - * handler bundles it, and return its path. Bundlers that do not run voidPlugin() import this - * module for `pg` or `mysql2`: their CommonJS handling breaks the drivers in workerd. + * Build the PostgreSQL or MySQL driver whose entry file is `main` as an ES module for workerd, + * the way the migration handler bundles Void's own, and return its path. Bundlers that do not run + * voidPlugin() import this module for `pg` or `mysql2`: their CommonJS handling breaks the + * drivers in workerd. Each driver copy gets its own folder in `baseDir`. The same build gives + * mysql2's `mysql2/promise` API as `promise.mjs` next to it, so both share one copy of the driver. */ export async function bundleWorkerSqlDriver( dialect: 'postgresql' | 'mysql', - outDir: string, + main: string, + baseDir: string, ): Promise { const packageName = dialect === 'postgresql' ? 'pg' : 'mysql2'; - const require = createRequire(import.meta.url); - const main = require.resolve(packageName); - const names = Object.keys(require(packageName) as object).filter( - (name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name), - ); + const outDir = join(baseDir, createHash('sha256').update(main).digest('hex').slice(0, 12)); mkdirSync(outDir, { recursive: true }); - const entry = join(outDir, 'entry.mjs'); - writeFileSync( - entry, - `import driver from ${JSON.stringify(toSlash(main))};\nexport default driver;\nexport const { ${names.join(', ')} } = driver;\n`, - ); + const input: Record = { + [packageName]: writeDriverEntry(join(outDir, 'entry.mjs'), main), + }; + const promise = dialect === 'mysql' ? resolveMysqlPromise(main) : undefined; + if (promise !== undefined) { + input.promise = writeDriverEntry(join(outDir, 'promise-entry.mjs'), promise); + } const bundleDir = join(outDir, 'out'); await build({ root: outDir, @@ -356,13 +359,13 @@ export async function bundleWorkerSqlDriver( outDir: bundleDir, emptyOutDir: true, minify: false, - ssr: entry, + ssr: true, target: 'esnext', rollupOptions: { - input: entry, + input, external: SQL_WORKER_EXTERNAL, output: { - entryFileNames: `${packageName}.mjs`, + entryFileNames: '[name].mjs', chunkFileNames: `${packageName}-[hash].mjs`, }, }, @@ -376,6 +379,27 @@ export async function bundleWorkerSqlDriver( return join(bundleDir, `${packageName}.mjs`); } +/** Write an ES module at `entry` that exports the CommonJS module `file`, and return `entry`. */ +function writeDriverEntry(entry: string, file: string): string { + const names = Object.keys(createRequire(file)(file) as object).filter( + (name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name), + ); + writeFileSync( + entry, + `import driver from ${JSON.stringify(toSlash(file))};\nexport default driver;\nexport const { ${names.join(', ')} } = driver;\n`, + ); + return entry; +} + +/** The `mysql2/promise` file of the mysql2 copy whose entry file is `main`, if it has one. */ +function resolveMysqlPromise(main: string): string | undefined { + try { + return createRequire(main).resolve('mysql2/promise'); + } catch { + return undefined; + } +} + /** `bundle` names the bundle in the error a failed require throws. */ function patchWorkerRequireBundle( outDir: string, diff --git a/packages/void/src/nitro.ts b/packages/void/src/nitro.ts index f42e2de3f..ee09a28aa 100644 --- a/packages/void/src/nitro.ts +++ b/packages/void/src/nitro.ts @@ -1,3 +1,4 @@ +import { resolve as resolveWindowsPath } from 'node:path/win32'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; @@ -20,9 +21,23 @@ type NitroInstance = { plugins: Array; virtual: Record string | Promise)>; rollupConfig?: { plugins?: unknown }; + cloudflare?: { nodeCompat?: boolean }; }; }; +const NODEJS_COMPAT_FLAGS = new Set(['nodejs_compat', 'nodejs_compat_v2']); + +/** + * `path` as Nitro's Rollup names its modules: with backslashes on Windows, as its node-resolve + * plugin returns them. That plugin looks up an absolute import, such as the `global` polyfill + * Nitro adds to pg, by walking up from the importer to a package.json. From a module named with + * forward slashes there, the walk does not stop at the drive root, so it never ends when no + * package.json is above the module. + */ +function nitroModuleId(path: string): string { + return process.platform === 'win32' ? resolveWindowsPath(path) : path; +} + /** * A Nitro runtime plugin that runs each request in Void's request scope, as the Class A wrapper * does, so void/db keeps one PostgreSQL or MySQL client per request instead of one per query. @@ -41,9 +56,9 @@ export default function voidRequestScope(nitroApp) { } /** - * Keep Void's `node:async_hooks` imports for workerd's own module. Without nodejs_compat in a - * Wrangler config that it reads, Nitro bundles a stub whose AsyncLocalStorage loses the request - * scope at the first await. + * Keep Void's `node:async_hooks` imports for workerd's own module. With its `cloudflare.nodeCompat` + * option off, Nitro bundles a stub whose AsyncLocalStorage loses the request scope at the first + * await. */ const nativeAsyncHooksPlugin: Plugin = { name: 'void:nitro-async-hooks', @@ -77,11 +92,30 @@ const voidNitroModule = { voidDbBundlerPlugin(options.rootDir, { config, ...(workerd && { - sqlDriver: (sqlDialect) => - bundleWorkerSqlDriver(sqlDialect, join(options.buildDir, 'void')), + sqlDriver: async (sqlDialect, entry) => + nitroModuleId( + await bundleWorkerSqlDriver(sqlDialect, entry, join(options.buildDir, 'void')), + ), }), }), ]; + // Nitro uses workerd's own Node modules instead of its stubs only when it finds nodejs_compat + // in a Wrangler config, and Void's Cloudflare config is not one it reads. Deploys add the flag + // for PostgreSQL and MySQL, whose drivers need it. Otherwise the Worker gets the Cloudflare + // config's flags, or in a managed deploy `worker.compatibility_flags` when those are set: a + // Worker built for native modules does not start without the flag. + const nodeCompat = (flags: Array | undefined) => + flags?.some((flag) => NODEJS_COMPAT_FLAGS.has(flag)) === true; + const cloudflareFlags = config.cloudflare?.compatibility_flags; + if ( + workerd && + (dialect !== 'sqlite' || + (nodeCompat(cloudflareFlags) && + nodeCompat(config.worker?.compatibility_flags ?? cloudflareFlags))) + ) { + options.cloudflare ??= {}; + options.cloudflare.nodeCompat ??= true; + } // D1 apps keep Nitro's requests as they are: in a request scope, void/db reads D1 through // a replica-capable session instead of the primary. if (dialect !== 'sqlite') { diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index e7d50589c..996e4f98e 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -1,6 +1,7 @@ -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'pathslash'; +import { dirname, isAbsolute, join, relative } from 'pathslash'; import { normalizePath, type Plugin } from 'vite'; import { DRIZZLE_OPERATORS, generateDbDts } from './codegen/db-types.ts'; import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts'; @@ -54,6 +55,8 @@ type DrizzlePluginOptions = VoidDbModuleOptions & { localD1DatabaseId?: string; localD1PersistRoot?: string; redirectDbTypePaths?: boolean; + /** Nitro bundles the server build's output (Nuxt and Analog). */ + nitroBundlesServer?: boolean; }; /** @@ -356,7 +359,19 @@ export function drizzlePlugin( if (id === 'void/db' && this.environment?.name === 'client') { return; } - return dbModule.resolveId(id, importer); + const resolved = dbModule.resolveId(id, importer); + // Nitro bundles this output with the server routes. Leave a PostgreSQL or MySQL app's + // `void/db` to it, so the app has one client and one driver copy: the one `void/nitro` + // serves, or without the module the package's own, which says to add it. + if ( + resolved === VIRTUAL_DB_ID && + dialect !== 'sqlite' && + options.nitroBundlesServer && + this.environment?.mode === 'build' + ) { + return { id, external: true }; + } + return resolved; }, }, @@ -403,14 +418,17 @@ export function drizzlePlugin( * schema. It reads the dialect, schema and D1 binding from the project, as the Vite plugin does. * Its hooks are plain Rollup hooks; Vite also needs `enforce` to run them before its resolver. * - * For a bundle that runs in workerd, `sqlDriver` builds the PostgreSQL or MySQL driver, and the - * plugin serves that module for `pg` or `mysql2`. + * For a bundle that runs in workerd, `sqlDriver` builds a copy of the PostgreSQL or MySQL driver + * from its entry file, and the plugin serves that module for `pg` or `mysql2` (for + * `mysql2/promise`, the promise API from the same build). Each import gets the copy that the + * bundler resolves for it: the generated client's drizzle-orm gets the one it depends on, and an + * app's own import gets the app's version. */ export function voidDbBundlerPlugin( root: string, options: { config?: VoidConfig; - sqlDriver?: (dialect: 'postgresql' | 'mysql') => Promise; + sqlDriver?: (dialect: 'postgresql' | 'mysql', entry: string) => Promise; } = {}, ): Plugin { const config = options.config ?? readConfig(root); @@ -425,26 +443,48 @@ export function voidDbBundlerPlugin( ); const driverPackage = dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql2' : undefined; - let driver: Promise | undefined; - let driverDir: string | undefined; + const drivers = new Map>(); + const driverDirs: Array = []; return { name: 'void:db', enforce: 'pre', - async resolveId(id, importer) { - if (id === driverPackage && options.sqlDriver && dialect !== 'sqlite') { - driver ??= options.sqlDriver(dialect).then((path) => { - driverDir = `${normalizePath(dirname(path))}/`; - return path; + async resolveId(id, importer, resolveOptions) { + const promiseApi = driverPackage === 'mysql2' && MYSQL2_PROMISE.test(id); + if ((id === driverPackage || promiseApi) && options.sqlDriver && dialect !== 'sqlite') { + // Resolve it as a `require` would. For an import, Rollup's CommonJS plugin loads a CommonJS + // driver to wrap it: that hides the file, and its build of the driver can fail on optional + // native modules. + const custom = resolveOptions?.custom; + const resolved = await this.resolve(id, importer, { + ...resolveOptions, + skipSelf: true, + custom: { ...custom, 'node-resolve': { ...custom?.['node-resolve'], isRequire: true } }, }); - return driver; + const entry = + resolved && !resolved.external + ? resolvePackageEntry(driverPackage, resolved.id) + : undefined; + if (entry === undefined) { + return resolved; + } + let driver = drivers.get(entry); + if (!driver) { + assertWorkerDriver(driverPackage, entry, root, importer); + driver = options.sqlDriver(dialect, entry).then((path) => { + driverDirs.push(`${normalizePath(dirname(path))}/`); + return path; + }); + drivers.set(entry, driver); + } + // The promise API sits next to the driver, named the way `sqlDriver` names the driver. + return promiseApi ? (await driver).replace(/mysql2\.mjs$/, 'promise.mjs') : driver; } - // The driver needs workerd's own Node modules: Nitro replaces them with stubs unless it - // finds nodejs_compat in a Wrangler config, and Void's is not one it reads. + // The driver needs workerd's own Node modules: Nitro replaces them with stubs when its + // `cloudflare.nodeCompat` option is off. if ( - driverDir && id.startsWith('node:') && importer && - normalizePath(importer).startsWith(driverDir) + driverDirs.some((dir) => normalizePath(importer).startsWith(dir)) ) { return { id, external: true }; } @@ -464,3 +504,60 @@ export function voidDbBundlerPlugin( }, }; } + +/** mysql2's promise API, which `bundleWorkerSqlDriver` builds next to the driver. */ +const MYSQL2_PROMISE = /^mysql2\/promise(?:\.js)?$/; + +/** + * The first driver versions that run in Workers: pg 8.11 brings pg-cloudflare, its socket there, + * and mysql2 3.13 the `disableEval` option, without which it compiles its parsers with + * `new Function`, which workerd forbids. + */ +const WORKER_DRIVER_MINIMUM: Record<'pg' | 'mysql2', [major: number, minor: number]> = { + pg: [8, 11], + mysql2: [3, 13], +}; + +/** Throw when the driver whose entry file is `entry` is older than Workers need. */ +function assertWorkerDriver( + name: 'pg' | 'mysql2', + entry: string, + root: string, + importer: string | undefined, +): void { + let version: string | undefined; + try { + const packageJson = createRequire(entry).resolve(`${name}/package.json`); + ({ version } = JSON.parse(readFileSync(packageJson, 'utf8')) as { version?: string }); + } catch { + return; + } + const match = /^(\d+)\.(\d+)\./.exec(version ?? ''); + const [major, minor] = WORKER_DRIVER_MINIMUM[name]; + if ( + !match || + Number(match[1]) > major || + (Number(match[1]) === major && Number(match[2]) >= minor) + ) { + return; + } + const source = importer ? ` Imported by ${relative(root, importer)}.` : ''; + throw new Error( + `${name} ${version} cannot run in Cloudflare Workers, which need ${name} ${major}.${minor} or later. Upgrade ${name}.${source}`, + ); +} + +/** + * The CommonJS entry of package `name`, from `file` inside one of its copies (which a bundler may + * have resolved to an ES module entry), or undefined if `file` is not on disk. + */ +function resolvePackageEntry(name: string, file: string): string | undefined { + if (!isAbsolute(file)) { + return undefined; + } + try { + return createRequire(file).resolve(name); + } catch { + return undefined; + } +} diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 484dd9aa8..774fc58b3 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -358,6 +358,7 @@ export default { forceVirtualDb: bindingNames?.d1 !== undefined, localD1DatabaseId: localD1.databaseId, localD1PersistRoot: frameworkPersistRoot, + nitroBundlesServer: detected.name === 'nuxt' || detected.name === 'analog', }), migrationPlugin(root, dialect, localD1.databaseId, frameworkPersistRoot, { enabled: false, diff --git a/packages/void/test/integration/nitro-worker.test.ts b/packages/void/test/integration/nitro-worker.test.ts new file mode 100644 index 000000000..86c515533 --- /dev/null +++ b/packages/void/test/integration/nitro-worker.test.ts @@ -0,0 +1,454 @@ +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { PGlite } from '@electric-sql/pglite'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { createServer as createPgServer } from 'pglite-server'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import voidNitroModule from '../../src/nitro.ts'; +import { startFakeMySqlServer } from '../helpers/fake-mysql-server.ts'; + +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } +}); + +// Half of the integration project's test timeout, which Windows triples. A step that stops +// making progress fails with its name before the case times out. +const STEP_TIMEOUT_MS = (process.platform === 'win32' ? 3 : 1) * 60_000; + +/** + * Run `step`, or fail with its `name` when it has not finished within STEP_TIMEOUT_MS. The error + * ends with `report()`, which says what the step was doing. + */ +async function withinStepTimeout( + name: string, + step: () => Promise, + report?: () => string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + step(), + new Promise((_, reject) => { + timer = setTimeout(() => { + const details = report ? `\n${report()}` : ''; + reject(new Error(`${name} did not finish in ${STEP_TIMEOUT_MS} ms.${details}`)); + }, STEP_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +type RollupHook = (this: unknown, ...args: Array) => unknown; +type RollupPlugin = { name: string } & Record; + +const TRACED_HOOKS = ['resolveId', 'load', 'transform', 'renderChunk'] as const; +// A plugin call is recorded when it fails or takes at least this long. +const SLOW_HOOK_MS = 1000; +const TRACE_TAIL = 40; +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..'); + +/** + * What a Nitro build is doing, for the error when it does not finish: the Rollup plugin calls + * (resolveId, load, transform, renderChunk) still running, the build steps, the last parsed + * modules with any plugin call that failed or was slow, the last results of void's plugins, and + * void's driver build files. Kept in memory: a build that finishes prints none of it. + */ +function createBuildTrace(root: string) { + const startedAt = Date.now(); + const elapsed = () => Date.now() - startedAt; + const steps: Array = []; + const records: Array = []; + const voidResults: Array = []; + const running = new Set<{ start: number; describe: () => string }>(); + let driverDir: string | undefined; + + const show = (path: string) => { + let text = path.replaceAll('\0', '\\0'); + for (const [prefix, name] of [ + [root, ''], + [REPO_ROOT, ''], + ] as const) { + text = text.replaceAll(prefix, name).replaceAll(prefix.replaceAll('\\', '/'), name); + } + text = text.replace(/\.pnpm([\\/])[^\\/]+[\\/]node_modules[\\/]/g, '.pnpm$1…$1'); + return text.length > 240 ? `${text.slice(0, 240)}…` : text; + }; + const record = (list: Array, text: string) => { + list.push(`[${elapsed()}ms] ${text}`); + if (list.length > TRACE_TAIL * 4) { + list.splice(0, list.length - TRACE_TAIL); + } + }; + /** Track a call while it runs. The returned function ends it and returns how long it took. */ + const begin = (describe: () => string) => { + const call = { start: elapsed(), describe }; + running.add(call); + return () => { + running.delete(call); + return elapsed() - call.start; + }; + }; + const describeCall = (hook: string, args: Array) => { + if (hook === 'resolveId') { + const importer = typeof args[1] === 'string' ? show(args[1]) : '(entry)'; + return `${show(String(args[0]))} <- ${importer}`; + } + if (hook === 'renderChunk') { + return String((args[1] as { fileName?: string } | undefined)?.fileName); + } + // load(id) and transform(code, id) + return show(String(hook === 'load' ? args[0] : args[1])); + }; + const describeResult = (hook: string, value: unknown): string | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (hook === 'resolveId') { + if (typeof value === 'object') { + const { id, external } = value as { id: string; external?: unknown }; + return `${show(id)}${external ? ' (external)' : ''}`; + } + return value === false ? '(external)' : show(String(value)); + } + const code = typeof value === 'object' ? (value as { code?: unknown }).code : value; + return typeof code === 'string' ? `${code.length} chars` : 'a result'; + }; + + const tracePlugin = (plugin: RollupPlugin): RollupPlugin => { + const results = plugin.name.startsWith('void:') ? voidResults : undefined; + // A copy: void/nitro's plugins are shared between builds. + const traced: RollupPlugin = { ...plugin }; + for (const hook of TRACED_HOOKS) { + const value = plugin[hook] as RollupHook | { handler: RollupHook } | undefined; + const handler = typeof value === 'function' ? value : value?.handler; + if (typeof handler !== 'function') { + continue; + } + const wrapped: RollupHook = function (...args) { + const describe = () => `${plugin.name} ${hook} ${describeCall(hook, args)}`; + const end = begin(describe); + // Recorded when it failed or was slow, and among void's results when it returned one. + const settle = (outcome: string | undefined, failed = false) => { + const took = end(); + const list = + failed || took >= SLOW_HOOK_MS ? records : outcome === undefined ? undefined : results; + if (list) { + record(list, `${describe()} => ${outcome ?? 'null'} (took ${took}ms)`); + } + }; + let result: unknown; + try { + result = handler.apply(this, args); + } catch (error) { + settle(`threw ${String(error)}`, true); + throw error; + } + if (typeof (result as PromiseLike | undefined)?.then === 'function') { + Promise.resolve(result).then( + (resolved) => settle(describeResult(hook, resolved)), + (error) => settle(`rejected ${String(error)}`, true), + ); + } else { + settle(describeResult(hook, result)); + } + return result; + }; + traced[hook] = typeof value === 'function' ? wrapped : { ...value, handler: wrapped }; + } + return traced; + }; + const stepsPlugin: RollupPlugin = { + name: 'nitro-worker-test:trace', + buildStart: () => record(steps, 'rollup buildStart'), + moduleParsed: ({ id }: { id: string }) => record(records, `parsed ${show(id)}`), + buildEnd: (error?: Error) => + record(steps, `rollup buildEnd${error ? `: ${error.message}` : ''}`), + renderStart: () => record(steps, 'rollup renderStart'), + writeBundle: () => record(steps, 'rollup writeBundle'), + }; + + /** Each file of void's driver builds, with when it was last written. */ + const driverFiles = () => { + if (driverDir === undefined) { + return []; + } + const dir = driverDir; + try { + const files = readdirSync(dir, { recursive: true, encoding: 'utf8' }).map( + (file) => ` [${Math.round(statSync(join(dir, file)).mtimeMs) - startedAt}ms] ${file}`, + ); + return [`Files in ${show(dir)}, by last write:`, ...files]; + } catch { + return [`No ${show(dir)} folder.`]; + } + }; + const resources = () => { + const counts = new Map(); + for (const type of process.getActiveResourcesInfo()) { + counts.set(type, (counts.get(type) ?? 0) + 1); + } + return [...counts].map(([type, count]) => `${type} x${count}`).join(', '); + }; + + return { + /** Run one step of the build, and record when it ended and how long it took. */ + async step(name: string, run: () => Promise): Promise { + const end = begin(() => name); + try { + const result = await run(); + record(steps, `${name} done (took ${end()}ms)`); + return result; + } catch (error) { + record(steps, `${name} failed (took ${end()}ms): ${String(error)}`); + throw error; + } + }, + /** Trace the Rollup plugins that `nitro` builds with, and void's driver builds in it. */ + attach(nitro: NitroInstance) { + driverDir = join(nitro.options.buildDir, 'void'); + nitro.hooks.hook('rollup:before', (_, config) => { + const plugins = [config.plugins].flat(Infinity as 1) as Array; + config.plugins = [ + stepsPlugin, + ...plugins.map((plugin) => + plugin && typeof plugin === 'object' && 'name' in plugin + ? tracePlugin(plugin as RollupPlugin) + : plugin, + ), + ]; + }); + }, + report: () => { + const open = [...running].sort((a, b) => a.start - b.start); + const more = open.length - TRACE_TAIL; + const list = (title: string, lines: Array) => [ + title, + ...lines.slice(-TRACE_TAIL).map((line) => ` ${line}`), + ]; + return [ + `Still running at ${elapsed()}ms (by start time):`, + ...open.slice(0, TRACE_TAIL).map((call) => ` [${call.start}ms] ${call.describe()}`), + ...(more > 0 ? [` and ${more} more`] : []), + ...list('Build steps:', steps), + ...list('Last parsed modules, and plugin calls that failed or were slow:', records), + ...list("Last results of void's plugins:", voidResults), + ...driverFiles(), + `Active resources: ${resources()}`, + ].join('\n'); + }, + }; +} + +type NitroInstance = { + options: { buildDir: string }; + hooks: { + hook(name: 'rollup:before', run: (nitro: unknown, config: { plugins?: unknown }) => void): void; + }; + close(): Promise; +}; + +type NitroBuildApi = { + createNitro(config: Record): Promise; + prepare(nitro: NitroInstance): Promise; + build(nitro: NitroInstance): Promise; +}; + +/** Nitro's build API, from the Nitro that the Nuxt playground's Nuxt installs. */ +async function importNitro(): Promise { + let require = createRequire( + join(import.meta.dirname, '../../../../playground/nuxt/package.json'), + ); + for (const name of ['nuxt', '@nuxt/nitro-server', 'nitropack']) { + require = createRequire(require.resolve(`${name}/package.json`)); + } + const { exports } = require('./package.json') as { exports: { '.': { import: string } } }; + return import(pathToFileURL(require.resolve(exports['.'].import)).href) as Promise; +} + +/** + * Build a Nitro Worker, as Nuxt and Analog do, whose `/users` route runs eight parallel void/db + * queries and whose `/hash` route hashes with node:crypto. Returns its server output folder. + */ +async function buildNitroWorker( + database: 'pg' | 'mysql', + nitroConfig: Record = {}, +): Promise { + // Windows runners report a short-name temp path, which the bundler refuses to serve. + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-nitro-worker-'))); + roots.push(root); + const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + }; + write('void.json', JSON.stringify({ database })); + const [table, id] = database === 'pg' ? ['pgTable', 'serial'] : ['mysqlTable', 'int']; + write( + 'db/schema.ts', + `import { ${table}, ${id}, text } from 'void/schema-${database}'; +export const users = ${table}('users', { id: ${id}('id').primaryKey(), name: text('name').notNull() }); +`, + ); + write( + 'routes/users.ts', + `import { users } from '@schema'; +import { db } from 'void/db'; + +export default defineEventHandler(async () => { + const results = await Promise.all(Array.from({ length: 8 }, () => db.select().from(users))); + return results.map((rows) => rows.length); +}); +`, + ); + write( + 'routes/hash.ts', + `import { createHash } from 'node:crypto'; + +export default defineEventHandler(() => ({ hash: createHash('sha256').update('void').digest('hex') })); +`, + ); + // The schema imports Void's SQL helpers by package name, as an app does. + mkdirSync(join(root, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(root, 'node_modules', 'void'), + 'junction', + ); + + const trace = createBuildTrace(root); + await withinStepTimeout( + 'The Nitro build', + async () => { + const { createNitro, prepare, build } = await trace.step('import Nitro', importNitro); + const nitro = await trace.step('createNitro', () => + createNitro({ + rootDir: root, + dev: false, + preset: 'cloudflare-module', + compatibilityDate: '2026-02-24', + modules: [voidNitroModule], + logLevel: 0, + minify: false, + sourceMap: false, + ...nitroConfig, + }), + ); + trace.attach(nitro); + try { + await trace.step('prepare', () => prepare(nitro)); + await trace.step('build', () => build(nitro)); + } finally { + await trace.step('close', () => nitro.close()); + } + }, + trace.report, + ); + return join(root, '.output', 'server'); +} + +/** Request `paths` at once from the Worker in workerd, with the flags a deploy gives it. */ +async function fetchNitroWorker( + serverDir: string, + connectionString: string, + paths: Array, +): Promise> { + const modules = readdirSync(serverDir, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.mjs')) + .sort((a, b) => Number(b === 'index.mjs') - Number(a === 'index.mjs')) + .map((file) => ({ type: 'ESModule' as const, path: join(serverDir, file) })); + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modulesRoot: serverDir, + modules, + compatibilityDate: '2026-02-24', + compatibilityFlags: ['nodejs_compat'], + bindings: { HYPERDRIVE: { connectionString } }, + }), + ); + try { + await withinStepTimeout('workerd startup', () => miniflare.ready); + return await Promise.all( + paths.map((path) => + withinStepTimeout(`The Worker's response to ${path}`, async () => { + const response = await miniflare.dispatchFetch(`http://localhost${path}`); + return { status: response.status, body: await response.json() }; + }), + ), + ); + } finally { + await miniflare.dispose(); + } +} + +// Each case runs a Nitro build and starts workerd. +describe('a Nitro Worker with void/nitro', () => { + const requests = ['/users', '/users', '/users', '/hash']; + const hash = createHash('sha256').update('void').digest('hex'); + const expected = [ + ...Array.from({ length: 3 }, () => ({ status: 200, body: Array(8).fill(1) })), + { status: 200, body: { hash } }, + ]; + + it('runs PostgreSQL queries in workerd on one connection per request', async () => { + const serverDir = await buildNitroWorker('pg'); + const pglite = new PGlite(); + await withinStepTimeout('PGlite startup', async () => { + // pglite-server does not wait for PGlite: a query that arrives during startup spins + // forever. + await pglite.waitReady; + await pglite.exec( + "CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL); INSERT INTO users (name) VALUES ('alice');", + ); + }); + const server = createPgServer(pglite); + let connections = 0; + server.on('connection', () => connections++); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address() as AddressInfo; + const url = `postgresql://postgres@127.0.0.1:${port}/postgres`; + expect(await fetchNitroWorker(serverDir, url, requests)).toEqual(expected); + expect(connections).toBe(3); + } finally { + server.close(); + await pglite.close(); + } + }); + + // Nitro's `commonJS.strictRequires` wraps every CommonJS module, mysql2 included. + it.each([ + ['', {}], + [' when Nitro wraps every CommonJS module', { commonJS: { strictRequires: true } }], + ])('runs MySQL queries in workerd on one connection per request%s', async (_, nitroConfig) => { + const serverDir = await buildNitroWorker('mysql', nitroConfig); + const mysql = await startFakeMySqlServer([{ id: 1, name: 'alice' }]); + try { + const url = `mysql://void@127.0.0.1:${mysql.port}/app`; + expect(await fetchNitroWorker(serverDir, url, requests)).toEqual(expected); + expect(mysql.acceptedConnections()).toBe(3); + } finally { + await mysql.close(); + } + }); +}); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index fe8bef57a..2033673b5 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -6549,6 +6549,41 @@ describe('runCloudflareDeploy — framework artifact guard', () => { }, ); + // The PostgreSQL and MySQL drivers import workerd's Node modules, as a managed deploy knows. + it.each([ + ['pg', ['nodejs_als', 'nodejs_compat']], + ['mysql', ['nodejs_als', 'nodejs_compat']], + ['d1', ['nodejs_als']], + ])( + 'gives a %s Nuxt Worker the compatibility flags its database needs', + async (database, flags) => { + const preset = FRAMEWORK_PRESETS.nuxt!; + const root = makeProject({ + 'void.json': JSON.stringify(database === 'd1' ? {} : { database }), + 'wrangler.jsonc': JSON.stringify({ + account_id: 'acc123', + name: 'nuxt-app', + compatibility_flags: ['nodejs_als'], + }), + [`${preset.workerDir}/${preset.workerMain}`]: + 'export default { fetch() { return new Response("page"); } };', + [`${preset.assetsDir}/client.js`]: 'console.log("client");', + }); + await prepareCloudflareFrameworkArtifact({ + root, + configPath: join(root, 'wrangler.jsonc'), + accountId: 'acc123', + workerName: 'nuxt-app', + voidConfig: readConfig(root), + framework: { name: 'nuxt', class: 'c' }, + preset, + }); + expect(parseRootWranglerConfig(join(root, 'wrangler.jsonc')).compatibility_flags).toEqual( + flags, + ); + }, + ); + it('does not add ISR to a framework worker when routing.isr is false', async () => { const preset = FRAMEWORK_PRESETS['tanstack-start']!; const root = makeProject({ diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 8d86a8dce..18b035fde 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1868,10 +1868,20 @@ jobs: expect.stringContaining('export default defineNuxtConfig'), 'Manual Nuxt config step', ); - expect(mockNote).toHaveBeenCalledWith( - expect.stringContaining('nitro: { modules: ["void/nitro"] }'), - 'Manual Nuxt config step', + // Without the preset Nuxt builds a Node server; without the dev config, dev has no env; + // without the top-level alias, `nuxi typecheck` cannot find `@schema`. + const nuxtNote = mockNote.mock.calls.find( + ([, title]) => title === 'Manual Nuxt config step', ); + for (const line of [ + 'import { resolve } from "node:path";', + ' alias: {\n "@schema": resolve(import.meta.dirname, "db/schema.ts"),\n },\n nitro: {', + 'preset: "cloudflare-module",', + 'modules: ["void/nitro"],', + 'cloudflare: { dev: { configPath: "./.void-wrangler.jsonc" } },', + ]) { + expect(nuxtNote?.[0]).toContain(line); + } } finally { rmSync(root, { recursive: true, force: true }); } @@ -1899,12 +1909,17 @@ jobs: await runInitCommand(root); expect(existsSync(join(root, 'vite.config.ts'))).toBe(false); - expect(mockNote).toHaveBeenCalledWith( - expect.stringContaining( - 'analog({ nitro: { modules: ["nitro-cloudflare-dev", "void/nitro"] } })', - ), - 'Manual Analog config step', - ); + const analogNote = mockNote.mock.calls.find( + ([, title]) => title === 'Manual Analog config step', + ); + for (const line of [ + 'Install `nitro-cloudflare-dev` as a dev dependency.', + 'preset: "cloudflare-module",', + 'modules: ["nitro-cloudflare-dev", "void/nitro"],', + 'cloudflareDev: { configPath: "./.void-wrangler.jsonc" },', + ]) { + expect(analogNote?.[0]).toContain(line); + } } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/void/test/unit/nitro.test.ts b/packages/void/test/unit/nitro.test.ts index 761bcc2eb..a1b1f4899 100644 --- a/packages/void/test/unit/nitro.test.ts +++ b/packages/void/test/unit/nitro.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, isAbsolute, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { toSlash } from 'pathslash'; import type { Plugin } from 'vite'; @@ -11,6 +12,8 @@ import { getRuntimeRequestResource, withRuntimeEnv } from '../../src/runtime/env const roots: Array = []; const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env')); +// The generated MySQL void/db imports mysql2 through Void's drizzle-orm. +const drizzleMysqlFile = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); afterEach(() => { for (const root of roots.splice(0)) { @@ -19,10 +22,13 @@ afterEach(() => { delete (globalThis as { __env__?: unknown }).__env__; }); -function createApp(database: 'pg' | 'mysql' | 'd1'): string { +function createApp(database: 'pg' | 'mysql' | 'd1', config: Record = {}): string { const root = mkdtempSync(join(tmpdir(), 'void-nitro-')); roots.push(root); - writeFileSync(join(root, 'void.json'), JSON.stringify(database === 'd1' ? {} : { database })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ ...config, ...(database !== 'd1' && { database }) }), + ); mkdirSync(join(root, 'db')); writeFileSync(join(root, 'db', 'schema.ts'), 'export const users = {};\n'); return root; @@ -36,10 +42,14 @@ type NitroOptions = { plugins: Array; virtual: Record string | Promise)>; rollupConfig: { plugins?: unknown }; + cloudflare?: { nodeCompat?: boolean }; }; /** Run the module against the options of a Nitro Worker build. */ -function setUp(root: string, options: { dev: boolean; plugins?: unknown }): NitroOptions { +function setUp( + root: string, + options: { dev: boolean; plugins?: unknown; cloudflare?: NitroOptions['cloudflare'] }, +): NitroOptions { const nitro = { options: { dev: options.dev, @@ -49,6 +59,7 @@ function setUp(root: string, options: { dev: boolean; plugins?: unknown }): Nitr plugins: [], virtual: {}, rollupConfig: { plugins: options.plugins }, + ...(options.cloudflare && { cloudflare: options.cloudflare }), } as NitroOptions, }; voidNitroModule.setup(nitro); @@ -57,10 +68,18 @@ function setUp(root: string, options: { dev: boolean; plugins?: unknown }): Nitr type ResolveResult = string | { id: string; external?: boolean } | null | undefined; -// Nitro's Rollup calls the hooks as plain functions, with its plugin context as `this`. +// Nitro's Rollup calls the hooks as plain functions, with its plugin context as `this`. Its +// resolver finds a package the way Node does. const context = { - async resolve(source: string): Promise<{ id: string }> { - return { id: source }; + async resolve(source: string, importer: string): Promise<{ id: string } | null> { + if (isAbsolute(source)) { + return { id: source }; + } + try { + return { id: createRequire(importer).resolve(source) }; + } catch { + return null; + } }, }; @@ -96,10 +115,13 @@ describe('void/nitro', () => { const [plugin, asyncHooks] = setUp(root, { dev: false }).rollupConfig.plugins as Array; const route = join(root, 'server', 'api', 'users.ts'); - const driver = await resolve(plugin!, 'mysql2', route); + const driver = await resolve(plugin!, 'mysql2', drizzleMysqlFile); expect(typeof driver).toBe('string'); expect(existsSync(driver as string)).toBe(true); expect(readFileSync(driver as string, 'utf8')).toContain('export {'); + // mysql2's promise API comes from the same build. + const promiseApi = await resolve(plugin!, 'mysql2/promise', drizzleMysqlFile); + expect(dirname(promiseApi as string)).toBe(dirname(driver as string)); // The driver and Void's request scope keep workerd's Node modules instead of Nitro's stubs. expect(await resolve(plugin!, 'node:net', driver as string)).toEqual({ id: 'node:net', @@ -112,6 +134,159 @@ describe('void/nitro', () => { expect(await resolve(asyncHooks!, 'node:async_hooks', route)).toBeNull(); }); + it("gives the app's own driver import the app's copy, built for workerd", async () => { + const root = createApp('mysql'); + // An app that installs its own mysql2, which need not be the version Void's drizzle loads. + mkdirSync(join(root, 'node_modules', 'mysql2'), { recursive: true }); + writeFileSync( + join(root, 'node_modules', 'mysql2', 'package.json'), + JSON.stringify({ name: 'mysql2', version: '3.13.0', main: 'index.js' }), + ); + writeFileSync( + join(root, 'node_modules', 'mysql2', 'index.js'), + "exports.createPool = () => ({});\nexports.appCopy = require('node:util').format('app');\n", + ); + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + + const appDriver = (await resolve( + plugin!, + 'mysql2', + join(root, 'server', 'api', 'raw.ts'), + )) as string; + const voidDriver = (await resolve(plugin!, 'mysql2', drizzleMysqlFile)) as string; + expect(appDriver).not.toBe(voidDriver); + // Each is named by its native path, as Nitro's Rollup names modules: on Windows, Nitro's + // lookup of the `global` polyfill that pg imports never ends from a forward-slash name. + for (const driver of [appDriver, voidDriver]) { + expect(driver).toBe(fileURLToPath(pathToFileURL(driver))); + } + // Each copy exports its own names. + expect(readFileSync(appDriver, 'utf8')).toMatch(/export \{[^}]*\bappCopy\b/); + expect(readFileSync(voidDriver, 'utf8')).not.toContain('appCopy'); + for (const driver of [appDriver, voidDriver]) { + expect(await resolve(plugin!, 'node:util', driver)).toEqual({ + id: 'node:util', + external: true, + }); + } + // An import the bundler cannot resolve stays its error. + expect(await resolve(plugin!, 'mysql2', join(tmpdir(), 'elsewhere.ts'))).toBeNull(); + }); + + it("serves mysql2/promise from the build of the copy's driver", async () => { + const root = createApp('mysql'); + // Both entries of mysql2 load the same driver modules. + const copy = join(root, 'node_modules', 'mysql2'); + mkdirSync(join(copy, 'lib'), { recursive: true }); + writeFileSync( + join(copy, 'package.json'), + JSON.stringify({ + name: 'mysql2', + version: '3.13.0', + main: 'index.js', + exports: { + '.': './index.js', + './package.json': './package.json', + './promise': './promise.js', + }, + }), + ); + writeFileSync(join(copy, 'lib', 'state.js'), 'module.exports = { parsers: new Map() };\n'); + writeFileSync(join(copy, 'index.js'), "exports.state = require('./lib/state.js');\n"); + writeFileSync( + join(copy, 'promise.js'), + "exports.state = require('./lib/state.js');\nexports.createPool = () => ({});\n", + ); + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + const route = join(root, 'server', 'api', 'raw.ts'); + + const promiseApi = await resolve(plugin!, 'mysql2/promise', route); + expect(typeof promiseApi).toBe('string'); + const driver = (await resolve(plugin!, 'mysql2', route)) as string; + const [promiseModule, driverModule] = (await Promise.all( + [promiseApi as string, driver].map((file) => import(pathToFileURL(file).href)), + )) as Array<{ default: Record; state: object; createPool?: unknown }>; + expect(typeof promiseModule!.createPool).toBe('function'); + expect(promiseModule!.default.createPool).toBe(promiseModule!.createPool); + expect(promiseModule!.state).toBe(driverModule!.state); + }); + + it.each([ + // pg 8.10 predates pg-cloudflare, the socket pg uses in Workers. + { database: 'pg' as const, name: 'pg', id: 'pg', version: '8.10.0', minimum: '8.11' }, + // mysql2 3.12 has no disableEval option, so it compiles its parsers with new Function. + { + database: 'mysql' as const, + name: 'mysql2', + id: 'mysql2', + version: '3.12.0', + minimum: '3.13', + }, + { + database: 'mysql' as const, + name: 'mysql2', + id: 'mysql2/promise', + version: '3.12.0', + minimum: '3.13', + }, + ])( + 'names the import of $id from a $name too old for Workers', + async ({ database, name, id, version, minimum }) => { + const root = createApp(database); + mkdirSync(join(root, 'node_modules', name), { recursive: true }); + writeFileSync( + join(root, 'node_modules', name, 'package.json'), + JSON.stringify({ name, version, main: 'index.js' }), + ); + writeFileSync(join(root, 'node_modules', name, 'index.js'), 'exports.Client = class {};\n'); + if (id !== name) { + writeFileSync( + join(root, 'node_modules', name, 'promise.js'), + 'exports.Client = class {};\n', + ); + } + const [plugin] = setUp(root, { dev: false }).rollupConfig.plugins as Array; + + await expect(resolve(plugin!, id, join(root, 'server', 'api', 'raw.ts'))).rejects.toThrow( + `${name} ${version} cannot run in Cloudflare Workers, which need ${name} ${minimum} or later. ` + + `Upgrade ${name}. Imported by server/api/raw.ts.`, + ); + }, + ); + + // Nitro keeps Node stubs unless it finds nodejs_compat in a Wrangler config, which it reads + // from wrangler.* files only. + it("turns on Nitro's Node compatibility for Workers that deploy with nodejs_compat", () => { + const nodeCompat = ( + database: 'pg' | 'mysql' | 'd1', + options: { dev?: boolean; config?: Record; nodeCompat?: boolean } = {}, + ) => + setUp(createApp(database, options.config), { + dev: options.dev ?? false, + ...(options.nodeCompat !== undefined && { + cloudflare: { nodeCompat: options.nodeCompat }, + }), + }).cloudflare?.nodeCompat; + + expect(nodeCompat('pg')).toBe(true); + expect(nodeCompat('mysql')).toBe(true); + expect(nodeCompat('d1')).toBeUndefined(); + const flags = (worker: Array | undefined, cloudflare: Array | undefined) => ({ + config: { + ...(worker && { worker: { compatibility_flags: worker } }), + ...(cloudflare && { cloudflare: { compatibility_flags: cloudflare } }), + }, + }); + expect(nodeCompat('d1', flags(undefined, ['nodejs_compat', 'nodejs_als']))).toBe(true); + expect(nodeCompat('d1', flags(undefined, ['nodejs_compat_v2']))).toBe(true); + // A native deploy gives the Worker only the Cloudflare config's flags, and a managed deploy + // prefers `worker.compatibility_flags`. A Worker built for native modules needs the flag. + expect(nodeCompat('d1', flags(['nodejs_compat'], undefined))).toBeUndefined(); + expect(nodeCompat('d1', flags(['nodejs_als'], ['nodejs_compat']))).toBeUndefined(); + expect(nodeCompat('pg', { nodeCompat: false })).toBe(false); + expect(nodeCompat('pg', { dev: true })).toBeUndefined(); + }); + it('runs each PostgreSQL or MySQL request in one Void request scope', async () => { const options = setUp(createApp('pg'), { dev: true }); expect(options.plugins).toHaveLength(1); @@ -164,5 +339,11 @@ describe('void/nitro', () => { expect(() => withRuntimeEnv({ HYPERDRIVE: { connectionString: 'postgresql://db/app' } }, () => db.select), ).toThrow("add 'void/nitro' to the Nitro modules"); + // Vite serves the app's void/db only to the installed packages it inlines. + expect(() => + withRuntimeEnv({ HYPERDRIVE: { connectionString: 'postgresql://db/app' } }, () => db.select), + ).toThrow( + 'An installed package that imports void/db must be an ES module that lists void in its dependencies or peerDependencies', + ); }); }); diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 5790fb2a9..fcb1bf4f9 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -796,6 +796,37 @@ export const requestPoolOptions = (env) => expect(readFileSync(join(root, '.void', 'db.d.ts'), 'utf8')).toContain('Record'); }); + // Nitro bundles a Nuxt or Analog server build's output with the server routes, and serves + // them all one void/db: the app's through `void/nitro`, or the package's, which names it. + it('leaves a PostgreSQL or MySQL void/db to Nitro in a Nitro framework build', () => { + const root = createProjectRoot(); + const schemaPath = join(root, 'db', 'schema.ts'); + const resolveIn = ( + dialect: 'sqlite' | 'postgresql' | 'mysql', + mode: 'build' | 'dev', + importer?: string, + ) => { + const plugin = drizzlePlugin(root, false, dialect, { nitroBundlesServer: true }); + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string; mode: string } }, + source: string, + importer: string | undefined, + options: { isEntry: boolean }, + ) => unknown; + return resolveId.call({ environment: { name: 'ssr', mode } }, 'void/db', importer, { + isEntry: false, + }); + }; + + const page = join(root, 'src', 'app', 'pages', 'users.server.ts'); + expect(resolveIn('postgresql', 'build', page)).toEqual({ id: 'void/db', external: true }); + expect(resolveIn('mysql', 'build', page)).toEqual({ id: 'void/db', external: true }); + expect(resolveIn('postgresql', 'dev', page)).toBe('\0virtual:void-db'); + expect(resolveIn('sqlite', 'build', page)).toBe('\0virtual:void-db'); + // The schema's own import of void/db still gets its SQL helpers. + expect(resolveIn('postgresql', 'build', schemaPath)).toBe('\0virtual:void-db-schema'); + }); + it('uses a custom D1 binding name in the sqlite virtual module', () => { const root = createProjectRoot({ schema: false }); const plugin = drizzlePlugin(root, false, 'sqlite', { diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index d35a74dc2..012951c6b 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -8,7 +8,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import { createRequire } from 'node:module'; +import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -17,6 +17,7 @@ import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite' import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { voidPlugin } from '../../src/index.ts'; import { findVoidDependentPackages } from '../../src/installed-packages.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; const roots: Array = []; @@ -39,6 +40,7 @@ function createProjectRoot(): string { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); for (const root of roots.splice(0, roots.length)) { rmSync(root, { recursive: true, force: true }); } @@ -539,8 +541,48 @@ describe('voidPlugin Vite environments', () => { ]); }); + it('finds packages that depend on void through the Yarn PnP runtime', () => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { 'db-lib': '*' } }), + ); + // No node_modules: Yarn serves each package from its cache, and `exports` may hide its + // package.json, so the runtime's unqualified resolution finds the package. + const store = join(root, '.yarn/cache'); + const usesVoid = { type: 'module', exports: './index.js', peerDependencies: { void: '*' } }; + for (const [name, manifest] of [ + ['db-lib', { ...usesVoid, dependencies: { 'inner-db-lib': '*' } }], + ['inner-db-lib', usesVoid], + ] as const) { + mkdirSync(join(store, name), { recursive: true }); + writeFileSync(join(store, name, 'package.json'), JSON.stringify({ name, ...manifest })); + writeFileSync(join(store, name, 'index.js'), "export { db } from 'void/db';\n"); + } + // Yarn's API reads a Windows path with either separator, and the crawler passes + // forward slashes. + const inRoot = (path: string) => normalizePath(path).startsWith(normalizePath(root)); + const pnp = { + resolveToUnqualified(request: string, issuer: string) { + expect(inRoot(issuer)).toBe(true); + const [name] = request.split('/package.json'); + if (!existsSync(join(store, name!))) { + throw new Error(`${name} is not a dependency of ${issuer}`); + } + return join(store, request); + }, + }; + const runtime = module as { findPnpApi?: (path: string) => typeof pnp | null }; + runtime.findPnpApi = (path) => (inRoot(path) ? pnp : null); + try { + expect(findVoidDependentPackages(root)).toEqual(['db-lib', 'inner-db-lib']); + } finally { + delete runtime.findPnpApi; + } + }); + // Vite's module runner evaluates an inlined package as ES modules, so a package whose import - // entry loads CommonJS stays with Node. + // entry reaches CommonJS through relative imports stays with Node. const wrapper = { 'wrapper.mjs': "import lib from './index.cjs';\nexport const db = lib.db;\n", 'index.cjs': 'exports.db = 1;\n', @@ -579,7 +621,114 @@ describe('voidPlugin Vite environments', () => { }, found: false, }, - ])('finds an installed package with $shape: $found', ({ manifest, files, found }) => { + ...[ + "export * from './vendor.cjs';\n", + "export {} from './vendor.cjs';\n", + "import './vendor.cjs';\n", + "export const db = await import('./vendor.cjs');\n", + ].map((entry) => ({ + shape: `an ES module entry with \`${entry.trim()}\``, + manifest: { type: 'module' }, + files: { 'index.js': entry, 'vendor.cjs': 'module.exports = { db: 1 };\n' }, + found: false, + })), + { + shape: 'CommonJS imports only in comments and strings', + manifest: { type: 'module' }, + files: { + 'index.js': [ + "// Before 2.0 this file did `import './legacy.cjs'`.", + "/* export * from './legacy.cjs'; */", + 'export const note = "import legacy from \'./legacy.cjs\'";', + "export const hint = `await import('./legacy.cjs')`;", + 'export const db = 1;', + '', + ].join('\n'), + 'legacy.cjs': 'module.exports = { db: 1 };\n', + }, + found: true, + }, + // Vite's module runner parses the entry with the same parser, and cannot load it either. + { + shape: 'an entry that does not parse', + manifest: { type: 'module' }, + files: { 'index.js': 'export const db = ;\n' }, + found: false, + }, + { + shape: 'a CommonJS file that a relative ES module imports', + manifest: { type: 'module' }, + files: { + 'index.js': "export { db } from './internal.js';\n", + 'internal.js': "import legacy from './legacy.cjs';\nexport const db = legacy.db;\n", + 'legacy.cjs': 'module.exports = { db: 1 };\n', + }, + found: false, + }, + { + shape: 'a CommonJS folder that a dynamically imported ES module imports', + manifest: { type: 'module' }, + files: { + 'index.js': "export const load = () => import('./lib/internal.js');\n", + 'lib/internal.js': "export { db } from '../cjs/db.js';\n", + 'cjs/package.json': '{"type":"commonjs"}', + 'cjs/db.js': 'exports.db = 1;\n', + }, + found: false, + }, + { + shape: 'a relative ES module that does not parse', + manifest: { type: 'module' }, + files: { + 'index.js': "export * from './internal.js';\n", + 'internal.js': 'export const db = ;\n', + }, + found: false, + }, + { + shape: 'ES modules that import each other and CommonJS of another package', + manifest: { type: 'module' }, + files: { + 'index.js': "export * from './a.js';\n", + 'a.js': "export * from './b.mjs';\nexport const a = 1;\n", + 'b.mjs': + "import { a } from './a.js';\nimport 'other-lib/legacy.cjs';\nexport const db = () => a;\n", + }, + found: true, + }, + { + shape: 'more relative modules than the check reads', + manifest: { type: 'module' }, + files: { + 'index.js': Array.from({ length: 2000 }, (_, i) => `import './${i}.js';\n`).join(''), + ...Object.fromEntries(Array.from({ length: 2000 }, (_, i) => [`${i}.js`, ''])), + }, + found: false, + }, + // Vite also matches `development`, or `production` when NODE_ENV is production, as in a build. + { + shape: 'ES modules only under development', + manifest: { exports: { development: './dev.mjs', default: './index.cjs' } }, + files: { 'dev.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: true, + }, + { + shape: 'ES modules only under development, in production', + nodeEnv: 'production', + manifest: { exports: { development: './dev.mjs', default: './index.cjs' } }, + files: { 'dev.mjs': 'export const db = 1;\n', 'index.cjs': 'exports.db = 1;\n' }, + found: false, + }, + { + shape: 'CommonJS under development', + manifest: { exports: { development: './dev.cjs', default: './index.mjs' } }, + files: { 'dev.cjs': 'exports.db = 1;\n', 'index.mjs': 'export const db = 1;\n' }, + found: false, + }, + ])('finds an installed package with $shape: $found', ({ manifest, files, found, nodeEnv }) => { + if (nodeEnv) { + vi.stubEnv('NODE_ENV', nodeEnv); + } const root = createProjectRoot(); writeFileSync( join(root, 'package.json'), @@ -599,6 +748,51 @@ describe('voidPlugin Vite environments', () => { expect(findVoidDependentPackages(root)).toEqual(found ? ['db-lib'] : []); }); + // Nitro bundles a Nuxt or Analog server build's output, and serves it the app's void/db. + it.each([ + ['nuxt', 'nuxt', true], + ['analog', '@analogjs/platform', true], + ['sveltekit', '@sveltejs/kit', false], + ] as const)( + 'leaves a PostgreSQL void/db to Nitro in a %s build (dependency %s): %s', + async (_name, dependency, external) => { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { [dependency]: '*' } }), + ); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ database: 'pg', worker: { compatibility_date: '2026-02-24' } }), + ); + mkdirSync(join(root, 'db')); + writeFileSync(join(root, 'db/schema.ts'), 'export {};\n'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const config = await resolveConfig( + { root, configFile: false, logLevel: 'silent', plugins: [voidPlugin()] }, + 'build', + 'production', + ); + const plugin = config.plugins.find((candidate) => candidate.name === 'void:drizzle')!; + const resolveId = getHookHandler(plugin.resolveId) as ( + this: { environment: { name: string; mode: string } }, + source: string, + importer: string, + options: { isEntry: boolean }, + ) => unknown; + + expect( + await resolveId.call( + { environment: { name: 'ssr', mode: 'build' } }, + 'void/db', + join(root, 'src/server/users.ts'), + { isEntry: false }, + ), + ).toEqual(external ? { id: 'void/db', external: true } : '\0virtual:void-db'); + }, + ); + it('rejects Cloudflare-only imports from installed packages on the Node target dev server', async () => { const root = realpathSync.native(createProjectRoot()); writeFileSync( diff --git a/playground/nuxt/nuxt.config.ts b/playground/nuxt/nuxt.config.ts index bc43ec205..9f0e77848 100644 --- a/playground/nuxt/nuxt.config.ts +++ b/playground/nuxt/nuxt.config.ts @@ -4,18 +4,18 @@ import { voidPlugin } from 'void'; export default defineNuxtConfig({ compatibilityDate: '2026-02-24', + // Server routes are also typed in the app project, which reads Nuxt's aliases. + alias: { + '@schema': resolve(import.meta.dirname, 'db/schema.ts'), + }, nitro: { preset: 'cloudflare-module', modules: ['void/nitro'], - cloudflareDev: { configPath: './.void-wrangler.jsonc' }, + cloudflare: { dev: { configPath: './.void-wrangler.jsonc' } }, // Remove when Nuxt ships https://github.com/nuxt/nuxt/commit/21657c6fcf. externals: { inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/], }, - alias: { - // Nuxt types server routes from Nitro's aliases. - '@schema': resolve(import.meta.dirname, 'db/schema.ts'), - }, }, vite: { plugins: [voidPlugin() as unknown as PluginOption], diff --git a/test/integration-timings.ts b/test/integration-timings.ts index d1c293f9e..4b952f41e 100644 --- a/test/integration-timings.ts +++ b/test/integration-timings.ts @@ -23,6 +23,7 @@ export const integrationTimings: Readonly> = { 'packages/void/test/integration/kitchen-sink.test.ts': 20887, 'packages/void/test/integration/mysql-test.test.ts': 11000, 'packages/void/test/integration/named-layouts.test.ts': 9939, + 'packages/void/test/integration/nitro-worker.test.ts': 30000, 'packages/void/test/integration/node-static.test.ts': 13667, 'packages/void/test/integration/node-target.test.ts': 10833, 'packages/void/test/integration/nuxt.test.ts': 43000,