import { createHash } from "node:crypto"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { CORE_MIGRATION_SOURCE_ID, discoverAllMigrations, discoverCoreMigrations, } from "../src/migrations/index.js"; import { ModuleMigrationDiscoveryError } from "../src/modules/index.js"; const originSnapshotId = "00000000-0000-0000-0000-000000000000"; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); async function createTemporaryDirectory(prefix: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), prefix)); temporaryDirectories.push(directory); return directory; } /** Writes a single-migration Drizzle directory (journal + snapshot + policy + SQL). */ async function writeMigrationsDirectory( migrationsDirectory: string, moduleId: string, tag: string, sql: string, ): Promise { await mkdir(path.join(migrationsDirectory, "meta"), { recursive: true }); await Promise.all([ writeFile( path.join(migrationsDirectory, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [{ idx: 0, version: "7", when: 1_700_000_000_000, tag, breakpoints: true }], }), ), writeFile( path.join(migrationsDirectory, "meta/_ekklesia.json"), JSON.stringify({ version: 1, moduleId, entries: [{ tag, destructive: { mode: "none" } }], }), ), writeFile( path.join(migrationsDirectory, "meta/0000_snapshot.json"), JSON.stringify({ version: "7", dialect: "postgresql", id: "10000000-0000-4000-8000-000000000001", prevId: originSnapshotId, }), ), writeFile(path.join(migrationsDirectory, `${tag}.sql`), sql), ]); return sql; } async function createModule( modulesDirectory: string, id: string, options: { readonly dependsOn?: readonly string[] } = {}, ): Promise { const moduleDirectory = path.join(modulesDirectory, id); const migrationsDirectory = path.join(moduleDirectory, "drizzle"); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); const requiredDependencies = Object.fromEntries( (options.dependsOn ?? []).map((dependencyId) => [dependencyId, "^0.1.0"]), ); await Promise.all([ writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ name: `@test/module-${id}`, version: "0.1.0", type: "module", ekklesia: { layer: "module" }, }), ), writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ manifestVersion: 2, id, name: `${id} module`, version: "0.1.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, dependencies: { modules: { required: requiredDependencies, optional: {} }, capabilities: { required: {}, optional: {} }, }, capabilities: [], permissions: [], entrypoints: { server: "./dist/server/index.js", migrations: "./drizzle" }, }), ), writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, ), ]); await writeMigrationsDirectory( migrationsDirectory, id, "0000_initial", `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`, ); } function sha256(contents: string): string { return createHash("sha256").update(contents).digest("hex"); } describe("Core-owned migration discovery", () => { it("discovers Core's own migrations with the reserved core identity and an exact checksum", async () => { const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-"); const sql = await writeMigrationsDirectory( coreMigrationsDirectory, "core", "0000_identity_core_tables", 'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n', ); const migrations = await discoverCoreMigrations({ coreMigrationsDirectory }); expect(CORE_MIGRATION_SOURCE_ID).toBe("core"); expect(migrations).toHaveLength(1); expect(migrations[0]).toMatchObject({ approvalRequired: false, checksum: sha256(sql), identity: "core:0000_identity_core_tables", index: 0, moduleId: "core", tag: "0000_identity_core_tables", }); }); it("is deterministic across repeated discovery calls", async () => { const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-"); await writeMigrationsDirectory( coreMigrationsDirectory, "core", "0000_identity_core_tables", 'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n', ); const first = await discoverCoreMigrations({ coreMigrationsDirectory }); const second = await discoverCoreMigrations({ coreMigrationsDirectory }); expect(first).toEqual(second); }); it("orders Core migrations before every module migration while preserving dependency-first module order", async () => { const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-"); const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-"); await writeMigrationsDirectory( coreMigrationsDirectory, "core", "0000_identity_core_tables", 'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n', ); // "calendar" depends on "people"; created on disk in the opposite order so only the // dependency graph (not directory order) can explain the resulting sequence. await createModule(modulesDirectory, "calendar", { dependsOn: ["people"] }); await createModule(modulesDirectory, "people"); const aggregate = await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" }, }); expect(aggregate.modules.map((module) => module.moduleId)).toEqual([ "core", "people", "calendar", ]); expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([ "core:0000_identity_core_tables", "people:0000_initial", "calendar:0000_initial", ]); }); it("rejects an installed module that reuses the reserved core migration identity", async () => { const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-"); const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-"); await writeMigrationsDirectory( coreMigrationsDirectory, "core", "0000_identity_core_tables", 'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n', ); await createModule(modulesDirectory, "core"); let captured: unknown; try { await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" }, }); } catch (error) { captured = error; } expect(captured).toBeInstanceOf(ModuleMigrationDiscoveryError); expect((captured as ModuleMigrationDiscoveryError).issues).toEqual([ expect.objectContaining({ code: "MODULE_MIGRATION_ID_DUPLICATE", moduleId: "core" }), ]); }); it("keeps a fresh, module-free repository honestly reporting only Core's migration", async () => { const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-"); const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-"); await writeMigrationsDirectory( coreMigrationsDirectory, "core", "0000_identity_core_tables", 'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n', ); const aggregate = await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" }, }); expect(aggregate.discoveredModules).toEqual([]); expect(aggregate.modules).toHaveLength(1); expect(aggregate.modules[0]?.moduleId).toBe("core"); expect(aggregate.migrations).toHaveLength(1); }); });