From dac4ef6bfbd3e50a2943d3d14db1a65cb9ec5cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cfinderz=E2=80=9D?= <“xtgjzwj@outlook.com”> Date: Mon, 10 Aug 2026 01:16:28 +0800 Subject: [PATCH 1/3] fix(runtime): use dynamic import for `#content/adapter` to prevent prerender failure When `sqliteConnector: 'bun'` is configured and the build runs on Node.js, the prerender stage fails because Node.js cannot resolve the `bun:` protocol. Root cause: `database.server.ts` used a static top-level import for `#content/adapter`. Node.js ESM loader resolves all static imports at module load time, regardless of whether the binding is called at runtime. During prerender only `localAdapter` is used, but the static import of `adapter` still forces resolution of `bun:sqlite`. Fix: Replace the static import with a lazy dynamic `import()` that is only resolved in the production code path (non-prerender, non-dev). This makes `loadDatabaseAdapter` async, which is a minimal API change since all callers already operate in async contexts. Closes #3829 Co-authored-by: Cursor --- src/runtime/api/query.post.ts | 2 +- src/runtime/internal/database.server.ts | 15 ++- test/mock/content-adapter.ts | 9 ++ test/mock/content-local-adapter.ts | 9 ++ test/mock/content-manifest.ts | 10 ++ test/unit/database.server.prerender.test.ts | 119 ++++++++++++++++++++ vitest.config.ts | 2 + 7 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 test/mock/content-adapter.ts create mode 100644 test/mock/content-local-adapter.ts create mode 100644 test/unit/database.server.prerender.test.ts 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..56af055ef 100644 --- a/src/runtime/internal/database.server.ts +++ b/src/runtime/internal/database.server.ts @@ -6,11 +6,19 @@ 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']) { +let _adapterPromise: Promise<(opts: unknown) => Connector> | undefined + +function getAdapter(): Promise<(opts: unknown) => Connector> { + if (!_adapterPromise) { + _adapterPromise = import('#content/adapter').then(m => m.default || m) + } + return _adapterPromise +} + +export default async function loadDatabaseAdapter(config: RuntimeConfig['content']) { const { database, localDatabase } = config if (!db) { @@ -18,6 +26,7 @@ export default function loadDatabaseAdapter(config: RuntimeConfig['content']) { db = localAdapter(refineDatabaseConfig(localDatabase)) } else { + const adapter = await getAdapter() db = adapter(refineDatabaseConfig(database)) } } @@ -63,7 +72,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..337c910e5 --- /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..79a33b24b --- /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..43eb11b7a --- /dev/null +++ b/test/unit/database.server.prerender.test.ts @@ -0,0 +1,119 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { afterEach, describe, expect, test, vi } from 'vitest' + +/** + * 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)', () => { + 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 + + // Verify the function is async (returns a Promise) + const config = { + database: { type: 'sqlite' as const, filename: ':memory:' }, + localDatabase: { type: 'sqlite' as const, filename: ':memory:' }, + databaseVersion: 'test', + } + + const result = loadDatabaseAdapter(config as any) + 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 + + const config = { + database: { type: 'sqlite' as const, filename: ':memory:' }, + localDatabase: { type: 'sqlite' as const, filename: ':memory:' }, + databaseVersion: 'test', + } + + // In the test environment (non-dev, non-prerender), the production path is taken + const db = await loadDatabaseAdapter(config as any) + expect(adapterFn).toHaveBeenCalledOnce() + + // Subsequent calls should reuse the cached connection + const db2 = await loadDatabaseAdapter(config as any) + expect(adapterFn).toHaveBeenCalledOnce() // still only once + + const result = await db.all('SELECT * FROM test') + expect(result).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: [ From e335c56f948ea47ec5ae2fc38c96bdfe04c4713f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cfinderz=E2=80=9D?= <“xtgjzwj@outlook.com”> Date: Mon, 10 Aug 2026 13:52:36 +0800 Subject: [PATCH 2/3] fix(test): resolve eslint errors in test mocks and regression test - Prefix unused parameters with `_` to satisfy @typescript-eslint/no-unused-vars - Replace `as any` casts with a shared typed config fixture - Remove unused variable assignment Co-authored-by: Cursor --- test/mock/content-adapter.ts | 2 +- test/mock/content-local-adapter.ts | 2 +- test/unit/database.server.prerender.test.ts | 32 +++++++++------------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/test/mock/content-adapter.ts b/test/mock/content-adapter.ts index 337c910e5..ae9a5f9de 100644 --- a/test/mock/content-adapter.ts +++ b/test/mock/content-adapter.ts @@ -1,6 +1,6 @@ export default function mockAdapter(_opts: unknown) { return { - prepare: (sql: string) => ({ + 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 index 79a33b24b..5c0f5ebf4 100644 --- a/test/mock/content-local-adapter.ts +++ b/test/mock/content-local-adapter.ts @@ -1,6 +1,6 @@ export default function mockLocalAdapter(_opts: unknown) { return { - prepare: (sql: string) => ({ + prepare: (_sql: string) => ({ all: (..._params: unknown[]) => Promise.resolve([]), get: (..._params: unknown[]) => Promise.resolve(null), run: (..._params: unknown[]) => Promise.resolve(undefined), diff --git a/test/unit/database.server.prerender.test.ts b/test/unit/database.server.prerender.test.ts index 43eb11b7a..591b6b342 100644 --- a/test/unit/database.server.prerender.test.ts +++ b/test/unit/database.server.prerender.test.ts @@ -1,6 +1,7 @@ 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 @@ -17,6 +18,12 @@ import { afterEach, describe, expect, test, vi } from 'vitest' * 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() @@ -45,7 +52,7 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { test('loadDatabaseAdapter is async and returns a DatabaseAdapter', async () => { vi.doMock('#content/adapter', () => ({ default: (_opts: unknown) => ({ - prepare: (sql: string) => ({ + 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), @@ -54,7 +61,7 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { })) vi.doMock('#content/local-adapter', () => ({ default: (_opts: unknown) => ({ - prepare: (sql: string) => ({ + prepare: (_sql: string) => ({ all: (..._params: unknown[]) => Promise.resolve([]), get: (..._params: unknown[]) => Promise.resolve(null), run: (..._params: unknown[]) => Promise.resolve(undefined), @@ -65,14 +72,7 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { const mod = await import('../../src/runtime/internal/database.server') const loadDatabaseAdapter = mod.default - // Verify the function is async (returns a Promise) - const config = { - database: { type: 'sqlite' as const, filename: ':memory:' }, - localDatabase: { type: 'sqlite' as const, filename: ':memory:' }, - databaseVersion: 'test', - } - - const result = loadDatabaseAdapter(config as any) + const result = loadDatabaseAdapter(config) expect(result).toBeInstanceOf(Promise) const db = await result @@ -84,7 +84,7 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { test('loadDatabaseAdapter production path uses dynamic adapter import', async () => { const adapterFn = vi.fn((_opts: unknown) => ({ - prepare: (sql: string) => ({ + prepare: (_sql: string) => ({ all: (..._params: unknown[]) => Promise.resolve([{ id: '1' }]), get: (..._params: unknown[]) => Promise.resolve({ id: '1' }), run: (..._params: unknown[]) => Promise.resolve(undefined), @@ -99,18 +99,12 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { const mod = await import('../../src/runtime/internal/database.server') const loadDatabaseAdapter = mod.default - const config = { - database: { type: 'sqlite' as const, filename: ':memory:' }, - localDatabase: { type: 'sqlite' as const, filename: ':memory:' }, - databaseVersion: 'test', - } - // In the test environment (non-dev, non-prerender), the production path is taken - const db = await loadDatabaseAdapter(config as any) + const db = await loadDatabaseAdapter(config) expect(adapterFn).toHaveBeenCalledOnce() // Subsequent calls should reuse the cached connection - const db2 = await loadDatabaseAdapter(config as any) + await loadDatabaseAdapter(config) expect(adapterFn).toHaveBeenCalledOnce() // still only once const result = await db.all('SELECT * FROM test') From fc911a16cd8557b8d80100ac81fcf9b2d2bb5234 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 26 Aug 2026 14:02:58 +0200 Subject: [PATCH 3/3] fix: load database race condition --- src/runtime/internal/database.server.ts | 35 +++++++++++---- test/unit/database.server.prerender.test.ts | 49 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/runtime/internal/database.server.ts b/src/runtime/internal/database.server.ts index 56af055ef..59090b48e 100644 --- a/src/runtime/internal/database.server.ts +++ b/src/runtime/internal/database.server.ts @@ -10,6 +10,7 @@ import localAdapter from '#content/local-adapter' let db: Connector let _adapterPromise: Promise<(opts: unknown) => Connector> | undefined +let _databasePromise: Promise | undefined function getAdapter(): Promise<(opts: unknown) => Connector> { if (!_adapterPromise) { @@ -18,19 +19,35 @@ function getAdapter(): Promise<(opts: unknown) => Connector> { return _adapterPromise } -export default async function loadDatabaseAdapter(config: RuntimeConfig['content']) { - const { database, localDatabase } = config +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)) + } - if (!db) { - if (import.meta.dev || ['nitro-prerender', 'nitro-dev'].includes(import.meta.preset as string)) { - db = localAdapter(refineDatabaseConfig(localDatabase)) - } - else { const adapter = await getAdapter() - db = adapter(refineDatabaseConfig(database)) - } + 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) diff --git a/test/unit/database.server.prerender.test.ts b/test/unit/database.server.prerender.test.ts index 591b6b342..48e603530 100644 --- a/test/unit/database.server.prerender.test.ts +++ b/test/unit/database.server.prerender.test.ts @@ -110,4 +110,53 @@ describe('database.server - lazy adapter loading (issue #3829)', () => { 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) + }) })