feat: add module database access #13
@@ -7,8 +7,8 @@ surfaces.
|
||||
## Current status
|
||||
|
||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
||||
Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
|
||||
repository includes
|
||||
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
|
||||
transport), and Phase 5C (module database access) have landed. The repository includes
|
||||
public module contracts, independently versioned capabilities, static module manifests,
|
||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||
@@ -80,6 +80,17 @@ See [API](docs/development/api.md#module-http-routes),
|
||||
[Module development](docs/module-development/README.md#http-routes), and
|
||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
||||
|
||||
Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own
|
||||
PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over
|
||||
Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/
|
||||
`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`,
|
||||
`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already
|
||||
owns; registration remains declarative and database-free. Modules continue to own and import their
|
||||
own Drizzle schemas and generated migrations, applied explicitly and never automatically at
|
||||
startup. See [API](docs/development/api.md#module-database-access),
|
||||
[Module development](docs/module-development/README.md#database-access), and
|
||||
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
+11
-1
@@ -1,4 +1,8 @@
|
||||
import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts";
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
AuthorizationEvaluator,
|
||||
AuthorizationStore,
|
||||
@@ -28,6 +32,11 @@ export interface CreateApiAppOptions {
|
||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||
* implicit fallback. */
|
||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
||||
/** The same frozen services object the caller's started module runtime already passed to every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||
readonly moduleServices: ModuleRuntimeServices;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
@@ -147,6 +156,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
services: options.moduleServices,
|
||||
trustedOrigins: allowedOrigins,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
readonly module: ModuleDatabase;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
|
||||
* closes the pool in this composition — `close()` here is the only thing that ever calls
|
||||
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
|
||||
* available, unaffected, and independently testable for callers that only need one domain.
|
||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
||||
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
||||
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||
* for callers that only need one domain.
|
||||
*/
|
||||
export function createApiDatabase(
|
||||
databaseUrl: string,
|
||||
@@ -32,5 +36,6 @@ export function createApiDatabase(
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ModuleId,
|
||||
ModuleRouteHandlerContext,
|
||||
ModuleRuntimeServices,
|
||||
RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { Context, Hono } from "hono";
|
||||
@@ -24,6 +25,11 @@ const STRIPPED_CORS_RESPONSE_HEADERS = [
|
||||
|
||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** The same frozen services object the started module runtime's `start`/`stop` hooks already
|
||||
* 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
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
/** Never carries the offending permission id, route path, module id, or any other configuration
|
||||
@@ -149,7 +155,11 @@ function sanitizeModuleResponse(response: Response): Response {
|
||||
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
|
||||
* response body afterward — there is no separate `HEAD` registration or permission path.
|
||||
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
|
||||
* frozen context — never a Hono `Context`, database, store, or configuration.
|
||||
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono
|
||||
* `Context`, authorization store, or configuration; `services.database` is the module's only
|
||||
* reachable database surface, and is unreachable to anything short of a successfully authorized
|
||||
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
|
||||
* this handler at all).
|
||||
*
|
||||
* Because the routes and registry both derive from the same discovered inventory
|
||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||
@@ -183,6 +193,7 @@ export function mountModuleHttpRoutes(
|
||||
moduleId: route.moduleId,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
services: options.services,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
|
||||
+28
-18
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
@@ -84,12 +85,15 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* 1. Discover every installed module's manifest exactly once and compose the one immutable
|
||||
* platform permission registry from Core's own declarations plus every module's
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
||||
* Drizzle view over it — see `./database.js`).
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
* 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`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 4. Start the module runtime from that exact same discovered inventory
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||
* same services object every module's `start`/`stop` hook receives.
|
||||
* 5. Create the Hono application, mounting the started module runtime's published routes and
|
||||
* passing the exact same services object to every module HTTP handler.
|
||||
* 6. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
@@ -97,12 +101,13 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
||||
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
|
||||
* identity, authorization) exists before any executable module lifecycle code runs, and every
|
||||
* module route is published only after the entire module runtime has registered and started
|
||||
* successfully, so no request can ever reach a module before every module has started. Returns a
|
||||
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* module database facade, identity, authorization) exists before any executable module lifecycle
|
||||
* code runs, and every module route is published only after the entire module runtime has
|
||||
* registered and started successfully, so no request can ever reach a module before every module
|
||||
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||
* applies migrations, generates schema, or issues a module database query merely because modules
|
||||
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
@@ -122,9 +127,11 @@ export async function startApi(
|
||||
operationTimeoutMs,
|
||||
});
|
||||
|
||||
let services: ModuleRuntimeServices;
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
try {
|
||||
services = Object.freeze({ database: databaseHandle.module });
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
@@ -146,7 +153,7 @@ export async function startApi(
|
||||
// handle to stop here, only the pool this failure leaves open.
|
||||
let moduleRuntime: StartedModuleRuntime;
|
||||
try {
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
|
||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
@@ -161,6 +168,7 @@ export async function startApi(
|
||||
store: authorizationStore,
|
||||
},
|
||||
moduleRoutes: moduleRuntime.routes,
|
||||
moduleServices: services,
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
@@ -184,12 +192,14 @@ export async function startApi(
|
||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||
// observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
|
||||
// closed first so no new or in-flight request can reach a module handler while the module
|
||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
||||
// stop failure never leaves the pool open.
|
||||
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
||||
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
|
||||
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
|
||||
// database cleanup still always runs first, so a stop failure never leaves the pool open.
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
@@ -21,6 +26,22 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
* never real database behavior, so no PostgreSQL is required. */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. The registry
|
||||
* defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the
|
||||
@@ -53,6 +74,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -69,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -86,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -105,6 +129,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -126,6 +151,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -147,6 +173,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -165,6 +192,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -182,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -196,6 +225,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -212,6 +242,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -227,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -240,6 +272,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -260,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -283,6 +317,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -309,6 +344,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
};
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
registry: emptyRegistry,
|
||||
@@ -326,6 +362,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -111,6 +111,19 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
|
||||
* reference (such as `context.services.database`) rather than a string label, so a test can prove
|
||||
* two values pushed from different, independently dynamically-imported invocations (a module's own
|
||||
* `start` hook and one of its HTTP handlers) are the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const values: unknown[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||
eventSymbols.push(symbol);
|
||||
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
interface WorkingFixtureModuleOptions {
|
||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||
readonly withRoute?: boolean;
|
||||
@@ -659,6 +672,47 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-identity", {
|
||||
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
|
||||
});
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-identity",
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: "db-identity.ping.read",
|
||||
handler: async (_request, context) => {
|
||||
${refs.expression}.push(context.services.database);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
},
|
||||
start(context) {
|
||||
${refs.expression}.push(context.services.database);
|
||||
}
|
||||
};`,
|
||||
);
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
const response = await running.app.request("/api/v1/modules/db-identity/ping");
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "broken-export");
|
||||
@@ -789,6 +843,31 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||
});
|
||||
|
||||
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-stop-identity");
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-stop-identity",
|
||||
version: "0.1.0",
|
||||
start(context) { ${refs.expression}.push(context.services.database); },
|
||||
stop(context) { ${refs.expression}.push(context.services.database); }
|
||||
};`,
|
||||
);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
await running.shutdown();
|
||||
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import { startApi, type RunningApi } from "../src/server.js";
|
||||
|
||||
/**
|
||||
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
||||
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
|
||||
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
|
||||
* transaction, execute — against its own migrated table, exclusively through the facade the
|
||||
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
|
||||
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
|
||||
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
|
||||
* running API across related assertions) rather than introducing a second integration-test
|
||||
* framework or Docker orchestration path.
|
||||
*
|
||||
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
|
||||
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
|
||||
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
|
||||
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
|
||||
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
|
||||
* regardless of which package's test actually points discovery at it. It is never registered as a
|
||||
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
|
||||
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
|
||||
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
|
||||
* `pnpm modules:validate` never see it.
|
||||
*/
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
|
||||
const fixtureModulesDirectory = path.join(
|
||||
thisDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"core",
|
||||
"tests",
|
||||
"fixtures",
|
||||
"module-database-integration",
|
||||
);
|
||||
|
||||
const RECORDS_TABLE = "db_facade_records";
|
||||
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
|
||||
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
|
||||
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
const identityBaseUrl = "https://identity.example.test";
|
||||
const dashboardOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The module database integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The module database integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
let databaseCounter = 0;
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure remains static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The module database integration connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
const trackedDatabases = new Set<string>();
|
||||
|
||||
async function createDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
|
||||
[databaseName],
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
|
||||
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
|
||||
);
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
|
||||
* before any module runtime exists — proving the platform never infers or auto-generates this
|
||||
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
|
||||
* was built. */
|
||||
async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: fixtureModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
);
|
||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||
);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
url: string,
|
||||
modulesDirectoryOverride?: string,
|
||||
): Promise<RunningApi> {
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
return startApi(config, {
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
||||
});
|
||||
}
|
||||
|
||||
function apiOrigin(running: RunningApi): string {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The API integration server has no bound address.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
interface SignUpResult {
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { user: { id: string } };
|
||||
return { userId: body.user.id };
|
||||
}
|
||||
|
||||
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email, password }),
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
if (!setCookie) {
|
||||
throw harnessError("Sign-in did not return a session cookie.");
|
||||
}
|
||||
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
interface RecordedEvent {
|
||||
readonly type: "register" | "start" | "stop" | "handler";
|
||||
readonly services?: { readonly database: unknown };
|
||||
readonly route?: string;
|
||||
readonly contextKeys?: readonly string[];
|
||||
}
|
||||
|
||||
function globalRecord(): Record<string, unknown> {
|
||||
return globalThis as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resetRecorder(): void {
|
||||
globalRecord()[RECORDER_GLOBAL_KEY] = [];
|
||||
}
|
||||
|
||||
function readRecorder(): RecordedEvent[] {
|
||||
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
async function tableExists(url: string): Promise<boolean> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ exists: boolean }>(
|
||||
admin,
|
||||
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
|
||||
[RECORDS_TABLE],
|
||||
);
|
||||
return rows[0]?.exists ?? false;
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecordRow(
|
||||
url: string,
|
||||
id: string,
|
||||
): Promise<{ id: string; value: string } | undefined> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ id: string; value: string }>(
|
||||
admin,
|
||||
`select id, value from "${RECORDS_TABLE}" where id = $1`,
|
||||
[id],
|
||||
);
|
||||
return rows[0];
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordExists(url: string, id: string): Promise<boolean> {
|
||||
return (await fetchRecordRow(url, id)) !== undefined;
|
||||
}
|
||||
|
||||
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
|
||||
const admin = await openAdministrativeClient(url);
|
||||
try {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
|
||||
[identity],
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
delete globalRecord()[RECORDER_GLOBAL_KEY];
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live API — module database explicit migration ownership", () => {
|
||||
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
|
||||
const database = await createDisposableDatabase("mig_absent");
|
||||
try {
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
await applyCoreMigrations(database.url);
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
const handle = createApiDatabase(database.url);
|
||||
expect(handle.module).toBeDefined();
|
||||
await handle.close();
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
await applyMigrationsWithModule(database.url);
|
||||
expect(await tableExists(database.url)).toBe(true);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
|
||||
const database = await createDisposableDatabase("mig_once");
|
||||
try {
|
||||
await applyMigrationsWithModule(database.url);
|
||||
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||
|
||||
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
await first.shutdown();
|
||||
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
await second.shutdown();
|
||||
|
||||
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
||||
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
|
||||
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
|
||||
const database = await createDisposableDatabase("mig_skip");
|
||||
try {
|
||||
await applyCoreMigrations(database.url);
|
||||
resetRecorder();
|
||||
|
||||
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
|
||||
issues: [expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" })],
|
||||
});
|
||||
|
||||
// No schema inference, no automatic table creation as a side effect of the failed attempt.
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ownerCookie: string;
|
||||
|
||||
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
|
||||
database = await createDisposableDatabase("db_facade");
|
||||
await applyMigrationsWithModule(database.url);
|
||||
resetRecorder();
|
||||
|
||||
running = await startMigratedApi(database.url, fixtureModulesDirectory);
|
||||
origin = apiOrigin(running);
|
||||
|
||||
const events = readRecorder();
|
||||
const registerEvents = events.filter((event) => event.type === "register");
|
||||
const startEvents = events.filter((event) => event.type === "start");
|
||||
expect(registerEvents).toHaveLength(1);
|
||||
expect(registerEvents[0]?.services).toBeUndefined();
|
||||
expect(startEvents).toHaveLength(1);
|
||||
expect(startEvents[0]?.services?.database).toBeDefined();
|
||||
|
||||
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
|
||||
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||
expect(signedUp.userId).toBeDefined();
|
||||
});
|
||||
|
||||
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||
|
||||
const events = readRecorder();
|
||||
const startEvent = events.find((event) => event.type === "start");
|
||||
const readHandlerEvent = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
||||
expect(readHandlerEvent?.contextKeys).toEqual(["moduleId", "params", "principal", "services"]);
|
||||
expect(readHandlerEvent?.services).toBeDefined();
|
||||
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
||||
});
|
||||
|
||||
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
|
||||
const id = randomUUID();
|
||||
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "no-origin" }),
|
||||
headers: { "content-type": "application/json", cookie: ownerCookie },
|
||||
method: "POST",
|
||||
});
|
||||
expect(noOrigin.status).toBe(403);
|
||||
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
|
||||
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "created-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(await created.json()).toEqual({ id, value: "created-value" });
|
||||
|
||||
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
|
||||
expect(readBack.status).toBe(200);
|
||||
expect(await readBack.json()).toEqual({ id, value: "created-value" });
|
||||
|
||||
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
|
||||
body: JSON.stringify({ value: "patched-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
expect(patched.status).toBe(200);
|
||||
expect(await patched.json()).toEqual({ id, value: "patched-value" });
|
||||
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
|
||||
});
|
||||
|
||||
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
body: JSON.stringify({ id, value: "should-not-persist" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: untrustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
|
||||
const id = randomUUID();
|
||||
const eventsBefore = readRecorder().length;
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
||||
headers: {
|
||||
"access-control-request-method": "POST",
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
||||
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
|
||||
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
||||
expect(readRecorder().length).toBe(eventsBefore);
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
|
||||
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(anonymousHead.status).toBe(403);
|
||||
expect(await anonymousHead.text()).toBe("");
|
||||
|
||||
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(authorizedHead.status).toBe(200);
|
||||
expect(await authorizedHead.text()).toBe("");
|
||||
|
||||
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
|
||||
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
it("commits a real transaction and persists its result", async () => {
|
||||
const id = randomUUID();
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||
body: JSON.stringify({ id, value: "committed-value" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ committed: true });
|
||||
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
|
||||
});
|
||||
|
||||
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
|
||||
const id = randomUUID();
|
||||
const conflictId = randomUUID();
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
||||
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({ committed: false });
|
||||
|
||||
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
|
||||
expect(await recordExists(database.url, id)).toBe(false);
|
||||
expect(await recordExists(database.url, conflictId)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
|
||||
});
|
||||
|
||||
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
|
||||
const lowered = text.toLowerCase();
|
||||
for (const forbidden of [
|
||||
"division",
|
||||
"sql",
|
||||
"postgres",
|
||||
"22012",
|
||||
"select",
|
||||
"relation",
|
||||
"stack",
|
||||
"node_modules",
|
||||
fixtureModulesDirectory.toLowerCase(),
|
||||
]) {
|
||||
expect(lowered).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
|
||||
const events = readRecorder();
|
||||
const startEvent = events.find((event) => event.type === "start");
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
const eventsAfterShutdown = readRecorder();
|
||||
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
|
||||
expect(stopEvents).toHaveLength(1);
|
||||
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
|
||||
|
||||
// Read via a separate administrative connection, entirely independent of the API's own
|
||||
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
|
||||
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
||||
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
|
||||
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
|
||||
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
||||
headers: { cookie },
|
||||
});
|
||||
const body = (await response.json()) as { count: number };
|
||||
return body.count;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
@@ -51,24 +53,56 @@ function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRou
|
||||
return Object.freeze({ ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function buildModuleApp(options: {
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly services?: ModuleRuntimeServices;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const services = options.services ?? fakeModuleServices();
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||
moduleRoutes: options.routes,
|
||||
moduleServices: services,
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
return { app, evaluator, runtime, services };
|
||||
}
|
||||
|
||||
function permissionRegistry(
|
||||
@@ -262,6 +296,7 @@ describe("module route handler adaptation", () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const services = fakeModuleServices();
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
@@ -274,6 +309,7 @@ describe("module route handler adaptation", () => {
|
||||
}),
|
||||
],
|
||||
runtime,
|
||||
services,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
||||
@@ -288,13 +324,25 @@ describe("module route handler adaptation", () => {
|
||||
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||
expect(capturedRequest).toBeInstanceOf(Request);
|
||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
||||
expect(capturedContext).toEqual({
|
||||
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
||||
// or configuration reaches a module handler.
|
||||
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
||||
"moduleId",
|
||||
"params",
|
||||
"principal",
|
||||
"services",
|
||||
]);
|
||||
expect(capturedContext).toMatchObject({
|
||||
moduleId: "calendar",
|
||||
params: { eventId: "evt-1" },
|
||||
principal: { kind: "authenticated", userId: "user_1" },
|
||||
});
|
||||
// Reference-identical to the exact services object the caller passed in — never a fresh one
|
||||
// built per request.
|
||||
expect(capturedContext?.services).toBe(services);
|
||||
expect(Object.isFrozen(capturedContext)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
||||
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives static and root routes a frozen, empty params object", async () => {
|
||||
@@ -394,12 +442,12 @@ describe("module route authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when authorization denies", async () => {
|
||||
it("does not invoke the handler or touch the module database when authorization denies", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
@@ -417,6 +465,7 @@ describe("module route authorization", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
@@ -518,7 +567,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: methods.map((method) =>
|
||||
@@ -545,6 +594,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
@@ -647,12 +697,12 @@ describe("module route CORS", () => {
|
||||
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never invokes authorization or the handler for an OPTIONS preflight", async () => {
|
||||
it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
const { app, services } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
@@ -673,6 +723,7 @@ describe("module route CORS", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||
* `createAuthorizationDrizzleView` (`core/src/authorization/database.ts`): the same "one pool, one
|
||||
* typed view per domain" composition, extended to a third domain. Never constructs, registers an
|
||||
* error listener on, or closes a pool itself — the caller (`apps/api/src/database.ts`) retains that
|
||||
* responsibility exactly once, regardless of how many typed views it creates over the same pool.
|
||||
*
|
||||
* Carries no schema, unlike the identity and authorization views: a module brings its own Drizzle
|
||||
* table objects to each call rather than selecting against a schema this facade would otherwise
|
||||
* have to know about, so there is no `TFullSchema` for `drizzle(...)` to accept here. Building this
|
||||
* view performs no query and no migration — `drizzle(pool)` only wraps the given client, the same
|
||||
* synchronous, I/O-free construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
|
||||
* already rely on.
|
||||
*
|
||||
* Deliberately returns a fresh object with each operation explicitly bound to the underlying
|
||||
* Drizzle instance, rather than the `drizzle(pool)` instance itself: Drizzle's own query methods
|
||||
* are plain prototype methods that read `this.session`/`this.dialect` internally (not
|
||||
* arrow-function instance properties), so handing that instance straight to a module would both
|
||||
* break the moment a module destructured a method off it (`const { select } = database` loses
|
||||
* `this`) and, more importantly, would leave every non-public property (`$client`, `_`, `query`,
|
||||
* `$cache`, `with`) physically reachable on the object at runtime — reachable to a module written
|
||||
* in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not just hidden
|
||||
* by TypeScript. Building this literal, `Object.freeze`d object closes both gaps: only the eight
|
||||
* intended operations exist on it at all, each correctly bound, and nothing else does.
|
||||
*/
|
||||
export function createModuleDatabaseView(pool: Pool): ModuleDatabase {
|
||||
const db = drizzle(pool);
|
||||
return Object.freeze({
|
||||
delete: db.delete.bind(db),
|
||||
execute: db.execute.bind(db),
|
||||
insert: db.insert.bind(db),
|
||||
select: db.select.bind(db),
|
||||
selectDistinct: db.selectDistinct.bind(db),
|
||||
selectDistinctOn: db.selectDistinctOn.bind(db),
|
||||
transaction: db.transaction.bind(db),
|
||||
update: db.update.bind(db),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
type ModuleRuntimeServices,
|
||||
moduleRouteShapeKey,
|
||||
parseModuleRuntimeDefinition,
|
||||
type RegisteredCapability,
|
||||
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
readonly definition: ModuleRuntimeDefinition;
|
||||
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
|
||||
createRuntimeContext(
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
): ModuleRuntimeContext {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
|
||||
return this.#registered.get(capabilityId);
|
||||
},
|
||||
});
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id });
|
||||
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||
}
|
||||
|
||||
publish(): CapabilityRegistry {
|
||||
@@ -466,11 +473,14 @@ async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
for (const module of [...modules].reverse()) {
|
||||
try {
|
||||
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
|
||||
await module.definition.stop?.(
|
||||
registry.createRuntimeContext(module.discovered, phase, services),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
issues.push(error.issue);
|
||||
@@ -492,11 +502,14 @@ async function stopModules(
|
||||
async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<void> {
|
||||
const started: LoadedModule[] = [];
|
||||
for (const module of modules) {
|
||||
try {
|
||||
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
|
||||
await module.definition.start?.(
|
||||
registry.createRuntimeContext(module.discovered, "start", services),
|
||||
);
|
||||
started.push(module);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
@@ -508,7 +521,7 @@ async function startModules(
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback");
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback", services);
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
}
|
||||
}
|
||||
@@ -524,12 +537,13 @@ async function startModules(
|
||||
*/
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||
await startModules(modules, capabilityRegistry);
|
||||
await startModules(modules, capabilityRegistry, services);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop");
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
|
||||
options: StartModuleRuntimeOptions,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services);
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "db_facade_records" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"value" text NOT NULL
|
||||
);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "8764dbfb-a411-4727-a382-530e4cdf3a2c",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.db_facade_records": {
|
||||
"name": "db_facade_records",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"moduleId": "db-facade",
|
||||
"entries": [
|
||||
{
|
||||
"tag": "0000_initial",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1700000000000,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"manifestVersion": 3,
|
||||
"id": "db-facade",
|
||||
"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.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"id": "db-facade.records.read",
|
||||
"baseline": "public",
|
||||
"description": "Read database facade test records."
|
||||
},
|
||||
{
|
||||
"id": "db-facade.records.write",
|
||||
"baseline": "authenticated",
|
||||
"description": "Write database facade test records."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./server/index.js",
|
||||
"schema": "./schema/index.js",
|
||||
"migrations": "./drizzle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@test/module-db-facade",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"ekklesia": {
|
||||
"layer": "module"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { moduleTableName } from "@ekklesia/contracts";
|
||||
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
const moduleTable = pgTableCreator((localName) => moduleTableName("db-facade", localName));
|
||||
|
||||
export const records = moduleTable("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export default {
|
||||
moduleId: "db-facade",
|
||||
tables: { records },
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { records } from "../schema/index.js";
|
||||
|
||||
// A fixed (not per-run-random) globalThis symbol key: this fixture is a static, committed file
|
||||
// (not a per-test generated template string), so it cannot bake a unique key into itself the way
|
||||
// every other dynamically-written fixture in this codebase does. A fixed key is safe here because
|
||||
// the PostgreSQL integration suite never runs its files in parallel (`fileParallelism: false`,
|
||||
// `vitest.migrations.integration.config.ts`), so there is never more than one active recorder at a
|
||||
// time. The test file resets this array immediately before each scenario and deletes the property
|
||||
// during cleanup.
|
||||
const RECORDER_KEY = "ekklesia.integration.db-facade.recorder";
|
||||
|
||||
// A fixed row id `start()` writes and `stop()` later updates, so both the integration test and the
|
||||
// module's own HTTP handlers can address the exact same row deterministically, without needing a
|
||||
// side channel to learn a generated id.
|
||||
export const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
function recordEvent(event) {
|
||||
const globalValues = globalThis;
|
||||
if (!Array.isArray(globalValues[RECORDER_KEY])) {
|
||||
globalValues[RECORDER_KEY] = [];
|
||||
}
|
||||
globalValues[RECORDER_KEY].push(event);
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "db-facade",
|
||||
version: "0.1.0",
|
||||
|
||||
register({ http }) {
|
||||
// Registration is declarative and database-free: there is no `services` field on this
|
||||
// context at all (see `ModuleRegistrationContext`), so `context.services` reads as
|
||||
// `undefined` here — recorded for the integration test to assert on directly.
|
||||
recordEvent({ services: undefined, type: "register" });
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/records/:id",
|
||||
permission: "db-facade.records.read",
|
||||
async handler(_request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "GET /records/:id",
|
||||
});
|
||||
const [row] = await context.services.database
|
||||
.select({ id: records.id, value: records.value })
|
||||
.from(records)
|
||||
.where(eq(records.id, context.params.id));
|
||||
if (!row) {
|
||||
return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return Response.json(row, { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/records-count",
|
||||
// Deliberately the "write" (authenticated) permission, not "read" (public) — this is the
|
||||
// fixture's one authenticated-baseline GET route, used to prove HEAD cannot bypass
|
||||
// authorization the way it could if HEAD skipped the same middleware chain GET uses.
|
||||
permission: "db-facade.records.write",
|
||||
async handler(_request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "GET /records-count",
|
||||
});
|
||||
const result = await context.services.database.execute(
|
||||
sql`select count(*)::int as count from ${records}`,
|
||||
);
|
||||
const row = Array.isArray(result) ? result[0] : result.rows?.[0];
|
||||
return Response.json({ count: row?.count ?? 0 }, { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/records",
|
||||
permission: "db-facade.records.write",
|
||||
async handler(request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "POST /records",
|
||||
});
|
||||
const { id, value } = await request.json();
|
||||
await context.services.database.insert(records).values({ id, value });
|
||||
return Response.json({ id, value }, { status: 201 });
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "PATCH",
|
||||
path: "/records/:id",
|
||||
permission: "db-facade.records.write",
|
||||
async handler(request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "PATCH /records/:id",
|
||||
});
|
||||
const { value } = await request.json();
|
||||
const updated = await context.services.database
|
||||
.update(records)
|
||||
.set({ value })
|
||||
.where(eq(records.id, context.params.id))
|
||||
.returning({ id: records.id, value: records.value });
|
||||
if (updated.length === 0) {
|
||||
return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return Response.json(updated[0], { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/records/transactional",
|
||||
permission: "db-facade.records.write",
|
||||
async handler(request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "POST /records/transactional",
|
||||
});
|
||||
const { id, value, conflictId } = await request.json();
|
||||
try {
|
||||
await context.services.database.transaction(async (tx) => {
|
||||
await tx.insert(records).values({ id, value });
|
||||
if (conflictId) {
|
||||
// The same primary key inserted twice inside one transaction: PostgreSQL rejects
|
||||
// the second insert with a unique-violation, and the whole transaction — including
|
||||
// the first, otherwise-valid insert above — must roll back atomically.
|
||||
await tx.insert(records).values({ id: conflictId, value: "conflict" });
|
||||
await tx.insert(records).values({ id: conflictId, value: "conflict-again" });
|
||||
}
|
||||
});
|
||||
return Response.json({ committed: true }, { status: 201 });
|
||||
} catch {
|
||||
return Response.json({ committed: false }, { status: 409 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/boom",
|
||||
permission: "db-facade.records.read",
|
||||
async handler(_request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "GET /boom",
|
||||
});
|
||||
// Deliberately uncaught: a real PostgreSQL error (division by zero) must escape this
|
||||
// handler so the platform's own sanitization boundary is exercised end to end.
|
||||
await context.services.database.execute(sql`select 1/0`);
|
||||
return Response.json({ unreachable: true });
|
||||
},
|
||||
});
|
||||
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/safe-boom",
|
||||
permission: "db-facade.records.read",
|
||||
async handler(_request, context) {
|
||||
recordEvent({
|
||||
contextKeys: Object.keys(context).sort(),
|
||||
services: context.services,
|
||||
type: "handler",
|
||||
route: "GET /safe-boom",
|
||||
});
|
||||
try {
|
||||
await context.services.database.execute(sql`select 1/0`);
|
||||
return Response.json({ unreachable: true });
|
||||
} catch {
|
||||
// A module deliberately catching its own database failure and returning a valid,
|
||||
// sanitized response of its own choosing — the platform must preserve this untouched.
|
||||
return Response.json({ code: "BOOM_HANDLED" }, { status: 409 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async start(context) {
|
||||
recordEvent({ services: context.services, type: "start" });
|
||||
// `onConflictDoNothing` makes repeated startups against an already-started database safe —
|
||||
// a real module's own idempotency choice, not a platform requirement — so a test can start
|
||||
// the same migrated database more than once without `start()` itself failing on the fixed
|
||||
// `STARTUP_RECORD_ID` row a previous run already inserted.
|
||||
await context.services.database
|
||||
.insert(records)
|
||||
.values({ id: STARTUP_RECORD_ID, value: "startup-value" })
|
||||
.onConflictDoNothing();
|
||||
},
|
||||
|
||||
async stop(context) {
|
||||
recordEvent({ services: context.services, type: "stop" });
|
||||
await context.services.database
|
||||
.update(records)
|
||||
.set({ value: "stopped-by-shutdown" })
|
||||
.where(eq(records.id, STARTUP_RECORD_ID));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createModuleDatabaseView } from "../src/modules/database.js";
|
||||
|
||||
const testTable = pgTable("core_module_database_test", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
|
||||
function fakePool(): Pool {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
end: vi.fn(),
|
||||
on: vi.fn(),
|
||||
query: vi.fn(),
|
||||
} as unknown as Pool;
|
||||
}
|
||||
|
||||
describe("createModuleDatabaseView", () => {
|
||||
it("wraps an externally owned pool without constructing, connecting to, registering an error listener on, or closing one itself", () => {
|
||||
const pool = fakePool();
|
||||
|
||||
const database = createModuleDatabaseView(pool);
|
||||
|
||||
expect(database).toBeDefined();
|
||||
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.end)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.on)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(pool.query)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes exactly the intended query surface, frozen, with no raw pool/client/session/dialect escape hatch", () => {
|
||||
const database = createModuleDatabaseView(fakePool());
|
||||
|
||||
expect(Object.keys(database).sort()).toEqual([
|
||||
"delete",
|
||||
"execute",
|
||||
"insert",
|
||||
"select",
|
||||
"selectDistinct",
|
||||
"selectDistinctOn",
|
||||
"transaction",
|
||||
"update",
|
||||
]);
|
||||
expect(Object.isFrozen(database)).toBe(true);
|
||||
for (const forbidden of ["$client", "_", "query", "session", "dialect", "pool", "with"]) {
|
||||
expect(forbidden in database).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every forwarded operation correctly bound to its underlying Drizzle instance, even when destructured off the facade", async () => {
|
||||
const database = createModuleDatabaseView(fakePool());
|
||||
const {
|
||||
select,
|
||||
selectDistinct,
|
||||
selectDistinctOn,
|
||||
insert,
|
||||
update,
|
||||
delete: remove,
|
||||
execute,
|
||||
transaction,
|
||||
} = database;
|
||||
|
||||
// Each of these synchronously builds a query object from `this.session`/`this.dialect` without
|
||||
// performing I/O — if the forwarded method lost its `this` binding when destructured (a real JS
|
||||
// pitfall for plain prototype methods), the very first property access inside would throw.
|
||||
expect(() => select()).not.toThrow();
|
||||
expect(() => selectDistinct()).not.toThrow();
|
||||
expect(() => selectDistinctOn([testTable.name])).not.toThrow();
|
||||
expect(() => insert(testTable)).not.toThrow();
|
||||
expect(() => update(testTable)).not.toThrow();
|
||||
expect(() => remove(testTable)).not.toThrow();
|
||||
expect(() => execute("select 1")).not.toThrow();
|
||||
|
||||
let transactionResult: unknown;
|
||||
expect(() => {
|
||||
transactionResult = transaction(async () => undefined);
|
||||
}).not.toThrow();
|
||||
expect(transactionResult).toBeInstanceOf(Promise);
|
||||
// The fake pool cannot complete a real transaction; only the synchronous binding matters here.
|
||||
await Promise.resolve(transactionResult).catch(() => undefined);
|
||||
});
|
||||
|
||||
it("never constructs a second pool — the given pool instance is the only one ever touched", () => {
|
||||
const pool = fakePool();
|
||||
|
||||
createModuleDatabaseView(pool);
|
||||
createModuleDatabaseView(pool);
|
||||
|
||||
// Two views built from the same pool never allocate a new connection — there is nothing on
|
||||
// `pool` for either call to have invoked at all (see the construction test above), so calling
|
||||
// this twice is itself the proof that no per-call pool allocation ever happens.
|
||||
expect(vi.mocked(pool.connect)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import {
|
||||
@@ -108,11 +109,50 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
|
||||
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
|
||||
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
|
||||
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.runtime.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const values: unknown[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||
eventSymbols.push(symbol);
|
||||
return {
|
||||
values,
|
||||
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
|
||||
* ever assert on context wiring and object identity, never on actual query behavior. */
|
||||
function fakeModuleDatabase(): ModuleDatabase {
|
||||
return Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered);
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||
await runtime.stop();
|
||||
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
[...discovered].reverse(),
|
||||
fakeModuleRuntimeServices(),
|
||||
);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||
await runtime.stop();
|
||||
});
|
||||
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
|
||||
await runtime.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const registerServices = createReferenceRecorder();
|
||||
const lifecycleServices = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "wired", {
|
||||
serverSource: `export default {
|
||||
id: "wired", version: "0.1.0",
|
||||
register(context) { ${registerServices.expression}.push(context.services); },
|
||||
start(context) { ${lifecycleServices.expression}.push(context.services); },
|
||||
stop(context) { ${lifecycleServices.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||
serverSource: `export default {
|
||||
id: "alpha", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "beta", {
|
||||
manifest: manifestFor("beta", {
|
||||
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "beta", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("gives a rollback stop hook the same services object a start failure left available", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "started", {
|
||||
serverSource: `export default {
|
||||
id: "started", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); },
|
||||
stop(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "failing", {
|
||||
manifest: manifestFor("failing", {
|
||||
dependencies: { modules: { required: { started: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "failing", version: "0.1.0",
|
||||
start() { throw new Error("boom"); }
|
||||
};`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+7
-5
@@ -28,14 +28,16 @@ registry, which conflicts with install-by-cloning discovery.
|
||||
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
|
||||
> historical record of the manifest's prior shape.
|
||||
|
||||
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
|
||||
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md) and
|
||||
> [ADR 0016](0016-module-database-access.md).**
|
||||
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
|
||||
> contain only the module ID and a capability registrar or resolver" (this section, below) described
|
||||
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
|
||||
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
|
||||
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
|
||||
> limited to module ID and capability access as originally decided here. Every other statement in
|
||||
> this ADR remains accepted and unchanged.
|
||||
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016
|
||||
> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes)
|
||||
> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself
|
||||
> gains no such field and remains exactly as database-free as originally decided here. Every other
|
||||
> statement in this ADR remains accepted and unchanged.
|
||||
|
||||
`ekklesia.module.json` remains the one canonical static manifest and moves to
|
||||
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
||||
|
||||
@@ -162,6 +162,16 @@ configured trusted origin — never a wildcard, and never anything module-influe
|
||||
|
||||
### A frozen, minimal handler context and sanitized failure boundary
|
||||
|
||||
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
|
||||
> "A frozen context of exactly `moduleId`... and an immutable, Hono-decoded parameter record" and "A
|
||||
> handler never receives a Hono `Context`, a database connection... or any other middleware-internal
|
||||
> state," below, described the handler context's complete shape accurately at the time. ADR 0016
|
||||
> adds a fourth field, `services`, carrying `ModuleRuntimeServices` (currently just a typed
|
||||
> `ModuleDatabase` facade) — never a raw database connection, `pg.Pool`, `PoolClient`, connection
|
||||
> string, or other infrastructure object. Every other statement in this section remains accepted and
|
||||
> unchanged: a handler still never receives a Hono `Context`, the authorization store or evaluator,
|
||||
> raw PostgreSQL access, configuration, or secrets.
|
||||
|
||||
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
|
||||
body, and a streaming request all behave normally, and the full query string remains available
|
||||
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
|
||||
@@ -276,6 +286,10 @@ runs, since nothing about this design sandboxes it.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
|
||||
> Module database or Drizzle access, listed immediately below as deferred, is now implemented.
|
||||
> Every other item in this list remains deferred and unimplemented.
|
||||
|
||||
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
|
||||
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
|
||||
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# 0016: Module database access
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-25
|
||||
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
|
||||
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0007 and ADR 0008 already let a module own a Drizzle schema and a validated, checksummed
|
||||
migration; ADR 0009 already applies those migrations transactionally. ADR 0015 let a module mount
|
||||
permissioned HTTP routes and receive an authorized principal. None of these gave a module's
|
||||
*executable* code — a `start`/`stop` lifecycle hook or a route handler — any supported way to
|
||||
actually query or write the tables its own schema declares. A module wanting to persist or read
|
||||
product data had no platform-sanctioned path to PostgreSQL at all.
|
||||
|
||||
The platform already owns exactly one physical PostgreSQL pool per API runtime
|
||||
(`apps/api/src/database.ts`), wrapped in typed Drizzle views for Core's own identity and
|
||||
authorization schemas (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Handing a
|
||||
module that raw pool, a connection string, or an independently constructed Drizzle instance would
|
||||
either leak platform connection infrastructure into module code or require a second, competing
|
||||
pool — both rejected by the same reasoning ADR 0006 and ADR 0015 already applied to capability
|
||||
registration and HTTP routing. This ADR records the third typed view over that one pool, and the
|
||||
runtime-services boundary that delivers it to trusted module code without exposing the pool
|
||||
itself.
|
||||
|
||||
## Decision
|
||||
|
||||
### `ModuleDatabase`: a restricted, typed view over `NodePgDatabase`
|
||||
|
||||
`packages/contracts/src/module-database.ts` defines `ModuleDatabase` as
|
||||
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
|
||||
"delete" | "execute" | "transaction">` — derived from Drizzle's own `node-postgres` database type
|
||||
with `Pick`, not hand-declared, so every method keeps Drizzle's own table-aware argument and
|
||||
result inference exactly as-is. A module calling `database.select().from(table)` or
|
||||
`database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||
would; no `any` or `unknown` erasure exists anywhere in the public type. `ModuleDatabase` carries
|
||||
no schema generic: a module brings its own Drizzle table objects to each call, so there is no
|
||||
global relational-query object spanning every installed module's tables for this type to expose.
|
||||
|
||||
The type excludes everything else `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||
carries: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||
`query` (a schema-bound relational query builder), `$cache`, `$count`, `$with`, `with`, and
|
||||
`refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||
reachable through the type.
|
||||
|
||||
### The facade physically closes the same gaps at runtime, not only in the type system
|
||||
|
||||
`core/src/modules/database.ts`'s `createModuleDatabaseView(pool)` does not return the
|
||||
`drizzle(pool)` instance directly. Drizzle's own query methods are plain prototype methods that
|
||||
read `this.session`/`this.dialect` internally, not arrow-function instance properties, so handing
|
||||
that instance to a module directly would both break the moment a module destructured a method off
|
||||
it (`const { select } = database` loses `this`) and — more importantly — would leave every
|
||||
non-public property physically reachable on the object at runtime, reachable to a module written
|
||||
in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not merely hidden
|
||||
by TypeScript. `createModuleDatabaseView` instead builds a fresh, `Object.freeze`d object with
|
||||
each of the eight operations explicitly bound to the underlying Drizzle instance: only those eight
|
||||
exist on the object at all, each correctly bound, and nothing else does. Building this view
|
||||
performs no query and no migration — `drizzle(pool)` only wraps the given client, synchronously
|
||||
and without I/O, the same construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
|
||||
already rely on. Core never constructs a `pg.Pool` itself; `apps/api/src/database.ts` remains the
|
||||
one place that does, and `createModuleDatabaseView` wraps the pool it is given.
|
||||
|
||||
### `ModuleRuntimeServices` is the one typed boundary carrying it into trusted module code
|
||||
|
||||
`ModuleRuntimeServices` (`packages/contracts/src/module-database.ts`) currently contains exactly
|
||||
one field: `database: ModuleDatabase`. It is frozen, and it is never handed to a module's
|
||||
`register` hook — `ModuleRegistrationContext` (ADR 0006, extended by ADR 0015 with `http`) gains no
|
||||
`services` field at all, so registration remains exactly as declarative and database-free as
|
||||
originally decided. `ModuleRuntimeContext` (the shared `start`/`stop` context) and
|
||||
`ModuleRouteHandlerContext` (ADR 0015's authorized-handler context, now exactly `moduleId`,
|
||||
`principal`, `params`, `services`) both gain a `services` field instead. For a given API runtime,
|
||||
`start`, `stop`, and every authorized HTTP handler receive the *same* `ModuleRuntimeServices`
|
||||
object — the same object identity, not a fresh one built per hook or per request — so a module
|
||||
never needs to stash a database reference in module-global state to share it between lifecycle
|
||||
code and its own route handlers; the platform simply provides it directly, every time, to every
|
||||
authorized call site.
|
||||
|
||||
### One pool, one facade, one services object, per API runtime
|
||||
|
||||
`apps/api/src/server.ts`'s `startApi` composes in a fixed order: discover the module inventory once
|
||||
and compose the permission registry from it; construct the one runtime `pg.Pool`
|
||||
(`createApiDatabase`, wrapping identity, authorization, and module views over it); construct the
|
||||
frozen `ModuleRuntimeServices` from that same pool's module view; construct Identity, the
|
||||
authorization store, and the evaluator; start the already-discovered module runtime, passing it
|
||||
that services object; create the Hono application and mount the started runtime's routes, passing
|
||||
the identical services object; bind the HTTP server. `startDiscoveredModuleRuntime` threads the
|
||||
same services object into `ModuleRuntimeContext` for every module's `start` and `stop`;
|
||||
`mountModuleHttpRoutes` threads the identical object into every authorized handler's
|
||||
`ModuleRouteHandlerContext`. No second pool, no second facade, and no second services object is
|
||||
ever constructed for one running API. A services/Identity/Authorization construction failure
|
||||
closes the one pool and starts no module; a module-runtime failure closes the pool without binding
|
||||
a server; an app-construction or bind failure stops the module runtime, then closes the pool —
|
||||
matching the cleanup-ordering discipline ADR 0015 already established, now additionally covering
|
||||
the services object's own dependencies.
|
||||
|
||||
### Shutdown remains drain → stop → close, now load-bearing for in-flight database use
|
||||
|
||||
Normal shutdown is unchanged from ADR 0015: drain and close the HTTP listener first, so no new or
|
||||
in-flight request can reach a module handler while the module runtime is stopping; stop the module
|
||||
runtime next — a module's `stop` hook may still use `services.database`, which is safe because the
|
||||
pool has not yet closed; only then close the shared pool, so no module code can run against it
|
||||
afterward. This ordering was already correct under ADR 0015; it is now additionally load-bearing,
|
||||
because a `stop` hook doing real database work depends on the pool still being open when it runs.
|
||||
Shutdown remains memoized — one shared promise every concurrent or later caller awaits — and a
|
||||
module-stop failure still does not prevent pool closure, though (unlike a startup-cleanup path) it
|
||||
is not swallowed: it remains a genuine result a caller observes.
|
||||
|
||||
### Module schemas and migrations are unchanged; the facade is not a migration tool
|
||||
|
||||
A module continues to own and import its own Drizzle table definitions (ADR 0007) and its own
|
||||
generated, checksummed migration (ADR 0008), applied transactionally and explicitly (ADR 0009).
|
||||
`ModuleDatabase` exposes no migration operation, and nothing in the module runtime infers a
|
||||
schema, creates a table automatically, or runs a migration as a side effect of module discovery,
|
||||
registration, start, or an HTTP request. A module's schema and migration changes still follow the
|
||||
existing generation, validation, policy, and deployment flow unchanged; they must be applied,
|
||||
exactly as before, before starting module code that depends on the new shape.
|
||||
|
||||
## Trust and security boundary
|
||||
|
||||
Modules remain trusted, compiled backend plugins — the same trust model ADR 0006 established for
|
||||
capability registration and ADR 0015 extended to HTTP handlers. `ModuleDatabase` is a deliberately
|
||||
restricted *integration* boundary, not a security sandbox: it exists to keep a module's code
|
||||
Drizzle-typed and decoupled from how the platform physically connects to PostgreSQL, not to defend
|
||||
against a malicious module, which already runs arbitrary code in this process and could reach
|
||||
PostgreSQL through other means regardless of what this facade does or does not expose. It provides
|
||||
no tenant isolation, no PostgreSQL-role-level separation between modules, and no runtime
|
||||
enforcement that one module's code only ever queries its own tables — table ownership is an
|
||||
architectural and migration-policy convention (ADR 0007's naming/prefix rules), not something this
|
||||
facade inspects or restricts at the SQL level; `execute` in particular lets a trusted module issue
|
||||
arbitrary SQL, exactly as `select`/`insert`/`update`/`delete`/`transaction` already let it construct
|
||||
arbitrary queries against any table object it imports. Authorization still fully protects the HTTP
|
||||
path into that trusted code: `requirePermission` and, for state-changing methods,
|
||||
`requireTrustedOrigin` (ADR 0013, ADR 0015) complete before a handler — and therefore before
|
||||
`services.database` is ever reachable — runs. A handler-thrown database error, caught or not,
|
||||
remains sanitized by the existing module-handler boundary (ADR 0015): an uncaught failure becomes
|
||||
exactly `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` and no SQL,
|
||||
PostgreSQL message or code, table/constraint name, stack, connection detail, or filesystem path;
|
||||
this ADR introduces no second, database-specific public error contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A module can persist and query its own product data with full Drizzle type inference, without
|
||||
ever owning a connection, a pool, or connection configuration.
|
||||
- The platform still owns exactly one physical pool and one shutdown lifecycle per API runtime;
|
||||
adding module database access did not require a second one.
|
||||
- Lifecycle hooks and HTTP handlers share one consistent services boundary
|
||||
(`ModuleRuntimeServices`), extensible the same deliberate way in the future.
|
||||
- Registration remains declarative and side-effect-free, preserving every existing discovery,
|
||||
Doctor, and `modules:validate` guarantee (none of them constructs the facade or executes module
|
||||
code).
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- Adding a new Drizzle operation to the module-facing surface requires deliberately extending
|
||||
`ModuleDatabase`'s `Pick`, not merely relying on whatever `NodePgDatabase` happens to expose.
|
||||
- A trusted module can still issue arbitrary SQL through `execute`; this facade does not, and does
|
||||
not claim to, prevent that.
|
||||
- Table ownership remains a naming/migration-policy convention, not a runtime-enforced sandbox —
|
||||
the same tradeoff ADR 0007 already accepted for schema ownership.
|
||||
- Static installation and API-restart requirements are unchanged: a module's database access, like
|
||||
its routes and permissions, only takes effect on the next restart.
|
||||
- A module's migration must be applied before starting code that depends on it; the platform does
|
||||
not infer or run it automatically, so an operator error here surfaces as a real database error
|
||||
from the module's own code, sanitized like any other handler failure.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Exposing `pg.Pool` or `$client` directly** was rejected: it would leak platform connection
|
||||
infrastructure into module code and make every module responsible for connection lifecycle the
|
||||
platform already owns correctly.
|
||||
- **Supplying `DATABASE_URL` to modules** was rejected for the same reason, plus the operational
|
||||
risk of a module opening its own competing connections outside the platform's one validated pool.
|
||||
- **One pool per module** was rejected: it would multiply connection overhead and configuration
|
||||
surface for no isolation benefit this facade does not already provide at the type level, and
|
||||
contradicts the one-pool-per-runtime model ADR 0015 already established.
|
||||
- **Letting a module construct its own Drizzle instance** was rejected: it would reintroduce
|
||||
exactly the raw-pool/connection-string exposure this ADR exists to avoid.
|
||||
- **Database access during `register`** was rejected: registration remains declarative — the same
|
||||
reasoning ADR 0006 already applied to capability provision — and a database-free registration
|
||||
hook can never have a partially-open connection to clean up if discovery aborts.
|
||||
- **Module-global service assignment** (a module stashing `services` in a top-level variable from
|
||||
`start` for its handlers to read later) was rejected as the *documented* pattern: the platform
|
||||
provides the identical object directly to every authorized call site, so no module needs to
|
||||
invent its own storage for it.
|
||||
- **Running migrations automatically at startup** was rejected: it would violate the existing,
|
||||
explicit, operator-invoked migration model (ADR 0009) and risk running unreviewed DDL as a
|
||||
side effect of an API restart.
|
||||
- **A generic, string-keyed service locator** (`services.get("database")`) was rejected in favor of
|
||||
a fixed, typed `ModuleRuntimeServices` shape: a locator would erase type information at exactly
|
||||
the boundary this ADR exists to keep typed, and would let a module probe for services that do
|
||||
not exist yet.
|
||||
- **A global schema/query object spanning every installed module** (Drizzle's own schema-bound
|
||||
`query` relational builder, extended to cover every module) was rejected: it would require the
|
||||
platform to aggregate every module's schema into one object at runtime, reintroducing exactly the
|
||||
cross-module coupling ADR 0007's per-module schema ownership already rejected.
|
||||
- **SQL parsing, rewriting, or query interception as a runtime isolation boundary** was rejected:
|
||||
modules are trusted compiled code already capable of running arbitrary queries through `execute`;
|
||||
a parsing layer would be both bypassable and a false promise of a security boundary this facade
|
||||
does not attempt to provide.
|
||||
|
||||
## Testing evidence
|
||||
|
||||
- Unit coverage (contracts, Module SDK, Core, `apps/api`) proves: the public `ModuleDatabase`
|
||||
surface preserves Drizzle's table-aware inference with no `any`; raw pool/client/session/dialect
|
||||
properties are absent from the type and, separately, from the constructed facade at runtime;
|
||||
every forwarded method remains correctly bound when destructured; `register` receives no
|
||||
services/database field; `start`/`stop` receive the same frozen services object; a module HTTP
|
||||
handler's database is reference-equal to its module's lifecycle database; denied,
|
||||
trusted-origin-rejected, and `OPTIONS` requests never touch the database fake; a zero-module
|
||||
runtime never constructs or touches a database; startup/shutdown cleanup ordering holds under
|
||||
every failure path exercised.
|
||||
- A real installed-module PostgreSQL integration fixture (`core/tests/fixtures/
|
||||
module-database-integration/db-facade/`, exercised from `apps/api/tests/
|
||||
module-database.integration.ts`) proves the same guarantees against a real, disposable
|
||||
PostgreSQL database: the module's table does not exist before its migration is explicitly
|
||||
applied; constructing the pool and facade performs no DDL; starting the API never runs or
|
||||
regenerates a migration; a real `start` hook write is visible to a later authorized HTTP read
|
||||
through the identical facade object; a real authorized mutation persists and a later read
|
||||
observes it, gated by permission enforcement and trusted-Origin checking exactly as ADR 0015
|
||||
requires; a real committed transaction persists and a real rollback (a deliberate unique-
|
||||
constraint violation) leaves no partial mutation; a module deliberately catching a real
|
||||
PostgreSQL failure returns its own response untouched, while an uncaught real failure sanitizes
|
||||
to the existing `MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; a
|
||||
`stop` hook's write completes and is independently observable before the runtime's pool closes.
|
||||
@@ -24,3 +24,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
|
||||
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
|
||||
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
|
||||
- [0016: Module database access](0016-module-database-access.md) — Accepted
|
||||
|
||||
@@ -95,12 +95,29 @@ identical lowest-privilege baseline status, and signup order never grants elevat
|
||||
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
|
||||
[the API guide](../development/api.md), and
|
||||
[the identity development guide](../development/identity.md) for the underlying schema and
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
|
||||
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
|
||||
permission.
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
|
||||
|
||||
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
|
||||
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
|
||||
command. Other target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
|
||||
contracts, dashboard, Builder, and reference feature modules.
|
||||
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
|
||||
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
|
||||
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
|
||||
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
|
||||
[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and
|
||||
[the authorization guide](../development/authorization.md).
|
||||
|
||||
Phase 5A wired module-declared permissions into that same authorization system — a version-3
|
||||
manifest, one composed platform permission registry spanning Core's own declarations and every
|
||||
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
|
||||
module mount its own framework-neutral HTTP routes in production, under
|
||||
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. This
|
||||
branch completes Phase 5C: typed module database access (`context.services.database`) for a
|
||||
module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL
|
||||
pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
|
||||
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[ADR 0016](decisions/0016-module-database-access.md), and the repository root
|
||||
[README](../../README.md#current-status) for the complete, current status of every phase.
|
||||
|
||||
The project continues finishing the module platform's backend foundations before Phase 6 Surface
|
||||
work begins. Target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
|
||||
scheduled by this document.
|
||||
|
||||
+75
-27
@@ -215,11 +215,15 @@ abuse-control design.
|
||||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||||
`apps/api/src/platform-permissions.ts`.
|
||||
2. Construct the one shared PostgreSQL pool.
|
||||
3. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
4. Start the module runtime from that exact same discovered inventory
|
||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
|
||||
5. Create the Hono application, mounting the started module runtime's published routes.
|
||||
6. Bind the HTTP server.
|
||||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||
pool (see [Module database access](#module-database-access)).
|
||||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||
5. Start the module runtime from that exact same discovered inventory
|
||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
|
||||
services object every module's `start`/`stop` hook receives.
|
||||
6. Create the Hono application, mounting the started module runtime's published routes and passing
|
||||
the identical services object to every module HTTP handler.
|
||||
7. Bind the HTTP server.
|
||||
|
||||
Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
|
||||
@@ -257,9 +261,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
|
||||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
|
||||
body all behave normally, and the full query string remains available) plus a frozen context of
|
||||
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
|
||||
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
|
||||
the authorization store, storage, jobs, configuration, or secrets.
|
||||
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
|
||||
empty frozen object for a route with none), and `services` — the same frozen
|
||||
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see
|
||||
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization
|
||||
store, storage, jobs, configuration, or secrets.
|
||||
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||||
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||||
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||||
@@ -276,6 +282,46 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
|
||||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||||
|
||||
## Module database access
|
||||
|
||||
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
|
||||
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
|
||||
stays declarative and database-free. See
|
||||
[Module development](module-development/README.md#database-access) for the module-authoring
|
||||
contract; this section covers only how `apps/api` composes and wires it.
|
||||
|
||||
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
||||
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
|
||||
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
||||
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
||||
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
||||
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
|
||||
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
|
||||
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
|
||||
own instance).
|
||||
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
|
||||
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
|
||||
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
|
||||
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
|
||||
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
|
||||
lifetime of one running API — never a fresh object per hook or per request, and never a second
|
||||
pool or facade.
|
||||
- **No database access without authorization.** A handler's context — and therefore
|
||||
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
|
||||
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
|
||||
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
|
||||
module's database.
|
||||
- **Errors.** A handler-thrown database error is sanitized through the exact same
|
||||
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
|
||||
error contract. A module that deliberately catches its own database failure may still return any
|
||||
valid `Response` of its own choosing.
|
||||
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
|
||||
Module schema and migration changes still follow the existing generation, validation, policy, and
|
||||
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
|
||||
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
|
||||
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
|
||||
complete design and its rejected alternatives.
|
||||
|
||||
## Database connection limits
|
||||
|
||||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||||
@@ -287,10 +333,10 @@ a query is already running on one. The operation timeout additionally sets `pg`'
|
||||
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
|
||||
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
|
||||
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
|
||||
constructed, sanitized the same way an invalid connection string already is. Both the identity and
|
||||
authorization domains read from this one pool through independent typed Drizzle views — see
|
||||
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
|
||||
not two.
|
||||
constructed, sanitized the same way an invalid connection string already is. The identity,
|
||||
authorization, and module domains all read from this one pool through independent typed Drizzle
|
||||
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
||||
one pool, not two.
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
@@ -300,19 +346,22 @@ shared promise the first time it is called: every caller, concurrent or sequenti
|
||||
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||||
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
|
||||
requests finish; then stops the module runtime (so no module resource stops while a route handler
|
||||
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
|
||||
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
|
||||
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
|
||||
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
|
||||
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
|
||||
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||||
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
|
||||
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
|
||||
and the module database facade all read from it — see
|
||||
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
|
||||
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
|
||||
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
|
||||
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM`
|
||||
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||||
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||||
signal arrived" without sending a real process signal or invoking the real `process.exit`.
|
||||
|
||||
A startup failure after the database pool was already created always closes that pool before the
|
||||
failure propagates: identity/authorization construction failing (no module runtime was started
|
||||
yet); the server failing to bind its listen address; or an app-construction failure, both of which
|
||||
also stop the module runtime first, since it may have already started successfully. A module
|
||||
failure propagates: module-services/identity/authorization construction failing (no module runtime
|
||||
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
|
||||
same pool); the server failing to bind its listen address; or an app-construction failure, both of
|
||||
which also stop the module runtime first, since it may have already started successfully. A module
|
||||
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
|
||||
reverse order, before the failure propagates) and then closes the pool — there is no separate
|
||||
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
|
||||
@@ -354,10 +403,9 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
{"kind":"anonymous"}
|
||||
```
|
||||
|
||||
## Next phase
|
||||
## What's next
|
||||
|
||||
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
|
||||
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
|
||||
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
|
||||
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
|
||||
Dashboard Surface, which still depends on an authorization model that is only now getting started.
|
||||
See the repository root [README](../../README.md#current-status) and
|
||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||
for what has landed across every phase and what remains before Surface work begins. This document
|
||||
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
|
||||
|
||||
@@ -396,15 +396,19 @@ Every response sets `Cache-Control: no-store`.
|
||||
|
||||
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
|
||||
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
|
||||
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
|
||||
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
|
||||
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
|
||||
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
|
||||
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
|
||||
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
|
||||
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
|
||||
and on any startup failure after the pool was already constructed (mirroring the existing
|
||||
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
|
||||
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three
|
||||
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
|
||||
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
|
||||
facade module code queries through `context.services.database`; see
|
||||
[API](api.md#module-database-access) and
|
||||
[Module development](../module-development/README.md#database-access)) — rather than three
|
||||
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
|
||||
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
|
||||
once during `startApi()`, builds the identity runtime from `.identity`, the
|
||||
`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen
|
||||
`ModuleRuntimeServices` from `.module` off that same handle, and closes the handle exactly once —
|
||||
on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring
|
||||
the existing identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
|
||||
|
||||
The existing per-domain factories (`createIdentityDrizzleDatabase`,
|
||||
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
|
||||
@@ -413,9 +417,9 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
|
||||
the authorization schema and has no reason to share a pool with anything else.
|
||||
|
||||
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
||||
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
|
||||
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
|
||||
there are or whether the failure happened during startup.
|
||||
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
|
||||
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
|
||||
`shutdown()` callers there are or whether the failure happened during startup.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is.
|
||||
## What is not operational yet
|
||||
|
||||
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
||||
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
|
||||
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model
|
||||
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
|
||||
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
|
||||
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
|
||||
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
|
||||
Surface, which still depends on an authorization model that does not exist yet.
|
||||
public registration is intentional and grants no elevation. Core's authorization foundation
|
||||
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
|
||||
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
|
||||
depends on an authorization model that only now exists and has not yet been built on.
|
||||
|
||||
@@ -97,6 +97,23 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
|
||||
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
|
||||
emits a raw exception, SQL, connection detail, or the rejected request's origin.
|
||||
|
||||
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
|
||||
`pnpm test:migrations:integration` run) proves `context.services.database` against a real,
|
||||
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
|
||||
repository's live `modules/` directory, and invisible to normal discovery/Doctor/`modules:validate`
|
||||
unless a test explicitly points discovery at it): the module's own table does not exist until its
|
||||
migration is explicitly applied, and constructing the pool or facade never creates it; `start`,
|
||||
`stop`, and an authorized HTTP handler all receive the identical `ModuleRuntimeServices` object,
|
||||
proven by reference equality; a real authorized mutation persists and a later read observes it,
|
||||
gated by the same permission/trusted-Origin enforcement every other module route uses; a real
|
||||
committed transaction persists and a real rollback (a deliberate unique-constraint violation)
|
||||
leaves no partial mutation; a module deliberately catching a real PostgreSQL failure returns its
|
||||
own response untouched, while an uncaught one sanitizes to the existing
|
||||
`MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; and a `stop` hook's write
|
||||
completes and is independently verifiable, through the harness's own administrative connection —
|
||||
never the API's own pool — before that pool closes. See
|
||||
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
|
||||
|
||||
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
||||
the following pull-request status context to `main` branch protection:
|
||||
|
||||
|
||||
@@ -157,9 +157,9 @@ export default defineModuleRuntime({
|
||||
capabilities.provide("calendar.events", calendarEvents);
|
||||
},
|
||||
|
||||
async start({ capabilities }) {
|
||||
async start({ capabilities, services }) {
|
||||
const people = capabilities.resolve("people.records");
|
||||
// Acquire runtime resources here.
|
||||
await services.database.insert(events).values({ id: "seed", title: "First event" });
|
||||
},
|
||||
|
||||
async stop() {
|
||||
@@ -170,8 +170,11 @@ export default defineModuleRuntime({
|
||||
|
||||
ID and version are intentional build-artifact identity markers and must match the manifest. Core
|
||||
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
|
||||
own capabilities and the capability dependencies declared in its manifest. The runtime context is
|
||||
deliberately limited to the module ID and capability access.
|
||||
own capabilities and the capability dependencies declared in its manifest. The `register` context
|
||||
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) —
|
||||
never `services`. `start` and `stop` share one context shape and both additionally receive
|
||||
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API,
|
||||
currently exposing only `database` — see [Database access](#database-access).
|
||||
|
||||
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
|
||||
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
||||
@@ -235,10 +238,12 @@ method and path (or two paths that are identical once every parameter is treated
|
||||
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
|
||||
|
||||
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
|
||||
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
|
||||
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
|
||||
a framework-specific request context, or a raw Better Auth session. The full query string remains
|
||||
available through `Request.url`; there is no separate query or body abstraction.
|
||||
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the
|
||||
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
|
||||
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
|
||||
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request
|
||||
context, or a raw Better Auth session. The full query string remains available through
|
||||
`Request.url`; there is no separate query or body abstraction.
|
||||
|
||||
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
|
||||
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
|
||||
@@ -344,6 +349,73 @@ destructive behavior. Review those properties explicitly. Core is the only migra
|
||||
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
||||
falls back to non-transactional execution.
|
||||
|
||||
## Database access
|
||||
|
||||
`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`,
|
||||
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
|
||||
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
|
||||
supported way for module code to reach PostgreSQL:
|
||||
|
||||
```ts
|
||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
import { events } from "./schema/tables.js";
|
||||
|
||||
export default defineModuleRuntime({
|
||||
id: "calendar",
|
||||
version: "0.1.0",
|
||||
|
||||
register({ http }) {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/events",
|
||||
permission: "calendar.events.read",
|
||||
async handler(_request, context) {
|
||||
const rows = await context.services.database
|
||||
.select({ id: events.id, title: events.title })
|
||||
.from(events);
|
||||
return Response.json(rows);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Import your module's own table objects (`src/schema/tables.js` — the same declarations
|
||||
[Drizzle schema definition](#drizzle-schema-definition) already covers) exactly as you would with
|
||||
an independently constructed Drizzle instance; the only difference is that `services.database`,
|
||||
not a client you built yourself, executes the query. Never:
|
||||
|
||||
- Construct a `Pool`, a `Client`, or a Drizzle instance in module code.
|
||||
- Read `DATABASE_URL` or any other connection configuration.
|
||||
- Access `$client`, a session, or a dialect object — `ModuleDatabase` exposes none of them, and not
|
||||
merely at the type level: the constructed facade physically has no such property.
|
||||
- Stash `services` (or `services.database`) in a module-global variable. The platform provides the
|
||||
identical object directly to `start`, `stop`, and every authorized handler; there is nothing to
|
||||
cache.
|
||||
- Run or generate a migration from `register`, `start`, `stop`, or a route handler. Schema and
|
||||
migration changes remain [explicit deployment work](#drizzle-migrations) — generate, review,
|
||||
commit, and apply them the same way as any other module migration, *before* starting code that
|
||||
depends on the new shape. Nothing in discovery, registration, or startup infers a schema or
|
||||
creates a table automatically.
|
||||
|
||||
A handler's database error is sanitized through the same `500 {"code":"MODULE_HANDLER_FAILED"}`
|
||||
boundary as any other handler failure (see [HTTP routes](#http-routes)) — there is no separate,
|
||||
database-specific error contract. Catch a failure yourself if your route should return something
|
||||
more specific; an uncaught one is sanitized automatically.
|
||||
|
||||
`ModuleDatabase` is an integration boundary for trusted, compiled backend module code, not a
|
||||
sandbox: it exists so your module never has to construct or configure a PostgreSQL connection
|
||||
itself, not to defend against malicious code, since a module already runs arbitrary code in this
|
||||
process. It provides no tenant isolation and does not stop your own code from querying a table it
|
||||
imports; `execute` in particular lets trusted code run arbitrary SQL, exactly as every other
|
||||
operation already lets it construct arbitrary queries. Keep querying only your own module's tables
|
||||
— the same convention [Drizzle schema definition](#drizzle-schema-definition) already establishes
|
||||
for imports, just extended to runtime queries. See
|
||||
[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the
|
||||
facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete
|
||||
design.
|
||||
|
||||
## Discovery and lifecycle
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
|
||||
/**
|
||||
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||
*
|
||||
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||
* objects (`@ekklesia/module-sdk`'s `defineModuleSchema`) to each call, so there is no global
|
||||
* relational schema spanning every installed module for this type to leak.
|
||||
*
|
||||
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||
* malicious module, which this facade cannot do and does not attempt to do.
|
||||
*/
|
||||
export type ModuleDatabase = Pick<
|
||||
NodePgDatabase,
|
||||
| "select"
|
||||
| "selectDistinct"
|
||||
| "selectDistinctOn"
|
||||
| "insert"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "execute"
|
||||
| "transaction"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The complete set of platform-provided services a trusted module's `start`/`stop` lifecycle hooks
|
||||
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. Frozen, and
|
||||
* never handed to a module's `register` hook (`ModuleRegistrationContext` has no `services` field):
|
||||
* registration stays declarative and database-free, exactly as before this contract existed.
|
||||
* Deliberately has no storage, jobs, configuration, secrets, or authorization field yet — those
|
||||
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
|
||||
* `database` was added.
|
||||
*/
|
||||
export interface ModuleRuntimeServices {
|
||||
readonly database: ModuleDatabase;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { EkklesiaPrincipal } from "./identity.js";
|
||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
|
||||
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
|
||||
// it would create a source dependency cycle with that module, which also imports from this one for
|
||||
@@ -124,16 +125,19 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
|
||||
}
|
||||
|
||||
/**
|
||||
* What a module's route handler receives about the caller and the match — never a database
|
||||
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
|
||||
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
|
||||
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
|
||||
* invoking its handler; this contracts-layer module never invokes a handler itself.
|
||||
* What a module's route handler receives about the caller, the match, and the platform services
|
||||
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
|
||||
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
|
||||
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
|
||||
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
|
||||
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
|
||||
* hooks receive for this API runtime — never a fresh one built per request.
|
||||
*/
|
||||
export interface ModuleRouteHandlerContext {
|
||||
readonly moduleId: ModuleId;
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||
import { semanticVersionSchema } from "./versions.js";
|
||||
|
||||
@@ -29,9 +30,14 @@ export interface ModuleRegistrationContext {
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
|
||||
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
|
||||
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
|
||||
* carries `http`: a route can only ever be declared during `register`. */
|
||||
export interface ModuleRuntimeContext {
|
||||
readonly capabilities: CapabilityResolver;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly services: ModuleRuntimeServices;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeDefinition {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
||||
|
||||
const testTable = pgTable("contracts_module_database_test", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Never invoked — exists solely so `pnpm typecheck` (part of `pnpm verify`) proves `ModuleDatabase`
|
||||
* preserves Drizzle's own table-aware argument and result inference for every required operation,
|
||||
* with no `any`/`unknown` erasure anywhere in the chain. A regression that widened any of these
|
||||
* signatures to `any` would still compile a *call* to the method but would break one of the
|
||||
* explicit row-shape assertions below.
|
||||
*/
|
||||
function typeOnlyOperationInference(database: ModuleDatabase): void {
|
||||
const selected = database.select({ id: testTable.id, name: testTable.name }).from(testTable);
|
||||
const selectedRow: { id: number; name: string } = {} as Awaited<typeof selected>[number];
|
||||
void selectedRow;
|
||||
|
||||
const distinct = database.selectDistinct().from(testTable);
|
||||
const distinctRow: { id: number; name: string } = {} as Awaited<typeof distinct>[number];
|
||||
void distinctRow;
|
||||
|
||||
const distinctOn = database.selectDistinctOn([testTable.name]).from(testTable);
|
||||
const distinctOnRow: { id: number; name: string } = {} as Awaited<typeof distinctOn>[number];
|
||||
void distinctOnRow;
|
||||
|
||||
const inserted = database.insert(testTable).values({ id: 1, name: "a" });
|
||||
void inserted;
|
||||
|
||||
const updated = database.update(testTable).set({ name: "b" });
|
||||
void updated;
|
||||
|
||||
const deleted = database.delete(testTable);
|
||||
void deleted;
|
||||
|
||||
const executed = database.execute("select 1");
|
||||
void executed;
|
||||
|
||||
void database.transaction(async (tx) => {
|
||||
const rows: Array<{ id: number }> = await tx.select({ id: testTable.id }).from(testTable);
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
void typeOnlyOperationInference;
|
||||
|
||||
/**
|
||||
* Never invoked — proves at typecheck time that `ModuleDatabase` structurally excludes every raw
|
||||
* PostgreSQL/Drizzle escape hatch a module must never reach through this facade. If any of these
|
||||
* properties were ever (re)added to the picked surface, the corresponding `@ts-expect-error` would
|
||||
* itself become a type error ("unused '@ts-expect-error' directive"), failing `pnpm typecheck`.
|
||||
*/
|
||||
function typeOnlyExclusions(database: ModuleDatabase): void {
|
||||
// @ts-expect-error `$client` (the raw pg client/pool) is not part of the public facade.
|
||||
void database.$client;
|
||||
// @ts-expect-error `_` (internal session/dialect/schema state) is not part of the public facade.
|
||||
void database._;
|
||||
// @ts-expect-error `query` (a schema-bound relational query builder) is not part of the public facade.
|
||||
void database.query;
|
||||
// @ts-expect-error a raw connection pool is not part of the public facade.
|
||||
void database.pool;
|
||||
// @ts-expect-error a raw connection string is not part of the public facade.
|
||||
void database.connectionString;
|
||||
// @ts-expect-error migration operations are not part of the public facade.
|
||||
void database.migrate;
|
||||
}
|
||||
void typeOnlyExclusions;
|
||||
|
||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
||||
const database: ModuleDatabase = services.database;
|
||||
void database;
|
||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
||||
void services.storage;
|
||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
||||
void services.jobs;
|
||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
||||
void services.authorization;
|
||||
}
|
||||
void typeOnlyServicesShape;
|
||||
|
||||
describe("ModuleDatabase", () => {
|
||||
it("is a real, importable type contract enforced by pnpm typecheck", () => {
|
||||
// The meaningful assertions for this module-level type contract are compile-time
|
||||
// (`typeOnlyOperationInference`, `typeOnlyExclusions`, `typeOnlyServicesShape` above, checked
|
||||
// by `pnpm typecheck`); this keeps the file a genuine, executed Vitest suite rather than a
|
||||
// file Vitest would otherwise skip for declaring no runnable test.
|
||||
expect(typeof typeOnlyOperationInference).toBe("function");
|
||||
expect(typeof typeOnlyExclusions).toBe("function");
|
||||
expect(typeof typeOnlyServicesShape).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||
generated module-owned migration, and logical storage-bucket declarations. Migration execution,
|
||||
API routing, and storage provisioning are intentionally not included.
|
||||
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
||||
route (`GET /records`, declared in `register`) that reads that table through
|
||||
`context.services.database` — the platform-provided `ModuleDatabase` facade, never a
|
||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||
provisioning are intentionally not included.
|
||||
|
||||
To create a module inside this repository:
|
||||
|
||||
@@ -14,7 +17,10 @@ To create a module inside this repository:
|
||||
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
|
||||
module actually implements and consumes.
|
||||
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
|
||||
must match the manifest; do not duplicate the rest of the manifest in TypeScript.
|
||||
must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP
|
||||
routes only in `register`, against a permission the manifest already declares; `start`, `stop`,
|
||||
and every route handler — never `register` — receive `context.services.database`, a typed
|
||||
`ModuleDatabase` view over the platform's one PostgreSQL pool.
|
||||
6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned
|
||||
with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts`
|
||||
aligned too, and never import another module's schema.
|
||||
@@ -29,7 +35,9 @@ To create a module inside this repository:
|
||||
|
||||
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
and release them in `stop`.
|
||||
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
|
||||
raw `pg` client — `context.services.database` is the only supported way to query this module's own
|
||||
tables, and it is never available during `register`.
|
||||
|
||||
The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and
|
||||
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
|
||||
import { createModuleTable } from "@ekklesia/module-sdk";
|
||||
import { text, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
const moduleTable = pgTableCreator((localName) => `example_${localName}`);
|
||||
const moduleTable = createModuleTable("example");
|
||||
|
||||
export const records = moduleTable("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
import { records } from "../schema/tables.js";
|
||||
|
||||
export const recordsCapability = Object.freeze({
|
||||
kind: "example-records",
|
||||
@@ -7,7 +8,25 @@ export const recordsCapability = Object.freeze({
|
||||
export default defineModuleRuntime({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
register({ capabilities }) {
|
||||
register({ capabilities, http }) {
|
||||
capabilities.provide("example.records", recordsCapability);
|
||||
|
||||
// The accepted module database boundary: `context.services.database` — provided only to an
|
||||
// authorized handler, never to `register` — is this module's one way to reach PostgreSQL. It
|
||||
// is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`),
|
||||
// never a raw `pg` client, a connection string, or a Drizzle instance this module built
|
||||
// itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every
|
||||
// other permission a module route enforces.
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
async handler(_request, context) {
|
||||
const rows = await context.services.database
|
||||
.select({ id: records.id, value: records.value })
|
||||
.from(records);
|
||||
return Response.json(rows);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type CapabilityId,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseModuleManifest,
|
||||
parseModuleMigrationPolicy,
|
||||
@@ -12,6 +17,38 @@ import { describe, expect, it } from "vitest";
|
||||
import moduleSchema, { records } from "../src/schema/index.js";
|
||||
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
|
||||
|
||||
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only
|
||||
* proves the example handler queries through the facade and shapes its response correctly. */
|
||||
function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase {
|
||||
const selectBuilder = { from: () => Promise.resolve(rows) };
|
||||
return { select: () => selectBuilder } as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
async function registerAndCaptureRoutes(): Promise<{
|
||||
provided: Map<CapabilityId, unknown>;
|
||||
routes: ModuleRouteDefinitionInput[];
|
||||
}> {
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
const routes: ModuleRouteDefinitionInput[] = [];
|
||||
await moduleRuntime.register?.({
|
||||
capabilities: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
},
|
||||
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
http: {
|
||||
route(definition) {
|
||||
routes.push(definition);
|
||||
},
|
||||
},
|
||||
moduleId: "example",
|
||||
});
|
||||
return { provided, routes };
|
||||
}
|
||||
|
||||
describe("module template", () => {
|
||||
it("ships a valid backend-only manifest", async () => {
|
||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||
@@ -59,27 +96,41 @@ describe("module template", () => {
|
||||
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
|
||||
});
|
||||
|
||||
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
await moduleRuntime.register?.({
|
||||
capabilities: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
},
|
||||
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
http: {
|
||||
route() {
|
||||
throw new Error("The template does not register any HTTP route.");
|
||||
},
|
||||
},
|
||||
moduleId: "example",
|
||||
});
|
||||
it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => {
|
||||
const { provided, routes } = await registerAndCaptureRoutes();
|
||||
|
||||
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
|
||||
expect(provided.get("example.records")).toBe(recordsCapability);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
});
|
||||
});
|
||||
|
||||
it("queries the module's own table through context.services.database in the example route handler", async () => {
|
||||
const { routes } = await registerAndCaptureRoutes();
|
||||
const route = routes[0];
|
||||
if (!route) {
|
||||
throw new Error("Expected the example route to be registered.");
|
||||
}
|
||||
|
||||
const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }];
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
database: fakeModuleDatabase(fakeRows),
|
||||
});
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
moduleId: "example",
|
||||
params: Object.freeze({}),
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
services,
|
||||
});
|
||||
|
||||
const response = await route.handler(new Request("http://localhost/records"), context);
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(await response.json()).toEqual(fakeRows);
|
||||
});
|
||||
|
||||
it("exports a namespaced module-owned Drizzle schema", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"core/tests/authorization-store.integration.ts",
|
||||
"core/tests/authorization-bootstrap.integration.ts",
|
||||
"apps/api/tests/api-http.integration.ts",
|
||||
"apps/api/tests/module-database.integration.ts",
|
||||
],
|
||||
passWithNoTests: false,
|
||||
testTimeout: 30_000,
|
||||
|
||||
Reference in New Issue
Block a user