diff --git a/src/runtime/api/query.post.ts b/src/runtime/api/query.post.ts index 83ffe9d45..2156de530 100644 --- a/src/runtime/api/query.post.ts +++ b/src/runtime/api/query.post.ts @@ -15,5 +15,5 @@ export default eventHandler(async (event) => { await checkAndImportDatabaseIntegrity(event, collection, conf) } - return loadDatabaseAdapter(conf).all(sql) + return (await loadDatabaseAdapter(conf)).all(sql) }) diff --git a/src/runtime/internal/database.server.ts b/src/runtime/internal/database.server.ts index f93767692..59090b48e 100644 --- a/src/runtime/internal/database.server.ts +++ b/src/runtime/internal/database.server.ts @@ -6,22 +6,48 @@ import { fetchDatabase } from './api' import { refineContentFields } from './collection' import type { DatabaseAdapter, RuntimeConfig } from '@nuxt/content' import { tables, checksums, checksumsStructure } from '#content/manifest' -import adapter from '#content/adapter' import localAdapter from '#content/local-adapter' let db: Connector -export default function loadDatabaseAdapter(config: RuntimeConfig['content']) { - const { database, localDatabase } = config +let _adapterPromise: Promise<(opts: unknown) => Connector> | undefined +let _databasePromise: Promise | undefined - if (!db) { - if (import.meta.dev || ['nitro-prerender', 'nitro-dev'].includes(import.meta.preset as string)) { - db = localAdapter(refineDatabaseConfig(localDatabase)) - } - else { - db = adapter(refineDatabaseConfig(database)) - } +function getAdapter(): Promise<(opts: unknown) => Connector> { + if (!_adapterPromise) { + _adapterPromise = import('#content/adapter').then(m => m.default || m) + } + return _adapterPromise +} + +async function getDatabase(config: RuntimeConfig['content']): Promise { + if (db) { + return db + } + + if (!_databasePromise) { + _databasePromise = (async () => { + const { database, localDatabase } = config + + if (import.meta.dev || ['nitro-prerender', 'nitro-dev'].includes(import.meta.preset as string)) { + return localAdapter(refineDatabaseConfig(localDatabase)) + } + + const adapter = await getAdapter() + return adapter(refineDatabaseConfig(database)) + })().catch((error) => { + // Allow a later request to retry initialization after a transient failure. + _databasePromise = undefined + throw error + }) } + db = await _databasePromise + return db +} + +export default async function loadDatabaseAdapter(config: RuntimeConfig['content']) { + await getDatabase(config) + return { all: async (sql, params = []) => { return db.prepare(sql).all(...params) @@ -63,7 +89,7 @@ export async function checkAndImportDatabaseIntegrity(event: H3Event, collection } async function _checkAndImportDatabaseIntegrity(event: H3Event, collection: string, integrityVersion: string, structureIntegrityVersion: string, config: RuntimeConfig['content']) { - const db = loadDatabaseAdapter(config) + const db = await loadDatabaseAdapter(config) const before = await db.first<{ version: string, structureVersion: string, ready: boolean }>(`SELECT * FROM ${tables.info} WHERE id = ?`, [`checksum_${collection}`]).catch((): null => null) diff --git a/test/mock/content-adapter.ts b/test/mock/content-adapter.ts new file mode 100644 index 000000000..ae9a5f9de --- /dev/null +++ b/test/mock/content-adapter.ts @@ -0,0 +1,9 @@ +export default function mockAdapter(_opts: unknown) { + return { + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([]), + get: (..._params: unknown[]) => Promise.resolve(null), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + } +} diff --git a/test/mock/content-local-adapter.ts b/test/mock/content-local-adapter.ts new file mode 100644 index 000000000..5c0f5ebf4 --- /dev/null +++ b/test/mock/content-local-adapter.ts @@ -0,0 +1,9 @@ +export default function mockLocalAdapter(_opts: unknown) { + return { + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([]), + get: (..._params: unknown[]) => Promise.resolve(null), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + } +} diff --git a/test/mock/content-manifest.ts b/test/mock/content-manifest.ts index 9b77d5e40..45b00587c 100644 --- a/test/mock/content-manifest.ts +++ b/test/mock/content-manifest.ts @@ -1,3 +1,13 @@ export const tables = { test: '_content_test', + info: '_content_info', } + +export const checksums: Record = {} +export const checksumsStructure: Record = {} + +const manifest: Record }> = { + test: { fields: { id: 'string', title: 'string' } }, +} + +export default manifest diff --git a/test/unit/database.server.prerender.test.ts b/test/unit/database.server.prerender.test.ts new file mode 100644 index 000000000..48e603530 --- /dev/null +++ b/test/unit/database.server.prerender.test.ts @@ -0,0 +1,162 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { afterEach, describe, expect, test, vi } from 'vitest' +import type { RuntimeConfig } from '@nuxt/content' + +/** + * Regression test for https://github.com/nuxt/content/issues/3829 + * + * When `sqliteConnector: 'bun'` is configured and the build runs on Node.js, + * the prerender stage fails because Node.js cannot resolve `bun:sqlite`. + * + * Root cause: `database.server.ts` had a static top-level import for + * `#content/adapter`. Node.js ESM loader resolves ALL static imports at + * module load time, even when the imported binding is never called at runtime. + * + * Fix: `#content/adapter` is now loaded via dynamic `import()` only in the + * production code path, so the prerender stage never triggers `bun:sqlite` + * resolution. + */ +describe('database.server - lazy adapter loading (issue #3829)', () => { + const config: RuntimeConfig['content'] = { + database: { type: 'sqlite', filename: ':memory:' }, + localDatabase: { type: 'sqlite', filename: ':memory:' }, + databaseVersion: 'test', + } as RuntimeConfig['content'] + + afterEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + test('source does NOT contain a static top-level import of #content/adapter', async () => { + const source = await readFile( + resolve(__dirname, '../../src/runtime/internal/database.server.ts'), + 'utf-8', + ) + + // Should NOT have a static import statement for #content/adapter + const staticImportPattern = /^import\s+\w+\s+from\s+['"]#content\/adapter['"]/m + expect(source).not.toMatch(staticImportPattern) + + // Should still have the local-adapter static import (that one is fine, + // it resolves to a Node.js-compatible connector) + const localAdapterPattern = /^import\s+\w+\s+from\s+['"]#content\/local-adapter['"]/m + expect(source).toMatch(localAdapterPattern) + + // Should have a dynamic import of #content/adapter + const dynamicImportPattern = /import\(['"]#content\/adapter['"]\)/ + expect(source).toMatch(dynamicImportPattern) + }) + + test('loadDatabaseAdapter is async and returns a DatabaseAdapter', async () => { + vi.doMock('#content/adapter', () => ({ + default: (_opts: unknown) => ({ + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([{ id: '1', title: 'Hello' }]), + get: (..._params: unknown[]) => Promise.resolve({ id: '1', title: 'Hello' }), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + }), + })) + vi.doMock('#content/local-adapter', () => ({ + default: (_opts: unknown) => ({ + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([]), + get: (..._params: unknown[]) => Promise.resolve(null), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + }), + })) + + const mod = await import('../../src/runtime/internal/database.server') + const loadDatabaseAdapter = mod.default + + const result = loadDatabaseAdapter(config) + expect(result).toBeInstanceOf(Promise) + + const db = await result + expect(db).toBeDefined() + expect(db.all).toBeTypeOf('function') + expect(db.first).toBeTypeOf('function') + expect(db.exec).toBeTypeOf('function') + }) + + test('loadDatabaseAdapter production path uses dynamic adapter import', async () => { + const adapterFn = vi.fn((_opts: unknown) => ({ + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([{ id: '1' }]), + get: (..._params: unknown[]) => Promise.resolve({ id: '1' }), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + })) + + vi.doMock('#content/adapter', () => ({ default: adapterFn })) + vi.doMock('#content/local-adapter', () => ({ + default: vi.fn(() => ({ prepare: vi.fn() })), + })) + + const mod = await import('../../src/runtime/internal/database.server') + const loadDatabaseAdapter = mod.default + + // In the test environment (non-dev, non-prerender), the production path is taken + const db = await loadDatabaseAdapter(config) + expect(adapterFn).toHaveBeenCalledOnce() + + // Subsequent calls should reuse the cached connection + await loadDatabaseAdapter(config) + expect(adapterFn).toHaveBeenCalledOnce() // still only once + + const result = await db.all('SELECT * FROM test') + expect(result).toHaveLength(1) + }) + + test('concurrent first calls share a single adapter initialization', async () => { + let releaseAdapter!: () => void + const adapterGate = new Promise((resolve) => { + releaseAdapter = resolve + }) + + const adapterFn = vi.fn((_opts: unknown) => ({ + prepare: (_sql: string) => ({ + all: (..._params: unknown[]) => Promise.resolve([{ id: '1' }]), + get: (..._params: unknown[]) => Promise.resolve({ id: '1' }), + run: (..._params: unknown[]) => Promise.resolve(undefined), + }), + })) + + // Keep the dynamic import pending so both callers enter initialization + // before either can finish creating the connector. + vi.doMock('#content/adapter', async () => { + await adapterGate + return { default: adapterFn } + }) + vi.doMock('#content/local-adapter', () => ({ + default: vi.fn(() => ({ prepare: vi.fn() })), + })) + + const loadDatabaseAdapter = (await import('../../src/runtime/internal/database.server')).default + + const firstPromise = loadDatabaseAdapter(config) + const secondPromise = loadDatabaseAdapter(config) + + // Allow both calls to reach the shared initialization await + await Promise.resolve() + expect(adapterFn).not.toHaveBeenCalled() + + releaseAdapter() + + const [first, second] = await Promise.all([firstPromise, secondPromise]) + + expect(adapterFn).toHaveBeenCalledOnce() + expect(first).toBeDefined() + expect(second).toBeDefined() + + const [a, b] = await Promise.all([ + first.all('SELECT * FROM test'), + second.all('SELECT * FROM test'), + ]) + expect(a).toHaveLength(1) + expect(b).toHaveLength(1) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 180db1bdb..89f8d9fc0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,8 @@ export default defineVitestConfig({ environment: 'nuxt', alias: { '#content/manifest': resolve('./test/mock/content-manifest.ts'), + '#content/adapter': resolve('./test/mock/content-adapter.ts'), + '#content/local-adapter': resolve('./test/mock/content-local-adapter.ts'), }, include: ['test/**/*.test.ts'], exclude: [