From b8f3efdc312c5bb823f4940105715cddcdf0f14b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 13:25:25 +0800 Subject: [PATCH] fix(void): load SQL helpers before the schema in void/db (#472) A table file that db/schema.ts re-exports from outside db/ (for example src/resources/projects/projects.schema.ts) imports `sql` from void/db. When a route loaded void/db first, the cycle void/db -> db/schema.ts -> projects.schema.ts -> void/db handed the table file a half-loaded void/db, and `sql` was undefined in dev ("__vite_ssr_import_0__.sql is not a function"). Vite SSR runs imports in source order, so void/db now re-exports the drizzle-orm helpers before it imports the schema, for SQLite, PostgreSQL and MySQL. The helpers are ready whatever path the schema file lives at. Build output already linked the helpers directly and keeps working. Fixes #472 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-drizzle.ts | 11 ++-- .../void/test/unit/plugin-drizzle.test.ts | 16 ++++++ .../test/unit/plugin-vite-environment.test.ts | 55 ++++++++++++++++++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 2c5a9adf4..f92f646e3 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -145,7 +145,10 @@ export function createDb() { `; } - // void/db virtual module + // void/db virtual module. Each dialect keeps the SQL helper export first: schema files + // that db/schema.ts reaches from outside db/ import void/db while this module is still + // loading. Vite SSR runs imports in source order, so the helpers must be loaded before + // the schema import. const schemaImport = schema ? `import * as schema from ${JSON.stringify(schema.path)};` : ''; @@ -161,10 +164,10 @@ export function createDb() { const mysqlDrizzleConfig = (schema ? ', schema, mode: "default"' : '') + (casingOption ? `, ${casingOption}` : ''); return ` +export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; import { drizzle } from ${JSON.stringify(drizzleMysql2Resolved)}; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} -export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; const _requestResourceKey = {}; @@ -243,10 +246,10 @@ export function createDb(connectionString) { if (dialect === 'postgresql') { // Like MySQL: drizzle builds the pool, so its bare pg import is the only one. return ` +export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; import { drizzle } from ${JSON.stringify(drizzleNodePgResolved)}; import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} -export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; const _requestResourceKey = {}; @@ -295,10 +298,10 @@ export function createDb(connectionString) { // SQLite (default) return ` +export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; import { drizzle } from ${JSON.stringify(drizzleD1Resolved)}; import { getRuntimeRequestResource, requireRuntimeBinding } from ${JSON.stringify(envModuleResolved)}; ${schemaImport} -export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; const _requestResourceKey = {}; diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 053d12754..74af5cbb4 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -750,6 +750,22 @@ export const requestPoolOptions = (env) => expect(code).not.toContain('requireRuntimeBinding'); }); + it.each(['sqlite', 'postgresql', 'mysql'] as const)( + 'exports the %s SQL helpers before void/db imports the schema', + (dialect) => { + // A table file that db/schema.ts re-exports from outside db/ imports this module while + // it loads the schema. Vite SSR runs imports in source order (#472). + const root = createProjectRoot(); + const plugin = drizzlePlugin(root, false, dialect); + const load = getHookHandler(plugin.load) as (this: object, id: string) => string; + + const code = load.call({}, '\0virtual:void-db').trim(); + + expect(code.startsWith('export { sql, eq, ')).toBe(true); + expect(code).toContain('import * as schema'); + }, + ); + it('treats files in db/schema/ beside db/schema.ts as schema files', () => { // `void gen model` writes db/schema/.ts and re-exports it from db/schema.ts. const root = createProjectRoot(); diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 71fddb1a9..e3f64d52a 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -11,7 +11,7 @@ import { import module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { cloudflare } from '@cloudflare/vite-plugin'; import { createBuilder, createServer, normalizePath, resolveConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -945,6 +945,59 @@ describe('voidPlugin Vite environments', () => { } }); + it('loads a table file outside db/ that imports sql from void/db after a route loads void/db', async () => { + // #472: db/schema.ts re-exports a table file outside db/, which imports void/db while + // void/db is still loading db/schema.ts. + const root = realpathSync.native(createProjectRoot()); + mkdirSync(join(root, 'db')); + writeFileSync( + join(root, 'db/schema.ts'), + 'export * from "../src/resources/projects/projects.schema";\n', + ); + mkdirSync(join(root, 'src/resources/projects'), { recursive: true }); + const sqliteCore = fileURLToPath(import.meta.resolve('drizzle-orm/sqlite-core')); + writeFileSync( + join(root, 'src/resources/projects/projects.schema.ts'), + [ + "import { sql } from 'void/db';", + `import { integer, sqliteTable, text } from ${JSON.stringify(normalizePath(sqliteCore))};`, + "export const projects = sqliteTable('projects', {", + " id: integer('id').primaryKey(),", + " createdAt: text('created_at').default(sql`(datetime('now'))`),", + '});', + '', + ].join('\n'), + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/projects.ts'), + [ + "import { db } from 'void/db';", + "import { projects } from '@schema';", + 'export const GET = () => Response.json({ db: typeof db, projects: typeof projects });', + '', + ].join('\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 route = await server.ssrLoadModule('/routes/api/projects.ts'); + const response = (route.GET as () => Response)(); + + await expect(response.json()).resolves.toEqual({ db: 'object', projects: 'object' }); + } 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 {