Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Client, type QueryResultRow } from "pg";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createModuleLifecycleStore,
|
|
createModuleLifecycleView,
|
|
type ModuleLifecycleStore,
|
|
ModuleLifecycleStoreError,
|
|
resolveModuleEligibility,
|
|
} from "../src/modules/index.js";
|
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
|
import { createValidatedPostgresPool } from "../src/database/index.js";
|
|
import {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "../src/migrations/index.js";
|
|
|
|
/**
|
|
* The module lifecycle configuration store against real PostgreSQL.
|
|
*
|
|
* `core/tests/module-lifecycle.test.ts` proves the resolution rules with no database at all; this
|
|
* proves the things only a database can: that migration `0004` really creates the table with the
|
|
* constraints the schema declares, that a decision survives a completely new connection, that the
|
|
* upsert really is idempotent on `module_id`, that `enabled` has **no** column default (so the
|
|
* platform's default for a new module is genuinely the absence of a row, not a materialized one),
|
|
* and that a record for a module that is no longer deployed is retained and simply ignored. The
|
|
* in-memory double the API route tests use reproduces none of that, which is why it lives here.
|
|
*/
|
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
|
const coreMigrationsDirectory = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
"drizzle",
|
|
);
|
|
const trackedDatabases = new Set<string>();
|
|
let databaseCounter = 0;
|
|
|
|
class IntegrationHarnessError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "IntegrationHarnessError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
}
|
|
}
|
|
|
|
function harnessError(message: string): IntegrationHarnessError {
|
|
return new IntegrationHarnessError(message);
|
|
}
|
|
|
|
function baseDatabaseUrl(): string {
|
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw harnessError("The module lifecycle integration database is not configured.");
|
|
}
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
|
throw new TypeError("Unsupported protocol.");
|
|
}
|
|
} catch {
|
|
throw harnessError("The module lifecycle integration database configuration is invalid.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function quoteDatabaseName(databaseName: string): string {
|
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
|
throw harnessError("The generated integration database name is invalid.");
|
|
}
|
|
return `"${databaseName}"`;
|
|
}
|
|
|
|
function makeDatabaseName(label: string): string {
|
|
const safeLabel = label
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
|
.replaceAll(/^_+|_+$/gu, "")
|
|
.slice(0, 10);
|
|
databaseCounter += 1;
|
|
return `ekk_lifecycle_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
|
}
|
|
|
|
function databaseUrl(databaseName: string): string {
|
|
const parsed = new URL(baseDatabaseUrl());
|
|
parsed.pathname = `/${databaseName}`;
|
|
parsed.searchParams.delete("application_name");
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
|
const client = new Client({ connectionString });
|
|
client.on("error", () => undefined);
|
|
try {
|
|
await client.connect();
|
|
return client;
|
|
} catch {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
// The public harness failure stays static when connection cleanup also fails.
|
|
}
|
|
throw harnessError("The module lifecycle integration database connection failed.");
|
|
}
|
|
}
|
|
|
|
async function safeQuery<Row extends QueryResultRow>(
|
|
client: Client,
|
|
text: string,
|
|
values: unknown[] = [],
|
|
): Promise<readonly Row[]> {
|
|
try {
|
|
const result = await client.query<Row>(text, values);
|
|
return result.rows;
|
|
} catch {
|
|
throw harnessError("An integration database administration operation failed.");
|
|
}
|
|
}
|
|
|
|
async function safeEnd(client: Client): Promise<void> {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
throw harnessError("An integration database connection could not be closed.");
|
|
}
|
|
}
|
|
|
|
async function dropDatabase(databaseName: string): Promise<void> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
|
[databaseName],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
|
);
|
|
trackedDatabases.delete(databaseName);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
|
const name = makeDatabaseName(label);
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
trackedDatabases.add(name);
|
|
return { name, url: databaseUrl(name) };
|
|
}
|
|
|
|
async function applyCoreMigrations(url: string): Promise<void> {
|
|
const emptyModulesDirectory = await mkdtemp(
|
|
path.join(tmpdir(), "ekklesia-lifecycle-no-modules-"),
|
|
);
|
|
try {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: emptyModulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
|
"core:0000_identity_core_tables",
|
|
"core:0001_authorization_core_tables",
|
|
"core:0002_authorization_integrity",
|
|
"core:0003_dashboard_navigation_tables",
|
|
"core:0004_module_lifecycle_state",
|
|
]);
|
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
|
} finally {
|
|
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
afterAll(async () => {
|
|
for (const databaseName of [...trackedDatabases]) {
|
|
await dropDatabase(databaseName).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
/** A minimal, in-memory `DiscoveredModule` double, built through `parseModuleManifest` so a fixture
|
|
* real discovery would reject can never stand in for one it would accept. Only `manifest` is read
|
|
* by eligibility resolution. */
|
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
manifestVersion: 4,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "1.0.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
...(dependencies ? { dependencies } : {}),
|
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
|
});
|
|
return {
|
|
directory: `/modules/${id}`,
|
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
|
manifest,
|
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
|
packageName: `@test/${id}`,
|
|
};
|
|
}
|
|
|
|
describe.sequential("live Core module lifecycle store", () => {
|
|
let database: { name: string; url: string };
|
|
let admin: Client;
|
|
let store: ModuleLifecycleStore;
|
|
let close: () => Promise<void>;
|
|
|
|
it("creates exactly the one lifecycle table, with its declared constraints and no enabled default", async () => {
|
|
database = await createDisposableDatabase("fresh");
|
|
await applyCoreMigrations(database.url);
|
|
admin = await openAdministrativeClient(database.url);
|
|
|
|
const tables = await safeQuery<{ table_name: string }>(
|
|
admin,
|
|
`select table_name from information_schema.tables
|
|
where table_schema = 'public' and table_name like 'ekklesia_core_module%'
|
|
order by table_name`,
|
|
);
|
|
const columns = await safeQuery<{
|
|
column_name: string;
|
|
is_nullable: string;
|
|
column_default: string | null;
|
|
}>(
|
|
admin,
|
|
`select column_name, is_nullable, column_default
|
|
from information_schema.columns
|
|
where table_name = 'ekklesia_core_module_state'
|
|
order by column_name`,
|
|
);
|
|
// Only the explicitly declared CHECK constraint: PostgreSQL 17 also materializes a system
|
|
// `..._not_null` CHECK per NOT NULL column, which is not something this schema declares.
|
|
const checks = await safeQuery<{ constraint_name: string }>(
|
|
admin,
|
|
`select tc.constraint_name
|
|
from information_schema.table_constraints tc
|
|
where tc.table_name = 'ekklesia_core_module_state' and tc.constraint_type = 'CHECK'
|
|
and tc.constraint_name not like '%_not_null'
|
|
order by tc.constraint_name`,
|
|
);
|
|
|
|
expect(tables.map((table) => table.table_name)).toEqual(["ekklesia_core_module_state"]);
|
|
// Deliberately minimal: an identity, a decision, and two audit timestamps. No install source,
|
|
// download URL, update channel, signature, version, or JSON blob — the columns an installer
|
|
// would need are absent, which is what keeps one from being built here by accident.
|
|
expect(columns.map((column) => column.column_name)).toEqual([
|
|
"created_at",
|
|
"enabled",
|
|
"module_id",
|
|
"updated_at",
|
|
]);
|
|
const enabled = columns.find((column) => column.column_name === "enabled");
|
|
expect(enabled?.is_nullable).toBe("NO");
|
|
// The load-bearing assertion: a row exists only because somebody decided. The platform's
|
|
// default for a newly discovered module is the *absence* of a row, resolved in
|
|
// `resolveModuleEligibility` — never a column default that would materialize a decision
|
|
// nobody made.
|
|
expect(enabled?.column_default).toBeNull();
|
|
expect(checks.map((check) => check.constraint_name)).toEqual([
|
|
"ekklesia_core_module_state_module_check",
|
|
]);
|
|
});
|
|
|
|
it("reads an empty configuration from a freshly migrated database", async () => {
|
|
const view = createValidatedPostgresPool(database.url, {
|
|
connectionTimeoutMs: 10_000,
|
|
operationTimeoutMs: 10_000,
|
|
});
|
|
close = view.close;
|
|
store = createModuleLifecycleStore(createModuleLifecycleView(view.pool));
|
|
|
|
expect(await store.readConfiguration()).toEqual({ modules: [] });
|
|
});
|
|
|
|
it("persists an explicit decision and reads it back over a completely new connection", async () => {
|
|
await store.setEnabled({ enabled: false, moduleId: "announcements" });
|
|
|
|
const separate = createValidatedPostgresPool(database.url, {
|
|
connectionTimeoutMs: 10_000,
|
|
operationTimeoutMs: 10_000,
|
|
});
|
|
try {
|
|
const reread = await createModuleLifecycleStore(
|
|
createModuleLifecycleView(separate.pool),
|
|
).readConfiguration();
|
|
expect(reread.modules).toEqual([{ enabled: false, moduleId: "announcements" }]);
|
|
} finally {
|
|
await separate.close();
|
|
}
|
|
});
|
|
|
|
it("is idempotent on module_id: the same decision twice leaves exactly one row", async () => {
|
|
await store.setEnabled({ enabled: false, moduleId: "announcements" });
|
|
await store.setEnabled({ enabled: false, moduleId: "announcements" });
|
|
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from ekklesia_core_module_state where module_id = 'announcements'`,
|
|
);
|
|
expect(rows[0]?.count).toBe("1");
|
|
});
|
|
|
|
it("replaces a decision in place, and bumps updated_at rather than inserting a second row", async () => {
|
|
const before = await safeQuery<{ created_at: Date; updated_at: Date }>(
|
|
admin,
|
|
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
|
|
);
|
|
await store.setEnabled({ enabled: true, moduleId: "announcements" });
|
|
const after = await safeQuery<{ created_at: Date; updated_at: Date }>(
|
|
admin,
|
|
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
|
|
);
|
|
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]?.created_at).toEqual(before[0]?.created_at);
|
|
expect(after[0]?.updated_at.getTime()).toBeGreaterThanOrEqual(
|
|
before[0]?.updated_at.getTime() ?? 0,
|
|
);
|
|
expect((await store.readConfiguration()).modules).toEqual([
|
|
{ enabled: true, moduleId: "announcements" },
|
|
]);
|
|
});
|
|
|
|
it("returns records in deterministic module-id order", async () => {
|
|
await store.setEnabled({ enabled: false, moduleId: "video" });
|
|
await store.setEnabled({ enabled: true, moduleId: "calendar" });
|
|
|
|
expect((await store.readConfiguration()).modules.map((record) => record.moduleId)).toEqual([
|
|
"announcements",
|
|
"calendar",
|
|
"video",
|
|
]);
|
|
});
|
|
|
|
it("rejects a malformed module identifier before it ever reaches PostgreSQL", async () => {
|
|
await expect(store.setEnabled({ enabled: false, moduleId: "Not A Module" })).rejects.toThrow(
|
|
ModuleLifecycleStoreError,
|
|
);
|
|
await expect(store.setEnabled({ enabled: false, moduleId: "" })).rejects.toMatchObject({
|
|
code: "MODULE_LIFECYCLE_MODULE_ID_INVALID",
|
|
});
|
|
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from ekklesia_core_module_state`,
|
|
);
|
|
expect(rows[0]?.count).toBe("3");
|
|
});
|
|
|
|
it("rejects an empty module id at the database level too, beneath the application contract", async () => {
|
|
await expect(
|
|
safeQuery(
|
|
admin,
|
|
`insert into ekklesia_core_module_state (module_id, enabled) values ('', true)`,
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("retains a record for a module that is no longer deployed, and resolution ignores it", async () => {
|
|
// The whole configuration, including the row for `video`, which is not in this inventory.
|
|
const configuration = await store.readConfiguration();
|
|
const deployed = [fakeModule("announcements"), fakeModule("calendar")];
|
|
|
|
const resolution = resolveModuleEligibility(deployed, configuration);
|
|
|
|
expect(resolution.entries.map((entry) => entry.moduleId)).toEqual([
|
|
"announcements",
|
|
"calendar",
|
|
]);
|
|
expect(resolution.eligibleModules.map((module) => module.manifest.id)).toEqual([
|
|
"announcements",
|
|
"calendar",
|
|
]);
|
|
// Retained, not deleted: redeploying `video` restores the decision with no new write.
|
|
expect(configuration.modules.some((record) => record.moduleId === "video")).toBe(true);
|
|
const redeployed = resolveModuleEligibility([...deployed, fakeModule("video")], configuration);
|
|
expect(redeployed.eligibleModules.map((module) => module.manifest.id)).toEqual([
|
|
"announcements",
|
|
"calendar",
|
|
]);
|
|
});
|
|
|
|
it("drives real dependency blocking from real stored rows", async () => {
|
|
await store.setEnabled({ enabled: false, moduleId: "storage" });
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", { modules: { required: { storage: "^1.0.0" } } }),
|
|
],
|
|
await store.readConfiguration(),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(resolution.entries.find((entry) => entry.moduleId === "announcements")?.blocked).toEqual(
|
|
{
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
},
|
|
);
|
|
|
|
await close();
|
|
});
|
|
});
|