feat: one generic image, with installed modules in installation-owned storage (ADR 0048) #64

Merged
Zayin_Admin merged 2 commits from feat/installed-module-storage into main 2026-10-07 14:15:32 +00:00
3 changed files with 203 additions and 181 deletions
Showing only changes of commit f122e910fc - Show all commits
+7 -159
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
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";
@@ -10,34 +10,15 @@ import {
ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssueCode,
} from "../src/modules/index.js";
import {
createMigrationModule,
manifestFor,
originSnapshotId,
snapshotId,
} from "./support/migration-module-fixture.js";
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
const temporaryDirectories: string[] = [];
interface MigrationFixture {
readonly breakpoints?: boolean;
readonly destructive?:
| { readonly mode: "none" }
| { readonly mode: "approval-required"; readonly reason: string };
readonly snapshotId?: string;
readonly sql: string | Uint8Array;
readonly tag: string;
readonly timestamp?: number;
}
interface MigrationModuleFixture {
readonly dependencies?: Record<string, unknown>;
readonly manifest?: Record<string, unknown>;
readonly migrations?: readonly MigrationFixture[] | false;
readonly schemaSource?: string;
readonly serverSource?: string;
}
interface CreatedMigrationModule {
readonly migrationDirectory?: string;
readonly moduleDirectory: string;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
@@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise<string> {
return directory;
}
function snapshotId(index: number): string {
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
}
function manifestFor(
id: string,
options: {
readonly dependencies?: Record<string, unknown>;
readonly migrations?: boolean;
readonly schema?: boolean;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
},
};
}
function defaultMigrations(id: string): readonly MigrationFixture[] {
return [
{
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
tag: "0000_initial",
},
];
}
async function createMigrationModule(
modulesDirectory: string,
id: string,
fixture: MigrationModuleFixture = {},
): Promise<CreatedMigrationModule> {
const migrations =
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
const moduleDirectory = path.join(modulesDirectory, id);
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
if (fixture.schemaSource !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
}
if (migrationDirectory) {
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
}
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(
fixture.manifest ??
manifestFor(id, {
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
migrations: migrations !== undefined,
schema: fixture.schemaSource !== undefined,
}),
),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
...(fixture.schemaSource === undefined
? []
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
]);
if (!migrationDirectory || !migrations) {
return { moduleDirectory };
}
const journalEntries = migrations.map((migration, index) => ({
idx: index,
version: "7",
when: migration.timestamp ?? 1_700_000_000_000 + index,
tag: migration.tag,
breakpoints: migration.breakpoints ?? true,
}));
const policyEntries = migrations.map((migration) => ({
tag: migration.tag,
destructive: migration.destructive ?? { mode: "none" },
}));
await Promise.all([
writeFile(
path.join(migrationDirectory, "meta/_journal.json"),
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
),
writeFile(
path.join(migrationDirectory, "meta/_ekklesia.json"),
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
),
...migrations.flatMap((migration, index) => {
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
const previousId =
index === 0
? originSnapshotId
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
return [
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
writeFile(
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: idForSnapshot,
prevId: previousId,
}),
),
];
}),
]);
return { migrationDirectory, moduleDirectory };
}
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
+25 -22
View File
@@ -1,14 +1,16 @@
import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
import { discoverCoreMigrations } from "../src/migrations/index.js";
import { ModuleDiscoveryError } from "../src/modules/index.js";
import { createMigrationModule } from "./support/migration-module-fixture.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const temporaryDirectories: string[] = [];
afterEach(async () => {
@@ -23,16 +25,6 @@ async function externalModulesDirectory(): Promise<string> {
return directory;
}
/** Installs the module template's migration-bearing package files as module `example`. Migration
* discovery imports nothing, so the template's manifest, package metadata, and migrations are all
* it reads; the compiled artifacts discovery checks for are copied too when they exist. */
async function installTemplate(modulesDirectory: string): Promise<void> {
const target = path.join(modulesDirectory, "example");
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
await cp(path.join(templateModule, entry), path.join(target, entry), { recursive: true });
}
}
describe("operator commands select the modules directory through host configuration", () => {
it("defaults to the repository's own modules directory", () => {
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
@@ -62,23 +54,34 @@ describe("operator commands select the modules directory through host configurat
});
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
// A minimal installed module generated from nothing in the repository, so this test never
// depends on build output or ignored files: a clean checkout runs it before anything is built.
const modulesDirectory = await externalModulesDirectory();
await installTemplate(modulesDirectory);
const installed = await createMigrationModule(modulesDirectory, "example");
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
EKKLESIA_MODULES_DIR: modulesDirectory,
});
const sources = aggregate.modules.map((module) => module.moduleId);
expect(sources[0]).toBe("core");
expect(sources).toContain("example");
expect(
aggregate.migrations.some((migration) => migration.identity === "example:0000_initial"),
).toBe(true);
const coreTags = (await readdir(path.join(repositoryRoot, "core", "drizzle"))).filter((name) =>
name.endsWith(".sql"),
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]);
// Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`.
const platformCore = await discoverCoreMigrations({
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
});
const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) =>
migrations.map((migration) => `${migration.identity} ${migration.checksum}`);
expect(platformCore.length).toBeGreaterThan(0);
expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore));
// The module's migration is the one installed outside the repository, byte for byte.
const installedSql = await readFile(
path.join(installed.migrationDirectory ?? "", "0000_initial.sql"),
);
expect(aggregate.modules[0]?.migrations).toHaveLength(coreTags.length);
expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([
`example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`,
]);
});
it("fails closed when the configured directory does not exist", async () => {
@@ -0,0 +1,171 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
* and SQL) that passes Core's migration discovery.
*
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
*/
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
export interface MigrationFixture {
readonly breakpoints?: boolean;
readonly destructive?:
| { readonly mode: "none" }
| { readonly mode: "approval-required"; readonly reason: string };
readonly snapshotId?: string;
readonly sql: string | Uint8Array;
readonly tag: string;
readonly timestamp?: number;
}
export interface MigrationModuleFixture {
readonly dependencies?: Record<string, unknown>;
readonly manifest?: Record<string, unknown>;
readonly migrations?: readonly MigrationFixture[] | false;
readonly schemaSource?: string;
readonly serverSource?: string;
}
export interface CreatedMigrationModule {
readonly migrationDirectory?: string;
readonly moduleDirectory: string;
}
export function snapshotId(index: number): string {
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
}
export function manifestFor(
id: string,
options: {
readonly dependencies?: Record<string, unknown>;
readonly migrations?: boolean;
readonly schema?: boolean;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
},
};
}
export function defaultMigrations(id: string): readonly MigrationFixture[] {
return [
{
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
tag: "0000_initial",
},
];
}
export async function createMigrationModule(
modulesDirectory: string,
id: string,
fixture: MigrationModuleFixture = {},
): Promise<CreatedMigrationModule> {
const migrations =
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
const moduleDirectory = path.join(modulesDirectory, id);
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
if (fixture.schemaSource !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
}
if (migrationDirectory) {
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
}
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(
fixture.manifest ??
manifestFor(id, {
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
migrations: migrations !== undefined,
schema: fixture.schemaSource !== undefined,
}),
),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
...(fixture.schemaSource === undefined
? []
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
]);
if (!migrationDirectory || !migrations) {
return { moduleDirectory };
}
const journalEntries = migrations.map((migration, index) => ({
idx: index,
version: "7",
when: migration.timestamp ?? 1_700_000_000_000 + index,
tag: migration.tag,
breakpoints: migration.breakpoints ?? true,
}));
const policyEntries = migrations.map((migration) => ({
tag: migration.tag,
destructive: migration.destructive ?? { mode: "none" },
}));
await Promise.all([
writeFile(
path.join(migrationDirectory, "meta/_journal.json"),
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
),
writeFile(
path.join(migrationDirectory, "meta/_ekklesia.json"),
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
),
...migrations.flatMap((migration, index) => {
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
const previousId =
index === 0
? originSnapshotId
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
return [
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
writeFile(
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: idForSnapshot,
prevId: previousId,
}),
),
];
}),
]);
return { migrationDirectory, moduleDirectory };
}