Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/runtime/api/query.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
48 changes: 37 additions & 11 deletions src/runtime/internal/database.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connector> | 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<Connector> {
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 <DatabaseAdapter>{
all: async (sql, params = []) => {
return db.prepare(sql).all(...params)
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions test/mock/content-adapter.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
}
}
9 changes: 9 additions & 0 deletions test/mock/content-local-adapter.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
}
}
10 changes: 10 additions & 0 deletions test/mock/content-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
export const tables = {
test: '_content_test',
info: '_content_info',
}

export const checksums: Record<string, string> = {}
export const checksumsStructure: Record<string, string> = {}

const manifest: Record<string, { fields: Record<string, string> }> = {
test: { fields: { id: 'string', title: 'string' } },
}

export default manifest
162 changes: 162 additions & 0 deletions test/unit/database.server.prerender.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
farnabaz marked this conversation as resolved.
})

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<void>((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)
})
})
2 changes: 2 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading