feat(FN-191): replace manual migration comment with working db:migrate step (+4 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- fix(FN-191): fix typecheck and lint issues for bootstrap changes
- test(FN-191): complete Step 4 — update tests for new multi-table bootstrap logic
- fix(FN-191): complete Step 3 — strengthen bootstrap canary to detect partial-baseline state
- fix(FN-191): complete Step 2 — add dotenv bootstrap loader to migrate.ts
- fix(FN-191): complete Step 1 — replace manual migration comment with working db:migrate step

Files changed:
apps/api/src/database/__tests__/migrate.spec.ts | 146 ++++++++++++++++++------
 apps/api/src/database/migrate.ts                | 124 ++++++++++++++------
 scripts/deploy.sh                               |  12 +-
 3 files changed, 203 insertions(+), 79 deletions(-)

Fusion-Task-Id: FN-191
This commit is contained in:
Fusion
2026-05-12 05:25:00 +00:00
parent 140065980c
commit c9c168b89c
3 changed files with 210 additions and 86 deletions

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Unit tests for the migration runner's bootstrap logic.
@@ -9,12 +9,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
* and mock the filesystem via vi.mock("node:fs").
*/
// ── Mock SQL content matching the baseline migration ──
const MOCK_JOURNAL = {
version: "7",
dialect: "postgresql",
entries: [
{
idx: 0,
version: "7",
when: 1778535325959,
tag: "0000_brief_guardian",
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"];
// ── FS mock ──
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) => {
// Default: no file — will cause errors unless overridden in test setup
throw new Error(`ENOENT: mock file not set up: ${path}`);
}),
};
@@ -30,6 +54,7 @@ interface MockSqlClient {
end: ReturnType<typeof vi.fn>;
__inserts: string[];
__unsafeCalls: string[];
_setQueryHandler: (handler: (template: string) => unknown[]) => void;
}
function createMockSqlClient(): MockSqlClient {
@@ -52,7 +77,6 @@ function createMockSqlClient(): MockSqlClient {
(sql as any).__inserts = [];
(sql as any).__unsafeCalls = [];
// Allow tests to set queryHandler
(sql as any)._setQueryHandler = (handler: typeof queryHandler) => {
queryHandler = handler;
};
@@ -60,16 +84,41 @@ function createMockSqlClient(): MockSqlClient {
return sql;
}
/**
* Set up the readFileSync mock to return journal + baseline SQL content.
*/
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}`);
});
}
// ── Tests ──
describe("bootstrapExistingDb", () => {
let sqlClient: MockSqlClient;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let exitMock: any;
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
sqlClient = createMockSqlClient();
exitMock = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
});
afterEach(() => {
exitMock.mockRestore();
});
// ── Group 1: __drizzle_migrations already exists ──
describe("when __drizzle_migrations already exists", () => {
it("should skip bootstrap (no CREATE, no INSERT)", async () => {
(sqlClient as any)._setQueryHandler((queryText: string) => {
@@ -92,45 +141,26 @@ describe("bootstrapExistingDb", () => {
});
});
describe("when __drizzle_migrations is missing but users exists (existing DB)", () => {
it("should create schema, create table, mark baseline entries as applied", async () => {
// ── Group 2: All baseline tables present → bootstrap ──
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();
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return []; // does not exist
return []; // __drizzle_migrations does NOT exist
}
if (queryText.includes("information_schema") && queryText.includes("'users'")) {
return [{ table_name: "users" }]; // users 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 [];
});
// Set up the filesystem mocks
const mockRFS = readFileSync as ReturnType<typeof vi.fn>;
mockRFS.mockImplementation((path: string, _encoding: string) => {
if (path.includes("_journal.json")) {
return JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [
{
idx: 0,
version: "7",
when: 1778535325959,
tag: "0000_brief_guardian",
breakpoints: true,
},
],
});
}
if (path.includes("0000_brief_guardian.sql")) {
return "CREATE TABLE foo";
}
throw new Error(`Unexpected readFileSync: ${path}`);
});
await bootstrapExistingDb(sqlClient as any);
// Verify CREATE SCHEMA was called
@@ -144,14 +174,27 @@ describe("bootstrapExistingDb", () => {
expect(createTableCall).toContain("__drizzle_migrations");
// Verify INSERT was called with a hash
expect((sqlClient as any).__inserts.length).toBeGreaterThanOrEqual(1);
expect((sqlClient as any).__inserts.length).toBe(1);
});
});
describe("when both __drizzle_migrations and users are missing (fresh DB)", () => {
// ── Group 3: No baseline tables exist → fresh DB ──
describe("when no baseline tables exist (fresh DB)", () => {
it("should skip bootstrap entirely", async () => {
(sqlClient as any)._setQueryHandler((_queryText: string) => {
return []; // nothing exists
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 [];
});
await bootstrapExistingDb(sqlClient as any);
@@ -162,6 +205,35 @@ describe("bootstrapExistingDb", () => {
expect((sqlClient as any).__inserts).toEqual([]);
});
});
// ── Group 4: Partial baseline state → error ──
describe("when some but not all baseline tables exist (partial state)", () => {
it("should call process.exit(1) with a clear error message", 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(")) {
// Only users and brands exist — changelog_entries is missing
return [{ table_name: "users" }, { table_name: "brands" }];
}
return [];
});
await bootstrapExistingDb(sqlClient as any);
// 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.
});
});
});
describe("hash computation", () => {

View File

@@ -1,11 +1,29 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
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) ──
// Load .env from multiple candidate locations (repo root, apps/api/, current cwd)
// dotenv's config() does NOT override already-set environment variables by default,
// so if DATABASE_URL is already set (e.g., via PM2 env_file), .env values are ignored.
const envCandidates = [
resolve(__dirname, "../../../../.env"), // dist/database/migrate.js → repo root
resolve(__dirname, "../../.env"), // dist/database/migrate.js → apps/api/.env
resolve(__dirname, "../../../.env"), // src/database/migrate.ts → apps/api/.env (tsx mode)
join(process.cwd(), ".env"),
];
for (const p of envCandidates) {
if (existsSync(p)) {
loadEnv({ path: p });
break;
}
}
/**
* Path to the drizzle migrations folder.
*
@@ -47,36 +65,7 @@ export async function bootstrapExistingDb(sqlClient: postgres.Sql): Promise<void
return;
}
// Check if users table exists (canary for existing DB)
const usersResult = await sqlClient`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'users'
`;
if (usersResult.length === 0) {
// Fresh DB — migrate() will handle everything
console.log("[migrate] Fresh database detected — skipping bootstrap");
return;
}
// Existing DB with no migration tracking — bootstrap it
console.log("[migrate] Bootstrapping existing database...");
// Create the drizzle schema if it doesn't 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,
hash text NOT NULL,
created_at bigint
)
`);
// Read journal and mark baseline migrations as applied
// Read journal to find baseline migration SQL files
const journalPath = join(migrationsFolder, "meta", "_journal.json");
let journal: Journal;
@@ -92,17 +81,81 @@ export async function bootstrapExistingDb(sqlClient: postgres.Sql): Promise<void
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
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,
hash text NOT NULL,
created_at bigint
)
`);
// Mark all journal entries as applied (hash the SQL content)
const now = Date.now();
for (const entry of journal.entries) {
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;
}
// 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");
const hash = createHash("sha256").update(sqlContent).digest("hex");
await sqlClient`
@@ -112,7 +165,8 @@ export async function bootstrapExistingDb(sqlClient: postgres.Sql): Promise<void
}
console.log(
`[migrate] Bootstrapped existing DB: marked ${journal.entries.length} baseline migration(s) as applied (no SQL executed)`,
`[migrate] Bootstrapped existing DB: all ${totalCount} baseline tables present, ` +
`marked ${journal.entries.length} migration(s) as applied`,
);
}