CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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: 8,
|
|
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);
|
|
});
|
|
});
|