feat(FN-190): move drizzle-kit to production dependencies (+8 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- feat(FN-190): complete Step 9 — add Migration Workflow section to docs/INDEX.md
- fix(FN-190): apply biome import sorting and formatting
- feat(FN-190): complete Step 7 — write unit tests for migrate runner (5 tests)
- feat(FN-190): complete Step 6 — replace db:push with db:migrate in deploy workflow
- feat(FN-190): complete Step 5 — update Dockerfile with drizzle/ copy and start.sh CMD
- feat(FN-190): complete Step 4 — add db:migrate and db:migrate:dist scripts
- feat(FN-190): complete Step 3 — write migrate runner with bootstrap logic
- feat(FN-190): complete Step 2 — generate baseline Drizzle migration (46 tables)
- feat(FN-190): complete Step 1 — move drizzle-kit to production dependencies

Files changed:
.github/workflows/deploy.yml                    |    2 +-
 Dockerfile                                      |    6 +-
 apps/api/drizzle/0000_brief_guardian.sql        |  658 +++
 apps/api/drizzle/meta/0000_snapshot.json        | 5167 +++++++++++++++++++++++
 apps/api/drizzle/meta/_journal.json             |   13 +
 apps/api/package.json                           |    4 +-
 apps/api/src/database/__tests__/migrate.spec.ts |  179 +
 apps/api/src/database/migrate.ts                |  160 +
 apps/api/start.sh                               |    8 +
 docs/INDEX.md                                   |   24 +-
 pnpm-lock.yaml                                  |    6 +-
 11 files changed, 6220 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-190
This commit is contained in:
Fusion
2026-05-11 22:03:17 +00:00
parent a9d68fa24f
commit e87aba8829
11 changed files with 6220 additions and 7 deletions

View File

@@ -0,0 +1,179 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Unit tests for the migration runner's bootstrap logic.
*
* We test bootstrapExistingDb() directly with a mock postgres.Sql client
* and mock the filesystem via vi.mock("node:fs").
*/
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}`);
}),
};
});
import { bootstrapExistingDb } from "../../database/migrate";
// ── Helpers ──
interface MockSqlClient {
(queryParts: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
unsafe: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
__inserts: string[];
__unsafeCalls: string[];
}
function createMockSqlClient(): MockSqlClient {
let queryHandler: (template: string) => unknown[] = () => [];
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 [];
}
return queryHandler(queryText);
}) as unknown as MockSqlClient;
(sql as any).unsafe = vi.fn(async (q: string) => {
((sql as any).__unsafeCalls as string[]).push(q);
});
(sql as any).end = vi.fn().mockResolvedValue(undefined);
(sql as any).__inserts = [];
(sql as any).__unsafeCalls = [];
// Allow tests to set queryHandler
(sql as any)._setQueryHandler = (handler: typeof queryHandler) => {
queryHandler = handler;
};
return sql;
}
// ── Tests ──
describe("bootstrapExistingDb", () => {
let sqlClient: MockSqlClient;
beforeEach(() => {
vi.clearAllMocks();
sqlClient = createMockSqlClient();
});
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 bootstrapExistingDb(sqlClient as any);
// Should NOT create schema or table
expect((sqlClient as any).__unsafeCalls).toEqual([]);
// Should NOT insert any migration records
expect((sqlClient as any).__inserts).toEqual([]);
});
});
describe("when __drizzle_migrations is missing but users exists (existing DB)", () => {
it("should create schema, create table, mark baseline entries as applied", async () => {
(sqlClient as any)._setQueryHandler((queryText: string) => {
if (
queryText.includes("information_schema") &&
queryText.includes("__drizzle_migrations")
) {
return []; // does not exist
}
if (queryText.includes("information_schema") && queryText.includes("'users'")) {
return [{ table_name: "users" }]; // users exist
}
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
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).toBeGreaterThanOrEqual(1);
});
});
describe("when both __drizzle_migrations and users are missing (fresh DB)", () => {
it("should skip bootstrap entirely", async () => {
(sqlClient as any)._setQueryHandler((_queryText: string) => {
return []; // nothing exists
});
await bootstrapExistingDb(sqlClient as any);
// No schema creation
expect((sqlClient as any).__unsafeCalls).toEqual([]);
// No inserts
expect((sqlClient as any).__inserts).toEqual([]);
});
});
});
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

@@ -0,0 +1,160 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
/**
* Path to the drizzle migrations folder.
*
* In development (tsx), __dirname = <repo>/apps/api/src/database,
* and the drizzle folder is at <repo>/apps/api/drizzle.
*
* In production (compiled), __dirname = <app>/apps/api/dist/database,
* and the drizzle folder is COPIED to <app>/apps/api/drizzle.
*
* Both resolve correctly with: join(__dirname, "../../drizzle")
*/
const migrationsFolder = process.env.MIGRATIONS_FOLDER || join(__dirname, "../../drizzle");
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface Journal {
version: string;
dialect: string;
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;
}
// 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
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;
}
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;
}
const hash = createHash("sha256").update(sqlContent).digest("hex");
await sqlClient`
INSERT INTO "drizzle"."__drizzle_migrations" (hash, created_at)
VALUES (${hash}, ${now})
`;
}
console.log(
`[migrate] Bootstrapped existing DB: marked ${journal.entries.length} baseline migration(s) as applied (no SQL executed)`,
);
}
async function run(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("[migrate] DATABASE_URL environment variable is required");
process.exit(1);
}
console.log(`[migrate] Migrations folder: ${migrationsFolder}`);
// Single connection for migration
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 sqlClient.end();
process.exit(0);
} catch (err) {
console.error("[migrate] Migration failed:", (err as Error).message);
try {
await sqlClient.end();
} catch {
// ignore close errors during failure
}
process.exit(1);
}
}
// Only auto-run when executed directly (not when imported in tests)
const isMainModule =
process.argv[1] &&
(process.argv[1].endsWith("migrate.ts") || process.argv[1].endsWith("migrate.js"));
if (isMainModule) {
run();
}