feat: add the minimal operational module lifecycle
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -48,6 +48,21 @@ function validConfig() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one query behavior every fake pool in this file needs: startup reads the stored module
|
||||
* enablement configuration (`ModuleLifecycleStore.readConfiguration`) before it imports any module
|
||||
* entrypoint, and an installation that has never disabled anything genuinely has zero rows. Returning
|
||||
* an empty result set is therefore the accurate stand-in for a real, migrated, unconfigured database
|
||||
* — not a way to skip the read. The tests that care about a *specific* configuration drive the real
|
||||
* resolver directly instead (`core/tests/module-lifecycle.test.ts`) or supply real rows through the
|
||||
* real store (`core/tests/module-lifecycle-store.integration.ts`).
|
||||
*/
|
||||
function emptyQuery() {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command: "SELECT", fields: [], oid: 0, rowCount: 0, rows: [] });
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function createTemporaryModulesDirectory(): Promise<string> {
|
||||
@@ -359,7 +374,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("makes every concurrent shutdown caller await the exact same server-close and pool-close, each exactly once", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -375,7 +390,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -395,7 +410,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { server } = mockSuccessfulServe();
|
||||
@@ -411,7 +426,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("closes an already-created database pool when identity construction fails during startup", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error("postgresql://admin:[email protected]/x");
|
||||
@@ -429,7 +444,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -467,7 +482,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -520,7 +535,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockFailingServe(new Error("EADDRINUSE"));
|
||||
@@ -584,7 +599,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -621,7 +636,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -673,7 +688,7 @@ describe("Ekklesia API Node server timeouts", () => {
|
||||
async function startAndCaptureClientErrorHandler() {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { clientErrorHandler } = mockSuccessfulServe();
|
||||
@@ -891,7 +906,7 @@ describe("Ekklesia API module permission composition at startup", () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
@@ -933,7 +948,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
return { end: poolEnd, on: vi.fn(), query: emptyQuery() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
return { poolEnd };
|
||||
@@ -1131,6 +1146,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
recorder.events.push("pool:end");
|
||||
}),
|
||||
on: vi.fn(),
|
||||
query: emptyQuery(),
|
||||
};
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
Reference in New Issue
Block a user