Files
Ekklesia/core/tests/module-migration-discovery.test.ts
T
ekklesiaandClaude Opus 5.5 98e59d4ba0
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
chore(release): prepare 0.2.0
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.

0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
  move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
  legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.

Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
  template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
  current platform's versions;
- the module-development guide's examples, and the release guide, which
  now names what a minor bump moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:26:13 -05:00

495 lines
18 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { formatMigrationValidationReport } from "../src/cli/migration-report.js";
import {
discoverModuleMigrations,
ModuleDiscoveryError,
ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssueCode,
} from "../src/modules/index.js";
import {
createMigrationModule,
manifestFor,
originSnapshotId,
snapshotId,
} from "./support/migration-module-fixture.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-migrations-"));
temporaryDirectories.push(directory);
return directory;
}
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.2.0",
modulesDirectory,
platformVersion: "0.2.0",
};
}
async function captureMigrationError(
modulesDirectory: string,
): Promise<ModuleMigrationDiscoveryError> {
try {
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
} catch (error) {
if (error instanceof ModuleMigrationDiscoveryError) {
return error;
}
throw error;
}
throw new Error("Expected module migration discovery to fail.");
}
function issueCodes(error: ModuleMigrationDiscoveryError): ModuleMigrationDiscoveryIssueCode[] {
return error.issues.map((issue) => issue.code);
}
function sha256(contents: string | Uint8Array): string {
return createHash("sha256").update(contents).digest("hex");
}
describe("module migration discovery", () => {
it("aggregates dependency-first modules and journal-ordered migrations with an exact report", async () => {
const modulesDirectory = await createModulesDirectory();
const peopleInitial = 'CREATE TABLE "people_records" ("id" integer);\n';
const peopleArchive = new TextEncoder().encode(
'ALTER TABLE "people_records" ADD COLUMN "archived" boolean;\r\n',
);
const calendarEvents = 'CREATE TABLE "calendar_events" ("id" integer);\n';
await createMigrationModule(modulesDirectory, "calendar", {
dependencies: { modules: { required: { people: "^0.1.0" } } },
migrations: [{ sql: calendarEvents, tag: "0000_events" }],
});
await createMigrationModule(modulesDirectory, "people", {
migrations: [
{ sql: peopleInitial, tag: "0000_initial" },
{
destructive: {
mode: "approval-required",
reason: "Existing deployments must approve the archive-column rollout.",
},
sql: peopleArchive,
tag: "0001_archive",
},
],
});
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]);
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
"people:0000_initial",
"people:0001_archive",
"calendar:0000_events",
]);
expect(aggregate.migrations.map((migration) => migration.checksum)).toEqual([
sha256(peopleInitial),
sha256(peopleArchive),
sha256(calendarEvents),
]);
expect(aggregate.migrations[1]).toMatchObject({
approvalRequired: true,
breakpoints: true,
destructive: {
mode: "approval-required",
reason: "Existing deployments must approve the archive-column rollout.",
},
file: "0001_archive.sql",
index: 1,
timestamp: 1_700_000_000_001,
});
expect(formatMigrationValidationReport(aggregate)).toEqual([
"Migration validation passed (2 modules, 3 migrations):",
`- people:0000_initial sha256=${sha256(peopleInitial)} destructive=none`,
`- people:0001_archive sha256=${sha256(peopleArchive)} destructive=approval-required`,
`- calendar:0000_events sha256=${sha256(calendarEvents)} destructive=none`,
]);
expect(Object.isFrozen(aggregate)).toBe(true);
expect(Object.isFrozen(aggregate.modules)).toBe(true);
expect(Object.isFrozen(aggregate.modules[0])).toBe(true);
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
expect(Object.isFrozen(aggregate.migrations)).toBe(true);
expect(Object.isFrozen(aggregate.migrations[0])).toBe(true);
expect(Object.isFrozen(aggregate.migrations[1]?.destructive)).toBe(true);
expect(Object.isFrozen(formatMigrationValidationReport(aggregate))).toBe(true);
});
it("hashes exact SQL bytes and changes only the checksum when content changes", async () => {
const modulesDirectory = await createModulesDirectory();
const original = new TextEncoder().encode("SELECT 1;\n");
const changed = new TextEncoder().encode("SELECT 1;\r\n");
const fixture = await createMigrationModule(modulesDirectory, "exact", {
migrations: [{ sql: original, tag: "0000_initial" }],
});
const first = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
expect(first.migrations[0]).toMatchObject({
checksum: sha256(original),
identity: "exact:0000_initial",
sql: "SELECT 1;\n",
});
await writeFile(path.join(fixture.migrationDirectory ?? "", "0000_initial.sql"), changed);
const second = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
expect(second.migrations[0]?.identity).toBe(first.migrations[0]?.identity);
expect(second.migrations[0]?.checksum).toBe(sha256(changed));
expect(second.migrations[0]?.checksum).not.toBe(first.migrations[0]?.checksum);
});
it("supports modules without migrations without importing server or schema code", async () => {
const modulesDirectory = await createModulesDirectory();
await createMigrationModule(modulesDirectory, "schema-only", {
migrations: false,
schemaSource: 'throw new Error("schema entrypoint must remain unloaded");',
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
});
await createMigrationModule(modulesDirectory, "server-only", {
migrations: false,
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
});
const aggregate = await discoverModuleMigrations(discoveryOptions(modulesDirectory));
expect(aggregate.discoveredModules).toHaveLength(2);
expect(aggregate.modules).toEqual([
{ migrations: [], moduleId: "schema-only" },
{ migrations: [], moduleId: "server-only" },
]);
expect(aggregate.migrations).toEqual([]);
expect(formatMigrationValidationReport(aggregate)).toEqual([
"Migration validation passed (2 modules, 0 migrations):",
"- schema-only (no migrations)",
"- server-only (no migrations)",
]);
expect(Object.isFrozen(aggregate.modules[0]?.migrations)).toBe(true);
});
it("completes static discovery before inspecting migration contents", async () => {
const modulesDirectory = await createModulesDirectory();
const valid = await createMigrationModule(modulesDirectory, "migration-invalid");
await writeFile(
path.join(valid.migrationDirectory ?? "", "meta/_journal.json"),
'{"secret":"postgres://admin:password@database/private"',
);
await createMigrationModule(modulesDirectory, "static-invalid", {
manifest: {
...manifestFor("static-invalid", { migrations: false }),
entrypoints: { server: "./dist/server/missing.js" },
},
migrations: false,
});
let discoveryError: ModuleDiscoveryError | undefined;
try {
await discoverModuleMigrations(discoveryOptions(modulesDirectory));
} catch (error) {
if (error instanceof ModuleDiscoveryError) {
discoveryError = error;
} else {
throw error;
}
}
expect(discoveryError?.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_MISSING",
moduleId: "static-invalid",
path: "entrypoints.server",
}),
);
expect(JSON.stringify(discoveryError)).not.toContain("postgres://");
});
it("rejects malformed journal and policy metadata without leaking their contents", async () => {
const modulesDirectory = await createModulesDirectory();
const journalInvalid = await createMigrationModule(modulesDirectory, "journal-invalid");
const policyInvalid = await createMigrationModule(modulesDirectory, "policy-invalid");
await Promise.all([
writeFile(
path.join(journalInvalid.migrationDirectory ?? "", "meta/_journal.json"),
'{"url":"postgres://admin:password@database/private","path":"/machine/journal"',
),
writeFile(
path.join(policyInvalid.migrationDirectory ?? "", "meta/_ekklesia.json"),
JSON.stringify({
version: 1,
moduleId: "policy-invalid",
entries: [
{
tag: "0000_initial",
destructive: { mode: "none" },
approved: "token-at-/machine/policy",
},
],
}),
),
]);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toEqual([
"MODULE_MIGRATION_JOURNAL_INVALID",
"MODULE_MIGRATION_METADATA_INVALID",
]);
const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`;
expect(report).not.toContain("postgres://");
expect(report).not.toContain("password");
expect(report).not.toContain("/machine/journal");
expect(report).not.toContain("token-at-/machine/policy");
expect(report).not.toContain(modulesDirectory);
});
it("rejects duplicate identities, invalid ordering, and inconsistent destructive policy", async () => {
const modulesDirectory = await createModulesDirectory();
const fixture = await createMigrationModule(modulesDirectory, "inconsistent", {
migrations: [
{ sql: "SELECT 1;\n", tag: "0000_first" },
{ sql: "SELECT 2;\n", tag: "0001_second" },
],
});
const root = fixture.migrationDirectory ?? "";
await Promise.all([
writeFile(
path.join(root, "meta/_journal.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [
{
idx: 1,
version: "7",
when: 200,
tag: "0001_second",
breakpoints: true,
},
{
idx: 1,
version: "7",
when: 100,
tag: "0001_second",
breakpoints: true,
},
],
}),
),
writeFile(
path.join(root, "meta/_ekklesia.json"),
JSON.stringify({
version: 1,
moduleId: "wrong-module",
entries: [
{ tag: "0001_second", destructive: { mode: "none" } },
{ tag: "0001_second", destructive: { mode: "none" } },
],
}),
),
]);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_MIGRATION_ID_DUPLICATE",
"MODULE_MIGRATION_METADATA_INCONSISTENT",
"MODULE_MIGRATION_ORDER_INVALID",
]),
);
expect(
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_METADATA_INCONSISTENT"),
).toHaveLength(2);
expect(
issueCodes(error).filter((code) => code === "MODULE_MIGRATION_ORDER_INVALID"),
).toHaveLength(2);
});
it("rejects unsafe journal tags before deriving or reading a migration path", async () => {
const modulesDirectory = await createModulesDirectory();
const fixture = await createMigrationModule(modulesDirectory, "unsafe-tag");
const root = fixture.migrationDirectory ?? "";
await Promise.all([
writeFile(
path.join(root, "meta/_journal.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [
{
idx: 0,
version: "7",
when: 1_700_000_000_000,
tag: "../outside",
breakpoints: true,
},
],
}),
),
writeFile(
path.join(fixture.moduleDirectory, "outside.sql"),
"SELECT 'database-password at /machine/outside';\n",
),
]);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_MIGRATION_ID_INVALID");
expect(error.issues).not.toContainEqual(expect.objectContaining({ path: "migrations.0.sql" }));
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
expect(report).not.toContain("../outside");
expect(report).not.toContain("database-password");
expect(report).not.toContain("/machine/outside");
});
it("rejects missing, undeclared, unsupported, empty, and invalid UTF-8 migration files", async () => {
const modulesDirectory = await createModulesDirectory();
const missing = await createMigrationModule(modulesDirectory, "missing");
const inventory = await createMigrationModule(modulesDirectory, "inventory");
await createMigrationModule(modulesDirectory, "empty", {
migrations: [{ sql: " \n\t", tag: "0000_initial" }],
});
await createMigrationModule(modulesDirectory, "invalid-utf8", {
migrations: [{ sql: Uint8Array.from([0xff, 0xfe, 0xfd]), tag: "0000_initial" }],
});
await Promise.all([
rm(path.join(missing.migrationDirectory ?? "", "0000_initial.sql")),
writeFile(path.join(inventory.migrationDirectory ?? "", "0001_orphan.sql"), "SELECT 2;\n"),
writeFile(path.join(inventory.migrationDirectory ?? "", "run.ts"), "throw new Error();\n"),
]);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_MIGRATION_EMPTY",
"MODULE_MIGRATION_FILE_INVALID",
"MODULE_MIGRATION_FILE_MISSING",
"MODULE_MIGRATION_FILE_UNDECLARED",
"MODULE_MIGRATION_UNSUPPORTED_FILE",
]),
);
expect(error.issues.some((issue) => issue.moduleId === "missing")).toBe(true);
expect(error.issues.some((issue) => issue.moduleId === "inventory")).toBe(true);
expect(error.issues.some((issue) => issue.moduleId === "empty")).toBe(true);
expect(error.issues.some((issue) => issue.moduleId === "invalid-utf8")).toBe(true);
});
it("rejects missing, broken, and undeclared Drizzle snapshots", async () => {
const modulesDirectory = await createModulesDirectory();
const fixture = await createMigrationModule(modulesDirectory, "snapshots", {
migrations: [
{ sql: "SELECT 1;\n", tag: "0000_first" },
{ sql: "SELECT 2;\n", tag: "0001_second" },
],
});
const missing = await createMigrationModule(modulesDirectory, "snapshot-missing");
const root = fixture.migrationDirectory ?? "";
await Promise.all([
rm(path.join(missing.migrationDirectory ?? "", "meta/0000_snapshot.json")),
writeFile(
path.join(root, "meta/0001_snapshot.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: snapshotId(1),
prevId: originSnapshotId,
}),
),
writeFile(
path.join(root, "meta/9999_snapshot.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: "99999999-9999-4999-8999-999999999999",
prevId: snapshotId(1),
}),
),
]);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_MIGRATION_FILE_MISSING",
"MODULE_MIGRATION_FILE_UNDECLARED",
"MODULE_MIGRATION_SNAPSHOT_INVALID",
]),
);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_MIGRATION_FILE_MISSING",
moduleId: "snapshot-missing",
path: "snapshots.0",
}),
);
});
it("rejects unsafe migration symlinks and sanitizes the target path", async () => {
const modulesDirectory = await createModulesDirectory();
const fixture = await createMigrationModule(modulesDirectory, "linked");
const outsideDirectory = await createModulesDirectory();
const outsideSql = path.join(outsideDirectory, "database-password.sql");
const migrationFile = path.join(fixture.migrationDirectory ?? "", "0000_initial.sql");
await writeFile(outsideSql, "SELECT 'secret-at-/machine/private';\n");
await rm(migrationFile);
await symlink(outsideSql, migrationFile);
const error = await captureMigrationError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_MIGRATION_PATH_UNSAFE");
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
expect(report).not.toContain("database-password");
expect(report).not.toContain("secret-at-/machine/private");
expect(report).not.toContain(outsideDirectory);
});
it("produces deterministic sanitized errors and no partial result when any module is invalid", async () => {
const firstModulesDirectory = await createModulesDirectory();
const secondModulesDirectory = await createModulesDirectory();
for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) {
await createMigrationModule(modulesDirectory, "valid");
const broken = await createMigrationModule(modulesDirectory, "broken");
await writeFile(
path.join(broken.migrationDirectory ?? "", "meta/_journal.json"),
'{"secret":"https://user:[email protected]/private","sql":"DROP TABLE private"',
);
}
const [firstError, secondError] = await Promise.all([
captureMigrationError(firstModulesDirectory),
captureMigrationError(secondModulesDirectory),
]);
expect(firstError.issues).toEqual(secondError.issues);
expect(firstError.stack).toBe(secondError.stack);
expect(firstError.issues).toEqual([
expect.objectContaining({
code: "MODULE_MIGRATION_JOURNAL_INVALID",
moduleId: "broken",
path: "journal",
}),
]);
const report = `${firstError.message}\n${firstError.stack}\n${JSON.stringify(firstError.issues)}`;
expect(report).not.toContain("https://");
expect(report).not.toContain("password");
expect(report).not.toContain("DROP TABLE");
expect(report).not.toContain(firstModulesDirectory);
expect(Object.isFrozen(firstError.issues)).toBe(true);
expect(Object.isFrozen(firstError.issues[0])).toBe(true);
});
});