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 {