diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0a18651..32dd36d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,5 @@ import { isAuthenticatedPrincipal, - type ModuleRuntimeServices, type ModuleSecretSource, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; @@ -13,6 +12,7 @@ import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DeviceStore } from "@ekklesia/core/devices"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import type { + AnyModuleRuntimeServices, DiscoveredModule, ModuleConfigurationLookup, ModuleConfigurationSettings, @@ -66,7 +66,7 @@ export interface CreateApiAppOptions { /** The same per-module service lookup the caller's started module runtime used for every * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's * reference-identical service object, never a fresh facade or executor built per request. */ - readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices; + readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices; /** The platform's module UI publication policy — always the one Core builds from the exact * discovered inventory this runtime started (`createModuleUiPublicationRegistry`, * `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes` diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 4864b98..ff651f4 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -1,4 +1,4 @@ -import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; +import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; import { type AuthorizationDrizzleDatabase, createAuthorizationDrizzleView, @@ -16,6 +16,7 @@ import { createModuleDatabaseView, createModuleLifecycleView, type ModuleConfigurationDrizzleDatabase, + type ModuleDatabase, type ModuleLifecycleDrizzleDatabase, } from "@ekklesia/core/modules"; diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index 64fe48a..03d6569 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -1,15 +1,15 @@ import { EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_STORAGE, - type ModuleDatabase, type ModuleDatabaseExecutor, type ModuleId, type ModuleRouteHandlerContext, - type ModuleRuntimeServices, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import type { + AnyModuleRuntimeServices, ModuleConfigurationLookup, + ModuleDatabase, ModuleRealtimeRegistry, ModuleRuntimeServicesLookup, ModuleStorageLookup, @@ -40,7 +40,7 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions * 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: ModuleRuntimeServicesLookup | ModuleRuntimeServices; + readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices; /** * Per-module frozen configuration snapshots — the exact map the module runtime started from * (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index aa2bbf2..f5357d4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,11 +1,7 @@ import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { - type AnyModuleRuntimeServices, - EMPTY_MODULE_SECRET_SOURCE, - type ModuleSecretSource, -} from "@ekklesia/contracts"; +import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts"; import { createAuthorizationEvaluator, createAuthorizationStore, @@ -21,6 +17,7 @@ import { } from "@ekklesia/core/identity"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { + type AnyModuleRuntimeServices, createModuleConfigurationStore, createModuleLifecycleStore, createModuleUiPublicationRegistry, diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index b7d3885..18f0f08 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -1,8 +1,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, moduleIdSchema, parseEkklesiaUserId, parsePermissionId, @@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, type ModuleUiPublicationRegistry, StagedModuleRealtimeRegistry, } from "@ekklesia/core/modules"; @@ -48,9 +48,9 @@ function fakeRuntime(overrides: Partial = {}): Ekklesia }; } -/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring, +/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring, * never real database behavior, so no PostgreSQL is required. */ -function fakeModuleServices(): ModuleRuntimeServices { +function fakeModuleServices(): LegacyModuleRuntimeServices { const database = Object.freeze({ delete: vi.fn(), execute: vi.fn(), diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index ca5ffce..f41b749 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -1,9 +1,7 @@ import { createHash } from "node:crypto"; import { ANONYMOUS_PRINCIPAL, - type ModuleDatabase, type ModuleRouteHandler, - type ModuleRuntimeServices, parsePermissionId, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; @@ -16,6 +14,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; @@ -74,7 +74,7 @@ function fakeStore(): AuthorizationStore { }; } -function fakeModuleServices(): ModuleRuntimeServices { +function fakeModuleServices(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); } diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index dc5c9ad..bf173cc 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -5,8 +5,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -20,6 +18,8 @@ import { createModuleUiPublicationRegistry, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -147,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, - }) as ModuleRuntimeServices, + }) as LegacyModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(modules), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor(modules), diff --git a/apps/api/tests/health-routes.test.ts b/apps/api/tests/health-routes.test.ts index 9def9b9..98531a6 100644 --- a/apps/api/tests/health-routes.test.ts +++ b/apps/api/tests/health-routes.test.ts @@ -1,8 +1,6 @@ import { ANONYMOUS_PRINCIPAL, - type ModuleDatabase, type ModuleRouteHandler, - type ModuleRuntimeServices, parsePermissionId, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; @@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; @@ -56,7 +56,7 @@ function fakeStore(): AuthorizationStore { }; } -function fakeModuleServices(): ModuleRuntimeServices { +function fakeModuleServices(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); } diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index 8f6549f..a241952 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -6,8 +6,6 @@ import { createAuthenticatedPrincipal, type EkklesiaPrincipal, type ModuleConfigurationDeclarationInput, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -22,10 +20,12 @@ import { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, + type LegacyModuleRuntimeServices, type ModuleConfigurationRecord, type ModuleConfigurationStore, ModuleConfigurationStoreError, EMPTY_MODULE_REALTIME_REGISTRY, + type ModuleDatabase, resolveModuleEligibility, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; @@ -213,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise { moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, - }) as ModuleRuntimeServices, + }) as LegacyModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index b16db88..eac0931 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -5,8 +5,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -21,6 +19,8 @@ import { type DiscoveredModule, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -155,7 +155,7 @@ interface Installation { readonly modules: readonly DiscoveredModule[]; } -function fakeServices(): ModuleRuntimeServices { +function fakeServices(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); } diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 5a99651..5ea8307 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -1,10 +1,8 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, - type ModuleDatabase, type ModuleRouteHandler, type ModuleRouteHandlerContext, - type ModuleRuntimeServices, moduleRoutesOverlap, type PermissionId, parsePermissionId, @@ -20,6 +18,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, type ModuleRuntimeServicesLookup, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; @@ -74,11 +74,11 @@ function moduleRoute( return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields }); } -/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, +/** A small typed fake `LegacyModuleRuntimeServices` — 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 { +function fakeModuleServices(): LegacyModuleRuntimeServices { const database = Object.freeze({ delete: vi.fn(), execute: vi.fn(), @@ -92,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices { return Object.freeze({ database }); } -function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void { +function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): 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(); @@ -108,7 +108,7 @@ function buildModuleApp(options: { readonly registry: PermissionRegistry; readonly routes: readonly RegisteredModuleHttpRoute[]; readonly runtime?: EkklesiaIdentityRuntime; - readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup; + readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup; readonly trustedOrigins?: readonly string[]; }) { const evaluator = options.evaluator ?? { @@ -539,7 +539,7 @@ describe("module route authorization", () => { expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(handler).not.toHaveBeenCalled(); - expectDatabaseFakeUntouched(services as ModuleRuntimeServices); + expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); }); it("does not invoke the handler when principal resolution fails", async () => { @@ -668,7 +668,7 @@ describe("module route trusted-origin enforcement", () => { } expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); - expectDatabaseFakeUntouched(services as ModuleRuntimeServices); + expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); }); it("allows a trusted-origin state-changing request through to the handler", async () => { @@ -797,7 +797,7 @@ describe("module route CORS", () => { expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); - expectDatabaseFakeUntouched(services as ModuleRuntimeServices); + expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); }); it("never reflects an untrusted origin for a module route", async () => { diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index 045bee7..252e7f6 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -5,8 +5,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -20,6 +18,8 @@ import { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, + type LegacyModuleRuntimeServices, + type ModuleDatabase, type ModuleLifecycleStore, ModuleLifecycleStoreError, EMPTY_MODULE_REALTIME_REGISTRY, @@ -189,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise { moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, - }) as ModuleRuntimeServices, + }) as LegacyModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/module-realtime-live.test.ts b/apps/api/tests/module-realtime-live.test.ts index 442a735..c1d50d1 100644 --- a/apps/api/tests/module-realtime-live.test.ts +++ b/apps/api/tests/module-realtime-live.test.ts @@ -4,9 +4,7 @@ import path from "node:path"; import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, - type ModuleDatabase, type ModuleRealtimePublisher, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, discoverModules, + type LegacyModuleRuntimeServices, + type ModuleDatabase, type ModuleRealtimeRegistry, type ModuleRealtimeSubscription, startDiscoveredModuleRuntime, @@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{ return { directory, symbol }; } -function services(): ModuleRuntimeServices { +function services(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as ModuleDatabase }); } diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index a0f7f5a..64a9e23 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -5,8 +5,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -18,6 +16,8 @@ import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, discoverModules, + type LegacyModuleRuntimeServices, + type ModuleDatabase, startDiscoveredModuleRuntime, } from "@ekklesia/core/modules"; import { @@ -241,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { modulesDirectory, platformVersion: "0.1.0", }); - const services: ModuleRuntimeServices = Object.freeze({ + const services: LegacyModuleRuntimeServices = Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }); // The module's real runtime: its server entrypoint is imported, registered, and started, and the diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 3003c48..217705d 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -5,8 +5,6 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, - type ModuleDatabase, - type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { @@ -21,6 +19,8 @@ import { type DiscoveredModule, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -192,7 +192,7 @@ async function install(options: InstallOptions): Promise { revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), } as unknown as AuthorizationStore; - const services: ModuleRuntimeServices = Object.freeze({ + const services: LegacyModuleRuntimeServices = Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }); const permissionRegistry = composePlatformPermissionRegistry(modules); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index fffb946..d19676b 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -1,11 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { - ANONYMOUS_PRINCIPAL, - type ModuleDatabase, - type ModuleRuntimeServices, -} from "@ekklesia/contracts"; +import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts"; import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { @@ -14,6 +10,8 @@ import { type DiscoveredModule, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, } from "@ekklesia/core/modules"; import { loadModuleUi } from "@ekklesia/module-ui-host"; import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk"; @@ -215,7 +213,7 @@ async function install( : modules.filter((module) => publicationModuleIds.includes(module.manifest.id)); const database = Object.freeze({}) as unknown as ModuleDatabase; - const services: ModuleRuntimeServices = Object.freeze({ database }); + const services: LegacyModuleRuntimeServices = Object.freeze({ database }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), // Anonymous, and every authorization decision denied: whatever this route does, it does it diff --git a/core/src/modules/database.ts b/core/src/modules/database.ts index dc88dc3..7d997dd 100644 --- a/core/src/modules/database.ts +++ b/core/src/modules/database.ts @@ -1,7 +1,54 @@ -import type { ModuleDatabase } from "@ekklesia/contracts"; -import { drizzle } from "drizzle-orm/node-postgres"; +import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import type { Pool } from "pg"; +/** + * 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 to each call, so there is no global relational schema spanning every installed module for + * this type to leak. + * + * Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published + * package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and + * the Module SDK never exposes this facade's type. + * + * 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 services a legacy module's hooks and handlers receive: the legacy facade. */ +export type LegacyModuleRuntimeServices = ModuleRuntimeServices; +/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */ +export type AnyModuleRuntimeServices = + | LegacyModuleRuntimeServices + | ModuleDatabaseAbiRuntimeServices; + /** * A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to * `createIdentityDrizzleView` (`core/src/identity/database.ts`) and diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index b75e64d..ad26544 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -1,6 +1,5 @@ import { pathToFileURL } from "node:url"; import { - type AnyModuleRuntimeServices, type CapabilityId, type CapabilityRegistrar, type CapabilityResolver, @@ -8,7 +7,6 @@ import { EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_STORAGE, type ModuleConfiguration, - type ModuleDatabase, type ModuleDatabaseExecutor, type ModuleHttpRouteRegistrar, type ModuleId, @@ -19,7 +17,6 @@ import { type ModuleRouteHandler, type ModuleRuntimeContext, type ModuleRuntimeDefinition, - type ModuleRuntimeServices, type ModuleStorage, moduleRouteShapeKey, moduleRoutesOverlap, @@ -28,6 +25,7 @@ import { type RegisteredModuleHttpRoute, safeParseModuleRouteDefinition, } from "@ekklesia/contracts"; +import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js"; import { type DiscoveredModule, discoverModules, @@ -137,7 +135,7 @@ export type ModuleStorageLookup = ReadonlyMap; /** One frozen service object per module id. Legacy modules may point at objects wrapping the same * facade; ABI-v1 modules each point at their own attributed executor. */ export type ModuleRuntimeServicesLookup = ReadonlyMap; -export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices; +export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices; function servicesFor( services: ModuleRuntimeServicesSource, @@ -347,11 +345,11 @@ class StagedCapabilityRegistry { createRuntimeContext( module: DiscoveredModule, phase: "rollback" | "start" | "stop", - services: ModuleRuntimeServices, + services: AnyModuleRuntimeServices, config: ModuleConfiguration, storage: ModuleStorage, realtime: ModuleRealtimePublisher, - ): ModuleRuntimeContext { + ): ModuleRuntimeContext { const capabilities: CapabilityResolver = Object.freeze({ resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { this.#assertAccess(module, capabilityId, phase); @@ -754,7 +752,7 @@ async function stopModules( registry.createRuntimeContext( module.discovered, phase, - servicesFor(services, module.discovered) as ModuleRuntimeServices, + servicesFor(services, module.discovered), configurationFor(configurations, moduleId), storageFor(storages, moduleId), realtimeRegistry.publisherFor(moduleId), @@ -814,7 +812,7 @@ async function startModules( registry.createRuntimeContext( module.discovered, "start", - servicesFor(services, module.discovered) as ModuleRuntimeServices, + servicesFor(services, module.discovered), configurationFor(configurations, module.discovered.manifest.id), storageFor(storages, module.discovered.manifest.id), realtimeRegistry.publisherFor(module.discovered.manifest.id), diff --git a/packages/contracts/tests/module-database.test.ts b/core/tests/module-database-type.test.ts similarity index 85% rename from packages/contracts/tests/module-database.test.ts rename to core/tests/module-database-type.test.ts index b6457e2..e28e793 100644 --- a/packages/contracts/tests/module-database.test.ts +++ b/core/tests/module-database-type.test.ts @@ -1,8 +1,8 @@ import { integer, pgTable, text } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js"; +import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js"; -const testTable = pgTable("contracts_module_database_test", { +const testTable = pgTable("core_module_database_type_test", { id: integer("id").primaryKey(), name: text("name").notNull(), }); @@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void { } 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 { +/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field, + * `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization + * field this phase. */ +function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void { const database: ModuleDatabase = services.database; void database; - // @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet. + // @ts-expect-error `storage` does not exist on the services yet. void services.storage; - // @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet. + // @ts-expect-error `jobs` does not exist on the services yet. void services.jobs; - // @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet. + // @ts-expect-error `authorization` does not exist on the services yet. void services.authorization; } void typeOnlyServicesShape; diff --git a/core/tests/module-realtime-runtime.test.ts b/core/tests/module-realtime-runtime.test.ts index 13b1034..507326b 100644 --- a/core/tests/module-realtime-runtime.test.ts +++ b/core/tests/module-realtime-runtime.test.ts @@ -1,11 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { - ModuleDatabase, - ModuleRealtimePublisher, - ModuleRuntimeServices, -} from "@ekklesia/contracts"; +import type { ModuleRealtimePublisher } from "@ekklesia/contracts"; +import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js"; import { afterEach, describe, expect, it } from "vitest"; import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js"; @@ -22,7 +19,7 @@ afterEach(async () => { ); }); -function fakeServices(): ModuleRuntimeServices { +function fakeServices(): LegacyModuleRuntimeServices { return Object.freeze({ database: Object.freeze({}) as ModuleDatabase }); } diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 6796879..2ba70e0 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -1,13 +1,12 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts"; import type { AnyModuleRuntimeServices, - ModuleConfiguration, + LegacyModuleRuntimeServices, ModuleDatabase, - ModuleDatabaseExecutor, - ModuleRuntimeServices, -} from "@ekklesia/contracts"; +} from "../src/modules/database.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { discoverModules } from "../src/modules/discovery.js"; import { @@ -151,7 +150,7 @@ function fakeModuleDatabase(): ModuleDatabase { } function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup { - const services = new Map(); + const services = new Map(); const database = fakeModuleDatabase(); return new Proxy(services, { get(target, property, receiver) { diff --git a/docs/development/api.md b/docs/development/api.md index fe82093..89e7b5c 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1403,7 +1403,8 @@ stays declarative and database-free. See 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`, + (`core/src/modules/database.ts`, Core's own type: no published package references Drizzle, per + ADR 0053), 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, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9865919..6015187 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -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" }, diff --git a/packages/contracts/src/module-database.ts b/packages/contracts/src/module-database.ts index 490ed06..7b147a5 100644 --- a/packages/contracts/src/module-database.ts +++ b/packages/contracts/src/module-database.ts @@ -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 { readonly database: Database; } -export type LegacyModuleRuntimeServices = ModuleRuntimeServices; export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices; -export type AnyModuleRuntimeServices = - | LegacyModuleRuntimeServices - | ModuleDatabaseAbiRuntimeServices; diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts index 11b53e5..3a871ce 100644 --- a/packages/contracts/src/module-http.ts +++ b/packages/contracts/src/module-http.ts @@ -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 { readonly moduleId: ModuleId; readonly principal: EkklesiaPrincipal; readonly params: Readonly>; @@ -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) => Awaitable; +export type ModuleRouteHandler = ( + request: Request, + context: ModuleRouteHandlerContext, +) => Awaitable; /** * 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 { 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 { 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 { route(definition: ModuleRouteDefinitionInput): void; } diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts index 9e55229..0b8094b 100644 --- a/packages/contracts/src/module-runtime.ts +++ b/packages/contracts/src/module-runtime.ts @@ -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 { 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 { readonly capabilities: CapabilityResolver; readonly moduleId: ModuleId; readonly services: ModuleRuntimeServices; @@ -87,7 +83,7 @@ export interface ModuleRuntimeContext< readonly config: ModuleConfiguration; } -interface ModuleRuntimeDefinitionBase { +interface ModuleRuntimeDefinitionBase { readonly id: ModuleId; readonly register?: (context: ModuleRegistrationContext) => Awaitable; readonly start?: (context: ModuleRuntimeContext) => Awaitable; @@ -95,7 +91,10 @@ interface ModuleRuntimeDefinitionBase { +/** 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 + extends ModuleRuntimeDefinitionBase { readonly database?: never; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b10d8d..cedfb88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,9 +171,6 @@ importers: '@types/spdx-expression-parse': specifier: 4.0.0 version: 4.0.0 - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) typescript: specifier: 7.0.2 version: 7.0.2 diff --git a/scripts/test-standalone-module.sh b/scripts/test-standalone-module.sh index c070199..b3f9be5 100755 --- a/scripts/test-standalone-module.sh +++ b/scripts/test-standalone-module.sh @@ -79,7 +79,15 @@ if (failures.length > 0) { } EOF -echo "==> Typechecking and building" +# The published declarations must stand on their own: no package may name a module a consumer has +# not installed (Drizzle above all), and the fixture checks every declaration it reaches. +for project in tsconfig.json tsconfig.ui.json; do + (cd "$module" && ./node_modules/.bin/tsc --showConfig --project "$project") | + node -e 'const c = JSON.parse(require("fs").readFileSync(0, "utf8")); if (c.compilerOptions.skipLibCheck !== false) process.exit(1);' || + fail "$project does not typecheck with skipLibCheck: false" +done + +echo "==> Typechecking (skipLibCheck: false) and building" (cd "$module" && pnpm run typecheck && pnpm run build) >"$work/build.log" 2>&1 || { tail -40 "$work/build.log" >&2; fail "typecheck or build"; } diff --git a/tests/fixtures/standalone-module/tsconfig.json b/tests/fixtures/standalone-module/tsconfig.json index 21c21ff..b8ec093 100644 --- a/tests/fixtures/standalone-module/tsconfig.json +++ b/tests/fixtures/standalone-module/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "@ekklesia/typescript-config/node.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "skipLibCheck": false }, - "include": ["src/server/**/*.ts", "src/schema/**/*.ts"] + "include": [ + "src/server/**/*.ts", + "src/schema/**/*.ts" + ] } diff --git a/tests/fixtures/standalone-module/tsconfig.ui.json b/tests/fixtures/standalone-module/tsconfig.ui.json index 4c82617..2d1cb41 100644 --- a/tests/fixtures/standalone-module/tsconfig.ui.json +++ b/tests/fixtures/standalone-module/tsconfig.ui.json @@ -2,7 +2,10 @@ "extends": "@ekklesia/typescript-config/browser-react.json", "compilerOptions": { "noEmit": true, - "types": [] + "types": [], + "skipLibCheck": false }, - "include": ["src/ui/**/*.tsx"] + "include": [ + "src/ui/**/*.tsx" + ] } diff --git a/tests/public-packages.test.ts b/tests/public-packages.test.ts index d529335..9175c2d 100644 --- a/tests/public-packages.test.ts +++ b/tests/public-packages.test.ts @@ -28,6 +28,7 @@ type Manifest = Record & { peerDependencies?: Record; peerDependenciesMeta?: Record; optionalDependencies?: Record; + devDependencies?: Record; ekklesia?: Record; files?: string[]; }; @@ -163,14 +164,21 @@ describe("the curated Module SDK", () => { } const contractsSource = path.join(repositoryRoot, "packages/contracts/src"); for (const file of await readdir(contractsSource)) { - // Only types: Core alone validates the legacy Drizzle schema definition, with its own Drizzle. + // Not even a type: the legacy facade's Drizzle type and schema validator are Core's own, so no + // declaration a consumer type-checks names Drizzle. const source = await readFile(path.join(contractsSource, file), "utf8"); - for (const line of source.split("\n").filter((text) => /from\s+"drizzle-orm/u.test(text))) { - expect(line, file).toMatch(/^import type /u); - } + expect(source, file).not.toMatch(/from\s+"drizzle-orm|import\(\s*"drizzle-orm/u); } const contracts = await readJson("packages/contracts/package.json"); expect(Object.keys(contracts.exports as object)).toEqual([".", "./module-package"]); + for (const field of [ + "dependencies", + "peerDependencies", + "optionalDependencies", + "devDependencies", + ] as const) { + expect(contracts[field]?.["drizzle-orm"], field).toBeUndefined(); + } }); }); @@ -190,6 +198,16 @@ describe("module authors' dependencies", () => { expect(guide).toContain("`@ekklesia/contracts` is not a direct dependency."); }); + it("type-check every published declaration in the standalone fixture", async () => { + // skipLibCheck would hide a declaration naming a package the module never installed. + for (const project of ["tsconfig.json", "tsconfig.ui.json"]) { + const config = await readJson<{ compilerOptions: { skipLibCheck?: boolean } }>( + `tests/fixtures/standalone-module/${project}`, + ); + expect(config.compilerOptions.skipLibCheck, project).toBe(false); + } + }); + it("pin module-pack exactly and declare engines.node in the standalone fixture", async () => { const fixture = await readJson("tests/fixtures/standalone-module/package.json"); const devDependencies = fixture.devDependencies as Record;