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);
});
});