doxa-maps-cdn/server/plugins/migrations.ts

114 lines
4.4 KiB
TypeScript

import { Migrator, type Migration, type MigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
import { pathToFileURL } from 'url'
import { getDb } from '../utils/database'
// Migration files are TypeScript, and they are imported at runtime by absolute
// path — the bundler never sees them, so the server's own runtime has to be able
// to load .ts. Production runs under Bun, which can; `nuxt dev` runs Nitro under
// Node, which only strips types from 22.18 (or behind --experimental-strip-types
// before that) and otherwise throws ERR_UNKNOWN_FILE_EXTENSION. Shipping .js
// migrations wouldn't fix it either — layers bring their own .ts ones. So: try the
// native import, and fall back to jiti, which transpiles on the fly.
let jitiImport: ((path: string) => Promise<unknown>) | undefined
async function importMigration(fullPath: string): Promise<Migration> {
try {
return await import(/* @vite-ignore */ pathToFileURL(fullPath).href) as Migration
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== 'ERR_UNKNOWN_FILE_EXTENSION') throw err
if (!jitiImport) {
const { createJiti } = await import('jiti')
const jiti = createJiti(import.meta.url)
jitiImport = (path: string) => jiti.import(path)
}
return await jitiImport(fullPath) as Migration
}
}
// Reads migration files from multiple folders (consumer + each layer's migrations/).
// Filenames are sorted globally for a stable order. Each layer prefixes its files
// (e.g. `oauth_001_*`) to avoid collision with the consumer's numeric-prefixed names.
class MultiFolderMigrationProvider implements MigrationProvider {
constructor(private folders: string[]) {}
async getMigrations(): Promise<Record<string, Migration>> {
const all: Record<string, Migration> = {}
for (const folder of this.folders) {
let files: string[]
try {
files = await fs.readdir(folder)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.ts') && !file.endsWith('.js') && !file.endsWith('.mjs')) continue
const name = file.replace(/\.(ts|js|mjs)$/, '')
if (all[name]) {
throw new Error(`Migration name collision: "${name}" appears in multiple layers. Rename one (use a layer prefix like \`oauth_001_*\`).`)
}
all[name] = await importMigration(path.join(folder, file))
}
}
return all
}
}
export default defineNitroPlugin(async () => {
// Skip during build-time prerender — the builder isn't attached to the VPC,
// so connecting to the private DB host would time out. Migrations run at runtime.
if (import.meta.prerender) return
const cfg = useRuntimeConfig()
const databaseUrl = cfg.databaseUrl || process.env.DATABASE_URL
if (!databaseUrl) {
console.warn('DATABASE_URL not set, skipping migrations')
return
}
const layerPaths = (cfg.layerMigrationPaths as string[] | undefined) ?? []
const folders = layerPaths.length > 0
? layerPaths
: [path.join(process.cwd(), 'migrations')]
const migrator = new Migrator({
db: getDb(),
provider: new MultiFolderMigrationProvider(folders),
// Layer-supplied migrations (e.g. `oauth_001_*`) sort alphabetically AFTER
// the consumer's numeric-prefixed migrations, so any consumer migration
// added after a layer migration was applied looks "out of order" to the
// strict default checker. Allowing unordered execution is the correct
// posture for this multi-folder setup — migrations are still run exactly
// once each and must remain idempotent regardless of relative order.
allowUnorderedMigrations: true
})
// Identify pending migrations so we can announce them before execution
const all = await migrator.getMigrations()
const pending = all.filter(m => !m.executedAt)
if (pending.length === 0) {
console.log('Migrations already up-to-date')
return
}
console.log(`Running ${pending.length} pending migration(s)...`)
for (const m of pending) {
console.log(` Migration: ${m.name}`)
}
const { error, results } = await migrator.migrateToLatest()
results?.forEach(r => {
if (r.status === 'Success') console.log(`✓ ${r.migrationName}`)
if (r.status === 'Error') console.error(`✗ ${r.migrationName}`)
})
if (error) {
console.error('Migration failed:', error)
throw error
}
console.log('Migrations complete')
})