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
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:
@@ -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", () => {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user