feat: define module database access

Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
This commit is contained in:
ekklesia
2026-07-25 20:57:32 -05:00
parent 9c9ede0418
commit 0e308eb9ad
17 changed files with 702 additions and 51 deletions
+1
View File
@@ -1,6 +1,7 @@
export * from "./authorization.js";
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-database.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
+50
View File
@@ -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;
}
+9 -5
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { EkklesiaPrincipal } from "./identity.js";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { ModuleRuntimeServices } from "./module-database.js";
// Not imported from `./module-runtime.js`'s own `Awaitable` (structurally identical) — importing
// it would create a source dependency cycle with that module, which also imports from this one for
@@ -124,16 +125,19 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
}
/**
* What a module's route handler receives about the caller and the match — never a database
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
* handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
* invoking its handler; this contracts-layer module never invokes a handler itself.
* What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
* secrets, the Hono `Context`, or a raw Better Auth session. `principal` is already authorized by
* the time a handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
* hooks receive for this API runtime — never a fresh one built per request.
*/
export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
readonly services: ModuleRuntimeServices;
}
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
+6
View File
@@ -1,5 +1,6 @@
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import { semanticVersionSchema } from "./versions.js";
@@ -29,9 +30,14 @@ export interface ModuleRegistrationContext {
readonly moduleId: ModuleId;
}
/** The same context shape for both `start` and `stop`: whichever hook runs, it receives the exact
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
* carries `http`: a route can only ever be declared during `register`. */
export interface ModuleRuntimeContext {
readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices;
}
export interface ModuleRuntimeDefinition {
@@ -0,0 +1,95 @@
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
const testTable = pgTable("contracts_module_database_test", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
/**
* Never invoked — exists solely so `pnpm typecheck` (part of `pnpm verify`) proves `ModuleDatabase`
* preserves Drizzle's own table-aware argument and result inference for every required operation,
* with no `any`/`unknown` erasure anywhere in the chain. A regression that widened any of these
* signatures to `any` would still compile a *call* to the method but would break one of the
* explicit row-shape assertions below.
*/
function typeOnlyOperationInference(database: ModuleDatabase): void {
const selected = database.select({ id: testTable.id, name: testTable.name }).from(testTable);
const selectedRow: { id: number; name: string } = {} as Awaited<typeof selected>[number];
void selectedRow;
const distinct = database.selectDistinct().from(testTable);
const distinctRow: { id: number; name: string } = {} as Awaited<typeof distinct>[number];
void distinctRow;
const distinctOn = database.selectDistinctOn([testTable.name]).from(testTable);
const distinctOnRow: { id: number; name: string } = {} as Awaited<typeof distinctOn>[number];
void distinctOnRow;
const inserted = database.insert(testTable).values({ id: 1, name: "a" });
void inserted;
const updated = database.update(testTable).set({ name: "b" });
void updated;
const deleted = database.delete(testTable);
void deleted;
const executed = database.execute("select 1");
void executed;
void database.transaction(async (tx) => {
const rows: Array<{ id: number }> = await tx.select({ id: testTable.id }).from(testTable);
return rows;
});
}
void typeOnlyOperationInference;
/**
* Never invoked — proves at typecheck time that `ModuleDatabase` structurally excludes every raw
* PostgreSQL/Drizzle escape hatch a module must never reach through this facade. If any of these
* properties were ever (re)added to the picked surface, the corresponding `@ts-expect-error` would
* itself become a type error ("unused '@ts-expect-error' directive"), failing `pnpm typecheck`.
*/
function typeOnlyExclusions(database: ModuleDatabase): void {
// @ts-expect-error `$client` (the raw pg client/pool) is not part of the public facade.
void database.$client;
// @ts-expect-error `_` (internal session/dialect/schema state) is not part of the public facade.
void database._;
// @ts-expect-error `query` (a schema-bound relational query builder) is not part of the public facade.
void database.query;
// @ts-expect-error a raw connection pool is not part of the public facade.
void database.pool;
// @ts-expect-error a raw connection string is not part of the public facade.
void database.connectionString;
// @ts-expect-error migration operations are not part of the public facade.
void database.migrate;
}
void typeOnlyExclusions;
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
const database: ModuleDatabase = services.database;
void database;
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
void services.storage;
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
void services.jobs;
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
void services.authorization;
}
void typeOnlyServicesShape;
describe("ModuleDatabase", () => {
it("is a real, importable type contract enforced by pnpm typecheck", () => {
// The meaningful assertions for this module-level type contract are compile-time
// (`typeOnlyOperationInference`, `typeOnlyExclusions`, `typeOnlyServicesShape` above, checked
// by `pnpm typecheck`); this keeps the file a genuine, executed Vitest suite rather than a
// file Vitest would otherwise skip for declaring no runnable test.
expect(typeof typeOnlyOperationInference).toBe("function");
expect(typeof typeOnlyExclusions).toBe("function");
expect(typeof typeOnlyServicesShape).toBe("function");
});
});