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", () => {