Merge pull request 'feat: add module database access' (#13) from phase-5/module-database-access into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
@@ -7,8 +7,8 @@ surfaces.
|
|||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
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
|
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
|
||||||
repository includes
|
transport), and Phase 5C (module database access) have landed. The repository includes
|
||||||
public module contracts, independently versioned capabilities, static module manifests,
|
public module contracts, independently versioned capabilities, static module manifests,
|
||||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
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
|
[Module development](docs/module-development/README.md#http-routes), and
|
||||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
[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
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
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 {
|
import type {
|
||||||
AuthorizationEvaluator,
|
AuthorizationEvaluator,
|
||||||
AuthorizationStore,
|
AuthorizationStore,
|
||||||
@@ -28,6 +32,11 @@ export interface CreateApiAppOptions {
|
|||||||
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
|
||||||
* implicit fallback. */
|
* implicit fallback. */
|
||||||
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
|
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;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** 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
|
* 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,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
|
services: options.moduleServices,
|
||||||
trustedOrigins: allowedOrigins,
|
trustedOrigins: allowedOrigins,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
|
import { createModuleDatabaseView } from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
|
readonly module: ModuleDatabase;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** 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. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
* `pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`), wrapped by three
|
||||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
|
* independent typed Drizzle views (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`,
|
||||||
* closes the pool in this composition — `close()` here is the only thing that ever calls
|
* `createModuleDatabaseView`). No domain owns or closes the pool in this composition — `close()`
|
||||||
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
|
* here is the only thing that ever calls `pool.end()`. `createIdentityDrizzleDatabase`/
|
||||||
* available, unaffected, and independently testable for callers that only need one domain.
|
* `createAuthorizationDrizzleDatabase` remain available, unaffected, and independently testable
|
||||||
|
* for callers that only need one domain.
|
||||||
*/
|
*/
|
||||||
export function createApiDatabase(
|
export function createApiDatabase(
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
@@ -32,5 +36,6 @@ export function createApiDatabase(
|
|||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
|
module: createModuleDatabaseView(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ModuleId,
|
ModuleId,
|
||||||
ModuleRouteHandlerContext,
|
ModuleRouteHandlerContext,
|
||||||
|
ModuleRuntimeServices,
|
||||||
RegisteredModuleHttpRoute,
|
RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
@@ -24,6 +25,11 @@ const STRIPPED_CORS_RESPONSE_HEADERS = [
|
|||||||
|
|
||||||
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
readonly trustedOrigins: ReadonlySet<string>;
|
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
|
/** 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
|
* 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.
|
* 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
|
* `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
|
* Because the routes and registry both derive from the same discovered inventory
|
||||||
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
* (`prepareStartupModuleInventory`), every registered route permission should already be
|
||||||
@@ -183,6 +193,7 @@ export function mountModuleHttpRoutes(
|
|||||||
moduleId: route.moduleId,
|
moduleId: route.moduleId,
|
||||||
params: Object.freeze({ ...c.req.param() }),
|
params: Object.freeze({ ...c.req.param() }),
|
||||||
principal: getAuthorizedPrincipal(c),
|
principal: getAuthorizedPrincipal(c),
|
||||||
|
services: options.services,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|||||||
+28
-18
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
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
|
* 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
|
* platform permission registry from Core's own declarations plus every module's
|
||||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||||
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
|
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||||
* Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the Core identity runtime, authorization store, and evaluator.
|
* 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
|
* 4. Start the module runtime from that exact same discovered inventory
|
||||||
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
|
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
|
||||||
* 5. Create the Hono application, mounting the started module runtime's published routes.
|
* 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.
|
* 6. Bind the Node HTTP server.
|
||||||
*
|
*
|
||||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||||
@@ -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
|
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
|
||||||
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
|
* 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,
|
* 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 database facade, identity, authorization) exists before any executable module lifecycle
|
||||||
* module route is published only after the entire module runtime has registered and started
|
* code runs, and every module route is published only after the entire module runtime has
|
||||||
* successfully, so no request can ever reach a module before every module has started. Returns a
|
* registered and started successfully, so no request can ever reach a module before every module
|
||||||
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
|
||||||
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
|
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
|
||||||
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
|
* 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(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
@@ -122,9 +127,11 @@ export async function startApi(
|
|||||||
operationTimeoutMs,
|
operationTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let services: ModuleRuntimeServices;
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
try {
|
try {
|
||||||
|
services = Object.freeze({ database: databaseHandle.module });
|
||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
config: config.identity,
|
config: config.identity,
|
||||||
db: databaseHandle.identity,
|
db: databaseHandle.identity,
|
||||||
@@ -146,7 +153,7 @@ export async function startApi(
|
|||||||
// handle to stop here, only the pool this failure leaves open.
|
// handle to stop here, only the pool this failure leaves open.
|
||||||
let moduleRuntime: StartedModuleRuntime;
|
let moduleRuntime: StartedModuleRuntime;
|
||||||
try {
|
try {
|
||||||
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
|
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -161,6 +168,7 @@ export async function startApi(
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
moduleRoutes: moduleRuntime.routes,
|
moduleRoutes: moduleRuntime.routes,
|
||||||
|
moduleServices: services,
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
@@ -184,12 +192,14 @@ export async function startApi(
|
|||||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
// 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
|
// 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
|
// 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
|
// observes it complete (or fail) exactly once. Order matters, and is now additionally
|
||||||
// closed first so no new or in-flight request can reach a module handler while the module
|
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
|
||||||
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
|
// no new or in-flight request can reach a module handler while the module runtime is stopping;
|
||||||
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
|
// then the module runtime stops — its stop hooks may still use `services.database`, which is
|
||||||
// shutdown result a caller should observe — but database cleanup still always runs first, so a
|
// safe because the pool is not yet closed; only then does the shared pool close, so no module
|
||||||
// stop failure never leaves the pool open.
|
// 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;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
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 {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
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
|
/** 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
|
* 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
|
* 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 runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -69,6 +91,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -86,6 +109,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -105,6 +129,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -126,6 +151,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -147,6 +173,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -165,6 +192,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -182,6 +210,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
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 () => {
|
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -212,6 +242,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -227,6 +258,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -240,6 +272,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -260,6 +293,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -283,6 +317,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
@@ -309,6 +344,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
};
|
};
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
registry: emptyRegistry,
|
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 () => {
|
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
|||||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
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 {
|
interface WorkingFixtureModuleOptions {
|
||||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||||
readonly withRoute?: boolean;
|
readonly withRoute?: boolean;
|
||||||
@@ -659,6 +672,47 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
await running.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 () => {
|
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
await createFixtureModule(modulesDirectory, "broken-export");
|
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"]);
|
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 () => {
|
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
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 {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
@@ -51,24 +53,56 @@ function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRou
|
|||||||
return Object.freeze({ ...fields });
|
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: {
|
function buildModuleApp(options: {
|
||||||
readonly evaluator?: AuthorizationEvaluator;
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
|
readonly services?: ModuleRuntimeServices;
|
||||||
readonly trustedOrigins?: readonly string[];
|
readonly trustedOrigins?: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const evaluator = options.evaluator ?? {
|
const evaluator = options.evaluator ?? {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const runtime = options.runtime ?? fakeRuntime();
|
const runtime = options.runtime ?? fakeRuntime();
|
||||||
|
const services = options.services ?? fakeModuleServices();
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
||||||
moduleRoutes: options.routes,
|
moduleRoutes: options.routes,
|
||||||
|
moduleServices: services,
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
return { app, evaluator, runtime };
|
return { app, evaluator, runtime, services };
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionRegistry(
|
function permissionRegistry(
|
||||||
@@ -262,6 +296,7 @@ describe("module route handler adaptation", () => {
|
|||||||
const runtime = fakeRuntime({
|
const runtime = fakeRuntime({
|
||||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||||
});
|
});
|
||||||
|
const services = fakeModuleServices();
|
||||||
const { app } = buildModuleApp({
|
const { app } = buildModuleApp({
|
||||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -274,6 +309,7 @@ describe("module route handler adaptation", () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
runtime,
|
runtime,
|
||||||
|
services,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
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(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
||||||
expect(capturedRequest).toBeInstanceOf(Request);
|
expect(capturedRequest).toBeInstanceOf(Request);
|
||||||
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
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",
|
moduleId: "calendar",
|
||||||
params: { eventId: "evt-1" },
|
params: { eventId: "evt-1" },
|
||||||
principal: { kind: "authenticated", userId: "user_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)).toBe(true);
|
||||||
expect(Object.isFrozen(capturedContext?.params)).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 () => {
|
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 handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
const evaluator: AuthorizationEvaluator = {
|
const evaluator: AuthorizationEvaluator = {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||||
};
|
};
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -417,6 +465,7 @@ describe("module route authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invoke the handler when principal resolution fails", async () => {
|
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 }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
routes: methods.map((method) =>
|
routes: methods.map((method) =>
|
||||||
@@ -545,6 +594,7 @@ describe("module route trusted-origin enforcement", () => {
|
|||||||
}
|
}
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||||
@@ -647,12 +697,12 @@ describe("module route CORS", () => {
|
|||||||
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
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 handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
const evaluator: AuthorizationEvaluator = {
|
const evaluator: AuthorizationEvaluator = {
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
};
|
};
|
||||||
const { app } = buildModuleApp({
|
const { app, services } = buildModuleApp({
|
||||||
evaluator,
|
evaluator,
|
||||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -673,6 +723,7 @@ describe("module route CORS", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expectDatabaseFakeUntouched(services);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reflects an untrusted origin for a module route", async () => {
|
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 "./discovery.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
type RegisteredCapability,
|
type RegisteredCapability,
|
||||||
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
|
|||||||
stop(): Promise<void>;
|
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 {
|
interface LoadedModule {
|
||||||
readonly definition: ModuleRuntimeDefinition;
|
readonly definition: ModuleRuntimeDefinition;
|
||||||
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
|
|||||||
createRuntimeContext(
|
createRuntimeContext(
|
||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
|
|||||||
return this.#registered.get(capabilityId);
|
return this.#registered.get(capabilityId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return Object.freeze({ capabilities, moduleId: module.manifest.id });
|
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(): CapabilityRegistry {
|
publish(): CapabilityRegistry {
|
||||||
@@ -466,11 +473,14 @@ async function stopModules(
|
|||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
phase: "rollback" | "stop",
|
phase: "rollback" | "stop",
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||||
const issues: ModuleRuntimeIssue[] = [];
|
const issues: ModuleRuntimeIssue[] = [];
|
||||||
for (const module of [...modules].reverse()) {
|
for (const module of [...modules].reverse()) {
|
||||||
try {
|
try {
|
||||||
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
|
await module.definition.stop?.(
|
||||||
|
registry.createRuntimeContext(module.discovered, phase, services),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModuleRuntimeSignal) {
|
if (error instanceof ModuleRuntimeSignal) {
|
||||||
issues.push(error.issue);
|
issues.push(error.issue);
|
||||||
@@ -492,11 +502,14 @@ async function stopModules(
|
|||||||
async function startModules(
|
async function startModules(
|
||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const started: LoadedModule[] = [];
|
const started: LoadedModule[] = [];
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
try {
|
try {
|
||||||
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
|
await module.definition.start?.(
|
||||||
|
registry.createRuntimeContext(module.discovered, "start", services),
|
||||||
|
);
|
||||||
started.push(module);
|
started.push(module);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const startIssue =
|
const startIssue =
|
||||||
@@ -508,7 +521,7 @@ async function startModules(
|
|||||||
module.discovered.manifest.id,
|
module.discovered.manifest.id,
|
||||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
`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]);
|
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,12 +537,13 @@ async function startModules(
|
|||||||
*/
|
*/
|
||||||
export async function startDiscoveredModuleRuntime(
|
export async function startDiscoveredModuleRuntime(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
|
services: ModuleRuntimeServices,
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||||
const httpRoutes = new StagedModuleHttpRoutes();
|
const httpRoutes = new StagedModuleHttpRoutes();
|
||||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||||
await startModules(modules, capabilityRegistry);
|
await startModules(modules, capabilityRegistry, services);
|
||||||
|
|
||||||
const capabilities = capabilityRegistry.publish();
|
const capabilities = capabilityRegistry.publish();
|
||||||
const routes = httpRoutes.publish();
|
const routes = httpRoutes.publish();
|
||||||
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
|
|||||||
routes,
|
routes,
|
||||||
stop: (): Promise<void> => {
|
stop: (): Promise<void> => {
|
||||||
stopPromise ??= (async () => {
|
stopPromise ??= (async () => {
|
||||||
const issues = await stopModules(modules, capabilityRegistry, "stop");
|
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleRuntimeError(issues);
|
throw new ModuleRuntimeError(issues);
|
||||||
}
|
}
|
||||||
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
|
|||||||
options: StartModuleRuntimeOptions,
|
options: StartModuleRuntimeOptions,
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const discovered = await discoverModules(options);
|
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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import {
|
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 {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
|
services,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
|
|||||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const runtime = await startDiscoveredModuleRuntime(discovered);
|
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
||||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||||
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
|
|||||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||||
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
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"]);
|
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
});
|
});
|
||||||
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
|
|||||||
await runtime.stop();
|
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
|
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
|
||||||
> historical record of the manifest's prior shape.
|
> 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
|
> "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
|
> 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
|
> 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
|
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016
|
||||||
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
|
> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes)
|
||||||
> limited to module ID and capability access as originally decided here. Every other statement in
|
> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself
|
||||||
> this ADR remains accepted and unchanged.
|
> 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
|
`ekklesia.module.json` remains the one canonical static manifest and moves to
|
||||||
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
`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
|
### 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,
|
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
|
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
|
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
|
## 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 database or Drizzle access; module storage access; jobs and workflow/event automation;
|
||||||
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
|
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
|
||||||
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
|
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
|
- [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
|
- [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
|
- [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
|
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
|
||||||
[the API guide](../development/api.md), and
|
[the API guide](../development/api.md), and
|
||||||
[the identity development guide](../development/identity.md) for the underlying schema 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
|
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
|
||||||
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
|
|
||||||
permission.
|
|
||||||
|
|
||||||
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
|
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
|
||||||
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
|
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
|
||||||
command. Other target architecture not yet implemented includes production approval-provider
|
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
|
||||||
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
|
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
|
||||||
contracts, dashboard, Builder, and reference feature modules.
|
[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`,
|
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||||||
`apps/api/src/platform-permissions.ts`.
|
`apps/api/src/platform-permissions.ts`.
|
||||||
2. Construct the one shared PostgreSQL pool.
|
2. Construct the one shared PostgreSQL pool.
|
||||||
3. Construct the Core identity runtime, authorization store, and evaluator.
|
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||||||
4. Start the module runtime from that exact same discovered inventory
|
pool (see [Module database access](#module-database-access)).
|
||||||
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
|
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||||||
5. Create the Hono application, mounting the started module runtime's published routes.
|
5. Start the module runtime from that exact same discovered inventory
|
||||||
6. Bind the HTTP server.
|
(`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
|
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
|
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`.
|
(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
|
- **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
|
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
|
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
|
||||||
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
|
empty frozen object for a route with none), and `services` — the same frozen
|
||||||
the authorization store, storage, jobs, configuration, or secrets.
|
`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`
|
- **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
|
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
|
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
|
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"}`.
|
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
|
## Database connection limits
|
||||||
|
|
||||||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
`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
|
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
|
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
|
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
|
constructed, sanitized the same way an invalid connection string already is. The identity,
|
||||||
authorization domains read from this one pool through independent typed Drizzle views — see
|
authorization, and module domains all read from this one pool through independent typed Drizzle
|
||||||
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
|
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
||||||
not two.
|
one pool, not two.
|
||||||
|
|
||||||
## Graceful shutdown
|
## 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()`
|
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
|
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
|
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
|
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
|
||||||
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
|
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
|
||||||
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
|
and the module database facade all read from it — see
|
||||||
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
|
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
|
||||||
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
|
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
|
||||||
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
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
|
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`.
|
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
|
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
|
failure propagates: module-services/identity/authorization construction failing (no module runtime
|
||||||
yet); the server failing to bind its listen address; or an app-construction failure, both of which
|
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
|
||||||
also stop the module runtime first, since it may have already started successfully. A module
|
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
|
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
|
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.
|
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"}
|
{"kind":"anonymous"}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next phase
|
## What's next
|
||||||
|
|
||||||
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
|
See the repository root [README](../../README.md#current-status) and
|
||||||
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
|
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||||
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
|
for what has landed across every phase and what remains before Surface work begins. This document
|
||||||
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
|
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
|
||||||
Dashboard Surface, which still depends on an authorization model that is only now getting started.
|
|
||||||
|
|||||||
@@ -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`
|
`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
|
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
|
||||||
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
|
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three
|
||||||
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
|
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
|
||||||
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
|
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
|
||||||
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
|
facade module code queries through `context.services.database`; see
|
||||||
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
|
[API](api.md#module-database-access) and
|
||||||
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
|
[Module development](../module-development/README.md#database-access)) — rather than three
|
||||||
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
|
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
|
||||||
and on any startup failure after the pool was already constructed (mirroring the existing
|
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
|
||||||
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
|
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`,
|
The existing per-domain factories (`createIdentityDrizzleDatabase`,
|
||||||
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
|
`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.
|
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
|
`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
|
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
|
||||||
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
|
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
|
||||||
there are or whether the failure happened during startup.
|
`shutdown()` callers there are or whether the failure happened during startup.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is.
|
|||||||
## What is not operational yet
|
## What is not operational yet
|
||||||
|
|
||||||
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
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**
|
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
|
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:
|
public registration is intentional and grants no elevation. Core's authorization foundation
|
||||||
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
|
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
|
||||||
Surface, which still depends on an authorization model that does not exist yet.
|
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
|
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.
|
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
|
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:
|
the following pull-request status context to `main` branch protection:
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ export default defineModuleRuntime({
|
|||||||
capabilities.provide("calendar.events", calendarEvents);
|
capabilities.provide("calendar.events", calendarEvents);
|
||||||
},
|
},
|
||||||
|
|
||||||
async start({ capabilities }) {
|
async start({ capabilities, services }) {
|
||||||
const people = capabilities.resolve("people.records");
|
const people = capabilities.resolve("people.records");
|
||||||
// Acquire runtime resources here.
|
await services.database.insert(events).values({ id: "seed", title: "First event" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
@@ -170,8 +170,11 @@ export default defineModuleRuntime({
|
|||||||
|
|
||||||
ID and version are intentional build-artifact identity markers and must match the manifest. Core
|
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
|
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
|
own capabilities and the capability dependencies declared in its manifest. The `register` context
|
||||||
deliberately limited to the module ID and capability access.
|
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
|
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
|
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.
|
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
|
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
|
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the
|
||||||
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
|
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
|
||||||
a framework-specific request context, or a raw Better Auth session. The full query string remains
|
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
|
||||||
available through `Request.url`; there is no separate query or body abstraction.
|
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
|
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
|
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
|
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
|
||||||
falls back to non-transactional execution.
|
falls back to non-transactional execution.
|
||||||
|
|
||||||
|
## 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
|
## Discovery and lifecycle
|
||||||
|
|
||||||
From the repository root, run:
|
From the repository root, run:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./authorization.js";
|
export * from "./authorization.js";
|
||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
|
export * from "./module-database.js";
|
||||||
export * from "./module-http.js";
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.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 { z } from "zod";
|
||||||
import type { EkklesiaPrincipal } from "./identity.js";
|
import type { EkklesiaPrincipal } from "./identity.js";
|
||||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.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
|
// 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
|
// 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
|
* What a module's route handler receives about the caller, the match, and the platform services
|
||||||
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
|
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
|
||||||
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
|
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
|
||||||
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
|
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
|
||||||
* invoking its handler; this contracts-layer module never invokes a handler itself.
|
* 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 {
|
export interface ModuleRouteHandlerContext {
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly principal: EkklesiaPrincipal;
|
readonly principal: EkklesiaPrincipal;
|
||||||
readonly params: Readonly<Record<string, string>>;
|
readonly params: Readonly<Record<string, string>>;
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||||
import { semanticVersionSchema } from "./versions.js";
|
import { semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -29,9 +30,14 @@ export interface ModuleRegistrationContext {
|
|||||||
readonly moduleId: ModuleId;
|
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 {
|
export interface ModuleRuntimeContext {
|
||||||
readonly capabilities: CapabilityResolver;
|
readonly capabilities: CapabilityResolver;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
|
readonly services: ModuleRuntimeServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleRuntimeDefinition {
|
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,
|
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
|
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||||
generated module-owned migration, and logical storage-bucket declarations. Migration execution,
|
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
||||||
API routing, and storage provisioning are intentionally not included.
|
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:
|
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
|
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
|
||||||
module actually implements and consumes.
|
module actually implements and consumes.
|
||||||
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
|
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
|
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`
|
with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts`
|
||||||
aligned too, and never import another module's schema.
|
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.
|
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`,
|
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
|
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
|
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", {
|
export const records = moduleTable("records", {
|
||||||
id: uuid("id").primaryKey(),
|
id: uuid("id").primaryKey(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||||
|
import { records } from "../schema/tables.js";
|
||||||
|
|
||||||
export const recordsCapability = Object.freeze({
|
export const recordsCapability = Object.freeze({
|
||||||
kind: "example-records",
|
kind: "example-records",
|
||||||
@@ -7,7 +8,25 @@ export const recordsCapability = Object.freeze({
|
|||||||
export default defineModuleRuntime({
|
export default defineModuleRuntime({
|
||||||
id: "example",
|
id: "example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
register({ capabilities }) {
|
register({ capabilities, http }) {
|
||||||
capabilities.provide("example.records", recordsCapability);
|
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 { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import {
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
type CapabilityId,
|
type CapabilityId,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteDefinitionInput,
|
||||||
|
type ModuleRouteHandlerContext,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
parseDrizzleMigrationJournal,
|
parseDrizzleMigrationJournal,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
parseModuleMigrationPolicy,
|
parseModuleMigrationPolicy,
|
||||||
@@ -12,6 +17,38 @@ import { describe, expect, it } from "vitest";
|
|||||||
import moduleSchema, { records } from "../src/schema/index.js";
|
import moduleSchema, { records } from "../src/schema/index.js";
|
||||||
import moduleRuntime, { recordsCapability } from "../src/server/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", () => {
|
describe("module template", () => {
|
||||||
it("ships a valid backend-only manifest", async () => {
|
it("ships a valid backend-only manifest", async () => {
|
||||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
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"');
|
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
|
it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => {
|
||||||
const provided = new Map<CapabilityId, unknown>();
|
const { provided, routes } = await registerAndCaptureRoutes();
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
|
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
|
||||||
expect(provided.get("example.records")).toBe(recordsCapability);
|
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", () => {
|
it("exports a namespaced module-owned Drizzle schema", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
"core/tests/authorization-store.integration.ts",
|
"core/tests/authorization-store.integration.ts",
|
||||||
"core/tests/authorization-bootstrap.integration.ts",
|
"core/tests/authorization-bootstrap.integration.ts",
|
||||||
"apps/api/tests/api-http.integration.ts",
|
"apps/api/tests/api-http.integration.ts",
|
||||||
|
"apps/api/tests/module-database.integration.ts",
|
||||||
],
|
],
|
||||||
passWithNoTests: false,
|
passWithNoTests: false,
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user