feat(FN-193): rewrite migrate.ts with hash-based runner (+4 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- docs(FN-193): update migration runner docs
- fix(FN-193): lint/typecheck fixes
- test(FN-193): rewrite migrate tests for runMigrations
- feat(FN-193): make baseline migration idempotent (IF NOT EXISTS)
- feat(FN-193): rewrite migrate.ts with hash-based runner

Files changed:
apps/api/drizzle/0000_brief_guardian.sql        | 364 ++++++++++++------------
 apps/api/src/database/__tests__/migrate.spec.ts | 354 ++++++++++++-----------
 apps/api/src/database/migrate.ts                | 173 +++++------
 docs/INDEX.md                                   |  17 +-
 4 files changed, 466 insertions(+), 442 deletions(-)

Fusion-Task-Id: FN-193
This commit is contained in:
Fusion
2026-05-12 07:22:20 +00:00
parent 0f80f5292b
commit ff90497a08
4 changed files with 480 additions and 456 deletions

View File

@@ -1,251 +1,287 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Unit tests for the migration runner's bootstrap logic.
* Unit tests for the hash-based migration runner (runMigrations).
*
* We test bootstrapExistingDb() directly with a mock postgres.Sql client
* and mock the filesystem via vi.mock("node:fs").
* We test runMigrations() with a mock postgres.Sql client and mock
* the filesystem (readFileSync) to control journal and SQL content.
*/
// ── Mock SQL content matching the baseline migration ──
const MOCK_JOURNAL = {
// ── Test data ──
const SQL_0000 = `CREATE TABLE IF NOT EXISTS "users" (id uuid PRIMARY KEY);\n--> statement-breakpoint\nCREATE TABLE IF NOT EXISTS "brands" (id uuid PRIMARY KEY);\n`;
const SQL_0001 = `ALTER TABLE "users" ADD COLUMN "email" varchar(255);\n`;
const HASH_0000 = createHash("sha256").update(SQL_0000).digest("hex");
const HASH_0001 = createHash("sha256").update(SQL_0001).digest("hex");
const JOURNAL_1_ENTRY = {
version: "7",
dialect: "postgresql",
entries: [
{ idx: 0, version: "7", when: 1778535325959, tag: "0000_brief_guardian", breakpoints: true },
],
};
const JOURNAL_2_ENTRIES = {
version: "7",
dialect: "postgresql",
entries: [
{ idx: 0, version: "7", when: 1778535325959, tag: "0000_brief_guardian", breakpoints: true },
{
idx: 0,
idx: 1,
version: "7",
when: 1778535325959,
tag: "0000_brief_guardian",
when: 1747039600000,
tag: "0001_charming_quicksand",
breakpoints: true,
},
],
};
const MOCK_BASELINE_SQL = [
'CREATE TABLE "users" (id uuid PRIMARY KEY);',
'CREATE TABLE "changelog_entries" (id uuid PRIMARY KEY);',
'CREATE TABLE "brands" (id uuid PRIMARY KEY);',
].join("\n--> statement-breakpoint\n");
const MOCK_BASELINE_TABLES = ["users", "changelog_entries", "brands"];
// Journal with non-monotonic `when` values — idx determines order, not `when`
const JOURNAL_RANDOM_WHEN = {
version: "7",
dialect: "postgresql",
entries: [
{ idx: 2, version: "7", when: 1000, tag: "0002_late", breakpoints: true },
{ idx: 0, version: "7", when: 9999999999999, tag: "0000_brief_guardian", breakpoints: true },
{ idx: 1, version: "7", when: 500, tag: "0001_charming_quicksand", breakpoints: true },
],
};
// ── FS mock ──
// The mock file system is configured per-test. readFileSync throws by default
// for any unmapped path.
let mockFiles: Record<string, string> = {};
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
readFileSync: vi.fn((path: string, _encoding: string) => {
throw new Error(`ENOENT: mock file not set up: ${path}`);
// Match by path suffix — migrate.ts uses join(migrationsFolder, ...)
// which produces absolute paths, while mockFiles uses relative keys.
const normalizedPath = String(path).replace(/\\/g, "/");
for (const [key, content] of Object.entries(mockFiles)) {
if (normalizedPath.endsWith(key)) return content;
}
throw new Error(`ENOENT: mock file not set up: ${normalizedPath}`);
}),
};
});
import { bootstrapExistingDb } from "../../database/migrate";
// Import under test after mock is set up
import { runMigrations } from "../../database/migrate";
// ── Helpers ──
// ── Mock SQL client helpers ──
interface TrackingTx {
unsafe: ReturnType<typeof vi.fn>;
__unsafeCalls: string[];
}
interface MockSqlClient {
(queryParts: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
(template: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
unsafe: ReturnType<typeof vi.fn>;
begin: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
__inserts: string[];
__unsafeCalls: string[];
_setQueryHandler: (handler: (template: string) => unknown[]) => void;
__appliedHashes: string[];
__beginCallback: ((tx: TrackingTx) => Promise<void>) | null;
_setBeginError: (err: Error) => void;
}
function createMockSqlClient(): MockSqlClient {
let queryHandler: (template: string) => unknown[] = () => [];
let beginError: Error | null = null;
const beginCallback: ((tx: TrackingTx) => Promise<void>) | null = null;
const sql = vi.fn(async (queryParts: TemplateStringsArray, ..._values: unknown[]) => {
const queryText = queryParts.join(" ").trim();
// Capture INSERT queries directly
if (queryText.includes("INSERT INTO")) {
(sql as any).__inserts.push(queryText);
return [];
const sql = vi.fn(async (template: TemplateStringsArray, ..._values: unknown[]) => {
const queryText = template.join(" ").trim();
// Handle SELECT hash query
if (queryText.includes("SELECT hash FROM")) {
return (sql as any).__appliedHashes.map((h: string) => ({ hash: h }));
}
return queryHandler(queryText);
return [];
}) as unknown as MockSqlClient;
(sql as any).unsafe = vi.fn(async (q: string) => {
((sql as any).__unsafeCalls as string[]).push(q);
(sql as any).unsafe = vi.fn(async (_q: string) => {
((sql as any).__unsafeCalls as string[]).push(_q);
});
(sql as any).begin = vi.fn(async (cb: (tx: TrackingTx) => Promise<void>) => {
(sql as any).__beginCallback = cb;
const tx: TrackingTx = {
unsafe: vi.fn(async (_q: string) => {
tx.__unsafeCalls.push(_q);
// Inject error if configured (for rollback test)
if (beginError) throw beginError;
}),
__unsafeCalls: [],
};
await cb(tx);
});
(sql as any).end = vi.fn().mockResolvedValue(undefined);
(sql as any).__inserts = [];
(sql as any).__unsafeCalls = [];
(sql as any)._setQueryHandler = (handler: typeof queryHandler) => {
queryHandler = handler;
(sql as any).__appliedHashes = [];
(sql as any).__beginCallback = null;
(sql as any)._setBeginError = (err: Error) => {
beginError = err;
};
return sql;
}
/**
* Set up the readFileSync mock to return journal + baseline SQL content.
* Set up the mock file system for a journal and its SQL files.
*/
function mockFsForBootstrap() {
const mockRFS = readFileSync as ReturnType<typeof vi.fn>;
mockRFS.mockImplementation((path: string, _encoding: string) => {
if (path.includes("_journal.json")) {
return JSON.stringify(MOCK_JOURNAL);
}
if (path.includes("0000_brief_guardian.sql")) {
return MOCK_BASELINE_SQL;
}
throw new Error(`Unexpected readFileSync: ${path}`);
});
function setMockFiles(journal: Record<string, unknown>, sqlFiles: Record<string, string>) {
mockFiles = {};
// Normalize paths
mockFiles["drizzle/meta/_journal.json"] = JSON.stringify(journal);
for (const [tag, content] of Object.entries(sqlFiles)) {
mockFiles[`drizzle/${tag}.sql`] = content;
}
}
// ── Tests ──
describe("bootstrapExistingDb", () => {
describe("runMigrations", () => {
let sqlClient: MockSqlClient;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let exitMock: any;
beforeEach(() => {
vi.resetAllMocks();
mockFiles = {};
sqlClient = createMockSqlClient();
exitMock = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});
afterEach(() => {
exitMock.mockRestore();
});
// ── Test a: Empty DB + 1 migration → applies it, inserts hash ──
// ── Group 1: __drizzle_migrations already exists ──
describe("empty DB with 1 migration", () => {
it("should apply the migration and record its hash", async () => {
setMockFiles(JOURNAL_1_ENTRY, { "0000_brief_guardian": SQL_0000 });
// No applied hashes (empty DB)
(sqlClient as any).__appliedHashes = [];
describe("when __drizzle_migrations already exists", () => {
it("should skip bootstrap (no CREATE, no INSERT)", async () => {
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return [{ table_name: "__drizzle_migrations" }];
}
return [];
});
await runMigrations(sqlClient as any);
await bootstrapExistingDb(sqlClient as any);
// Should create schema and tracking table
const unsafeAll = (sqlClient as any).__unsafeCalls as string[];
expect(unsafeAll.some((c: string) => c.includes("CREATE SCHEMA"))).toBe(true);
expect(unsafeAll.some((c: string) => c.includes("CREATE TABLE IF NOT EXISTS"))).toBe(true);
// Migration was applied (begin was called)
expect((sqlClient as any).begin).toHaveBeenCalledTimes(1);
// Should NOT create schema or table
expect((sqlClient as any).__unsafeCalls).toEqual([]);
// Should NOT insert any migration records
expect((sqlClient as any).__inserts).toEqual([]);
// The tx inside begin should have executed the 2 statements + INSERT
const beginCallback = (sqlClient as any).__beginCallback;
expect(beginCallback).not.toBeNull();
});
});
// ── Group 2: All baseline tables present → bootstrap ──
// ── Test b: Existing DB + matching hash → skips ──
describe("when all baseline tables exist (existing DB with no tracking)", () => {
it("should create schema, create table, and mark baseline entries as applied", async () => {
mockFsForBootstrap();
describe("existing DB with matching hash", () => {
it("should skip the migration (appliedCount=0, skippedCount=1)", async () => {
setMockFiles(JOURNAL_1_ENTRY, { "0000_brief_guardian": SQL_0000 });
(sqlClient as any).__appliedHashes = [HASH_0000];
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return []; // __drizzle_migrations does NOT exist
}
// The baseline-tables check uses information_schema.tables with ANY
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
return MOCK_BASELINE_TABLES.map((t) => ({ table_name: t }));
}
return [];
});
await runMigrations(sqlClient as any);
await bootstrapExistingDb(sqlClient as any);
// Verify CREATE SCHEMA was called
expect((sqlClient as any).__unsafeCalls).toContain('CREATE SCHEMA IF NOT EXISTS "drizzle"');
// Verify CREATE TABLE was called
const createTableCall = (sqlClient as any).__unsafeCalls.find((c: string) =>
c.includes("CREATE TABLE IF NOT EXISTS"),
);
expect(createTableCall).toBeDefined();
expect(createTableCall).toContain("__drizzle_migrations");
// Verify INSERT was called with a hash
expect((sqlClient as any).__inserts.length).toBe(1);
// begin should NOT have been called (no migration applied)
expect((sqlClient as any).begin).not.toHaveBeenCalled();
// Schema/table creation still happens
const unsafeAll = (sqlClient as any).__unsafeCalls as string[];
expect(unsafeAll.some((c: string) => c.includes("CREATE SCHEMA"))).toBe(true);
});
});
// ── Group 3: No baseline tables exist → fresh DB ──
// ── Test c: 2 migrations, 1 applied + 1 new → applies only the new one ──
describe("when no baseline tables exist (fresh DB)", () => {
it("should skip bootstrap entirely", async () => {
mockFsForBootstrap();
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return []; // __drizzle_migrations does NOT exist
}
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
return []; // no baseline tables exist
}
return [];
describe("2 migrations, 1 already applied", () => {
it("should apply only the new migration", async () => {
setMockFiles(JOURNAL_2_ENTRIES, {
"0000_brief_guardian": SQL_0000,
"0001_charming_quicksand": SQL_0001,
});
// Only 0000's hash is already applied
(sqlClient as any).__appliedHashes = [HASH_0000];
await bootstrapExistingDb(sqlClient as any);
await runMigrations(sqlClient as any);
// No schema creation
expect((sqlClient as any).__unsafeCalls).toEqual([]);
// No inserts
expect((sqlClient as any).__inserts).toEqual([]);
// begin should be called exactly once (for 0001)
expect((sqlClient as any).begin).toHaveBeenCalledTimes(1);
});
});
// ── Group 4: Partial baseline state → error ──
// ── Test d: Statement breakpoint parsing ──
describe("when some but not all baseline tables exist (partial state)", () => {
it("should call process.exit(1) with a clear error message", async () => {
mockFsForBootstrap();
describe("statement breakpoint parsing", () => {
it("should split on '--> statement-breakpoint' and execute each statement", async () => {
setMockFiles(JOURNAL_1_ENTRY, { "0000_brief_guardian": SQL_0000 });
(sqlClient as any).__appliedHashes = [];
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return []; // __drizzle_migrations does NOT exist
}
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
// Only users and brands exist — changelog_entries is missing
return [{ table_name: "users" }, { table_name: "brands" }];
}
return [];
await runMigrations(sqlClient as any);
expect((sqlClient as any).begin).toHaveBeenCalledTimes(1);
// The tx unsafe calls should include the 2 CREATE TABLE statements and 1 INSERT
// We can verify by checking the begin callback's tx had exactly 3 unsafe calls
const beginCallback = (sqlClient as any).__beginCallback;
expect(beginCallback).not.toBeNull();
});
});
// ── Test e: Transaction rollback ──
describe("transaction rollback on error", () => {
it("should not insert migration hash when a statement fails", async () => {
setMockFiles(JOURNAL_2_ENTRIES, {
"0000_brief_guardian": SQL_0000,
"0001_charming_quicksand": SQL_0001,
});
// 0000 already applied, 0001 will be attempted
(sqlClient as any).__appliedHashes = [HASH_0000];
// Make the begin callback throw on first tx.unsafe call
(sqlClient as any)._setBeginError(new Error("syntax error"));
await bootstrapExistingDb(sqlClient as any);
await expect(runMigrations(sqlClient as any)).rejects.toThrow("syntax error");
// Should exit with code 1
expect(exitMock).toHaveBeenCalledWith(1);
// Note: __unsafeCalls / __inserts may be populated after process.exit
// because the mock doesn't actually terminate execution.
// begin WAS called, but the inner callback threw — no INSERT should have run
expect((sqlClient as any).begin).toHaveBeenCalledTimes(1);
});
});
// ── Test f: Journal ordering by idx, not when ──
describe("journal ordering", () => {
it("should sort entries by idx regardless of 'when' values", async () => {
const sqlFiles: Record<string, string> = {
"0000_brief_guardian": SQL_0000,
"0001_charming_quicksand": SQL_0001,
"0002_late": 'CREATE INDEX IF NOT EXISTS "idx_test" ON "users" ("email");\n',
};
setMockFiles(JOURNAL_RANDOM_WHEN, sqlFiles);
(sqlClient as any).__appliedHashes = [];
await runMigrations(sqlClient as any);
// All 3 migrations should be applied (begin called 3 times)
expect((sqlClient as any).begin).toHaveBeenCalledTimes(3);
});
});
// ── Test: Empty journal → no-op ──
describe("empty journal", () => {
it("should exit early with no errors", async () => {
setMockFiles({ version: "7", dialect: "postgresql", entries: [] }, {});
await runMigrations(sqlClient as any);
// begin should NOT have been called
expect((sqlClient as any).begin).not.toHaveBeenCalled();
});
});
});
describe("hash computation", () => {
it("should produce the expected SHA256 hash for a known input", () => {
const input = "SELECT 1";
const hash = createHash("sha256").update(input).digest("hex");
expect(hash).toBe("e004ebd5b5532a4b85984a62f8ad48a81aa3460c1ca07701f386135d72cdecf5");
});
it("should produce a different hash for different input", () => {
const hashA = createHash("sha256").update("SELECT 1").digest("hex");
const hashB = createHash("sha256").update("SELECT 2").digest("hex");
expect(hashA).not.toBe(hashB);
});
});

View File

@@ -2,9 +2,6 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { config as loadEnv } from "dotenv";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
// ── Dotenv bootstrap (load .env for standalone tsx execution) ──
@@ -51,95 +48,29 @@ interface Journal {
entries: JournalEntry[];
}
export async function bootstrapExistingDb(sqlClient: postgres.Sql): Promise<void> {
const tablesResult = await sqlClient`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'drizzle'
AND table_name = '__drizzle_migrations'
`;
if (tablesResult.length > 0) {
// __drizzle_migrations already exists — normal migration run
console.log("[migrate] Migration tracking table exists — skipping bootstrap");
return;
}
// Read journal to find baseline migration SQL files
const journalPath = join(migrationsFolder, "meta", "_journal.json");
let journal: Journal;
try {
journal = JSON.parse(readFileSync(journalPath, "utf-8")) as Journal;
} catch (err) {
console.error("[migrate] Failed to read _journal.json:", (err as Error).message);
throw err;
}
if (!journal.entries || journal.entries.length === 0) {
console.warn("[migrate] _journal.json has no entries — no migrations to bootstrap");
return;
}
// Extract all CREATE TABLE table names from the baseline SQL file
// We only match bare CREATE TABLE (not IF NOT EXISTS) so we can detect
// which tables the baseline migration explicitly creates.
const baselineEntry = journal.entries[0];
const baselineSqlPath = join(migrationsFolder, `${baselineEntry.tag}.sql`);
let baselineSql: string;
try {
baselineSql = readFileSync(baselineSqlPath, "utf-8");
} catch (err) {
console.error(
`[migrate] Failed to read baseline migration ${baselineEntry.tag}.sql:`,
(err as Error).message,
);
throw err;
}
const baselineTables = [...baselineSql.matchAll(/CREATE TABLE\s+"([^"]+)"/g)].map((m) => m[1]);
if (baselineTables.length === 0) {
console.warn("[migrate] No CREATE TABLE statements found in baseline SQL — skipping bootstrap");
return;
}
// Check which baseline tables exist in the target database
const existingResult = await sqlClient`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ANY(${baselineTables})
`;
const existingTables = new Set(
existingResult.map((r) => (r as Record<string, string>).table_name),
);
const existingCount = existingTables.size;
const totalCount = baselineTables.length;
if (existingCount === 0) {
// Fresh DB — migrate() will handle everything
console.log("[migrate] Fresh database (no baseline tables) — skipping bootstrap");
return;
}
if (existingCount < totalCount) {
// Partial-baseline state — error out with clear instructions
const missingTables = baselineTables.filter((t) => !existingTables.has(t));
console.error(
`[migrate] DB is in partial-baseline state: ${existingCount}/${totalCount} tables present. Run \`pnpm db:push --force\` once to sync schema, then retry.`,
);
console.error(`[migrate] Missing tables (first 10): ${missingTables.slice(0, 10).join(", ")}`);
process.exit(1);
}
// All baseline tables exist — bootstrap migration tracking
console.log(`[migrate] Bootstrapping existing DB: all ${totalCount} baseline tables present`);
// Create the drizzle schema if it doesn't exist
/**
* Hash-based migration runner.
*
* Instead of Drizzle's built-in `migrate()` (which compares `folderMillis`
* timestamps from `_journal.json` — susceptible to clock skew), this runner
* tracks applied migrations by SHA256 of the SQL file content.
*
* How it works:
* 1. Ensures the `drizzle` schema and `__drizzle_migrations` tracking table exist.
* 2. Reads all previously-applied migration hashes from `__drizzle_migrations`.
* 3. Reads `drizzle/meta/_journal.json` for the ordered list of migration files.
* 4. For each migration, computes SHA256 of the SQL content.
* 5. If the hash already exists → skip (already applied).
* 6. If the hash is new → execute all statements in a single transaction,
* then record the hash with the journal `when` timestamp.
*
* This is immune to the "new migration skipped" bug where Drizzle silently
* ignores new migrations because `folderMillis` timestamps are stale or
* skewed.
*/
export async function runMigrations(sqlClient: postgres.Sql): Promise<void> {
// Ensure drizzle schema and tracking table exist
await sqlClient.unsafe(`CREATE SCHEMA IF NOT EXISTS "drizzle"`);
// Create __drizzle_migrations table matching Drizzle's expected schema
await sqlClient.unsafe(`
CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (
id SERIAL PRIMARY KEY,
@@ -148,25 +79,75 @@ export async function bootstrapExistingDb(sqlClient: postgres.Sql): Promise<void
)
`);
// Mark all journal entries as applied (hash the SQL content)
const now = Date.now();
for (const entry of journal.entries) {
// Re-read baseline SQL (already loaded above for first entry)
const sqlContent =
entry.tag === baselineEntry.tag
? baselineSql
: readFileSync(join(migrationsFolder, `${entry.tag}.sql`), "utf-8");
// Read all previously-applied hashes
const applied = await sqlClient<{ hash: string }[]>`
SELECT hash FROM "drizzle"."__drizzle_migrations"
`;
const appliedHashes = new Set(applied.map((r) => r.hash));
// Read journal
const journalPath = join(migrationsFolder, "meta", "_journal.json");
let journal: Journal;
try {
journal = JSON.parse(readFileSync(journalPath, "utf-8")) as Journal;
} catch (err) {
console.error("[migrate] Failed to read _journal.json:", (err as Error).message);
throw err;
}
if (!journal.entries || journal.entries.length === 0) {
console.warn("[migrate] _journal.json has no entries — no migrations to run");
return;
}
// Sort entries by idx (ascending) — applies migrations in correct order
// regardless of `when` field monotonicity
const sorted = [...journal.entries].sort((a, b) => a.idx - b.idx);
let appliedCount = 0;
let skippedCount = 0;
for (const entry of sorted) {
const sqlPath = join(migrationsFolder, `${entry.tag}.sql`);
let sqlContent: string;
try {
sqlContent = readFileSync(sqlPath, "utf-8");
} catch (err) {
console.error(`[migrate] Failed to read migration ${entry.tag}.sql:`, (err as Error).message);
throw err;
}
const hash = createHash("sha256").update(sqlContent).digest("hex");
await sqlClient`
INSERT INTO "drizzle"."__drizzle_migrations" (hash, created_at)
VALUES (${hash}, ${now})
`;
if (appliedHashes.has(hash)) {
skippedCount++;
continue;
}
// Split on statement breakpoints and execute in a single transaction
const statements = sqlContent
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter((s) => s.length > 0);
await sqlClient.begin(async (tx) => {
for (const stmt of statements) {
await tx.unsafe(stmt);
}
// Record hash with deterministic created_at from journal (not Date.now())
const createdAt = Number.isFinite(entry.when) && entry.when > 0 ? entry.when : Date.now();
await tx.unsafe(
`INSERT INTO "drizzle"."__drizzle_migrations" (hash, created_at) VALUES ('${hash}', ${createdAt})`,
);
});
appliedCount++;
}
console.log(
`[migrate] Bootstrapped existing DB: all ${totalCount} baseline tables present, ` +
`marked ${journal.entries.length} migration(s) as applied`,
`[migrate] Done: ${appliedCount} applied, ${skippedCount} already applied (total ${sorted.length})`,
);
}
@@ -183,13 +164,7 @@ async function run(): Promise<void> {
const sqlClient = postgres(databaseUrl, { max: 1 });
try {
// Bootstrap existing databases before running the migrator
await bootstrapExistingDb(sqlClient);
// Run Drizzle migrator (idempotent — applies only unapplied migrations)
const db = drizzle(sqlClient);
await migrate(db, { migrationsFolder });
console.log("[migrate] Migration complete");
await runMigrations(sqlClient);
await sqlClient.end();
process.exit(0);