import type { CapabilityId, ModuleId } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js"; import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleRuntimeServices } from "./module-database.js"; import type { ModuleDatabaseExecutor } from "./module-database-abi.js"; import type { ModuleHttpRouteRegistrar } from "./module-http.js"; import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js"; import type { ModuleStorage } from "./module-storage.js"; import { semanticVersionSchema } from "./versions.js"; export type Awaitable = Value | PromiseLike; export interface RegisteredCapability { readonly id: CapabilityId; readonly implementation: unknown; readonly moduleId: ModuleId; readonly version: string; } export interface CapabilityResolver { resolve(capabilityId: CapabilityId): RegisteredCapability | undefined; } export interface CapabilityRegistrar extends CapabilityResolver { provide(capabilityId: CapabilityId, implementation: unknown): void; } export interface ModuleRegistrationContext { readonly capabilities: CapabilityRegistrar; /** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever * be declared while Core is still building the immutable startup snapshot, never added or removed * afterward from `start`, `stop`, or any other post-registration code path. */ readonly http: ModuleHttpRouteRegistrar; /** Declarative, registration-only channel publication. Live publishing is available only from * runtime and request-handler contexts. */ readonly realtime: ModuleRealtimeChannelRegistrar; readonly moduleId: ModuleId; /** * This module's own validated configuration, available here as well as at runtime (ADR 0029). * * Present during `register` deliberately, and without reopening the rule that registration stays * declarative and I/O-free: this is an already-resolved frozen snapshot held in memory, not a * database-backed getter, so reading it performs no query and cannot fail. That is exactly why * `services` — which *is* a live database facade — remains absent from this context. */ readonly config: ModuleConfiguration; } /** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact * same frozen `services` object identity as the other, for a given API runtime — never a fresh * `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never * carries `http`: a route can only ever be declared during `register`. */ export interface ModuleRuntimeContext { readonly capabilities: CapabilityResolver; readonly moduleId: ModuleId; readonly services: ModuleRuntimeServices; /** * This module's own scoped object storage (ADR 0030). * * Present here and on HTTP handler contexts but **not** on `ModuleRegistrationContext`, exactly * like `services`: this is a live facade that performs network I/O, and registration stays * declarative and I/O-free. `config` is on registration precisely because it is the opposite — an * already-resolved in-memory snapshot. * * Scoped per module and never shared: it resolves only the logical buckets this module's own * manifest declared, and carries no endpoint, credential, or physical bucket name. */ readonly storage: ModuleStorage; /** This module's own publisher. Channel names are always resolved inside this module. */ readonly realtime: ModuleRealtimePublisher; /** * This module's own validated configuration, frozen for the lifetime of the process. * * Deliberately **not** a field on `ModuleRuntimeServices`: that object holds live platform * services while configuration is a separately resolved snapshot. It sits beside `moduleId` * — the context's other per-module fact — so the scoping is visible in the shape rather than * asserted in a comment. * * Immutable for the process lifetime: a configuration change is applied by the next restart, so a * value read during `start` and the same value read in a request handler an hour later are the * same value (ADR 0029). */ readonly config: ModuleConfiguration; } interface ModuleRuntimeDefinitionBase { readonly id: ModuleId; readonly register?: (context: ModuleRegistrationContext) => Awaitable; readonly start?: (context: ModuleRuntimeContext) => Awaitable; readonly stop?: (context: ModuleRuntimeContext) => Awaitable; readonly version: string; } /** A module on the legacy database facade (ADR 0016), until its deprecation window ends (ADR 0050). * The facade's type is Core's own, so here its database is opaque: Core specializes `Database`. */ export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase { readonly database?: never; } export interface ModuleDatabaseAbiRuntimeDefinition extends ModuleRuntimeDefinitionBase { readonly database: { readonly abi: 1 }; } export type ModuleRuntimeDefinition = | LegacyModuleRuntimeDefinition | ModuleDatabaseAbiRuntimeDefinition; const runtimeDefinitionKeys = new Set(["database", "id", "register", "start", "stop", "version"]); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntimeDefinition { if ( !isRecord(value) || !moduleIdSchema.safeParse(value.id).success || !semanticVersionSchema.safeParse(value.version).success ) { return false; } if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) { return false; } if ( value.database !== undefined && (!isRecord(value.database) || Reflect.ownKeys(value.database).length !== 1 || value.database.abi !== 1) ) { return false; } return [value.register, value.start, value.stop].every( (hook) => hook === undefined || typeof hook === "function", ); } export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefinition { if (!isModuleRuntimeDefinition(value)) { throw new TypeError("Invalid Ekklesia module runtime definition."); } const hooks = { ...(value.register ? { register: value.register } : {}), ...(value.start ? { start: value.start } : {}), ...(value.stop ? { stop: value.stop } : {}), }; if (value.database) { return Object.freeze({ database: Object.freeze({ abi: 1 as const }), id: value.id, version: value.version, ...hooks, }) as ModuleDatabaseAbiRuntimeDefinition; } return Object.freeze({ id: value.id, version: value.version, ...hooks, }) as LegacyModuleRuntimeDefinition; }