fix: keep Drizzle out of every published declaration (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s

@ekklesia/contracts still emitted a type-only import of
drizzle-orm/node-postgres for the legacy ModuleDatabase facade, so a
standalone module type-checked only because skipLibCheck hid it.

- ModuleDatabase, LegacyModuleRuntimeServices and
  AnyModuleRuntimeServices move to core/src/modules/database.ts, beside
  the facade Core builds; the facade itself is unchanged.
- contracts' runtime, route, and services generics no longer name an
  ORM: the database type is a plain parameter defaulting to the ABI v1
  executor, and the legacy definition's database is opaque until Core
  specializes it. Core and apps/api name the legacy types from Core.
- contracts drops its drizzle-orm optional peer and devDependency;
  Drizzle stays public only through @ekklesia/module-database/drizzle.
- The facade's type-level test moves from contracts to Core.
- Regression proof: the standalone fixture type-checks with
  skipLibCheck: false against the packed tarballs, with no drizzle-orm
  installed (the smoke asserts both). Against the previous contracts
  tarball the same check fails with TS2307 on drizzle-orm/node-postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-08 07:40:43 -05:00
co-authored by Claude Opus 5.5
parent 89d5d0f2da
commit aaa7ebfd84
31 changed files with 190 additions and 178 deletions
+5 -45
View File
@@ -1,59 +1,19 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
* 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
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. For a
* module that declares `database: { abi: 1 }` it is the Module Database ABI v1 executor; for a
* legacy module it is Core's legacy facade, whose type Core alone owns, so this package never
* references an ORM (ADR 0053). 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<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRuntimeServices<Database = ModuleDatabaseExecutor> {
readonly database: Database;
}
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
+9 -16
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
import type { EkklesiaPrincipal } from "./identity.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleRealtimePublisher } from "./module-realtime.js";
import type { ModuleStorage } from "./module-storage.js";
@@ -272,9 +272,7 @@ export function moduleRoutesOverlap(
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
* handler — and therefore any parameter validation it performs — can run.
*/
export interface ModuleRouteHandlerContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRouteHandlerContext<Database = ModuleDatabaseExecutor> {
readonly moduleId: ModuleId;
readonly principal: EkklesiaPrincipal;
readonly params: Readonly<Record<string, string>>;
@@ -296,9 +294,10 @@ export interface ModuleRouteHandlerContext<
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
* query string remains available through `Request.url`; there is no competing query or body
* abstraction. */
export type ModuleRouteHandler<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
export type ModuleRouteHandler<Database = ModuleDatabaseExecutor> = (
request: Request,
context: ModuleRouteHandlerContext<Database>,
) => Awaitable<Response>;
/**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
@@ -313,9 +312,7 @@ export type ModuleRouteHandler<
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
* metadata, another module's namespace, or an unprotected/no-permission route.
*/
export interface ModuleRouteDefinitionInput<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRouteDefinitionInput<Database = ModuleDatabaseExecutor> {
readonly method: ModuleHttpMethod;
readonly path: string;
readonly permission: string;
@@ -331,9 +328,7 @@ export interface ModuleRouteDefinitionInput<
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
* only after every field passes its own grammar. */
export interface ModuleRouteDefinition<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRouteDefinition<Database = ModuleDatabaseExecutor> {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
@@ -346,9 +341,7 @@ export interface ModuleRouteDefinition<
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
export interface ModuleHttpRouteRegistrar<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleHttpRouteRegistrar<Database = ModuleDatabaseExecutor> {
route(definition: ModuleRouteDefinitionInput<Database>): void;
}
+8 -9
View File
@@ -1,7 +1,7 @@
import type { CapabilityId, ModuleId } from "./identifiers.js";
import { moduleIdSchema } from "./identifiers.js";
import type { ModuleConfiguration } from "./module-configuration.js";
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
import type { ModuleRuntimeServices } from "./module-database.js";
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
@@ -25,9 +25,7 @@ export interface CapabilityRegistrar extends CapabilityResolver {
provide(capabilityId: CapabilityId, implementation: unknown): void;
}
export interface ModuleRegistrationContext<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRegistrationContext<Database = ModuleDatabaseExecutor> {
readonly capabilities: CapabilityRegistrar;
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
* be declared while Core is still building the immutable startup snapshot, never added or removed
@@ -52,9 +50,7 @@ export interface ModuleRegistrationContext<
* 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<
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
> {
export interface ModuleRuntimeContext<Database = ModuleDatabaseExecutor> {
readonly capabilities: CapabilityResolver;
readonly moduleId: ModuleId;
readonly services: ModuleRuntimeServices<Database>;
@@ -87,7 +83,7 @@ export interface ModuleRuntimeContext<
readonly config: ModuleConfiguration;
}
interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDatabaseExecutor> {
interface ModuleRuntimeDefinitionBase<Database> {
readonly id: ModuleId;
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
@@ -95,7 +91,10 @@ interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDa
readonly version: string;
}
export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase<ModuleDatabase> {
/** A module on the legacy database facade (ADR 0016), until its deprecation window ends (ADR 0050).
* The facade's type is Core's own, so here its database is opaque: Core specializes `Database`. */
export interface LegacyModuleRuntimeDefinition<Database = unknown>
extends ModuleRuntimeDefinitionBase<Database> {
readonly database?: never;
}