import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { sql } from 'drizzle-orm'; import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js'; import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator'; import { readMigrationFiles } from 'drizzle-orm/migrator'; import postgres from 'postgres'; import { DEFAULT_DATABASE_URL } from './defaults.js'; import type { DbHandle } from './client.js'; interface PgliteExecutor { exec(query: string): Promise; } interface ExecuteRows { rows: T[]; } function migrationsFolder(): string { const here = dirname(fileURLToPath(import.meta.url)); return resolve(here, '../drizzle'); } export async function runMigrations(url?: string): Promise { const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL; const sqlClient = postgres(connectionString, { max: 1 }); const db = drizzlePostgres(sqlClient); try { // TODO: postgres-tier first-install also fails because (a) Drizzle wraps every // migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT // sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008, // which the postgres-js migrator skips by `created_at < folderMillis`. The // PGlite path below sidesteps both. A follow-up should either share the // per-statement loop (see runPgliteMigrations) or fix the journal ordering. await migratePostgres(db, { migrationsFolder: migrationsFolder() }); } finally { await sqlClient.end(); } } // Apply Drizzle migrations against an embedded PGlite database. // // We don't reuse drizzle's pglite migrator because it wraps ALL migrations in // one outer transaction, which breaks Postgres' `check_safe_enum_use` rule — // e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references // `'pending'` as a default in the same tx. PGlite's `exec()` runs each // statement under the Simple Query protocol, autocommitting between them. // // We still write to the standard `drizzle.__drizzle_migrations` ledger so the // result is interoperable with `runMigrations()` on a postgres-backed deploy // (modulo the journal-ordering bug noted above). // // We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's // postgres-js migrator does). That's deliberate — out-of-order timestamps in // `_journal.json` won't silently drop migrations. // // Failure model: each statement autocommits, and the ledger row is written // only after all statements in a migration succeed. A crash mid-migration // leaves the prefix applied with no ledger entry, so the next boot will // replay those statements and fail loudly on "already exists". Recovery: // drop the partially-applied objects, or insert the migration's hash into // `drizzle.__drizzle_migrations` manually. The error log identifies which // statement of which migration was the culprit. export async function runPgliteMigrations(handle: DbHandle): Promise { const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client; if (!client || typeof client.exec !== 'function') { throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client'); } await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle'); await client.exec(` CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint ) `); const appliedRows = (await handle.db.execute( sql`SELECT hash FROM drizzle.__drizzle_migrations`, )) as unknown as ExecuteRows<{ hash: string }>; const applied = new Set(appliedRows.rows.map((r) => r.hash)); const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() }); for (const migration of migrations) { if (applied.has(migration.hash)) continue; // Run each statement-breakpoint chunk in its own exec() call so PGlite // commits between statements — this is what lets `ALTER TYPE ADD VALUE` // become visible before a subsequent statement references the new value. for (const [stmtIdx, stmt] of migration.sql.entries()) { const trimmed = stmt.trim(); if (!trimmed) continue; try { await client.exec(trimmed); } catch (err) { const cause = err instanceof Error ? err.message : String(err); throw new Error( `runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` + `Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`, { cause: err }, ); } } await handle.db.execute( sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`, ); } }