UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

343 lines (341 loc) • 13.9 kB
const require_core = require('./core.cjs'); const require_errors = require('./errors.cjs'); let node_fs = require("node:fs"); let node_path = require("node:path"); let effect = require("effect"); let node_crypto = require("node:crypto"); let drizzle_orm = require("drizzle-orm"); //#region src/db/run-migrations.ts const migrationJournalSchema = effect.Schema.Struct({ entries: effect.Schema.Array(effect.Schema.Struct({ idx: effect.Schema.Finite, when: effect.Schema.Finite, tag: effect.Schema.String })) }); const DEFAULT_DUPLICATE_SQLSTATES = require_core.DUPLICATE_OBJECT_SQLSTATES; function normalizeRows(result) { if (Array.isArray(result)) return result; if (result && typeof result === "object" && "rows" in result) return result.rows; return []; } function isDuplicateObjectError(error, codes) { return require_core.visitCauses(error, 6, (link) => { const code = link.code; return typeof code === "string" && codes.includes(code); }); } /** * Whether a failed migration statement should be treated as duplicate-DDL * tolerance: either an explicit duplicate-object class, or the concurrent-DDL * catalog collision (SQLSTATE 23505 on a pg_catalog unique index) that two * booting processes can produce when they run the same `CREATE TABLE` against * a fresh database at the same time. */ function isTolerableDuplicateDdl(error, codes) { return isDuplicateObjectError(error, codes) || require_core.isConcurrentDdlUniqueViolation(error); } /** * Check which of the given expected tables already exist in the target schema. * The schema name is bound as a query parameter. */ async function existingTablesIn(executor, tables, schemaName) { if (tables.length === 0) return /* @__PURE__ */ new Set(); const schema = schemaName ?? "public"; const result = await executor.execute(drizzle_orm.sql` SELECT table_name FROM information_schema.tables WHERE table_schema = ${schema} AND table_name = ANY(${drizzle_orm.sql.raw(require_core.toSqlArray(tables))}) `); return new Set(normalizeRows(result).map((r) => r.table_name)); } /** Read applied hashes from the migration journal. */ async function readAppliedHashesIn(executor, ref) { const result = await executor.execute(drizzle_orm.sql`SELECT hash FROM ${ref}`); return new Set(normalizeRows(result).map((r) => r.hash)); } function loadMigrations(opts = {}) { return effect.Effect.gen(function* () { if (opts.virtual) { const mod = yield* effect.Effect.tryPromise({ try: () => opts.virtual(), catch: (cause) => new require_errors.DatabaseError({ stage: "load", cause }) }).pipe(effect.Effect.option); const migrations = effect.Option.getOrUndefined(mod)?.default; if (migrations?.length) { yield* effect.Effect.logInfo(`[Database] Loaded ${migrations.length} migration(s) from virtual module`); return { migrations, source: "virtual" }; } yield* effect.Effect.logDebug("[Database] Virtual migrations unavailable, loading from disk"); } const diskMigrations = effect.Option.getOrUndefined(yield* loadMigrationsFromDisk(opts.fromDir).pipe(effect.Effect.option)); if (diskMigrations) { yield* effect.Effect.logInfo(`[Database] Loaded ${diskMigrations.length} migration(s) from disk`); return { migrations: diskMigrations, source: "disk" }; } yield* effect.Effect.logWarning("[Database] No migrations found from virtual or disk"); return { migrations: [], source: "disk" }; }); } function loadMigrationsFromDisk(fromDir) { return effect.Effect.gen(function* () { const migrationsDir = (0, node_path.resolve)(fromDir ?? __dirname, "migrations"); const journalPath = (0, node_path.join)(migrationsDir, "meta", "_journal.json"); if (!(0, node_fs.existsSync)(journalPath)) return yield* new require_errors.DatabaseError({ stage: "load", cause: /* @__PURE__ */ new Error(`Migrations journal not found at ${journalPath}. Run \`db:generate\` first.`) }); const journalContents = yield* effect.Effect.try({ try: () => (0, node_fs.readFileSync)(journalPath, "utf8"), catch: (cause) => new require_errors.DatabaseError({ stage: "load", cause }) }); const json = yield* effect.Schema.decodeEffect(effect.Schema.fromJsonString(effect.Schema.Json))(journalContents).pipe(effect.Effect.mapError((cause) => new require_errors.DatabaseError({ stage: "load", cause }))); const journal = yield* effect.Schema.decodeUnknownEffect(migrationJournalSchema)(json).pipe(effect.Effect.mapError((cause) => new require_errors.DatabaseError({ stage: "load", cause }))); return yield* effect.Effect.forEach(journal.entries, (entry) => effect.Effect.try({ try: () => { const sqlPath = (0, node_path.join)(migrationsDir, `${entry.tag}.sql`); if (!(0, node_fs.existsSync)(sqlPath)) throw new Error(`Migration SQL file not found: ${sqlPath}`); const raw = (0, node_fs.readFileSync)(sqlPath, "utf8"); const sqlStatements = raw.split("--> statement-breakpoint").map((statement) => statement.trim()); const hash = (0, node_crypto.createHash)("sha256").update(raw).digest("hex"); return { idx: entry.idx, when: entry.when, tag: entry.tag, hash, sql: sqlStatements }; }, catch: (cause) => new require_errors.DatabaseError({ stage: "load", cause }) })); }); } function journalRef(s) { return drizzle_orm.sql.raw(`"${s.schema}"."${s.table}"`); } /** * Apply one statement under a savepoint, tolerating duplicate DDL: either an * explicit duplicate-object class or the concurrent-DDL catalog collision a * non-participant migrator (drizzle-kit) can still produce. */ async function applyWithSavepoint(tx, query, migrationTag, savepoint, duplicateSqlStates) { await tx.execute(drizzle_orm.sql.raw(`SAVEPOINT ${savepoint}`)); try { await tx.execute(query); } catch (cause) { if (isTolerableDuplicateDdl(cause, duplicateSqlStates)) { await tx.execute(drizzle_orm.sql.raw(`ROLLBACK TO SAVEPOINT ${savepoint}`)); return; } throw new require_errors.DatabaseError({ stage: "migration", migrationTag, cause }); } await tx.execute(drizzle_orm.sql.raw(`RELEASE SAVEPOINT ${savepoint}`)); } /** * The whole migration run inside ONE transaction that opens with a * journal-scoped `pg_advisory_xact_lock`. Drizzle's `db.transaction` pins a * single pooled client for the callback, so the lock, the journal read, the * DDL, and the journal inserts all share one session; the lock releases at * COMMIT/ROLLBACK or on connection death — a crashed migrator can never * orphan it. A concurrent migrator blocks (bounded by the driver's * `lock_timeout`), then reads the winner's committed journal rows and applies * nothing. */ async function runLockedTransaction(db, args) { const { sorted, journal, ref, schemaName, duplicateSqlStates } = args; const appliedTags = []; const logs = []; await db.transaction(async (tx) => { await tx.execute(drizzle_orm.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${journal.schema}.${journal.table}`}, 0))`); await applyWithSavepoint(tx, drizzle_orm.sql`CREATE SCHEMA IF NOT EXISTS ${drizzle_orm.sql.raw(`"${journal.schema}"`)}`, "init-schema", "sp_init_schema", duplicateSqlStates); await applyWithSavepoint(tx, drizzle_orm.sql` CREATE TABLE IF NOT EXISTS ${ref} ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint ) `, "init-table", "sp_init_table", duplicateSqlStates); if (schemaName) await applyWithSavepoint(tx, drizzle_orm.sql`CREATE SCHEMA IF NOT EXISTS ${drizzle_orm.sql.raw(`"${schemaName}"`)}`, "init-data-schema", "sp_init_data_schema", duplicateSqlStates); const appliedHashes = await readAppliedHashesIn(tx, ref); for (const migration of sorted) { if (appliedHashes.has(migration.hash) || appliedHashes.has(migration.hash.slice(0, 12))) continue; const expectedTables = require_core.extractExpectedTables([migration]); if (expectedTables.length > 0) { const existing = await existingTablesIn(tx, expectedTables, schemaName); const missingTables = expectedTables.filter((t) => !existing.has(t)); if (missingTables.length === 0) { logs.push(`[Database] All tables for migration ${migration.tag} already exist — recording as applied without replaying DDL`); await tx.execute(drizzle_orm.sql`INSERT INTO ${ref} (hash, created_at) VALUES (${migration.hash}, ${migration.when})`); appliedHashes.add(migration.hash); appliedTags.push(migration.tag); continue; } if (missingTables.length < expectedTables.length) logs.push(`[Database] Partial table overlap for migration ${migration.tag}: ${expectedTables.length - missingTables.length} table(s) exist but not all. Applying migration — existing tables: ${expectedTables.filter((t) => existing.has(t)).join(", ")}`); } logs.push(`[Database] Applying migration: ${migration.tag}`); for (const [i, statement] of migration.sql.entries()) { const stmt = schemaName ? statement.replace(/"public"\./g, "") : statement; await applyWithSavepoint(tx, drizzle_orm.sql.raw(stmt), migration.tag, `stmt_${i}`, duplicateSqlStates); } await tx.execute(drizzle_orm.sql`INSERT INTO ${ref} (hash, created_at) VALUES (${migration.hash}, ${migration.when})`); appliedTags.push(migration.tag); } }); return { appliedTags, logs }; } /** * Apply pending migrations to the target database. * * Namespace model: `opts.schemaName` (`plugin_<slug>`) scopes data tables; the * journal lives in `opts.journal` (default the shared `drizzle.__drizzle_migrations`). * Undefined `schemaName` means public-schema (api) or dedicated-DB (auth) topology. * * Concurrency: the run serializes on a journal-scoped Postgres advisory * transaction lock, so the dev double-boot (plugin dev-server + the host's * in-process load), parallel test files, and overlapping production replicas * converge instead of colliding in pg_catalog. SAVEPOINT-based duplicate-DDL * tolerance stays as defense-in-depth for non-participants (drizzle-kit). * * Reliability floor: hash-tracked idempotence with a preflight that records * fully-overlapped migrations as applied, everything in one locked * transaction, retried on deadlock/serialization/lock-timeout/connection * states — a failed attempt rolls back cleanly and re-runs. */ function runMigrations(db, migrations, opts = {}) { return effect.Effect.gen(function* () { const sorted = [...migrations].sort((a, b) => a.idx - b.idx); const journal = opts.journal ?? require_core.getMigrationStorage(); const schemaName = opts.schemaName; const duplicateSqlStates = opts.duplicateSqlStates ?? DEFAULT_DUPLICATE_SQLSTATES; const { appliedTags, logs } = yield* effect.Effect.retry(effect.Effect.tryPromise({ try: () => runLockedTransaction(db, { sorted, journal, ref: journalRef(journal), schemaName, duplicateSqlStates }), catch: (cause) => cause instanceof require_errors.DatabaseError ? cause : new require_errors.DatabaseError({ stage: "migration", migrationTag: "run", cause }) }), { schedule: effect.Schedule.spaced("500 millis"), times: 3, while: require_core.isRetryableMigrationExecutionError }); for (const line of logs) yield* effect.Effect.logInfo(line); return { applied: appliedTags.length, total: sorted.length, storage: journal, schema: schemaName }; }); } /** * Detect drift between the local migration set and the database journal. * * Pass an explicit `journal` resolved from the caller's workspace for reliable * slug derivation; the default relies on `process.env.npm_package_name`, which is * unreliable under bundlers and Module Federation remotes. */ function detectDrift(db, migrations, journal, schemaName) { return effect.Effect.gen(function* () { const storage = journal ?? require_core.getMigrationStorage(); const expectedTables = require_core.extractExpectedTables(migrations); const ref = journalRef(storage); const appliedCount = (yield* effect.Effect.tryPromise({ try: () => readAppliedHashesIn(db, ref), catch: (cause) => new require_errors.DatabaseError({ stage: "migration", migrationTag: "read-applied", cause }) }).pipe(effect.Effect.orElseSucceed(() => /* @__PURE__ */ new Set()))).size; if (expectedTables.length === 0) return { status: "empty", expectedTables: [], missingTables: [], appliedHashes: appliedCount, localHashes: migrations.length, storage }; const existing = yield* effect.Effect.tryPromise({ try: () => existingTablesIn(db, expectedTables, schemaName), catch: (cause) => new require_errors.DatabaseError({ stage: "migration", migrationTag: "preflight-table-check", cause }) }).pipe(effect.Effect.orElseSucceed(() => /* @__PURE__ */ new Set())); const missingTables = expectedTables.filter((t) => !existing.has(t)); if (appliedCount === 0 && missingTables.length === 0) return { status: "untracked-existing-schema", expectedTables, missingTables: [], appliedHashes: 0, localHashes: migrations.length, storage }; if (missingTables.length === 0) return { status: "healthy", expectedTables, missingTables: [], appliedHashes: appliedCount, localHashes: migrations.length, storage }; if (missingTables.length === expectedTables.length) return { status: "drift-safe-repair", expectedTables, missingTables, appliedHashes: appliedCount, localHashes: migrations.length, storage }; return { status: "drift-manual", expectedTables, missingTables, appliedHashes: appliedCount, localHashes: migrations.length, storage }; }); } //#endregion exports.detectDrift = detectDrift; exports.loadMigrations = loadMigrations; exports.runMigrations = runMigrations; //# sourceMappingURL=run-migrations.cjs.map