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
71 changes: 48 additions & 23 deletions src/presets/nuxthub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,33 @@ import { provider } from 'std-env'
import { logger } from '../utils/dev'
import { definePreset } from '../utils/preset'
import type { Nuxt } from 'nuxt/schema'
import type { LibSQLDatabaseConfig, PGliteDatabaseConfig, SqliteDatabaseConfig } from '~/dist/module.mjs'
import type { D1DatabaseConfig, LibSQLDatabaseConfig, PGliteDatabaseConfig, PostgreSQLDatabaseConfig, SqliteDatabaseConfig } from '~/dist/module.mjs'
import cloudflarePreset from './cloudflare'
import nodePreset from './node'

type ContentDatabaseConfig = D1DatabaseConfig | SqliteDatabaseConfig | PostgreSQLDatabaseConfig | LibSQLDatabaseConfig | PGliteDatabaseConfig

// Map the resolved NuxtHub database config (`runtimeConfig.hub.db`) to a Nuxt Content database config
export function hubDatabaseToContentDatabase(hubDb: { driver: string, connection?: { url?: string, [key: string]: unknown } }): ContentDatabaseConfig | undefined {
if (hubDb.driver === 'd1') {
return { type: 'd1', bindingName: 'DB' }
}
if (['node-postgres', 'postgres-js', 'neon-http', 'postgres', 'postgresql'].includes(hubDb.driver)) {
return typeof hubDb.connection?.url === 'string' && hubDb.connection.url ? { type: 'postgresql', url: hubDb.connection.url } : undefined
}
if (['sqlite', 'better-sqlite3'].includes(hubDb.driver)) {
if (typeof hubDb.connection?.filename === 'string' && hubDb.connection.filename) {
return { type: 'sqlite', filename: hubDb.connection.filename }
}
const filename = typeof hubDb.connection?.url === 'string' ? hubDb.connection.url.replace(/^file:/, '') : ''
return filename ? { type: 'sqlite', filename } : undefined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (['libsql', 'pglite'].includes(hubDb.driver)) {
return { type: hubDb.driver, ...hubDb.connection } as unknown as ContentDatabaseConfig
}
return undefined
}

export default definePreset({
name: 'nuxthub',
async setup(options, nuxt, config) {
Expand All @@ -21,15 +44,14 @@ export default definePreset({
if (nuxtOptions.hub?.database === true) {
options.database ||= { type: 'd1', bindingName: 'DB' }
}
else if (typeof nuxtOptions.hub?.db === 'string' && typeof hubDb === 'object') {
if (hubDb.driver === 'd1') {
options.database ||= { type: 'd1', bindingName: 'DB' }
}
else if (hubDb.driver === 'node-postgres') {
options.database ||= { type: 'postgresql', url: hubDb.connection.url as string }
// NuxtHub >= 0.10, `hub.db` can be a string or an object
else if (nuxtOptions.hub?.db && typeof hubDb === 'object' && !options.database) {
const database = hubDatabaseToContentDatabase(hubDb)
if (database) {
options.database = database
}
else {
options.database ||= { type: hubDb.driver as 'sqlite' | 'postgresql' | 'postgres' | 'libsql' | 'pglite', ...hubDb.connection } as unknown as SqliteDatabaseConfig | LibSQLDatabaseConfig | PGliteDatabaseConfig
logger.warn(`Nuxt Content cannot use the NuxtHub \`${hubDb.driver}\` database configuration, using the default database instead.`)
}
}
}
Expand Down Expand Up @@ -62,21 +84,23 @@ export default definePreset({
nitroConfig.runtimeConfig!.content!.database = { type: 'd1', bindingName: 'DB' }
}
}
else if (typeof nuxt.options.hub?.db === 'string' && typeof hubConfig.db === 'object') {
const hubDb = hubConfig.db as unknown as { driver: string, connection: object }
if (hubDb.driver === 'd1') {
nitroConfig.runtimeConfig!.content!.database ||= { type: 'd1', bindingName: 'DB' }
}
else if (hubDb.driver === 'node-postgres') {
nitroConfig.runtimeConfig!.content!.database ||= { type: 'postgresql', ...hubDb.connection }
}
else {
nitroConfig.runtimeConfig!.content!.database ||= { type: hubDb.driver, ...hubDb.connection }
else if (nuxt.options.hub?.db && typeof hubConfig.db === 'object') {
const database = hubDatabaseToContentDatabase(hubConfig.db as unknown as { driver: string, connection?: { url?: string } })
if (database) {
nitroConfig.runtimeConfig!.content!.database ||= database
}
}

const database = nitroConfig.runtimeConfig?.content?.database
// Only a remote database migrated during build is the database the runtime will use,
// a local file database is discarded with the build machine
const isRemoteDatabase = database?.type === 'd1'
|| database?.type === 'postgresql'
|| database?.type === 'postgres'
|| (database?.type === 'libsql' && !!database.url && !database.url.startsWith('file:'))

// apply migrations during build if enabled
if (!nuxt.options.dev && hubConfig.db?.applyMigrationsDuringBuild) {
if (!nuxt.options.dev && hubConfig.db?.applyMigrationsDuringBuild && isRemoteDatabase) {
// Write SQL dump to database queries when not in dev mode
await mkdir(resolve(nitroConfig.rootDir!, hubConfig.dir, 'db/queries'), { recursive: true })
let i = 1
Expand Down Expand Up @@ -106,10 +130,11 @@ export default definePreset({
nitroConfig.runtimeConfig!.content.integrityCheck = false
}
// Handle local database (cannot be populated during build)
const database = nitroConfig.runtimeConfig?.content?.database
if (!nuxt.options.dev && database?.type === 'libsql' && database?.url?.startsWith('file:') && !database?.url?.startsWith('file:/tmp/')) {
logger.warn('Deploying local libsql database with Nuxthub is possible only in `/tmp` directory. Using `/tmp/sqlite.db` instead.')
database.url = 'file:/tmp/sqlite.db'
if (!nuxt.options.dev && database?.type === 'libsql' && database.url?.startsWith('file:')) {
if (!database.url.startsWith('file:/tmp/')) {
logger.warn('Deploying local libsql database with Nuxthub is possible only in `/tmp` directory. Using `/tmp/sqlite.db` instead.')
database.url = 'file:/tmp/sqlite.db'
}
// Enable integrity check in production as local database cannot be re-used after build
nitroConfig.runtimeConfig!.content ||= {}
nitroConfig.runtimeConfig!.content.integrityCheck = true
Expand Down
194 changes: 194 additions & 0 deletions test/unit/nuxthubPreset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, readdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'pathe'
import { describe, expect, test, vi } from 'vitest'
import type { Nuxt } from '@nuxt/schema'
import type { Resolver } from '@nuxt/kit'
import type { NitroConfig } from 'nitropack'
import nuxthubPreset, { hubDatabaseToContentDatabase } from '../../src/presets/nuxthub'
import type { ModuleOptions } from '../../src/types/module'
import type { Manifest } from '../../src/types/manifest'

// `addTemplate` and `addServerHandler` require a live Nuxt context
vi.mock('@nuxt/kit', async (importOriginal) => {
const original = await importOriginal<typeof import('@nuxt/kit')>()
return {
...original,
addTemplate: vi.fn(() => ({ dst: '' })),
addServerHandler: vi.fn(),
}
})

const resolver = { resolve: (p: string) => p } as unknown as Resolver
const manifest = { collections: [], dump: {} } as unknown as Manifest
const opts = { resolver, manifest }

function createNuxt(hub: Record<string, unknown>, runtimeHub: Record<string, unknown>): Nuxt {
return {
options: {
dev: false,
hub,
nitro: { preset: 'node-server' },
runtimeConfig: { hub: runtimeHub },
},
} as unknown as Nuxt
}

describe('hubDatabaseToContentDatabase', () => {
test('maps d1 driver to d1 database', () => {
expect(hubDatabaseToContentDatabase({ driver: 'd1' })).toEqual({ type: 'd1', bindingName: 'DB' })
})

test('maps postgres drivers to postgresql database', () => {
for (const driver of ['node-postgres', 'postgres-js', 'neon-http', 'postgres', 'postgresql']) {
expect(hubDatabaseToContentDatabase({ driver, connection: { url: 'postgres://localhost' } }))
.toEqual({ type: 'postgresql', url: 'postgres://localhost' })
}
})

test('maps sqlite drivers to sqlite database', () => {
for (const driver of ['sqlite', 'better-sqlite3']) {
expect(hubDatabaseToContentDatabase({ driver, connection: { url: 'file:.data/hub/db/sqlite.db' } }))
.toEqual({ type: 'sqlite', filename: '.data/hub/db/sqlite.db' })
}
})

test('keeps an explicit sqlite filename', () => {
expect(hubDatabaseToContentDatabase({ driver: 'sqlite', connection: { filename: './contents.sqlite' } }))
.toEqual({ type: 'sqlite', filename: './contents.sqlite' })
})

test('maps libsql driver with its connection', () => {
expect(hubDatabaseToContentDatabase({ driver: 'libsql', connection: { url: 'file:.data/hub/db/sqlite.db' } }))
.toEqual({ type: 'libsql', url: 'file:.data/hub/db/sqlite.db' })
})

test('maps pglite driver with its connection', () => {
expect(hubDatabaseToContentDatabase({ driver: 'pglite', connection: { dataDir: '.data/hub/db/pglite' } }))
.toEqual({ type: 'pglite', dataDir: '.data/hub/db/pglite' })
})

test('returns undefined for unsupported drivers', () => {
expect(hubDatabaseToContentDatabase({ driver: 'mysql2', connection: { uri: 'mysql://localhost' } })).toBeUndefined()
})

test('returns undefined when required connection values are missing', () => {
expect(hubDatabaseToContentDatabase({ driver: 'postgres-js' })).toBeUndefined()
expect(hubDatabaseToContentDatabase({ driver: 'postgres-js', connection: { url: '' } })).toBeUndefined()
expect(hubDatabaseToContentDatabase({ driver: 'sqlite' })).toBeUndefined()
expect(hubDatabaseToContentDatabase({ driver: 'sqlite', connection: {} })).toBeUndefined()
})

test('returns undefined for non-string connection values', () => {
expect(hubDatabaseToContentDatabase({ driver: 'postgres-js', connection: { url: 123 as unknown as string } })).toBeUndefined()
expect(hubDatabaseToContentDatabase({ driver: 'sqlite', connection: { filename: true, url: 123 as unknown as string } })).toBeUndefined()
expect(hubDatabaseToContentDatabase({ driver: 'sqlite', connection: { filename: true, url: 'file:.data/hub/db/sqlite.db' } }))
.toEqual({ type: 'sqlite', filename: '.data/hub/db/sqlite.db' })
})
})

describe('nuxthub preset setup', () => {
const resolvedSqliteDb = { driver: 'libsql', connection: { url: 'file:.data/hub/db/sqlite.db' } }

test('maps the string form of hub.db', async () => {
const options = {} as ModuleOptions
await nuxthubPreset.setup!(options, createNuxt({ db: 'sqlite' }, { db: resolvedSqliteDb }), opts)

expect(options.database).toEqual({ type: 'libsql', url: 'file:.data/hub/db/sqlite.db' })
})

test('maps the object form of hub.db', async () => {
const options = {} as ModuleOptions
await nuxthubPreset.setup!(options, createNuxt({ db: { dialect: 'sqlite', applyMigrationsDuringBuild: false } }, { db: resolvedSqliteDb }), opts)

expect(options.database).toEqual({ type: 'libsql', url: 'file:.data/hub/db/sqlite.db' })
})

test('does not override an explicitly configured database', async () => {
const options = { database: { type: 'libsql', url: 'file:/tmp/sqlite.db' } } as ModuleOptions
await nuxthubPreset.setup!(options, createNuxt({ db: { dialect: 'sqlite' } }, { db: resolvedSqliteDb }), opts)

expect(options.database).toEqual({ type: 'libsql', url: 'file:/tmp/sqlite.db' })
})

test('leaves the database unset for unsupported drivers', async () => {
const options = {} as ModuleOptions
await nuxthubPreset.setup!(options, createNuxt({ db: { dialect: 'mysql' } }, { db: { driver: 'mysql2', connection: { uri: 'mysql://localhost' } } }), opts)

expect(options.database).toBeUndefined()
})

test('uses d1 with NuxtHub <= 0.9', async () => {
const options = {} as ModuleOptions
await nuxthubPreset.setup!(options, createNuxt({ database: true }, { database: true }), opts)

expect(options.database).toEqual({ type: 'd1', bindingName: 'DB' })
})
})

describe('nuxthub preset setupNitro', () => {
test('maps the object form of hub.db and rewrites local libsql to /tmp', async () => {
const nuxt = createNuxt(
{ db: { dialect: 'sqlite', applyMigrationsDuringBuild: false } },
{ db: { driver: 'libsql', connection: { url: 'file:.data/hub/db/sqlite.db' }, applyMigrationsDuringBuild: false } },
)
const nitroConfig = { runtimeConfig: { content: {} }, rootDir: '/' } as unknown as NitroConfig
await nuxthubPreset.setupNitro(nitroConfig, { ...opts, moduleOptions: {} as ModuleOptions, nuxt })

expect(nitroConfig.runtimeConfig!.content!.database).toEqual({ type: 'libsql', url: 'file:/tmp/sqlite.db' })
expect(nitroConfig.runtimeConfig!.content!.integrityCheck).toBe(true)
})

test('maps the string form of hub.db to the same database', async () => {
const nuxt = createNuxt(
{ db: 'sqlite' },
{ db: { driver: 'libsql', connection: { url: 'file:.data/hub/db/sqlite.db' }, applyMigrationsDuringBuild: false } },
)
const nitroConfig = { runtimeConfig: { content: {} }, rootDir: '/' } as unknown as NitroConfig
await nuxthubPreset.setupNitro(nitroConfig, { ...opts, moduleOptions: {} as ModuleOptions, nuxt })

expect(nitroConfig.runtimeConfig!.content!.database).toEqual({ type: 'libsql', url: 'file:/tmp/sqlite.db' })
expect(nitroConfig.runtimeConfig!.content!.integrityCheck).toBe(true)
})
})

describe('nuxthub preset setupNitro dump handoff', () => {
const dumpManifest = { collections: [], dump: { posts: ['INSERT INTO posts VALUES (1);'] } } as unknown as Manifest

async function runSetupNitro(runtimeDb: Record<string, unknown>) {
const nuxt = createNuxt(
{ db: { dialect: 'sqlite' } },
{ db: runtimeDb, dir: '.data/hub' },
)
const rootDir = await mkdtemp(join(tmpdir(), 'nuxthub-preset-'))
const nitroConfig = { runtimeConfig: { content: {} }, rootDir } as unknown as NitroConfig
await nuxthubPreset.setupNitro(nitroConfig, { ...opts, manifest: dumpManifest, moduleOptions: {} as ModuleOptions, nuxt })
return { nitroConfig, queriesDir: join(rootDir, '.data/hub/db/queries') }
}

test('skips the handoff for a local file database and keeps the integrity check', async () => {
const { nitroConfig, queriesDir } = await runSetupNitro({ driver: 'libsql', connection: { url: 'file:.data/hub/db/sqlite.db' }, applyMigrationsDuringBuild: true })

expect(existsSync(queriesDir)).toBe(false)
expect(nitroConfig.runtimeConfig!.content!.database).toEqual({ type: 'libsql', url: 'file:/tmp/sqlite.db' })
expect(nitroConfig.runtimeConfig!.content!.integrityCheck).toBe(true)
})

test('keeps the integrity check when the local database is already in /tmp', async () => {
const { nitroConfig, queriesDir } = await runSetupNitro({ driver: 'libsql', connection: { url: 'file:/tmp/sqlite.db' }, applyMigrationsDuringBuild: true })

expect(existsSync(queriesDir)).toBe(false)
expect(nitroConfig.runtimeConfig!.content!.database).toEqual({ type: 'libsql', url: 'file:/tmp/sqlite.db' })
expect(nitroConfig.runtimeConfig!.content!.integrityCheck).toBe(true)
})

test('hands off the dump for a remote database and disables the integrity check', async () => {
const { nitroConfig, queriesDir } = await runSetupNitro({ driver: 'libsql', connection: { url: 'libsql://content.turso.io' }, applyMigrationsDuringBuild: true })

expect(await readdir(queriesDir)).toEqual(['content-database-001.sql'])
expect(await readFile(join(queriesDir, 'content-database-001.sql'), 'utf8')).toContain('INSERT INTO posts VALUES (1);')
expect(nitroConfig.runtimeConfig!.content!.database).toEqual({ type: 'libsql', url: 'libsql://content.turso.io' })
expect(nitroConfig.runtimeConfig!.content!.integrityCheck).toBe(false)
})
})
Loading