feat: deliver module database ABI through manifest v8 #71

Merged
Zayin_Admin merged 1 commits from feat/manifest-v8-database-abi into main 2026-10-07 22:45:53 +00:00
60 changed files with 948 additions and 237 deletions
Showing only changes of commit 49b4d4165d - Show all commits
+3 -3
View File
@@ -506,8 +506,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
browser contributions. hard cutover; a version 7 source module or package must be updated and rebuilt.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment no key management, so a `secret` key's value is supplied by the deployment
@@ -841,7 +841,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of ([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared means. Publication requires that another eligible module provides the capability inside the declared
+5 -5
View File
@@ -20,6 +20,7 @@ import type {
ModuleLifecycleSnapshot, ModuleLifecycleSnapshot,
ModuleLifecycleStore, ModuleLifecycleStore,
ModuleRealtimeRegistry, ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup, ModuleStorageLookup,
ModuleUiPublicationRegistry, ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when /** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */ * empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry; readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every /** The same per-module service lookup the caller's started module runtime used for every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one * reference-identical service object, never a fresh facade or executor built per request. */
* `ModuleDatabase` for the lifetime of this API runtime. */ readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
readonly moduleServices: ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact /** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`, * discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes` * `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
+5 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/contracts"; import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import { import {
type AuthorizationDrizzleDatabase, type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView, createAuthorizationDrizzleView,
@@ -12,6 +12,7 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import { import {
createModuleConfigurationView, createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView, createModuleDatabaseView,
createModuleLifecycleView, createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase, type ModuleConfigurationDrizzleDatabase,
@@ -24,6 +25,8 @@ export interface ApiDatabaseHandle {
readonly dashboard: DashboardDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase; readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase; readonly module: ModuleDatabase;
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/** /**
@@ -76,6 +79,7 @@ export function createApiDatabase(
dashboard: createDashboardDrizzleView(pool), dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool), identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool), module: createModuleDatabaseView(pool),
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
moduleConfiguration: createModuleConfigurationView(pool), moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool), moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => { ping: async (statementTimeoutMs: number) => {
+19 -6
View File
@@ -1,6 +1,8 @@
import { import {
EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleId, type ModuleId,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices, type ModuleRuntimeServices,
@@ -9,6 +11,7 @@ import {
import type { import type {
ModuleConfigurationLookup, ModuleConfigurationLookup,
ModuleRealtimeRegistry, ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup, ModuleStorageLookup,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* received for this API runtime — never a fresh one built per request, and never touched for a * received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked * denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */ * after authorization succeeds. */
readonly services: ModuleRuntimeServices; readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/** /**
* Per-module frozen configuration snapshots — the exact map the module runtime started from * Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object * (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives * rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present. * `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
* *
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only * Like `services`, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it. * ever reach its own module's configuration, because that is the only entry looked up for it.
*/ */
readonly configurations: ModuleConfigurationLookup; readonly configurations: ModuleConfigurationLookup;
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) { if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError(); throw new ModuleRouteConfigurationError();
} }
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode); const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION, config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId, moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE, storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }), params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c), principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId), realtime: options.realtime.publisherFor(route.moduleId),
services: options.services, services,
}); }) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
let response: Response; let response: Response;
try { try {
response = await route.handler(c.req.raw, context); response = await (
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch { } catch {
return sanitizedModuleHandlerFailure(c); return sanitizedModuleHandlerFailure(c);
} }
+14 -9
View File
@@ -2,8 +2,8 @@ import type { Socket } from "node:net";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
type AnyModuleRuntimeServices,
EMPTY_MODULE_SECRET_SOURCE, EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource, type ModuleSecretSource,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -27,6 +27,7 @@ import {
type DiscoveredModule, type DiscoveredModule,
type ModuleConfigurationSettings, type ModuleConfigurationSettings,
type ModuleEligibilityResolution, type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
resolveModuleEligibility, resolveModuleEligibility,
type StartedModuleRuntime, type StartedModuleRuntime,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
@@ -393,16 +394,13 @@ function bindServer(
* (`prepareStartupModuleInventory`, `./platform-permissions.js`). * (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database * 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`). * facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool, * 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
* then the Core identity runtime, authorization store, and evaluator.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered * 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`). * inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never * 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing it the same services object every module's * rediscovering modules a second time — passing the manifest-selected service map.
* `start`/`stop` hook receives.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing * 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP * UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
* handler.
* 7. Bind the Node HTTP server. * 7. Bind the Node HTTP server.
* *
* Discovery and permission composition happen before any pool, Better Auth instance, module * Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -459,12 +457,10 @@ export async function startApi(
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined; const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined; const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>; let deviceStore: ReturnType<typeof createDeviceStore>;
try { try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
@@ -532,6 +528,15 @@ export async function startApi(
throw error; throw error;
} }
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// One scoped facade per eligible module, over the exact buckets that module's own manifest // One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all. // declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup( const moduleStorages = createModuleStorageLookup(
+3 -3
View File
@@ -342,7 +342,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -386,7 +386,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -524,7 +524,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
+1 -1
View File
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js", server: "./dist/server/index.js",
}, },
id: "announcements", id: "announcements",
manifestVersion: 7, manifestVersion: 8,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+2 -2
View File
@@ -104,7 +104,7 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -270,7 +270,7 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [], configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -8,6 +8,7 @@ import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
} from "@ekklesia/core/migrations"; } from "@ekklesia/core/migrations";
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
@@ -441,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
const handle = createApiDatabase(database.url); const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined(); expect(handle.module).toBeDefined();
const alpha = handle.moduleExecutor("alpha");
const beta = handle.moduleExecutor("beta");
expect(alpha).not.toBe(beta);
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
await handle.close(); await handle.close();
expect(await tableExists(database.url)).toBe(false); expect(await tableExists(database.url)).toBe(false);
+1 -1
View File
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}), : {}),
}, },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
permissions: fixture.display permissions: fixture.display
? [ ? [
+41 -10
View File
@@ -6,8 +6,8 @@ import {
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleRoutesOverlap, moduleRoutesOverlap,
parsePermissionId,
type PermissionId, type PermissionId,
parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -20,20 +20,21 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { import {
canonicalizeVaryHeader, canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError, ModuleRouteConfigurationError,
mergedVaryHeader,
} from "../src/module-http-routes.js"; } from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -107,7 +108,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry; readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[]; readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime; readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices; readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly trustedOrigins?: readonly string[]; readonly trustedOrigins?: readonly string[];
}) { }) {
const evaluator = options.evaluator ?? { const evaluator = options.evaluator ?? {
@@ -145,6 +146,36 @@ function permissionRegistry(
} }
describe("module HTTP route mounting", () => { describe("module HTTP route mounting", () => {
it("selects the owning module's stable service entry for its handler", async () => {
const calendar = fakeModuleServices();
const people = fakeModuleServices();
const services: ModuleRuntimeServicesLookup = new Map([
["calendar", calendar],
["people", people],
]);
let received: unknown;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: (_request, context) => {
received = context.services;
return Response.json({ ok: true });
},
method: "GET",
moduleId: "calendar",
path: "/services",
permission: readPermission,
}),
],
services,
});
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
expect(received).toBe(calendar);
expect(received).not.toBe(people);
});
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => { it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 })); const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
const { app } = buildModuleApp({ const { app } = buildModuleApp({
@@ -508,7 +539,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403); expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services); expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
}); });
it("does not invoke the handler when principal resolution fails", async () => { it("does not invoke the handler when principal resolution fails", async () => {
@@ -637,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
} }
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services); expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
}); });
it("allows a trusted-origin state-changing request through to the handler", async () => { it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -766,7 +797,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services); expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
}); });
it("never reflects an untrusted origin for a module route", async () => { it("never reflects an untrusted origin for a module route", async () => {
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 7, manifestVersion: 8,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
+1 -1
View File
@@ -129,7 +129,7 @@ async function createFixtureModule(
: {}), : {}),
}, },
id, id,
manifestVersion: 7, manifestVersion: 8,
name, name,
permissions: [ permissions: [
{ {
+1 -1
View File
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0", version: "1.0.0",
})), })),
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...permission, ...permission,
+1 -1
View File
@@ -139,7 +139,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0", version: "1.0.0",
})), })),
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
+67 -23
View File
@@ -1,5 +1,6 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
type AnyModuleRuntimeServices,
type CapabilityId, type CapabilityId,
type CapabilityRegistrar, type CapabilityRegistrar,
type CapabilityResolver, type CapabilityResolver,
@@ -7,12 +8,15 @@ import {
EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
type ModuleConfiguration, type ModuleConfiguration,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar, type ModuleHttpRouteRegistrar,
type ModuleId, type ModuleId,
ModuleRealtimeError, ModuleRealtimeError,
type ModuleRealtimePublisher, type ModuleRealtimePublisher,
type ModuleRegistrationContext, type ModuleRegistrationContext,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
type ModuleRuntimeServices, type ModuleRuntimeServices,
@@ -68,6 +72,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED" | "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID" | "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH" | "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH" | "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -105,9 +110,8 @@ export interface StartedModuleRuntime {
/** /**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029). * Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
* *
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one * A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
* shared object identity every module receives** — a property this runtime has a test for — while * frozen per module independently of its live service object. A module with no entry receives
* configuration is per-module by definition. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and * `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one. * module code never branches on whether the platform supplied one.
*/ */
@@ -124,12 +128,33 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030). * Per-module scoped object storage, keyed by module id (ADR 0030).
* *
* A lookup for the same reason configuration is one, and emphatically **not** a field on * A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a * `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every * Sharing it would hand every
* module every other module's storage. * module every other module's storage.
*/ */
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>; export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage { function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE; return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
} }
@@ -164,10 +189,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions, extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions { ModuleRuntimeCompositionOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once /** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production * handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */ readonly services: ModuleRuntimeServicesSource;
readonly services: ModuleRuntimeServices;
} }
interface LoadedModule { interface LoadedModule {
@@ -175,6 +199,13 @@ interface LoadedModule {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
} }
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage { interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar; readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[]; commit(): readonly ModuleRuntimeIssue[];
@@ -469,7 +500,7 @@ class StagedModuleHttpRoutes {
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode, bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler, handler: parsed.data.handler as ModuleRouteHandler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
moduleId: module.manifest.id, moduleId: module.manifest.id,
@@ -555,6 +586,19 @@ async function loadRuntimeDefinitions(
), ),
); );
} }
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) { if (matchesManifest) {
loaded.push({ definition, discovered }); loaded.push({ definition, discovered });
} }
@@ -580,15 +624,15 @@ async function registerModules(
module.discovered.manifest.id, module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)), new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
); );
const context: ModuleRegistrationContext = Object.freeze({ const context = Object.freeze({
capabilities: capabilityStage.capabilities, capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id), config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http, http: routeStage.http,
moduleId: module.discovered.manifest.id, moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime, realtime: realtimeStage.realtime,
}); }) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
try { try {
await module.definition.register?.(context); await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
} catch (error) { } catch (error) {
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]); throw new ModuleRuntimeError([error.issue]);
@@ -685,7 +729,7 @@ async function stopModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
phase: "rollback" | "stop", phase: "rollback" | "stop",
services: ModuleRuntimeServices, services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry, realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -698,7 +742,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the // A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence. // module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId); realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop; const stop = module.definition.stop as AnyRuntimeHook | undefined;
if (!stop) { if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook. // No synthetic callback and no timer for a module that declares no stop hook.
continue; continue;
@@ -710,13 +754,13 @@ async function stopModules(
registry.createRuntimeContext( registry.createRuntimeContext(
module.discovered, module.discovered,
phase, phase,
services, servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, moduleId), configurationFor(configurations, moduleId),
storageFor(storages, moduleId), storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId), realtimeRegistry.publisherFor(moduleId),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
), ),
), ).then(() => undefined),
),
timeoutMs, timeoutMs,
); );
if (outcome === "timed-out") { if (outcome === "timed-out") {
@@ -750,7 +794,7 @@ async function stopModules(
async function startModules( async function startModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices, services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry, realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -766,15 +810,15 @@ async function startModules(
for (const module of modules) { for (const module of modules) {
rollbackCandidates.push(module); rollbackCandidates.push(module);
try { try {
await module.definition.start?.( await (module.definition.start as AnyRuntimeHook | undefined)?.(
registry.createRuntimeContext( registry.createRuntimeContext(
module.discovered, module.discovered,
"start", "start",
services, servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, module.discovered.manifest.id), configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id), storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id), realtimeRegistry.publisherFor(module.discovered.manifest.id),
), ) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
); );
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id); const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) { if (missingSnapshots.length > 0) {
@@ -831,7 +875,7 @@ async function startModules(
*/ */
export async function startDiscoveredModuleRuntime( export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices, services: ModuleRuntimeServicesSource,
timeouts: ModuleRuntimeCompositionOptions = {}, timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> { ): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules); const modules = await loadRuntimeDefinitions(discoveredModules);
+128 -10
View File
@@ -2,9 +2,11 @@ import { pathToFileURL } from "node:url";
import { import {
type ModuleId, type ModuleId,
type ModuleSchemaDefinition, type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema, moduleTableLocalNameSchema,
moduleTablePrefix, moduleTablePrefix,
parseModuleSchemaDefinition, parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema, physicalModuleTableNameSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { is } from "drizzle-orm"; import { is } from "drizzle-orm";
@@ -40,7 +42,9 @@ export interface DiscoveredModuleTable {
readonly exportName: string; readonly exportName: string;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
readonly name: string; readonly name: string;
readonly table: AnyPgTable; /** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
} }
export interface DiscoveredModuleSchema { export interface DiscoveredModuleSchema {
@@ -55,21 +59,40 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[]; readonly tables: readonly DiscoveredModuleTable[];
} }
interface LoadedModuleSchema { interface LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition; readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
readonly mode: "legacy";
} }
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema { interface InspectedModuleSchema {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[]; readonly tables: ModuleTableCandidate[];
} }
interface ModuleTableCandidate extends DiscoveredModuleTable { interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"]; readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string; readonly path: string;
readonly table: AnyPgTable;
} }
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias"); const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error { export class ModuleSchemaDiscoveryError extends Error {
@@ -156,9 +179,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue; continue;
} }
let definition: ModuleSchemaDefinition; let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try { try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace)); definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch { } catch {
issues.push( issues.push(
issue( issue(
@@ -182,7 +209,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue; continue;
} }
loaded.push({ definition, discovered }); loaded.push(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
} }
return { issues, loaded }; return { issues, loaded };
@@ -197,6 +228,58 @@ function inspectTables(
for (const loaded of loadedSchemas) { for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id; const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>(); const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = []; const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) => const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -293,6 +376,7 @@ function inspectTables(
const candidate: ModuleTableCandidate = { const candidate: ModuleTableCandidate = {
exportName, exportName,
foreignKeys: config.foreignKeys, foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId, moduleId,
name: config.name, name: config.name,
path, path,
@@ -321,8 +405,8 @@ function inspectTables(
} }
function validForeignKeyTarget( function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number], foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate, candidate: LegacyModuleTableCandidate,
): AnyPgTable | undefined { ): AnyPgTable | undefined {
try { try {
const reference = foreignKey.reference(); const reference = foreignKey.reference();
@@ -356,14 +440,42 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>(); const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) { for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) { for (const candidate of inspected.tables) {
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId); ownerByTable.set(candidate.table, candidate.moduleId);
} }
} }
}
for (const inspected of inspectedModules) { for (const inspected of inspectedModules) {
const moduleId = inspected.discovered.manifest.id; const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table)); const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) { for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => { candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`; const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate); const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -446,7 +558,9 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id, moduleId: inspected.discovered.manifest.id,
tables: Object.freeze( tables: Object.freeze(
inspected.tables inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table)) .map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)), .sort((left, right) => compareText(left.exportName, right.exportName)),
), ),
}), }),
@@ -458,7 +572,11 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)), .sort((left, right) => compareText(left.name, right.name)),
); );
const drizzleSchema = Object.freeze( const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)), Object.fromEntries(
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
); );
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables }); return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
+1 -1
View File
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
+10 -4
View File
@@ -1,8 +1,8 @@
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js"; import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import { import {
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64); const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64); const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { function discoveredModule(
id: string,
version = "0.1.0",
database?: { readonly abi: 1 },
): DiscoveredModule {
return { return {
contributions: [], contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
capabilities: [], capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [], configuration: [],
...(database ? { database } : {}),
dependencies: { dependencies: {
capabilities: { optional: {}, required: {} }, capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} }, modules: { optional: {}, required: {} },
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 7, manifestVersion: 8,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
describe("Ekklesia Doctor diagnostics", () => { describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => { it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people"); const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0"); const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
const inventory = migrationAggregate( const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)], [migration("people", "0000_initial", checksumA)],
[people, calendar], [people, calendar],
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
expect(check(report, "modules.permissions")).toMatchObject({ expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"], details: ["0 permissions registered"],
status: "pass", status: "pass",
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 7, "manifestVersion": 8,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -17,7 +17,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") { function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements", id = "announcements",
) { ) {
return parseModuleManifest({ return parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => { it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => { it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => { it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
], ],
])("rejects %s", (_label, declaration) => { ])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf", "valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => { ])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"", "",
])("rejects the malformed configuration key %s", (key) => { ])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({ const result = safeParseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id: "announcements", id: "announcements",
name: "announcements module", name: "announcements module",
version: "1.0.0", version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+1 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> { function manifestFor(id: string): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */ * by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule { function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [], permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+96 -23
View File
@@ -2,20 +2,23 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import type { import type {
AnyModuleRuntimeServices,
ModuleConfiguration, ModuleConfiguration,
ModuleDatabase, ModuleDatabase,
ModuleDatabaseExecutor,
ModuleRuntimeServices, ModuleRuntimeServices,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js"; import { discoverModules } from "../src/modules/discovery.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
import { import {
ModuleDiscoveryError, ModuleDiscoveryError,
ModuleRuntimeError, ModuleRuntimeError,
type ModuleRuntimeIssueCode, type ModuleRuntimeIssueCode,
type ModuleRuntimeServicesLookup,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
startModuleRuntime, startModuleRuntime,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => { vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>(); const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
@@ -52,12 +55,13 @@ function manifestFor(
id: string, id: string,
options: { options: {
capabilities?: Array<{ id: string; version: string }>; capabilities?: Array<{ id: string; version: string }>;
database?: { abi: 1 };
dependencies?: Record<string, unknown>; dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>; permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -67,6 +71,7 @@ function manifestFor(
}, },
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
permissions: options.permissions ?? [], permissions: options.permissions ?? [],
...(options.database ? { database: options.database } : {}),
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
@@ -145,13 +150,29 @@ function fakeModuleDatabase(): ModuleDatabase {
}) as unknown as ModuleDatabase; }) as unknown as ModuleDatabase;
} }
function fakeModuleRuntimeServices(): ModuleRuntimeServices { function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
return Object.freeze({ database: fakeModuleDatabase() }); const services = new Map<string, ModuleRuntimeServices>();
const database = fakeModuleDatabase();
return new Proxy(services, {
get(target, property, receiver) {
if (property === "get") {
return (moduleId: string) => {
let value = target.get(moduleId);
if (!value) {
value = Object.freeze({ database });
target.set(moduleId, value);
}
return value;
};
}
return Reflect.get(target, property, receiver);
},
});
} }
function runtimeOptions( function runtimeOptions(
modulesDirectory: string, modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(), services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
) { ) {
return { return {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
@@ -1067,6 +1088,54 @@ describe("already-discovered module runtime", () => {
}); });
describe("module runtime services", () => { describe("module runtime services", () => {
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
const legacyDatabase = fakeModuleDatabase();
const alphaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const betaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
["legacy", Object.freeze({ database: legacyDatabase })],
["alpha", Object.freeze({ database: alphaExecutor })],
["beta", Object.freeze({ database: betaExecutor })],
]);
await createRuntimeModule(modulesDirectory, "legacy", {
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
for (const id of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: manifestFor(id, { database: { abi: 1 } }),
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
}
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
expect("select" in alphaExecutor).toBe(false);
expect(alphaExecutor).not.toBe(betaExecutor);
await runtime.stop();
});
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mismatch", {
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
});
it("never gives register a services field, and gives start and stop the exact same services object", async () => { it("never gives register a services field, and gives start and stop the exact same services object", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices(); const services = fakeModuleRuntimeServices();
@@ -1086,11 +1155,11 @@ describe("module runtime services", () => {
expect(registerServices.values).toEqual([undefined]); expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2); expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services); expect(lifecycleServices.values[0]).toBe(services.get("wired"));
expect(lifecycleServices.values[1]).toBe(services); expect(lifecycleServices.values[1]).toBe(services.get("wired"));
}); });
it("shares the exact same services object identity across every module's start hook", async () => { it("selects a stable services object per module rather than sharing one across modules", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices(); const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder(); const recorder = createReferenceRecorder();
@@ -1112,7 +1181,8 @@ describe("module runtime services", () => {
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services)); const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]); expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
expect(recorder.values[0]).not.toBe(recorder.values[1]);
await runtime.stop(); await runtime.stop();
}); });
@@ -1141,23 +1211,26 @@ describe("module runtime services", () => {
startModuleRuntime(runtimeOptions(modulesDirectory, services)), startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError); ).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]); expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
}); });
it("starts and stops a zero-module runtime without ever touching the provided services", async () => { it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices(); const database = fakeModuleDatabase();
const services: ModuleRuntimeServicesLookup = new Map([
["unused", Object.freeze({ database })],
]);
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services)); const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop(); await runtime.stop();
expect(runtime.modules).toEqual([]); expect(runtime.modules).toEqual([]);
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled(); expect(vi.mocked(database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled(); expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled(); expect(vi.mocked(database.update)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled(); expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled(); expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled(); expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
}); });
}); });
@@ -1463,7 +1536,7 @@ describe("module configuration delivery (ADR 0029)", () => {
expect(recorder.values).toEqual([["object", []]]); expect(recorder.values).toEqual([["object", []]]);
}); });
it("keeps the services object one shared identity even though configuration is per-module", async () => { it("keeps each module's services object stable while configuration remains per-module", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices(); const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder(); const recorder = createReferenceRecorder();
@@ -1492,11 +1565,11 @@ describe("module configuration delivery (ADR 0029)", () => {
await runtime.stop(); await runtime.stop();
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values; const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
// This is the reason configuration is a sibling of `services` rather than a field inside it: // Configuration remains a sibling of `services`: both are selected per module, but they are
// `services` is deliberately one shared object for the whole runtime, while configuration must // independently composed snapshots with separate contracts and lifetimes.
// differ per module. Both properties hold simultaneously. expect(alphaServices).toBe(services.get("alpha"));
expect(alphaServices).toBe(services); expect(bravoServices).toBe(services.get("bravo"));
expect(bravoServices).toBe(services); expect(alphaServices).not.toBe(bravoServices);
expect(alphaConfig).not.toBe(bravoConfig); expect(alphaConfig).not.toBe(bravoConfig);
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha"); expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo"); expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
+63 -2
View File
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor( function manifestFor(
id: string, id: string,
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {}, options: {
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
schema?: string | false;
} = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
@@ -49,6 +53,7 @@ function manifestFor(
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [], capabilities: [],
permissions: [], permissions: [],
...(options.database ? { database: options.database } : {}),
entrypoints: { entrypoints: {
server: "./dist/server/index.js", server: "./dist/server/index.js",
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }), ...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
} }
describe("module schema discovery", () => { describe("module schema discovery", () => {
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", { database: { abi: 1 } }),
schemaSource: `
export default {
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
]
};
`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(aggregate.tables.map((table) => table.name)).toEqual([
"calendar_attendees",
"calendar_events",
]);
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
expect(aggregate.drizzleSchema).toEqual({});
});
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", { database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "alpha",
tables: [
{ name: "wrong_records", foreignKeys: [] },
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
]
};`,
});
await createSchemaModule(modulesDirectory, "malformed", {
manifest: manifestFor("malformed", { database: { abi: 1 } }),
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
"MODULE_SCHEMA_TABLE_DUPLICATE",
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
]),
);
});
it("returns a frozen empty aggregate without importing module servers", async () => { it("returns a frozen empty aggregate without importing module servers", async () => {
const modulesDirectory = await createModulesDirectory(); const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder(); const recorder = createGlobalEventRecorder();
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {}, } = {},
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "1.0.0", version: "1.0.0",
+1 -1
View File
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: contribution.version, version: contribution.version,
})), })),
id: fixture.id, id: fixture.id,
manifestVersion: 7, manifestVersion: 8,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -50,7 +50,7 @@ export function manifestFor(
} = {}, } = {},
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+6 -4
View File
@@ -609,7 +609,7 @@ a sandbox: it is `open` precisely because module code is trusted, same-realm cod
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser [ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target had since ADR 0006. `manifestVersion: 8` declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the "slide" is because the declaration cannot express it. Discovery resolves the artifact through the
@@ -687,6 +687,8 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
extract anything. extract anything.
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a - **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md) defines the Module Database ABI database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md) defines the Module Database ABI
that removes it for modules that declare that ABI. Core's ABI v1 executor that removes it for modules that declare that ABI. Manifest version 8 now lets a module declare
(`createModuleDatabaseExecutor`) exists and is proven against PostgreSQL. No module can declare the `database: { abi: 1 }`; Core gives each such module its own attributed ABI executor and validates
ABI or receive the executor yet: that needs manifest version 8. its plain schema metadata. A version 8 module that omits `database` remains on the temporary
legacy facade. Packaging still uses ADR 0049's transitional Drizzle agreement in this slice; the
packager transition and template ABI migration are deliberately next.
+1 -1
View File
@@ -138,7 +138,7 @@ enforces those permissions while the module is disabled, because it mounts no ro
## Module permission composition ## Module permission composition
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
manifest is version 7 — see manifest is version 8 — see
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in [Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register well-formed and correctly namespaced under the module's own id; it does not, by itself, register
+44 -12
View File
@@ -198,7 +198,7 @@ without executing module code:
```json ```json
{ {
"manifestVersion": 7, "manifestVersion": 8,
"id": "calendar", "id": "calendar",
"name": "Calendar", "name": "Calendar",
"version": "0.1.0", "version": "0.1.0",
@@ -275,10 +275,10 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible. may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a `manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 6 manifest, and any other version, fails validation outright rather than being silently version 7 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 7 adds the optional `contributions` array Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that ([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
contributes nothing changes only the version literal; every browser artifact remains independently contributes nothing changes only the version literal; every browser artifact remains independently
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
@@ -1491,13 +1491,14 @@ destructive behavior. Review those properties explicitly. Core is the only migra
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
falls back to non-transactional execution. falls back to non-transactional execution.
## Module Database ABI v1 adapters (not yet delivered at runtime) ## Module Database ABI v1
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
cross it. `@ekklesia/module-database` is module-side code for consuming that boundary once a later cross it. A manifest version 8 module opts in with `"database": { "abi": 1 }`; omitting `database`
manifest/runtime slice delivers a `ModuleDatabaseExecutor`; it is **not usable by deployed modules selects the temporary legacy facade. “Legacy” here describes that facade, never a version 7
yet**. manifest. Core selects the service from the validated manifest and gives each ABI module its own
executor, reused by that module's lifecycle hooks and route handlers.
A Drizzle module imports the optional adapter subpath and declares its own exact compatible A Drizzle module imports the optional adapter subpath and declares its own exact compatible
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API `drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
@@ -1505,13 +1506,25 @@ compatibility; it is not a Core or platform compatibility range:
```ts ```ts
import { moduleDrizzle } from "@ekklesia/module-database/drizzle"; import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
import { defineModuleRuntime } from "@ekklesia/module-sdk";
import * as schema from "./schema/tables.js"; import * as schema from "./schema/tables.js";
// `executor` will be supplied by later ABI service delivery work. export default defineModuleRuntime({
const db = moduleDrizzle(executor, { schema }); database: { abi: 1 },
id: "calendar",
version: "0.1.0",
async start({ services }) {
const db = moduleDrizzle(services.database, { schema });
const events = await db.select().from(schema.events); const events = await db.select().from(schema.events);
},
});
``` ```
The repeated runtime discriminator is what gives lifecycle and route-handler code the exact
`ModuleDatabaseExecutor` type instead of a legacy-or-ABI union. Core still chooses from the static
manifest and refuses startup when the runtime discriminator disagrees with it; neither contract can
silently receive the other service.
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies (`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
@@ -1560,9 +1573,28 @@ const result = await executor.query({ ...statement, rowMode: "object" });
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
rejects them; strings containing quotes or SQL syntax always remain parameters. rejects them; strings containing quotes or SQL syntax always remain parameters.
## Database access ABI modules also export plain schema metadata rather than Drizzle objects. Drizzle users can derive
it on the module side; raw-SQL modules can write the same inert shape directly:
`context.services.database` — available in `start`, `stop`, and every authorized route handler, ```ts
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
import * as tables from "./tables.js";
export default moduleDrizzleSchemaMetadata("calendar", tables);
```
The result is `{ moduleId, tables: [{ name, foreignKeys: [{ table }] }] }`. Core validates table
prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an
ABI module's ORM objects.
The canonical module template has moved to manifest version 8 but intentionally omits `database`,
so it remains on the legacy facade for one more slice. The packager still emits ADR 0049's
`transitional.databaseRuntime`; the next package-transition change will remove that fact for ABI
packages and migrate the template at the same time.
## Legacy database access
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`, never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, `selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
preserving Drizzle's own table-aware argument and result inference. It is the platform's only preserving Drizzle's own table-aware argument and result inference. It is the platform's only
+2
View File
@@ -13,5 +13,7 @@ export * from "./module-package.js";
export * from "./module-realtime.js"; export * from "./module-realtime.js";
export * from "./module-runtime.js"; export * from "./module-runtime.js";
export * from "./module-schema.js"; export * from "./module-schema.js";
export * from "./module-schema-metadata.js";
export * from "./module-schema-names.js";
export * from "./module-storage.js"; export * from "./module-storage.js";
export * from "./versions.js"; export * from "./versions.js";
+11 -2
View File
@@ -1,4 +1,5 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
/** /**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a * A trusted backend module's typed Drizzle query surface — the same query-building methods a
@@ -45,6 +46,14 @@ export type ModuleDatabase = Pick<
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way * remain out of scope until a later phase explicitly adds them here, one at a time, the same way
* `database` was added. * `database` was added.
*/ */
export interface ModuleRuntimeServices { export interface ModuleRuntimeServices<
readonly database: ModuleDatabase; Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly database: Database;
} }
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
+23 -13
View File
@@ -2,7 +2,8 @@ import { z } from "zod";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js"; import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { EkklesiaPrincipal } from "./identity.js"; import type { EkklesiaPrincipal } from "./identity.js";
import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js"; import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleRealtimePublisher } from "./module-realtime.js"; import type { ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js"; import type { ModuleStorage } from "./module-storage.js";
@@ -271,7 +272,9 @@ export function moduleRoutesOverlap(
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a * behavior, and uncaught-handler sanitization; permission enforcement always completes before a
* handler — and therefore any parameter validation it performs — can run. * handler — and therefore any parameter validation it performs — can run.
*/ */
export interface ModuleRouteHandlerContext { export interface ModuleRouteHandlerContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal; readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>; readonly params: Readonly<Record<string, string>>;
@@ -279,7 +282,7 @@ export interface ModuleRouteHandlerContext {
* object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight * object, not a re-resolved one (ADR 0030). This is what lets a module stream an object straight
* into a `Response` without the browser ever learning that object storage exists. */ * into a `Response` without the browser ever learning that object storage exists. */
readonly storage: ModuleStorage; readonly storage: ModuleStorage;
readonly services: ModuleRuntimeServices; readonly services: ModuleRuntimeServices<Database>;
/** The identical module-scoped realtime publisher used by lifecycle hooks. */ /** The identical module-scoped realtime publisher used by lifecycle hooks. */
readonly realtime: ModuleRealtimePublisher; readonly realtime: ModuleRealtimePublisher;
/** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the /** The identical frozen configuration snapshot this module's `start`/`stop` hooks received — the
@@ -293,10 +296,9 @@ export interface ModuleRouteHandlerContext {
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full /** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
* query string remains available through `Request.url`; there is no competing query or body * query string remains available through `Request.url`; there is no competing query or body
* abstraction. */ * abstraction. */
export type ModuleRouteHandler = ( export type ModuleRouteHandler<
request: Request, Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
context: ModuleRouteHandlerContext, > = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
) => Awaitable<Response>;
/** /**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here * What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
@@ -311,7 +313,9 @@ export type ModuleRouteHandler = (
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit * path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
* metadata, another module's namespace, or an unprotected/no-permission route. * metadata, another module's namespace, or an unprotected/no-permission route.
*/ */
export interface ModuleRouteDefinitionInput { export interface ModuleRouteDefinitionInput<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;
readonly path: string; readonly path: string;
readonly permission: string; readonly permission: string;
@@ -322,12 +326,14 @@ export interface ModuleRouteDefinitionInput {
* existed meant, so adding this field changed no existing route's behaviour. * existed meant, so adding this field changed no existing route's behaviour.
*/ */
readonly bodyMode?: ModuleRouteBodyMode; readonly bodyMode?: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler<Database>;
} }
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces /** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
* only after every field passes its own grammar. */ * only after every field passes its own grammar. */
export interface ModuleRouteDefinition { export interface ModuleRouteDefinition<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
@@ -335,13 +341,15 @@ export interface ModuleRouteDefinition {
/** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has /** Always present in the canonical form, defaulted to `buffered`, so no consumer downstream has
* to re-apply the default and none of them can disagree about it. */ * to re-apply the default and none of them can disagree about it. */
readonly bodyMode: ModuleRouteBodyMode; readonly bodyMode: ModuleRouteBodyMode;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler<Database>;
} }
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on /** The Fetch-based registrar exposed as `context.http` during `register` only — never on
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */ * `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
export interface ModuleHttpRouteRegistrar { export interface ModuleHttpRouteRegistrar<
route(definition: ModuleRouteDefinitionInput): void; Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
route(definition: ModuleRouteDefinitionInput<Database>): void;
} }
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated /** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
@@ -355,6 +363,8 @@ export interface RegisteredModuleHttpRoute {
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number; readonly maxBodyBytes: number;
readonly bodyMode: ModuleRouteBodyMode; readonly bodyMode: ModuleRouteBodyMode;
/** Runtime-erased callback. Module authors get the database-mode-specific handler type through
* `ModuleHttpRouteRegistrar`; Core stores the validated callback opaquely in this snapshot. */
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
+17 -9
View File
@@ -68,6 +68,14 @@ const moduleStorageSchema = z
.strict() .strict()
.default(() => ({ buckets: [] })); .default(() => ({ buckets: [] }));
/** The database contract Core delivers to this module. Omitted means the temporary ADR 0016
* legacy facade; ABI v1 is the stable SQL boundary from ADR 0050. */
export const moduleDatabaseDeclarationSchema = z
.object({
abi: z.literal(1),
})
.strict();
/** /**
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a * A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
* display can never be declared without saying which one of the module's own permissions protects * display can never be declared without saying which one of the module's own permissions protects
@@ -122,9 +130,9 @@ function reportDuplicates(
} }
/** /**
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`, * The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
* so the static browser-contribution declaration is an incompatible shape change and the canonical * the static ABI declaration is an incompatible shape change and the canonical manifest moves to
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6. * `manifestVersion: 8` without silently reinterpreting version 7.
* *
* Configuration is declared **statically, in this inert document**, rather than registered by * Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration * executable module code, for one decisive reason: the platform validates a module's configuration
@@ -133,14 +141,13 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without * inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have. * importing a single line of module code, exactly as they always have.
* *
* Version 6, and every other version, fails validation: there is no compatibility adapter, no * Version 7, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2 * automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5 * (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different * by version 8 (ADR 0050). One manifest document can therefore never be read as two different
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes * versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
* nothing: the new field defaults to empty.
*/ */
export const MODULE_MANIFEST_VERSION = 7; export const MODULE_MANIFEST_VERSION = 8;
export const moduleManifestSchema = z export const moduleManifestSchema = z
.object({ .object({
@@ -163,6 +170,7 @@ export const moduleManifestSchema = z
* module code during discovery. A module that needs nothing configured omits the field. */ * module code during discovery. A module that needs nothing configured omits the field. */
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
storage: moduleStorageSchema, storage: moduleStorageSchema,
database: moduleDatabaseDeclarationSchema.optional(),
/** Compiled browser renderers this module offers to capabilities **other** modules provide /** Compiled browser renderers this module offers to capabilities **other** modules provide
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and * (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
* never learns what a contribution renders. A module that contributes nothing omits the field. */ * never learns what a contribution renders. A module that contributes nothing omits the field. */
+53 -16
View File
@@ -1,7 +1,8 @@
import type { CapabilityId, ModuleId } from "./identifiers.js"; import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js"; import { moduleIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js"; import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleRuntimeServices } from "./module-database.js"; import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js"; import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js"; import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js"; import type { ModuleStorage } from "./module-storage.js";
@@ -24,12 +25,14 @@ export interface CapabilityRegistrar extends CapabilityResolver {
provide(capabilityId: CapabilityId, implementation: unknown): void; provide(capabilityId: CapabilityId, implementation: unknown): void;
} }
export interface ModuleRegistrationContext { export interface ModuleRegistrationContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly capabilities: CapabilityRegistrar; readonly capabilities: CapabilityRegistrar;
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever /** 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 * 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. */ * afterward from `start`, `stop`, or any other post-registration code path. */
readonly http: ModuleHttpRouteRegistrar; readonly http: ModuleHttpRouteRegistrar<Database>;
/** Declarative, registration-only channel publication. Live publishing is available only from /** Declarative, registration-only channel publication. Live publishing is available only from
* runtime and request-handler contexts. */ * runtime and request-handler contexts. */
readonly realtime: ModuleRealtimeChannelRegistrar; readonly realtime: ModuleRealtimeChannelRegistrar;
@@ -49,10 +52,12 @@ export interface ModuleRegistrationContext {
* same frozen `services` object identity as the other, for a given API runtime — never a fresh * 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 * `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
* carries `http`: a route can only ever be declared during `register`. */ * carries `http`: a route can only ever be declared during `register`. */
export interface ModuleRuntimeContext { export interface ModuleRuntimeContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
readonly capabilities: CapabilityResolver; readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices; readonly services: ModuleRuntimeServices<Database>;
/** /**
* This module's own scoped object storage (ADR 0030). * This module's own scoped object storage (ADR 0030).
* *
@@ -70,8 +75,8 @@ export interface ModuleRuntimeContext {
/** /**
* This module's own validated configuration, frozen for the lifetime of the process. * This module's own validated configuration, frozen for the lifetime of the process.
* *
* Deliberately **not** a field on `ModuleRuntimeServices`: that object is one shared instance * Deliberately **not** a field on `ModuleRuntimeServices`: that object holds live platform
* every module receives, and configuration is per-module by definition. It sits beside `moduleId` * 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 * — the context's other per-module fact — so the scoping is visible in the shape rather than
* asserted in a comment. * asserted in a comment.
* *
@@ -82,15 +87,28 @@ export interface ModuleRuntimeContext {
readonly config: ModuleConfiguration; readonly config: ModuleConfiguration;
} }
export interface ModuleRuntimeDefinition { interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDatabaseExecutor> {
readonly id: ModuleId; readonly id: ModuleId;
readonly register?: (context: ModuleRegistrationContext) => Awaitable<void>; readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>; readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>; readonly stop?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
readonly version: string; readonly version: string;
} }
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]); export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase<ModuleDatabase> {
readonly database?: never;
}
export interface ModuleDatabaseAbiRuntimeDefinition
extends ModuleRuntimeDefinitionBase<ModuleDatabaseExecutor> {
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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -107,6 +125,14 @@ export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntim
if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) { if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) {
return false; 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( return [value.register, value.start, value.stop].every(
(hook) => hook === undefined || typeof hook === "function", (hook) => hook === undefined || typeof hook === "function",
); );
@@ -116,11 +142,22 @@ export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefin
if (!isModuleRuntimeDefinition(value)) { if (!isModuleRuntimeDefinition(value)) {
throw new TypeError("Invalid Ekklesia module runtime definition."); throw new TypeError("Invalid Ekklesia module runtime definition.");
} }
return Object.freeze({ const hooks = {
id: value.id,
version: value.version,
...(value.register ? { register: value.register } : {}), ...(value.register ? { register: value.register } : {}),
...(value.start ? { start: value.start } : {}), ...(value.start ? { start: value.start } : {}),
...(value.stop ? { stop: value.stop } : {}), ...(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;
} }
@@ -0,0 +1,80 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
import { physicalModuleTableNameSchema } from "./module-schema-names.js";
export const moduleSchemaForeignKeyMetadataSchema = z
.object({ table: physicalModuleTableNameSchema })
.strict();
export const moduleSchemaTableMetadataSchema = z
.object({
name: physicalModuleTableNameSchema,
foreignKeys: z.array(moduleSchemaForeignKeyMetadataSchema).default(() => []),
})
.strict();
/** The ABI-v1 schema entrypoint: inert Ekklesia-owned data, with no ORM object or executable
* callback anywhere in its shape. Ownership and foreign-key targets are validated by Core against
* the manifest module id and the complete table list. */
export const moduleSchemaMetadataDefinitionSchema = z
.object({
moduleId: moduleIdSchema,
tables: z.array(moduleSchemaTableMetadataSchema).min(1),
})
.strict();
export interface ModuleSchemaForeignKeyMetadata {
readonly table: string;
}
export interface ModuleSchemaTableMetadata {
readonly name: string;
readonly foreignKeys: readonly ModuleSchemaForeignKeyMetadata[];
}
export interface ModuleSchemaMetadataDefinition {
readonly moduleId: z.output<typeof moduleIdSchema>;
readonly tables: readonly ModuleSchemaTableMetadata[];
}
function isPlainData(value: unknown): boolean {
if (value === null || typeof value === "string") {
return true;
}
if (Array.isArray(value)) {
return value.every(isPlainData);
}
if (typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
(prototype === Object.prototype || prototype === null) &&
Reflect.ownKeys(value).every(
(key) => typeof key === "string" && isPlainData((value as Record<string, unknown>)[key]),
)
);
}
export function parseModuleSchemaMetadataDefinition(
value: unknown,
): ModuleSchemaMetadataDefinition {
if (!isPlainData(value)) {
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
}
const parsed = moduleSchemaMetadataDefinitionSchema.safeParse(value);
if (!parsed.success) {
throw new TypeError("Invalid Ekklesia module schema metadata definition.");
}
return Object.freeze({
moduleId: parsed.data.moduleId,
tables: Object.freeze(
parsed.data.tables.map((table) =>
Object.freeze({
foreignKeys: Object.freeze(
table.foreignKeys.map((foreignKey) => Object.freeze(foreignKey)),
),
name: table.name,
}),
),
),
});
}
@@ -0,0 +1,31 @@
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
export const moduleTableLocalNameSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
);
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
+7 -28
View File
@@ -1,19 +1,15 @@
import { is } from "drizzle-orm"; import { is } from "drizzle-orm";
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core"; import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js"; import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63; export {
moduleTableLocalNameSchema,
export const moduleTableLocalNameSchema = z moduleTableName,
.string() moduleTableNamespace,
.min(1) moduleTablePrefix,
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
POSTGRES_IDENTIFIER_MAX_LENGTH, POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`, physicalModuleTableNameSchema,
); } from "./module-schema-names.js";
export interface ModuleSchemaDefinition< export interface ModuleSchemaDefinition<
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>, Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
); );
} }
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition { export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
try { try {
if ( if (
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> { function validManifest(): Record<string, unknown> {
return { return {
manifestVersion: 7, manifestVersion: 8,
id: "calendar", id: "calendar",
name: "Calendar", name: "Calendar",
version: "0.1.0", version: "0.1.0",
@@ -42,6 +42,24 @@ function validManifest(): Record<string, unknown> {
} }
describe("module manifest contract", () => { describe("module manifest contract", () => {
it("accepts both manifest-v8 database modes", () => {
expect(parseModuleManifest(validManifest()).database).toBeUndefined();
expect(parseModuleManifest({ ...validManifest(), database: { abi: 1 } }).database).toEqual({
abi: 1,
});
});
it("rejects manifest v7, unsupported ABIs, malformed declarations, and unknown database fields", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 7 }).success).toBe(false);
expect(safeParseModuleManifest({ ...validManifest(), database: { abi: 2 } }).success).toBe(
false,
);
expect(safeParseModuleManifest({ ...validManifest(), database: "abi-v1" }).success).toBe(false);
expect(
safeParseModuleManifest({ ...validManifest(), database: { abi: 1, driver: "drizzle" } })
.success,
).toBe(false);
});
it("normalizes a valid manifest with safe defaults", () => { it("normalizes a valid manifest with safe defaults", () => {
const manifest = parseModuleManifest(validManifest()); const manifest = parseModuleManifest(validManifest());
@@ -232,15 +250,15 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version5Manifest).success).toBe(false); expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
}); });
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => { it("rejects a version 6 manifest rather than silently interpreting it as version 8", () => {
const version6Manifest = { ...validManifest(), manifestVersion: 6 }; const version6Manifest = { ...validManifest(), manifestVersion: 6 };
expect(safeParseModuleManifest(version6Manifest).success).toBe(false); expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
}); });
it("accepts exactly one manifest version, so no document can be read as two versions", () => { it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(7); expect(MODULE_MANIFEST_VERSION).toBe(8);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) { for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false); expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
} }
expect( expect(
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { parseModuleSchemaMetadataDefinition } from "../src/index.js";
describe("module schema metadata contract", () => {
it("accepts and freezes plain ABI-v1 metadata", () => {
const definition = parseModuleSchemaMetadataDefinition({
moduleId: "calendar",
tables: [
{ name: "calendar_events" },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
],
});
expect(definition).toEqual({
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] },
],
});
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(definition.tables[1]?.foreignKeys)).toBe(true);
});
it("strictly rejects malformed, executable, and class-backed metadata", () => {
class Metadata {
moduleId = "calendar";
tables = [{ name: "calendar_events" }];
}
for (const value of [
{ moduleId: "calendar", tables: [] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", unknown: true }] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", foreignKeys: [{}] }] },
{ moduleId: "calendar", tables: [{ name: "calendar_events", inspect: () => undefined }] },
new Metadata(),
]) {
expect(() => parseModuleSchemaMetadataDefinition(value)).toThrow(
"Invalid Ekklesia module schema metadata definition.",
);
}
});
});
+27
View File
@@ -3,7 +3,11 @@ import type {
ModuleDatabaseParameter, ModuleDatabaseParameter,
ModuleDatabaseQueryable, ModuleDatabaseQueryable,
ModuleDatabaseTransaction, ModuleDatabaseTransaction,
ModuleId,
ModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { parseModuleSchemaMetadataDefinition } from "@ekklesia/contracts";
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy"; import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
import type { DrizzleConfig } from "drizzle-orm/utils"; import type { DrizzleConfig } from "drizzle-orm/utils";
@@ -83,3 +87,26 @@ export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<s
executor.transaction((transaction) => callback(transactionDatabase(transaction, config))), executor.transaction((transaction) => callback(transactionDatabase(transaction, config))),
); );
} }
/** Derives the plain ABI-v1 schema entrypoint from a module's own Drizzle tables. Drizzle stays on
* this module-side adapter path; Core consumes only the returned strings and arrays. */
export function moduleDrizzleSchemaMetadata(
moduleId: ModuleId,
tables: Readonly<Record<string, AnyPgTable>>,
): ModuleSchemaMetadataDefinition {
const metadata = Object.entries(tables)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([_exportName, table]) => {
const config = getTableConfig(table);
if (config.schema !== undefined && config.schema !== "public") {
throw new TypeError("Module schema metadata supports only the public PostgreSQL schema.");
}
return {
name: config.name,
foreignKeys: config.foreignKeys.map((foreignKey) => ({
table: getTableConfig(foreignKey.reference().foreignTable).name,
})),
};
});
return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata });
}
@@ -20,10 +20,12 @@ describe("the module-side database architecture", () => {
}); });
it("keeps ABI v1 contracts free of ORM imports", async () => { it("keeps ABI v1 contracts free of ORM imports", async () => {
const source = await text("packages/contracts/src/module-database-abi.ts"); for (const file of ["module-database-abi.ts", "module-schema-metadata.ts"]) {
const source = await text(`packages/contracts/src/${file}`);
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u); expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
expect(source).not.toMatch(/(?:import|export).*kysely/u); expect(source).not.toMatch(/(?:import|export).*kysely/u);
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u); expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
}
}); });
it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => { it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => {
+24 -2
View File
@@ -8,7 +8,7 @@ import {
type ModuleDatabaseQueryResult, type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction, type ModuleDatabaseTransaction,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { and, eq, sql as drizzleSql } from "drizzle-orm"; import { and, sql as drizzleSql, eq } from "drizzle-orm";
import { import {
bigint, bigint,
customType, customType,
@@ -22,7 +22,11 @@ import {
timestamp, timestamp,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { describe, expect, expectTypeOf, it } from "vitest"; import { describe, expect, expectTypeOf, it } from "vitest";
import { moduleDrizzle, type ModuleDrizzleDatabase } from "../src/drizzle.js"; import {
type ModuleDrizzleDatabase,
moduleDrizzle,
moduleDrizzleSchemaMetadata,
} from "../src/drizzle.js";
import { findEkklesiaDatabaseError } from "../src/index.js"; import { findEkklesiaDatabaseError } from "../src/index.js";
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({ const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
@@ -159,6 +163,24 @@ function setup(...outcomes: Outcome[]) {
} }
describe("moduleDrizzle query compilation and ABI mapping", () => { describe("moduleDrizzle query compilation and ABI mapping", () => {
it("derives plain Ekklesia schema metadata with foreign-key targets", () => {
const child = pgTable("calendar_children", {
id: integer("id").primaryKey(),
recordId: integer("record_id").references(() => records.id),
});
const metadata = moduleDrizzleSchemaMetadata("calendar", { records, child });
expect(metadata).toEqual({
moduleId: "calendar",
tables: [
{ name: "calendar_children", foreignKeys: [{ table: "calendar_records" }] },
{ name: "calendar_records", foreignKeys: [] },
],
});
expect(JSON.parse(JSON.stringify(metadata))).toEqual(metadata);
});
it("compiles a select and decodes its positional result on the module side", async () => { it("compiles a select and decodes its positional result on the module side", async () => {
const { db, executor } = setup(result([[7, "Launch"]])); const { db, executor } = setup(result([[7, "Launch"]]));
await expect( await expect(
@@ -78,7 +78,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
const tableName = `${id.replaceAll("-", "_")}_records`; const tableName = `${id.replaceAll("-", "_")}_records`;
const baseManifest: Record<string, unknown> = { const baseManifest: Record<string, unknown> = {
manifestVersion: 7, manifestVersion: 8,
id, id,
name: `${id} module`, name: `${id} module`,
version, version,
@@ -1,5 +1,16 @@
import { type ModuleRuntimeDefinition, parseModuleRuntimeDefinition } from "@ekklesia/contracts"; import {
type LegacyModuleRuntimeDefinition,
type ModuleDatabaseAbiRuntimeDefinition,
type ModuleRuntimeDefinition,
parseModuleRuntimeDefinition,
} from "@ekklesia/contracts";
export function defineModuleRuntime<const Definition extends ModuleDatabaseAbiRuntimeDefinition>(
definition: Definition,
): Readonly<Definition>;
export function defineModuleRuntime<const Definition extends LegacyModuleRuntimeDefinition>(
definition: Definition,
): Readonly<Definition>;
export function defineModuleRuntime<const Definition extends ModuleRuntimeDefinition>( export function defineModuleRuntime<const Definition extends ModuleRuntimeDefinition>(
definition: Definition, definition: Definition,
): Readonly<Definition> { ): Readonly<Definition> {
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { import {
EMPTY_MODULE_CONFIGURATION,
defineModuleRuntime, defineModuleRuntime,
EMPTY_MODULE_CONFIGURATION,
isModuleHttpMethod, isModuleHttpMethod,
MODULE_HTTP_METHODS, MODULE_HTTP_METHODS,
type ModuleHttpRouteRegistrar, type ModuleHttpRouteRegistrar,
@@ -9,6 +9,42 @@ import {
} from "../src/index.js"; } from "../src/index.js";
describe("defineModuleRuntime", () => { describe("defineModuleRuntime", () => {
it("types each database mode without exposing a legacy-or-ABI union", () => {
defineModuleRuntime({
database: { abi: 1 },
id: "example",
version: "0.1.0",
start({ services }) {
expectTypeOf(services.database.query).toBeFunction();
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
// @ts-expect-error ABI-v1 modules never receive the legacy Drizzle facade.
services.database.select();
},
register({ http }) {
http.route({
handler: (_request, { services }) => {
expectTypeOf(services.database.abiVersion).toEqualTypeOf<1>();
return new Response();
},
maxBodyBytes: 0,
method: "GET",
path: "/",
permission: "example.read",
});
},
});
defineModuleRuntime({
id: "legacy",
version: "0.1.0",
start({ services }) {
expectTypeOf(services.database.select).toBeFunction();
// @ts-expect-error Legacy modules do not receive the ABI executor.
services.database.abiVersion;
},
});
});
it("defines a frozen server runtime without duplicating the static manifest", async () => { it("defines a frozen server runtime without duplicating the static manifest", async () => {
const start = vi.fn(); const start = vi.fn();
const runtime = defineModuleRuntime({ const runtime = defineModuleRuntime({
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest = const manifest =
options.manifest ?? options.manifest ??
JSON.stringify({ JSON.stringify({
manifestVersion: 7, manifestVersion: 8,
id: "example", id: "example",
name: "Example", name: "Example",
version: "0.1.0", version: "0.1.0",
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 7, "manifestVersion": 8,
"id": "example", "id": "example",
"name": "Example", "name": "Example",
"version": "0.1.0", "version": "0.1.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"manifestVersion": 7, "manifestVersion": 8,
"id": "example", "id": "example",
"name": "Example Module", "name": "Example Module",
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.", "description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",