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
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:
179
apps/api/src/database/__tests__/migrate.spec.ts
Normal file
179
apps/api/src/database/__tests__/migrate.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user