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
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:
co-authored by
Claude Opus 5.5
parent
89d5d0f2da
commit
aaa7ebfd84
@@ -27,20 +27,11 @@
|
||||
"spdx-expression-parse": "5.0.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"drizzle-orm": ">=0.45.2 <0.46.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/spdx-expression-parse": "4.0.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user