From 89d5d0f2da7d7bba0602176b65b9b644a2954cd3 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 8 Oct 2026 07:28:54 -0500 Subject: [PATCH 1/2] feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053) Makes the public module-development package set usable from a module repository outside the monorepo. Nothing is published. - Curated @ekklesia/module-sdk: an explicit author API, no contracts barrel, no identity/authorization internals, no Drizzle and no legacy ModuleDatabase type. defineModuleRuntime returns the SDK's own named interfaces so a module's emitted declarations never reach into @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and defineModuleSchemaMetadata live in @ekklesia/module-database. - Drizzle: no longer a dependency of module-sdk or contracts. Core keeps the legacy Drizzle schema validator (moved to core/src/modules) and its legacy runtime support; drizzle-orm is an optional peer only. - React is a peer (plus devDependency) of @ekklesia/ui; the boundary checker allows exactly that consumer-selected peer. - @ekklesia/typescript-config is publishable; module-ui-host is private. Every public package carries "ekklesia.distribution": "module-development" and no monorepo-root prepack. - module-pack reads the Node.js range from the module's own engines.node and recognizes Ekklesia packages by name plus that marker, so registry-installed packages are incorporated, SDK-compatibility checked, and never credited as third-party. - scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs and inspects the nine public tarballs; scripts/test-standalone-module.sh (pnpm test:standalone-module, new CI job) installs them into a fixture module outside the repository and proves it typechecks, builds, packages, and verifies. - Public-package boundary tests and module-development docs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 31 + .gitignore | 3 + README.md | 7 + .../src/modules/legacy-schema-definition.ts | 20 +- core/src/modules/module-package-probe.ts | 7 +- core/src/modules/schema-discovery.ts | 6 +- core/tests/legacy-schema-definition.test.ts | 34 + core/tsconfig.json | 3 +- docs/architecture/repository-boundaries.md | 10 +- docs/module-development/README.md | 140 +++- package.json | 2 + packages/contracts/package.json | 14 +- packages/contracts/src/index.ts | 1 - .../contracts/tests/module-schema.test.ts | 37 +- packages/design-tokens/package.json | 4 +- packages/module-database/package.json | 4 +- packages/module-database/src/drizzle.ts | 18 +- packages/module-database/src/index.ts | 46 ++ .../tests/create-module-table.test.ts} | 17 +- packages/module-pack/LICENSE | 661 ++++++++++++++++++ packages/module-pack/package.json | 4 +- packages/module-pack/src/pack-module.ts | 58 +- packages/module-pack/src/packages.ts | 34 +- .../module-pack/tests/pack-module.test.ts | 79 ++- .../tests/support/fixture-module.ts | 98 ++- packages/module-sdk/package.json | 10 +- .../module-sdk/src/define-module-runtime.ts | 75 +- .../module-sdk/src/define-module-schema.ts | 19 - packages/module-sdk/src/http.ts | 24 + packages/module-sdk/src/index.ts | 105 ++- .../tests/define-module-runtime.test.ts | 23 +- packages/module-ui-build/package.json | 4 +- packages/module-ui-host/package.json | 2 +- packages/module-ui-sdk/package.json | 4 +- .../tests/browser-safety.test.ts | 6 +- packages/typescript-config/package.json | 4 +- packages/ui/package.json | 10 +- packages/ui/tests/tokens.test.ts | 17 +- pnpm-lock.yaml | 15 +- scripts/check-boundaries.ts | 12 +- scripts/pack-public-packages.ts | 304 ++++++++ scripts/test-standalone-module.sh | 132 ++++ templates/module/package.json | 3 + templates/module/src/schema/tables.ts | 2 +- templates/module/src/server/index.ts | 2 +- templates/module/tests/manifest.test.ts | 2 +- tests/boundaries.test.ts | 2 +- .../packages/ui/package.json | 13 + .../packages/ui/src/index.tsx | 6 + .../packages/ui/package.json | 2 +- .../drizzle/0000_initial.sql | 1 + .../drizzle/meta/0000_snapshot.json | 6 + .../drizzle/meta/_ekklesia.json | 5 + .../drizzle/meta/_journal.json | 7 + .../standalone-module/ekklesia.module.json | 28 + tests/fixtures/standalone-module/package.json | 33 + .../standalone-module/src/schema/index.ts | 6 + .../standalone-module/src/server/index.ts | 23 + .../standalone-module/src/ui/index.tsx | 23 + .../standalone-module/tsconfig.build.json | 9 + .../fixtures/standalone-module/tsconfig.json | 7 + .../standalone-module/tsconfig.ui.json | 8 + tests/public-packages.test.ts | 265 +++++++ 63 files changed, 2320 insertions(+), 237 deletions(-) rename packages/contracts/src/module-schema.ts => core/src/modules/legacy-schema-definition.ts (75%) create mode 100644 core/tests/legacy-schema-definition.test.ts rename packages/{module-sdk/tests/define-module-schema.test.ts => module-database/tests/create-module-table.test.ts} (58%) create mode 100644 packages/module-pack/LICENSE delete mode 100644 packages/module-sdk/src/define-module-schema.ts create mode 100644 packages/module-sdk/src/http.ts create mode 100644 scripts/pack-public-packages.ts create mode 100755 scripts/test-standalone-module.sh create mode 100644 tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/package.json create mode 100644 tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/src/index.tsx create mode 100644 tests/fixtures/standalone-module/drizzle/0000_initial.sql create mode 100644 tests/fixtures/standalone-module/drizzle/meta/0000_snapshot.json create mode 100644 tests/fixtures/standalone-module/drizzle/meta/_ekklesia.json create mode 100644 tests/fixtures/standalone-module/drizzle/meta/_journal.json create mode 100644 tests/fixtures/standalone-module/ekklesia.module.json create mode 100644 tests/fixtures/standalone-module/package.json create mode 100644 tests/fixtures/standalone-module/src/schema/index.ts create mode 100644 tests/fixtures/standalone-module/src/server/index.ts create mode 100644 tests/fixtures/standalone-module/src/ui/index.tsx create mode 100644 tests/fixtures/standalone-module/tsconfig.build.json create mode 100644 tests/fixtures/standalone-module/tsconfig.json create mode 100644 tests/fixtures/standalone-module/tsconfig.ui.json create mode 100644 tests/public-packages.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5efcfd9..4d3becc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,37 @@ jobs: if: always() run: bash scripts/ci-sweep.sh --job + standalone-module: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # `actions/checkout` defaults to leaving the job token in `.git/config`, where any later + # step — including one a pull request added to this file — could read or reuse it. Nothing + # in this workflow pushes, so the credential is never needed after checkout. + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Activate pinned pnpm + run: | + corepack enable + corepack prepare pnpm@11.12.0 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # ADR 0053: pack and inspect the public packages, then install them into a module outside this + # repository and prove it builds, packages, and verifies. Nothing is published. + - name: Build a standalone module from the packed public packages + run: pnpm test:standalone-module + image-build: needs: verify runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 53b2e0e..b3c9f31 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ core/.identity-schema-check-* # The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts). packages/module-pack/.fixtures/ + +# `pnpm packages:pack` writes the packed public packages here (scripts/pack-public-packages.ts). +.public-packages/ diff --git a/README.md b/README.md index a7c3a5c..873f052 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ omits licence material, the packager may add reviewed licence supplements ([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). Purge, other operating-system or CPU targets, signing, and any installer or marketplace do not exist yet. +A module can be built in its own repository from Ekklesia's public module-development packages +([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md), Proposed): +a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and +packaging tools. CI packs and inspects those packages and proves a module outside the repository +installs, builds, packages, and verifies from the tarballs alone. They are not published to any +registry yet. + Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema (`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`, `ekklesia_core_verification`), a first-class Core-owned migration source that coexists with diff --git a/packages/contracts/src/module-schema.ts b/core/src/modules/legacy-schema-definition.ts similarity index 75% rename from packages/contracts/src/module-schema.ts rename to core/src/modules/legacy-schema-definition.ts index 1f478bc..44ee2ed 100644 --- a/packages/contracts/src/module-schema.ts +++ b/core/src/modules/legacy-schema-definition.ts @@ -1,15 +1,15 @@ +/** + * The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default- + * exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it, + * for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle + * dependency, so that no package a module installs needs Drizzle at run time (ADR 0053). + * + * Erasable TypeScript with no relative imports: the verifier's probe imports this source file + * directly in tests (`module-package-probe.ts`). + */ import { is } from "drizzle-orm"; import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core"; -import { type ModuleId, moduleIdSchema } from "./identifiers.js"; - -export { - moduleTableLocalNameSchema, - moduleTableName, - moduleTableNamespace, - moduleTablePrefix, - POSTGRES_IDENTIFIER_MAX_LENGTH, - physicalModuleTableNameSchema, -} from "./module-schema-names.js"; +import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts"; export interface ModuleSchemaDefinition< Tables extends Readonly> = Readonly>, diff --git a/core/src/modules/module-package-probe.ts b/core/src/modules/module-package-probe.ts index eb279d1..ca9f8d8 100644 --- a/core/src/modules/module-package-probe.ts +++ b/core/src/modules/module-package-probe.ts @@ -9,14 +9,15 @@ // The result travels over the IPC channel, never stdout, so nothing a module prints can forge it. // It carries fixed role and reason names only: never an error message, a path, or module output. // -// Written as erasable TypeScript with no relative imports, so Node can run the source file directly -// in tests as well as the compiled file in the image. +// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the +// compiled file in the image. Its one relative import names the source file's own extension, which +// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules. import { pathToFileURL } from "node:url"; import { parseModuleRuntimeDefinition, - parseModuleSchemaDefinition, parseModuleSchemaMetadataDefinition, } from "@ekklesia/contracts"; +import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts"; interface ProbeRequest { readonly databaseAbi?: 1; diff --git a/core/src/modules/schema-discovery.ts b/core/src/modules/schema-discovery.ts index 9e49458..9c4a024 100644 --- a/core/src/modules/schema-discovery.ts +++ b/core/src/modules/schema-discovery.ts @@ -1,14 +1,16 @@ import { pathToFileURL } from "node:url"; import { type ModuleId, - type ModuleSchemaDefinition, type ModuleSchemaMetadataDefinition, moduleTableLocalNameSchema, moduleTablePrefix, - parseModuleSchemaDefinition, parseModuleSchemaMetadataDefinition, physicalModuleTableNameSchema, } from "@ekklesia/contracts"; +import { + type ModuleSchemaDefinition, + parseModuleSchemaDefinition, +} from "./legacy-schema-definition.js"; import { is } from "drizzle-orm"; import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core"; import { diff --git a/core/tests/legacy-schema-definition.test.ts b/core/tests/legacy-schema-definition.test.ts new file mode 100644 index 0000000..1ff109d --- /dev/null +++ b/core/tests/legacy-schema-definition.test.ts @@ -0,0 +1,34 @@ +import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js"; + +describe("the legacy module schema definition", () => { + it("normalizes and freezes a typed schema definition without freezing its tables", () => { + const records = pgTable("example_records", { id: integer().primaryKey() }); + const definition = parseModuleSchemaDefinition({ + moduleId: "example", + tables: { records }, + }); + + expect(definition.tables.records).toBe(records); + expect(getTableConfig(records).name).toBe("example_records"); + expect(Object.isFrozen(definition)).toBe(true); + expect(Object.isFrozen(definition.tables)).toBe(true); + expect(Object.isFrozen(records)).toBe(false); + }); + + it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => { + const records = pgTable("example_records", { id: integer() }); + const disguisedRecords = alias(records, "example_disguised_records"); + expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }), + ).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }), + ).toThrow(); + expect(() => + parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }), + ).toThrow(); + }); +}); diff --git a/core/tsconfig.json b/core/tsconfig.json index 6d11db5..a72ea77 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../packages/typescript-config/node.json", "compilerOptions": { "noEmit": true, - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals"], + "rewriteRelativeImportExtensions": true }, "include": [ "drizzle.config.ts", diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 723d01b..16c0d85 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -139,10 +139,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers as a directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs) - omits development dependencies. The one narrow exception is - `@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and the - Module Package v1 bundler incorporates it into their self-contained backend; Core never loads the - adapter package. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`, + omits development dependencies. There are two narrow exceptions, each for one package and one + peer. `@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and + the Module Package v1 bundler incorporates it into their self-contained backend; Core never loads + the adapter package. And `@ekklesia/ui`'s `react` peer (ADR 0053): the shared elements are only + ever bundled into browser artifacts, and the consumer's own React must be the one in the bundle; + no production Node.js process loads them. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly (`runtime-imports-are-production-dependencies`). - Workspace aliases are forbidden because they obscure package ownership in the dependency graph. diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 18d4013..398c8d1 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -66,8 +66,101 @@ module with no migration history omits `entrypoints.migrations` and needs no pla } ``` -The package version must match the manifest and runtime-definition versions. Internal package -dependencies use `workspace:` ranges and supported public package entrypoints. +The package version must match the manifest and runtime-definition versions. Inside this +repository, Ekklesia package dependencies use `workspace:` ranges and supported public package +entrypoints. A module in its own repository depends on the published packages instead +([Developing a module in its own repository](#developing-a-module-in-its-own-repository)). + +### Developing a module in its own repository + +> **Status:** the public packages are ready for a standalone module, and a release check proves it +> from locally packed tarballs. They are **not published yet**, and there is no registry URL to use +> yet: registry publication is the next slice +> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)). This +> section describes the workflow that publication enables. + +A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its +`modules/` directory: your module is its own repository, and it depends on Ekklesia's +module-development packages like any other npm packages. + +| Package | Where it goes | What it is | +| --- | --- | --- | +| `@ekklesia/module-sdk` | `dependencies` | `defineModuleRuntime`, routes, configuration, storage, realtime, principals | +| `@ekklesia/module-database` | `dependencies` | Module Database ABI v1 types, `sql`, schema metadata; `/drizzle` for Drizzle | +| `@ekklesia/module-ui-sdk` | `dependencies` | `defineModuleUi` and the host context | +| `@ekklesia/design-tokens` | `dependencies` | design-token names and helpers | +| `@ekklesia/ui` | `dependencies` | shared elements; a pre-1.0 API that may still change | +| `react`, `react-dom`, your ORM if any | `dependencies` | your own copies, bundled into your artifacts | +| `@ekklesia/module-ui-build` | `devDependencies` | `ekklesia-build-module-ui` | +| `@ekklesia/module-pack` | `devDependencies`, **exact version** | `ekklesia-pack-module` | +| `@ekklesia/typescript-config` | `devDependencies` | `node.json`, `browser-react.json`, ... | + +`@ekklesia/contracts` is not a direct dependency. The packages above install it; never add or +import it yourself, because what it exports is the platform's, not a supported author API. + +```json +{ + "name": "@acme/notes", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "private": true, + "type": "module", + "engines": { "node": ">=24.11.0 <25" }, + "scripts": { + "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui" + }, + "dependencies": { + "@ekklesia/module-database": "^0.1.2", + "@ekklesia/module-sdk": "^0.1.2", + "@ekklesia/module-ui-sdk": "^0.1.2", + "@ekklesia/ui": "^0.1.2", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@ekklesia/module-pack": "0.1.2", + "@ekklesia/module-ui-build": "^0.1.2", + "@ekklesia/typescript-config": "^0.1.2", + "typescript": "7.0.2" + } +} +``` + +- **One coordinated version.** Every Ekklesia module-development package has the same version, + equal to the Ekklesia release it belongs to, and the platform checks your manifest's + `compatibility.moduleSdk` against it. Ranges in your `package.json` are fine; your lockfile pins + exact artifacts, and you install with a frozen lockfile. +- **Pin `@ekklesia/module-pack` exactly** in `package.json`, not only in the lockfile: its version + decides the package it produces. +- **`engines.node` is required.** The packager records your module's supported Node.js range from + `engines.node` in your `package.json`, and refuses to package without it + (`MODULE_PACK_RUNTIME_UNKNOWN`). It never looks for a workspace file. +- **No Drizzle unless you use it.** The Module SDK does not install Drizzle. A module that uses the + Drizzle adapter, `@ekklesia/module-database/drizzle`, adds `drizzle-orm` to its own + dependencies; it is an optional peer of that adapter only. +- **One React.** `@ekklesia/ui` takes React as a peer, so your bundle contains one React, your own. + Your UI artifacts still bundle it + ([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). +- **esbuild's install script.** The UI build and the packager use esbuild, which runs an install + script. With pnpm, allow it in your repository's `pnpm-workspace.yaml`: + + ```yaml + allowBuilds: + esbuild: true + ``` +- **TypeScript.** Extend the shared configurations by package name, for example + `"extends": "@ekklesia/typescript-config/node.json"` for the backend and + `@ekklesia/typescript-config/browser-react.json` for the UI. + +Packaging and verification work exactly as for a module in this repository +([Packaging your module](#packaging-your-module)). The packager recognizes Ekklesia's packages by +their identity, not by where they are installed, so they are recorded under `incorporates` and never +credited as third-party software, wherever they came from. + +Ekklesia proves this before any release: `pnpm packages:pack` builds, packs, and inspects the public +packages, and `bash scripts/test-standalone-module.sh` installs them into a module outside the +repository (`tests/fixtures/standalone-module`), with no workspace link and no Ekklesia registry, +then typechecks, builds, packages, and verifies it. ### An installed module is self-contained @@ -92,8 +185,9 @@ through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bund #### Packaging your module -Build your module, then run the packager from the workspace your module is built in. It is a -development dependency like `@ekklesia/module-ui-build`: +Build your module, then run the packager from your module's directory. It is a development +dependency like `@ekklesia/module-ui-build`, and it takes your module's supported Node.js range from +`engines.node` in its `package.json`: ```bash pnpm exec ekklesia-pack-module --module . --target any --out ../packages \ @@ -163,7 +257,10 @@ The archive holds one directory named with your module id: licence's standard text is complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text - is sufficient for current official modules. + is sufficient for current official modules. An Ekklesia package is one named `@ekklesia/*` whose + `package.json` also carries `"ekklesia": { "distribution": "module-development" }`, whether it is + linked from this repository or installed from a registry; a package with only the name is + third-party. - **Reviewed licence supplements.** Some dependencies omit material that redistribution requires. For one of those, the packager adds material from a reviewed supplement checked in with it, after the package's own material and labelled as such @@ -1458,27 +1555,27 @@ export const events = moduleTable("events", { }); ``` -The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module -SDK ownership marker: +The production schema entrypoint in `src/schema/index.ts` turns those same objects into the plain +schema metadata an ABI-v1 module declares: ```ts -import { defineModuleSchema } from "@ekklesia/module-sdk"; +import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle"; import { events } from "./tables.js"; export { events } from "./tables.js"; -export default defineModuleSchema({ - moduleId: "calendar", - tables: { events } -}); +export default moduleDrizzleSchemaMetadata("calendar", { events }); ``` +A module without an ORM declares the same metadata directly with `defineModuleSchemaMetadata` from +`@ekklesia/module-database`. A legacy-v8 module's schema entrypoint default-exports +`{ moduleId, tables }` with the Drizzle table objects themselves. + The prefix callback converts the module ID to a snake-case namespace and prefixes every local table name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes `care_team_members`. Local names must be lowercase snake case, and the complete physical identifier -must not exceed 63 characters. The SDK's `createModuleTable` provides the same convenience when a -generator-facing pure declaration file is not needed; Core validates built metadata independently -of either helper. +must not exceed 63 characters. `createModuleTable` from `@ekklesia/module-database/drizzle` provides +the same convenience; Core validates built metadata independently of either helper. Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must be side-effect-free: do not connect to a database, read configuration, or perform I/O during import. @@ -1511,8 +1608,8 @@ export default defineConfig({ Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production compiled schema entrypoint and converts those same table objects to plain metadata with -`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to wrap -them with `defineModuleSchema` while that facade exists. +`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to export +them as `{ moduleId, tables }` while that facade exists. Run the module's `migration:generate` script with a lowercase snake-case name, review the generated SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must @@ -1636,6 +1733,15 @@ not Core. ## Legacy database access +> **New modules use [Module Database ABI v1](#module-database-abi-v1).** Core still serves the +> legacy facade described here for its deprecation window +> ([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)), so existing legacy modules +> keep running. But the Module SDK no longer exposes the facade's type +> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): built +> against the current SDK, a module that declares no database sees `services.database` as `never`, +> and the example below no longer typechecks. Migrate to ABI v1 rather than reaching for the type +> elsewhere. + For a version 8 manifest that omits `database`, `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`, diff --git a/package.json b/package.json index b8311ab..6e89ad1 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,11 @@ "modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs", "modules:validate": "turbo run build && node core/dist/cli/validate-modules.js", "modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js", + "packages:pack": "node scripts/pack-public-packages.ts --out .public-packages", "test": "turbo run test test:root", "test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh", "test:root": "vitest run --config vitest.config.ts", + "test:standalone-module": "pnpm build && bash scripts/test-standalone-module.sh", "test:watch": "vitest", "typecheck": "turbo run typecheck typecheck:root", "typecheck:root": "tsc --noEmit --project tsconfig.json", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c4ddb6e..9865919 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -19,25 +19,33 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { - "drizzle-orm": "0.45.2", "semver": "7.8.5", "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" }, "ekklesia": { - "layer": "contracts" + "layer": "contracts", + "distribution": "module-development" } } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8c4520b..f86a201 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,7 +11,6 @@ export * from "./module-manifest.js"; export * from "./module-migration.js"; export * from "./module-realtime.js"; export * from "./module-runtime.js"; -export * from "./module-schema.js"; export * from "./module-schema-metadata.js"; export * from "./module-schema-names.js"; export * from "./module-storage.js"; diff --git a/packages/contracts/tests/module-schema.test.ts b/packages/contracts/tests/module-schema.test.ts index 7e43179..0fe1607 100644 --- a/packages/contracts/tests/module-schema.test.ts +++ b/packages/contracts/tests/module-schema.test.ts @@ -1,11 +1,5 @@ -import { alias, getTableConfig, integer } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import { - moduleTableName, - parseModuleSchemaDefinition, - physicalModuleTableNameSchema, -} from "../src/index.js"; -import { pgTable } from "drizzle-orm/pg-core"; +import { moduleTableName, physicalModuleTableNameSchema } from "../src/index.js"; describe("module schema contract", () => { it("creates deterministic physical names from module and local identifiers", () => { @@ -22,33 +16,4 @@ describe("module schema contract", () => { "The namespaced PostgreSQL table name is invalid or too long.", ); }); - - it("normalizes and freezes a typed schema definition without freezing its tables", () => { - const records = pgTable("example_records", { id: integer().primaryKey() }); - const definition = parseModuleSchemaDefinition({ - moduleId: "example", - tables: { records }, - }); - - expect(definition.tables.records).toBe(records); - expect(getTableConfig(records).name).toBe("example_records"); - expect(Object.isFrozen(definition)).toBe(true); - expect(Object.isFrozen(definition.tables)).toBe(true); - expect(Object.isFrozen(records)).toBe(false); - }); - - it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => { - const records = pgTable("example_records", { id: integer() }); - const disguisedRecords = alias(records, "example_disguised_records"); - expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow(); - expect(() => - parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }), - ).toThrow(); - expect(() => - parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }), - ).toThrow(); - expect(() => - parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }), - ).toThrow(); - }); }); diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 0dc79b5..0f6b7ab 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -15,7 +15,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" }, @@ -28,6 +27,7 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "design-tokens" + "layer": "design-tokens", + "distribution": "module-development" } } diff --git a/packages/module-database/package.json b/packages/module-database/package.json index c65c1f1..c0363d3 100644 --- a/packages/module-database/package.json +++ b/packages/module-database/package.json @@ -19,7 +19,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-database", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json" }, @@ -34,7 +33,8 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "module-sdk" + "layer": "module-sdk", + "distribution": "module-development" }, "peerDependencies": { "drizzle-orm": ">=0.45.2 <0.46.0" diff --git a/packages/module-database/src/drizzle.ts b/packages/module-database/src/drizzle.ts index c620ad5..fcee058 100644 --- a/packages/module-database/src/drizzle.ts +++ b/packages/module-database/src/drizzle.ts @@ -6,8 +6,12 @@ import type { ModuleId, ModuleSchemaMetadataDefinition, } from "@ekklesia/contracts"; -import { parseModuleSchemaMetadataDefinition } from "@ekklesia/contracts"; -import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core"; +import { + moduleTableName, + moduleTablePrefix, + parseModuleSchemaMetadataDefinition, +} from "@ekklesia/contracts"; +import { type AnyPgTable, getTableConfig, pgTableCreator } from "drizzle-orm/pg-core"; import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy"; import type { DrizzleConfig } from "drizzle-orm/utils"; @@ -110,3 +114,13 @@ export function moduleDrizzleSchemaMetadata( }); return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata }); } + +/** + * A Drizzle table factory for this module's tables: every table it creates gets the module's + * mandatory namespace (ADR 0007), so `createModuleTable("care-team")("members", …)` is the physical + * table `care_team_members`. + */ +export function createModuleTable(moduleId: ModuleId) { + moduleTablePrefix(moduleId); + return pgTableCreator((localName) => moduleTableName(moduleId, localName)); +} diff --git a/packages/module-database/src/index.ts b/packages/module-database/src/index.ts index e644f05..61e2bf3 100644 --- a/packages/module-database/src/index.ts +++ b/packages/module-database/src/index.ts @@ -3,8 +3,54 @@ import { type ModuleDatabaseError, type ModuleDatabaseParameter, type ModuleDatabaseQuery, + type ModuleSchemaMetadataDefinition, + parseModuleSchemaMetadataDefinition, } from "@ekklesia/contracts"; +/** + * `@ekklesia/module-database`: everything a module needs for the Module Database ABI v1 (ADR 0050), + * with no ORM. Drizzle support lives in the `@ekklesia/module-database/drizzle` subpath, the only + * place that needs Drizzle (ADR 0053). + */ +export { + isModuleDatabaseError, + MODULE_DATABASE_ABI_VERSION, + MODULE_DATABASE_ERROR_CODES, + type ModuleDatabaseAbiVersion, + type ModuleDatabaseArrayRow, + ModuleDatabaseError, + type ModuleDatabaseErrorCode, + type ModuleDatabaseExecutor, + type ModuleDatabaseField, + type ModuleDatabaseIsolationLevel, + type ModuleDatabaseObjectRow, + type ModuleDatabaseParameter, + type ModuleDatabaseQuery, + type ModuleDatabaseQueryable, + type ModuleDatabaseQueryResult, + type ModuleDatabaseRowMode, + type ModuleDatabaseScalar, + type ModuleDatabaseTransaction, + type ModuleDatabaseTransactionOptions, + type ModuleDatabaseValue, + type ModuleSchemaForeignKeyMetadata, + type ModuleSchemaMetadataDefinition, + type ModuleSchemaTableMetadata, + moduleTableName, + moduleTablePrefix, +} from "@ekklesia/contracts"; + +/** + * A module's plain schema metadata (ADR 0050): the tables it owns and their foreign keys, with no + * ORM object. Validated and frozen; a Drizzle module derives the same data with + * `moduleDrizzleSchemaMetadata` from `@ekklesia/module-database/drizzle`. + */ +export function defineModuleSchemaMetadata( + definition: Definition, +): Readonly { + return parseModuleSchemaMetadataDefinition(definition) as Readonly; +} + /** SQL text and values ready to pass directly to a Module Database ABI v1 executor. */ export type ModuleSqlQuery = Readonly>>; diff --git a/packages/module-sdk/tests/define-module-schema.test.ts b/packages/module-database/tests/create-module-table.test.ts similarity index 58% rename from packages/module-sdk/tests/define-module-schema.test.ts rename to packages/module-database/tests/create-module-table.test.ts index 71ffc9f..0aaed59 100644 --- a/packages/module-sdk/tests/define-module-schema.test.ts +++ b/packages/module-database/tests/create-module-table.test.ts @@ -1,20 +1,17 @@ import { getTableConfig, integer } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import { createModuleTable, defineModuleSchema } from "../src/index.js"; +import { createModuleTable, moduleDrizzleSchemaMetadata } from "../src/drizzle.js"; -describe("module schema authoring", () => { - it("creates namespaced PostgreSQL tables and a typed frozen definition", () => { +describe("Drizzle module tables", () => { + it("creates namespaced PostgreSQL tables that become plain schema metadata", () => { const moduleTable = createModuleTable("care-team"); const members = moduleTable("members", { id: integer().primaryKey() }); - const definition = defineModuleSchema({ - moduleId: "care-team", - tables: { members }, - }); expect(getTableConfig(members).name).toBe("care_team_members"); - expect(definition.tables.members).toBe(members); - expect(Object.isFrozen(definition)).toBe(true); - expect(Object.isFrozen(definition.tables)).toBe(true); + expect(moduleDrizzleSchemaMetadata("care-team", { members })).toEqual({ + moduleId: "care-team", + tables: [{ foreignKeys: [], name: "care_team_members" }], + }); }); it("rejects invalid module and logical table names", () => { diff --git a/packages/module-pack/LICENSE b/packages/module-pack/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/packages/module-pack/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/module-pack/package.json b/packages/module-pack/package.json index 0b0aba5..f22719d 100644 --- a/packages/module-pack/package.json +++ b/packages/module-pack/package.json @@ -21,7 +21,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-pack", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json" }, @@ -45,6 +44,7 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "tooling" + "layer": "tooling", + "distribution": "module-development" } } diff --git a/packages/module-pack/src/pack-module.ts b/packages/module-pack/src/pack-module.ts index dc5d107..f54a855 100644 --- a/packages/module-pack/src/pack-module.ts +++ b/packages/module-pack/src/pack-module.ts @@ -20,10 +20,10 @@ import { build, type Message, type Plugin } from "esbuild"; import { minVersion, satisfies, subset, validRange } from "semver"; import { NATIVE_TARGETS, - type NativePackage, - type NativeTarget, NativeError, type NativeErrorCode, + type NativePackage, + type NativeTarget, nativeClosure, nativeRuntime, } from "./native.js"; @@ -130,7 +130,7 @@ const messages: Readonly> = Object.freeze({ "A package path is not portable printable ASCII that fits the archive format.", MODULE_PACK_REVISION_INVALID: "The source revision must be a full 40- or 64-character commit id.", MODULE_PACK_RUNTIME_UNKNOWN: - "The Node.js range could not be determined. Run inside an Ekklesia workspace, or pass one.", + "The module's package.json must declare the Node.js versions it supports in engines.node.", MODULE_PACK_SDK_INCOMPATIBLE: "The bundled Module SDK is outside the manifest's own compatibility.moduleSdk range.", MODULE_PACK_TARGET_UNSUPPORTED: @@ -170,8 +170,7 @@ export interface PackModuleOptions { readonly sourceRevision: string; /** Informational only; recorded as given. */ readonly repository?: string; - /** The Node.js range the package is built for. Defaults to the enclosing workspace's - * `engines.node`, the toolchain the module was built and tested with. */ + /** The Node.js range the package is built for. Defaults to the module's own `engines.node`. */ readonly nodeRange?: string; /** Reviewed license supplements (ADR 0052). Defaults to the ones this packager ships. */ readonly supplementsDirectory?: string; @@ -234,27 +233,16 @@ async function packagerIdentity(): Promise<{ readonly name: string; readonly ver return { name: String(manifest.name), version: String(manifest.version) }; } -async function workspaceNodeRange(moduleDirectory: string): Promise { - let directory = moduleDirectory; - for (;;) { - try { - await lstat(path.join(directory, "pnpm-workspace.yaml")); - const manifest = await readJson(path.join(directory, "package.json")); - const engines = isRecord(manifest) ? manifest.engines : undefined; - const range = isRecord(engines) ? engines.node : undefined; - if (typeof range === "string") { - return range; - } - break; - } catch { - const parent = path.dirname(directory); - if (parent === directory) { - break; - } - directory = parent; - } +/** The Node.js range a module declares for itself, in its own package.json `engines.node` (ADR + * 0053). Read from the module, never from an enclosing workspace: a standalone module repository has + * none. */ +function moduleNodeRange(developmentPackage: Readonly>): string { + const engines = developmentPackage.engines; + const range = isRecord(engines) ? engines.node : undefined; + if (typeof range !== "string" || range.trim() === "") { + throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN"); } - throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN"); + return range; } /** `./dist/server/index.js` → `dist/server/index.js`, inside the module directory. */ @@ -537,7 +525,7 @@ export async function packModule(options: PackModuleOptions): Promise>; } +/** The marker every published Ekklesia module-development package carries in its package.json. */ +export const EKKLESIA_PACKAGE_DISTRIBUTION = "module-development"; + +/** + * Whether a package is an Ekklesia module-development package: its name is in the `@ekklesia` scope + * **and** its package.json says so explicitly (`"ekklesia": { "distribution": "module-development" }`). + * Neither a path nor a scope name alone is enough. + */ +export function isEkklesiaPackage(manifest: Readonly>): boolean { + const ekklesia = manifest.ekklesia; + return ( + typeof manifest.name === "string" && + manifest.name.startsWith("@ekklesia/") && + isRecord(ekklesia) && + ekklesia.distribution === EKKLESIA_PACKAGE_DISTRIBUTION + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -48,11 +72,13 @@ export class BundledPackageIndex { visited.push(directory); const manifest = await readManifest(directory); if (manifest && typeof manifest.name === "string" && typeof manifest.version === "string") { + const ekklesiaOwned = isEkklesiaPackage(manifest); found = Object.freeze({ + ekklesiaOwned, manifest: Object.freeze(manifest), name: manifest.name, root: directory, - thirdParty: directory.split(path.sep).includes("node_modules"), + thirdParty: !ekklesiaOwned && directory.split(path.sep).includes("node_modules"), version: manifest.version, }); break; diff --git a/packages/module-pack/tests/pack-module.test.ts b/packages/module-pack/tests/pack-module.test.ts index 210c2eb..fc207db 100644 --- a/packages/module-pack/tests/pack-module.test.ts +++ b/packages/module-pack/tests/pack-module.test.ts @@ -23,7 +23,6 @@ async function pack(options: FixtureModuleOptions = {}, overrides: Record=24.11.0 <25", outDirectory, sourceRevision: SOURCE_REVISION, target: "any", @@ -379,6 +378,23 @@ describe("packModule: identity and inputs", () => { await packRejection({}, "MODULE_PACK_REVISION_INVALID", { sourceRevision: "86ae907" }); }); + it("takes the supported Node.js range from the module's own engines.node", async () => { + const { archive } = await pack({ engines: { node: ">=24.12.0 <25" } }); + const metadata = JSON.parse( + fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "", + ); + expect(metadata.runtime).toEqual({ node: ">=24.12.0 <25" }); + }); + + it("refuses a module that does not declare engines.node, without looking for a workspace", async () => { + for (const engines of [null, {}, { node: "" }, { node: 24 }]) { + const error = await packRejection({ engines }, "MODULE_PACK_RUNTIME_UNKNOWN"); + expect(error.message).toBe( + "The module's package.json must declare the Node.js versions it supports in engines.node.", + ); + } + }); + it("refuses an incorporated SDK outside the manifest's own range", async () => { await packRejection( { @@ -392,6 +408,67 @@ describe("packModule: identity and inputs", () => { }); }); +describe("packModule: recognising Ekklesia's packages by identity (ADR 0053)", () => { + async function metadataAndNotices(options: FixtureModuleOptions) { + const { archive } = await pack(options); + const files = fileEntries(archive); + return { + metadata: JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? ""), + notices: files.get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "", + }; + } + + it("incorporates registry-installed Ekklesia packages exactly as workspace-linked ones", async () => { + const options: FixtureModuleOptions = { databaseAbi: 1, withSchema: true }; + const linked = await metadataAndNotices(options); + const installed = await metadataAndNotices({ ...options, ekklesiaInstall: "registry" }); + expect(Object.keys(installed.metadata.incorporates).sort()).toEqual([ + "@ekklesia/contracts", + "@ekklesia/module-database", + "@ekklesia/module-sdk", + ]); + expect(installed.metadata.incorporates).toEqual(linked.metadata.incorporates); + // Never credited as third-party code, however they were installed. + expect(installed.notices).not.toContain("@ekklesia/"); + expect(linked.notices).not.toContain("@ekklesia/"); + }); + + it("still checks a registry-installed SDK against the manifest's moduleSdk range", async () => { + await packRejection( + { + ekklesiaInstall: "registry", + manifest: (manifest) => ({ + ...manifest, + compatibility: { platform: "^0.1.0", moduleSdk: "^9.0.0" }, + }), + }, + "MODULE_PACK_SDK_INCOMPATIBLE", + ); + }); + + it("treats a package that only borrows the @ekklesia scope, or only the marker, as third-party", async () => { + const { metadata, notices } = await metadataAndNotices({ + fakePackages: [ + { + files: { "index.js": "export default 1;\n", LICENSE: MIT }, + name: "@ekklesia/squatter", + packageJson: { ekklesia: { layer: "package" } }, + }, + { + files: { "index.js": "export default 2;\n", LICENSE: MIT }, + name: "not-ekklesia", + packageJson: { ekklesia: { distribution: "module-development" } }, + }, + ], + serverSource: + 'import a from "@ekklesia/squatter";\nimport b from "not-ekklesia";\nexport default { id: "example", version: "0.1.0", register() { return a + b; } };\n', + }); + expect(metadata.incorporates).toEqual({}); + expect(notices).toContain("\n@ekklesia/squatter 1.0.0\nLicense: MIT\n"); + expect(notices).toContain("\nnot-ekklesia 1.0.0\nLicense: MIT\n"); + }); +}); + describe("packModule: THIRD_PARTY_NOTICES", () => { const usesFake = (name: string) => `import value from "${name}";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n`; diff --git a/packages/module-pack/tests/support/fixture-module.ts b/packages/module-pack/tests/support/fixture-module.ts index 8576f06..9a11292 100644 --- a/packages/module-pack/tests/support/fixture-module.ts +++ b/packages/module-pack/tests/support/fixture-module.ts @@ -1,4 +1,14 @@ -import { cp, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -30,6 +40,12 @@ export interface FixtureModuleOptions { readonly serverSource?: string; readonly withModuleDatabase?: boolean; readonly withSchema?: boolean; + /** The module's `engines` field; omitted from `package.json` when `null`. */ + readonly engines?: Record | null; + /** How the module's `@ekklesia/*` dependencies are installed: linked to this workspace's packages + * (the default), or copied as real directories into a flat `node_modules`, as a registry install + * lays them out. */ + readonly ekklesiaInstall?: "workspace" | "registry"; readonly fakePackages?: readonly FakePackage[]; readonly manifest?: (manifest: Record) => Record; } @@ -63,12 +79,9 @@ const defaultServer = ( export default defineModuleRuntime({ ${databaseAbi === 1 ? "database: { abi: 1 }, " : ""}id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} }); `; -const schemaSource = (id: string, databaseAbi?: 1) => `${ - databaseAbi === 1 - ? 'import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";' - : 'import { defineModuleSchema } from "@ekklesia/module-sdk";' -} -import { createModuleTable } from "@ekklesia/module-sdk"; +const schemaSource = (id: string, databaseAbi?: 1) => `import { createModuleTable${ + databaseAbi === 1 ? ", moduleDrizzleSchemaMetadata" : "" +} } from "@ekklesia/module-database/drizzle"; import { text, uuid } from "drizzle-orm/pg-core"; export const records = createModuleTable(${JSON.stringify(id)})("records", { id: uuid("id").primaryKey(), @@ -77,7 +90,7 @@ export const records = createModuleTable(${JSON.stringify(id)})("records", { export default ${ databaseAbi === 1 ? `moduleDrizzleSchemaMetadata(${JSON.stringify(id)}, { records })` - : `defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } })` + : `{ moduleId: ${JSON.stringify(id)}, tables: { records } }` }; `; @@ -87,7 +100,9 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise const root = await scratchDirectory("ekklesia-pack-fixture-"); const moduleDirectory = path.join(root, id); const tableName = `${id.replaceAll("-", "_")}_records`; - const usesModuleDatabase = options.withModuleDatabase || options.databaseAbi === 1; + const usesModuleDatabase = + options.withModuleDatabase || options.withSchema || options.databaseAbi === 1; + const engines = options.engines === undefined ? { node: ">=24.11.0 <25" } : options.engines; const baseManifest: Record = { manifestVersion: 8, @@ -119,6 +134,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise license: Object.hasOwn(options, "license") ? options.license : "MIT", private: true, type: "module", + ...(engines === null ? {} : { engines }), scripts: { build: "tsc" }, dependencies: { "@ekklesia/module-sdk": "workspace:*", @@ -185,18 +201,23 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise }), ); - // The module's dependencies, as a workspace links them: the real packages this package depends on. + // The module's dependencies: the real packages this package depends on, either linked as a + // workspace links them, or copied as a registry install would place them. const modules = path.join(moduleDirectory, "node_modules"); await mkdir(path.join(modules, "@ekklesia"), { recursive: true }); - await symlink( - await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")), - path.join(modules, "@ekklesia", "module-sdk"), - ); - if (usesModuleDatabase) { - await symlink( - await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")), - path.join(modules, "@ekklesia", "module-database"), - ); + const ekklesiaPackages = [ + "module-sdk", + ...(usesModuleDatabase ? ["module-database"] : []), + ] as const; + if (options.ekklesiaInstall === "registry") { + await installLikeARegistry(modules, [...ekklesiaPackages, "contracts"]); + } else { + for (const name of ekklesiaPackages) { + await symlink( + await realpath(path.join(packageRoot, "node_modules", "@ekklesia", name)), + path.join(modules, "@ekklesia", name), + ); + } } await symlink( await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")), @@ -222,6 +243,45 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise return moduleDirectory; } +/** + * Copies each `@ekklesia/*` package's published files (`package.json`, `dist`, `LICENSE`) into a flat + * `node_modules` as real directories, and links the third-party packages they depend on beside them: + * the layout a registry install produces, with nothing pointing back into the workspace's sources. + */ +async function installLikeARegistry(modules: string, names: readonly string[]): Promise { + for (const name of names) { + const source = await realpath(path.join(packageRoot, "..", name)); + const target = path.join(modules, "@ekklesia", name); + await mkdir(target, { recursive: true }); + for (const entry of ["package.json", "dist", "LICENSE"]) { + await cp(path.join(source, entry), path.join(target, entry), { + force: false, + recursive: true, + }).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + const manifest = JSON.parse( + await readFile(path.join(source, "package.json"), "utf8"), + ) as Record | undefined>; + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + const link = path.join(modules, ...dependency.split("/")); + if (dependency.startsWith("@ekklesia/") || (await exists(link))) continue; + await mkdir(path.dirname(link), { recursive: true }); + await symlink(await realpath(path.join(source, "node_modules", dependency)), link); + } + } +} + +async function exists(file: string): Promise { + try { + await lstat(file); + return true; + } catch { + return false; + } +} + export const SOURCE_REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078"; const nativeFixtureSource = path.join(packageRoot, "tests", "fixtures", "native-module"); diff --git a/packages/module-sdk/package.json b/packages/module-sdk/package.json index d67f3e3..f2fb6ab 100644 --- a/packages/module-sdk/package.json +++ b/packages/module-sdk/package.json @@ -15,13 +15,11 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-sdk", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { - "@ekklesia/contracts": "workspace:*", - "drizzle-orm": "0.45.2" + "@ekklesia/contracts": "workspace:*" }, "devDependencies": { "@biomejs/biome": "2.5.3", @@ -30,9 +28,7 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "module-sdk" - }, - "peerDependencies": { - "drizzle-orm": "0.45.2" + "layer": "module-sdk", + "distribution": "module-development" } } diff --git a/packages/module-sdk/src/define-module-runtime.ts b/packages/module-sdk/src/define-module-runtime.ts index d31581f..c9a6211 100644 --- a/packages/module-sdk/src/define-module-runtime.ts +++ b/packages/module-sdk/src/define-module-runtime.ts @@ -1,18 +1,69 @@ import { - type LegacyModuleRuntimeDefinition, + type Awaitable, + type CapabilityRegistrar, + type CapabilityResolver, + type ModuleRegistrationContext as ContractModuleRegistrationContext, + type ModuleRuntimeContext as ContractModuleRuntimeContext, + type ModuleRuntimeServices as ContractModuleRuntimeServices, + type ModuleConfiguration, type ModuleDatabaseAbiRuntimeDefinition, - type ModuleRuntimeDefinition, + type ModuleDatabaseExecutor, + type ModuleId, parseModuleRuntimeDefinition, } from "@ekklesia/contracts"; -export function defineModuleRuntime( - definition: Definition, -): Readonly; -export function defineModuleRuntime( - definition: Definition, -): Readonly; -export function defineModuleRuntime( - definition: Definition, -): Readonly { - return parseModuleRuntimeDefinition(definition) as Readonly; +/** + * What a module's `register` hook receives. `Database` is what `services.database` is for the + * module: the Module Database ABI v1 executor when it declares `database: { abi: 1 }`, and `never` + * when it declares no database, so code cannot reach a database it never declared. + */ +export type ModuleRegistrationContext< + Database extends ModuleDatabaseExecutor = ModuleDatabaseExecutor, +> = ContractModuleRegistrationContext; + +/** What a module's `start` and `stop` hooks receive. */ +export type ModuleRuntimeContext = + ContractModuleRuntimeContext; + +/** The platform services every hook and handler receives. `database` is the ABI v1 executor for a + * module that declares one, and `never` otherwise. */ +export type ModuleRuntimeServices< + Database extends ModuleDatabaseExecutor = ModuleDatabaseExecutor, +> = ContractModuleRuntimeServices; + +/** A module that declares the Module Database ABI v1 (ADR 0050). Its hooks and route handlers see + * `services.database` as the ABI v1 executor. */ +export interface ModuleRuntimeDefinitionWithDatabase extends ModuleDatabaseAbiRuntimeDefinition {} + +/** A module that uses no database. Its hooks see `services.database` as `never`. */ +export interface ModuleRuntimeDefinitionWithoutDatabase { + readonly id: ModuleId; + readonly version: string; + readonly database?: never; + readonly register?: (context: ModuleRegistrationContext) => Awaitable; + readonly start?: (context: ModuleRuntimeContext) => Awaitable; + readonly stop?: (context: ModuleRuntimeContext) => Awaitable; } + +/** Every runtime definition the Module SDK supports. The legacy Drizzle database facade is not one + * of them: a module that uses a database declares `database: { abi: 1 }` (ADR 0053). */ +export type ModuleRuntimeDefinition = + | ModuleRuntimeDefinitionWithDatabase + | ModuleRuntimeDefinitionWithoutDatabase; + +/** + * Validates and freezes a module's server runtime. The result is typed as one of this package's own + * named interfaces, so a module that emits declarations can always name its runtime's type through + * `@ekklesia/module-sdk`, without depending on the packages behind it. + */ +export function defineModuleRuntime( + definition: ModuleRuntimeDefinitionWithDatabase, +): ModuleRuntimeDefinitionWithDatabase; +export function defineModuleRuntime( + definition: ModuleRuntimeDefinitionWithoutDatabase, +): ModuleRuntimeDefinitionWithoutDatabase; +export function defineModuleRuntime(definition: ModuleRuntimeDefinition): ModuleRuntimeDefinition { + return parseModuleRuntimeDefinition(definition) as ModuleRuntimeDefinition; +} + +export type { Awaitable, CapabilityRegistrar, CapabilityResolver, ModuleConfiguration }; diff --git a/packages/module-sdk/src/define-module-schema.ts b/packages/module-sdk/src/define-module-schema.ts deleted file mode 100644 index 1e04bfd..0000000 --- a/packages/module-sdk/src/define-module-schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - type ModuleId, - type ModuleSchemaDefinition, - moduleTableName, - moduleTablePrefix, - parseModuleSchemaDefinition, -} from "@ekklesia/contracts"; -import { pgTableCreator } from "drizzle-orm/pg-core"; - -export function createModuleTable(moduleId: ModuleId) { - moduleTablePrefix(moduleId); - return pgTableCreator((localName) => moduleTableName(moduleId, localName)); -} - -export function defineModuleSchema( - definition: Definition, -): Readonly { - return parseModuleSchemaDefinition(definition) as Readonly; -} diff --git a/packages/module-sdk/src/http.ts b/packages/module-sdk/src/http.ts new file mode 100644 index 0000000..4726cab --- /dev/null +++ b/packages/module-sdk/src/http.ts @@ -0,0 +1,24 @@ +import type { + ModuleHttpRouteRegistrar as ContractModuleHttpRouteRegistrar, + ModuleRouteDefinitionInput as ContractModuleRouteDefinitionInput, + ModuleRouteHandler as ContractModuleRouteHandler, + ModuleRouteHandlerContext as ContractModuleRouteHandlerContext, + ModuleDatabaseExecutor, +} from "@ekklesia/contracts"; + +/** What a route handler receives. `services.database` is the module's ABI v1 executor, or `never` + * for a module that declares no database. */ +export type ModuleRouteHandlerContext< + Database extends ModuleDatabaseExecutor = ModuleDatabaseExecutor, +> = ContractModuleRouteHandlerContext; + +export type ModuleRouteHandler = + ContractModuleRouteHandler; + +export type ModuleRouteDefinitionInput< + Database extends ModuleDatabaseExecutor = ModuleDatabaseExecutor, +> = ContractModuleRouteDefinitionInput; + +export type ModuleHttpRouteRegistrar< + Database extends ModuleDatabaseExecutor = ModuleDatabaseExecutor, +> = ContractModuleHttpRouteRegistrar; diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts index eb860e8..e10363c 100644 --- a/packages/module-sdk/src/index.ts +++ b/packages/module-sdk/src/index.ts @@ -1,3 +1,102 @@ -export * from "@ekklesia/contracts"; -export * from "./define-module-runtime.js"; -export * from "./define-module-schema.js"; +/** + * `@ekklesia/module-sdk`: the supported module-author API (ADR 0053). + * + * Every export is chosen. The platform's internal contracts (identity parsing, authorization, + * lifecycle, manifest internals beyond what a module validates about itself) are not re-exported, + * and neither is the legacy Drizzle database facade. A module that uses a database declares + * `database: { abi: 1 }` and gets its database types and helpers from `@ekklesia/module-database`. + * Nothing here loads or requires Drizzle. + */ + +export type { + Awaitable, + CapabilityId, + CapabilityRegistrar, + CapabilityResolver, + ModuleId, + RegisteredCapability, +} from "@ekklesia/contracts"; +// HTTP routes (ADR 0015, ADR 0042). +// Who is calling (ADR 0013, ADR 0036): only what a route handler receives and branches on. +// Configuration (ADR 0029). +// Object storage (ADR 0030, ADR 0031). +// Realtime (ADR 0032). +// The module's own static artifacts, for a module's tests to validate them the way Core does. +export { + ANONYMOUS_PRINCIPAL, + type EkklesiaAnonymousPrincipal, + type EkklesiaAuthenticatedPrincipal, + type EkklesiaDeviceId, + type EkklesiaDevicePrincipal, + type EkklesiaPrincipal, + type EkklesiaUserId, + EMPTY_MODULE_CONFIGURATION, + EMPTY_MODULE_STORAGE, + isAnonymousPrincipal, + isAuthenticatedPrincipal, + isDevicePrincipal, + isModuleHttpMethod, + isModuleRouteBodyMode, + isValidModuleStorageKey, + isValidModuleStorageReadUrlFilename, + MODULE_HTTP_BODY_MODES, + MODULE_HTTP_METHODS, + MODULE_REALTIME_CHANNEL_MAX_LENGTH, + MODULE_REALTIME_ERROR_CODES, + MODULE_REALTIME_MAX_CHANNELS_PER_MODULE, + MODULE_REALTIME_MAX_PAYLOAD_BYTES, + MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL, + MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES, + MODULE_ROUTE_MAX_BODY_BYTES_CEILING, + MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING, + MODULE_STORAGE_ERROR_CODES, + MODULE_STORAGE_KEY_MAX_BYTES, + MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, + MODULE_STORAGE_READ_URL_FILENAME_MAX_LENGTH, + MODULE_STORAGE_READ_URL_MAX_SECONDS, + MODULE_STORAGE_READ_URL_MIN_SECONDS, + type ModuleConfiguration, + type ModuleConfigurationValue, + type ModuleHttpMethod, + type ModuleManifest, + type ModuleRealtimeChannel, + type ModuleRealtimeChannelDefinitionInput, + type ModuleRealtimeChannelRegistrar, + ModuleRealtimeError, + type ModuleRealtimeErrorCode, + type ModuleRealtimeJsonPrimitive, + type ModuleRealtimeJsonValue, + type ModuleRealtimePublisher, + type ModuleRouteBodyMode, + type ModuleRoutePath, + type ModuleStorage, + type ModuleStorageBody, + type ModuleStorageBucket, + ModuleStorageError, + type ModuleStorageErrorCode, + type ModuleStorageListing, + type ModuleStorageListOptions, + type ModuleStorageObject, + type ModuleStorageObjectMetadata, + type ModuleStoragePutOptions, + type ModuleStorageReadUrl, + type ModuleStorageReadUrlOptions, + parseDrizzleMigrationJournal, + parseModuleManifest, + parseModuleMigrationPolicy, +} from "@ekklesia/contracts"; +export { + defineModuleRuntime, + type ModuleRegistrationContext, + type ModuleRuntimeContext, + type ModuleRuntimeDefinition, + type ModuleRuntimeDefinitionWithDatabase, + type ModuleRuntimeDefinitionWithoutDatabase, + type ModuleRuntimeServices, +} from "./define-module-runtime.js"; +export type { + ModuleHttpRouteRegistrar, + ModuleRouteDefinitionInput, + ModuleRouteHandler, + ModuleRouteHandlerContext, +} from "./http.js"; diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts index f57a470..27e9cda 100644 --- a/packages/module-sdk/tests/define-module-runtime.test.ts +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -6,10 +6,12 @@ import { MODULE_HTTP_METHODS, type ModuleHttpRouteRegistrar, type ModuleRegistrationContext, + type ModuleRuntimeDefinitionWithDatabase, + type ModuleRuntimeDefinitionWithoutDatabase, } from "../src/index.js"; describe("defineModuleRuntime", () => { - it("types each database mode without exposing a legacy-or-ABI union", () => { + it("types the ABI executor for a database module, and no database at all otherwise", () => { defineModuleRuntime({ database: { abi: 1 }, id: "example", @@ -35,16 +37,27 @@ describe("defineModuleRuntime", () => { }); defineModuleRuntime({ - id: "legacy", + id: "no-database", version: "0.1.0", start({ services }) { - expectTypeOf(services.database.select).toBeFunction(); - // @ts-expect-error Legacy modules do not receive the ABI executor. - services.database.abiVersion; + // The SDK never types the legacy Drizzle facade (ADR 0053): a module without the ABI has + // no database to reach. + expectTypeOf(services.database).toBeNever(); }, }); }); + it("types the result as the SDK's own named interfaces, so a module's declarations can name it", () => { + // A module that emits declarations names its default export's type; with an inferred literal + // type that would reach into packages a standalone module does not depend on directly. + expectTypeOf( + defineModuleRuntime({ database: { abi: 1 }, id: "example", version: "0.1.0" }), + ).toEqualTypeOf(); + expectTypeOf( + defineModuleRuntime({ id: "example", version: "0.1.0" }), + ).toEqualTypeOf(); + }); + it("defines a frozen server runtime without duplicating the static manifest", async () => { const start = vi.fn(); const runtime = defineModuleRuntime({ diff --git a/packages/module-ui-build/package.json b/packages/module-ui-build/package.json index c510de3..113747f 100644 --- a/packages/module-ui-build/package.json +++ b/packages/module-ui-build/package.json @@ -19,7 +19,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-build", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json" }, @@ -34,6 +33,7 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "tooling" + "layer": "tooling", + "distribution": "module-development" } } diff --git a/packages/module-ui-host/package.json b/packages/module-ui-host/package.json index bfe20fe..49bd196 100644 --- a/packages/module-ui-host/package.json +++ b/packages/module-ui-host/package.json @@ -1,6 +1,7 @@ { "name": "@ekklesia/module-ui-host", "version": "0.1.2", + "private": true, "license": "AGPL-3.0-or-later", "type": "module", "files": [ @@ -15,7 +16,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-host", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" }, diff --git a/packages/module-ui-sdk/package.json b/packages/module-ui-sdk/package.json index 3b5c252..08bff28 100644 --- a/packages/module-ui-sdk/package.json +++ b/packages/module-ui-sdk/package.json @@ -15,7 +15,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-sdk", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" }, @@ -26,6 +25,7 @@ "vitest": "4.1.10" }, "ekklesia": { - "layer": "module-ui-sdk" + "layer": "module-ui-sdk", + "distribution": "module-development" } } diff --git a/packages/module-ui-sdk/tests/browser-safety.test.ts b/packages/module-ui-sdk/tests/browser-safety.test.ts index 6b4501c..7a5b6fb 100644 --- a/packages/module-ui-sdk/tests/browser-safety.test.ts +++ b/packages/module-ui-sdk/tests/browser-safety.test.ts @@ -11,9 +11,9 @@ async function sourceFiles(): Promise { } /** - * This package is bundled into a browser. `@ekklesia/module-sdk` re-exports all of - * `@ekklesia/contracts`, which pulls Zod, Drizzle, and semver behind one barrel export, so a single - * value import from a backend package here would drag that whole graph into every module UI bundle + * This package is bundled into a browser. `@ekklesia/module-sdk` is built on `@ekklesia/contracts`, + * which pulls Zod and semver behind one barrel export, so a single value import from a backend + * package here would drag that whole graph into every module UI bundle * (ADR 0022). ADR 0024 removed even the former type-only contracts dependency because its type graph * leaked Node globals into browser programs; this package must remain completely dependency-free. */ diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index b80617a..9b3c69f 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -2,7 +2,6 @@ "name": "@ekklesia/typescript-config", "version": "0.1.2", "license": "AGPL-3.0-or-later", - "private": true, "files": [ "base.json", "browser-react.json", @@ -18,6 +17,7 @@ "./react-native.json": "./react-native.json" }, "ekklesia": { - "layer": "tooling" + "layer": "tooling", + "distribution": "module-development" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 9c1b27a..e5699b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,16 +15,14 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "lint": "biome lint .", - "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/ui", "test": "vitest run", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" }, "dependencies": { - "@ekklesia/design-tokens": "workspace:*", - "react": "19.2.8" + "@ekklesia/design-tokens": "workspace:*" }, "peerDependencies": { - "react": "19.2.8" + "react": "^19.2.0" }, "devDependencies": { "@biomejs/biome": "2.5.3", @@ -33,11 +31,13 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "happy-dom": "20.0.11", + "react": "19.2.8", "react-dom": "19.2.8", "typescript": "7.0.2", "vitest": "4.1.10" }, "ekklesia": { - "layer": "ui" + "layer": "ui", + "distribution": "module-development" } } diff --git a/packages/ui/tests/tokens.test.ts b/packages/ui/tests/tokens.test.ts index e4aa63d..b81827c 100644 --- a/packages/ui/tests/tokens.test.ts +++ b/packages/ui/tests/tokens.test.ts @@ -65,13 +65,18 @@ describe("the shared elements consume design tokens, never hardcoded theme value it("depends only on the design tokens and React", async () => { const manifest = JSON.parse( await readFile(path.join(packageDirectory, "package.json"), "utf8"), - ) as { dependencies?: Record; peerDependencies?: Record }; + ) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }; - // React is both a production dependency and a peer: the peer states that the consumer's React - // must be the same one, and the production declaration is what makes the package's own imports - // resolve, since a consumer never satisfies a linked workspace package's peer - // (`runtime-imports-are-production-dependencies`). - expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens", "react"]); + // React is a peer only (ADR 0053): the consumer's React is the one in every bundle these + // elements are built into. The development dependency resolves the package's own imports for its + // builds and tests; nothing loads it from this workspace in a production Node.js process + // (`runtime-imports-are-production-dependencies` allows exactly this peer). + expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]); + expect(manifest.devDependencies?.react).toBeDefined(); expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4ca5b4..4b10d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,9 +149,6 @@ importers: packages/contracts: dependencies: - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) semver: specifier: 7.8.5 version: 7.8.5 @@ -174,6 +171,9 @@ 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 @@ -278,9 +278,6 @@ importers: '@ekklesia/contracts': specifier: workspace:* version: link:../contracts - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -364,9 +361,6 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../design-tokens - react: - specifier: 19.2.8 - version: 19.2.8 devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -386,6 +380,9 @@ importers: happy-dom: specifier: 20.0.11 version: 20.0.11 + react: + specifier: 19.2.8 + version: 19.2.8 react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index b5c94a4..a4d0ce6 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -149,13 +149,17 @@ const DEPENDENCY_FIELDS = [ ] as const; /** - * Runtime peers whose identity is deliberately supplied by the final consumer. The sole entry is - * the module-side Drizzle adapter: a module chooses and bundles its own compatible Drizzle runtime, - * while Core must never select one for it. Keep this exception package-and-dependency specific; - * ordinary workspace packages still need production dependencies for runtime imports. + * Runtime peers whose identity is deliberately supplied by the final consumer, and which no + * production Node.js process loads from this workspace. The module-side Drizzle adapter: a module + * chooses and bundles its own compatible Drizzle runtime, while Core must never select one for it. + * And the shared UI elements' React (ADR 0053): `@ekklesia/ui` is only ever bundled into browser + * artifacts, by the shell's build and by module UI builds, and the consumer's own React must be the + * one in that bundle. Keep this exception package-and-dependency specific; ordinary workspace + * packages still need production dependencies for runtime imports. */ const CONSUMER_SELECTED_RUNTIME_PEERS = new Map>([ ["@ekklesia/module-database", new Set(["drizzle-orm"])], + ["@ekklesia/ui", new Set(["react"])], ]); const SHARED_PACKAGE_ROLES = new Set([ "api-client", diff --git a/scripts/pack-public-packages.ts b/scripts/pack-public-packages.ts new file mode 100644 index 0000000..06d0bcb --- /dev/null +++ b/scripts/pack-public-packages.ts @@ -0,0 +1,304 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +/** + * The public module-development package set (ADR 0053), and the release check that packs it. + * + * node scripts/pack-public-packages.ts --out + * + * Rebuilds each public package from clean output, packs it with `pnpm pack`, and inspects every + * tarball before anything may consume it: only the package's built files, its declarations, and a + * `LICENSE`; a manifest with no `workspace:` range, no install-time lifecycle script, the explicit + * distribution marker, and the one coordinated version. It publishes nothing. + */ + +const execFileAsync = promisify(execFile); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export type PublicPackageRole = "author-api" | "tooling" | "pre-1.0" | "implementation"; + +export interface PublicPackage { + readonly name: string; + /** Relative to the repository root. */ + readonly directory: string; + readonly role: PublicPackageRole; +} + +/** ADR 0053's public set. Every other workspace package is private and never published. */ +export const PUBLIC_PACKAGES: readonly PublicPackage[] = Object.freeze([ + { directory: "packages/module-sdk", name: "@ekklesia/module-sdk", role: "author-api" }, + { directory: "packages/module-database", name: "@ekklesia/module-database", role: "author-api" }, + { directory: "packages/module-ui-sdk", name: "@ekklesia/module-ui-sdk", role: "author-api" }, + { directory: "packages/design-tokens", name: "@ekklesia/design-tokens", role: "author-api" }, + { directory: "packages/module-ui-build", name: "@ekklesia/module-ui-build", role: "tooling" }, + { directory: "packages/module-pack", name: "@ekklesia/module-pack", role: "tooling" }, + { + directory: "packages/typescript-config", + name: "@ekklesia/typescript-config", + role: "tooling", + }, + { directory: "packages/ui", name: "@ekklesia/ui", role: "pre-1.0" }, + // A dependency of the packages above, never a supported direct module-author dependency. + { directory: "packages/contracts", name: "@ekklesia/contracts", role: "implementation" }, +]); + +/** `ekklesia.distribution` in a public package's manifest: the identity the canonical packager + * recognizes an installed Ekklesia package by (`packages/module-pack/src/packages.ts`). */ +export const PUBLIC_PACKAGE_DISTRIBUTION = "module-development"; + +/** Scripts a package manager runs on a consumer's machine when it installs, packs, or publishes. */ +const LIFECYCLE_SCRIPTS = Object.freeze([ + "preinstall", + "install", + "postinstall", + "prepare", + "prepack", + "postpack", + "prepublish", + "prepublishOnly", + "publish", + "postpublish", +]); + +/** The fields a consumer's install resolves. `devDependencies` are never installed from a published + * package, so a tooling package's test-only use of a private package is not a problem there. */ +const INSTALLED_DEPENDENCY_FIELDS = Object.freeze([ + "dependencies", + "peerDependencies", + "optionalDependencies", +]); + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** What is wrong with a packed `package.json`, or nothing. */ +export function packedManifestProblems( + manifest: Readonly>, + expected: { readonly name: string; readonly version: string }, +): string[] { + const problems: string[] = []; + if (manifest.name !== expected.name) problems.push(`is named ${String(manifest.name)}`); + if (manifest.version !== expected.version) { + problems.push( + `has version ${String(manifest.version)}, not the coordinated ${expected.version}`, + ); + } + if (manifest.private !== undefined) problems.push("declares private"); + if (manifest.license !== "AGPL-3.0-or-later") problems.push("does not declare its licence"); + if (JSON.stringify(manifest).includes("workspace:")) problems.push("keeps a workspace: range"); + const ekklesia = manifest.ekklesia; + if (!isRecord(ekklesia) || ekklesia.distribution !== PUBLIC_PACKAGE_DISTRIBUTION) { + problems.push("lacks the module-development distribution marker"); + } + const scripts = isRecord(manifest.scripts) ? manifest.scripts : {}; + for (const script of LIFECYCLE_SCRIPTS) { + if (Object.hasOwn(scripts, script)) problems.push(`runs a ${script} script`); + } + const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name)); + for (const field of INSTALLED_DEPENDENCY_FIELDS) { + const dependencies = manifest[field]; + if (!isRecord(dependencies)) continue; + for (const [name, range] of Object.entries(dependencies)) { + if (!name.startsWith("@ekklesia/")) continue; + if (!publicNames.has(name)) problems.push(`${field} names the private ${name}`); + else if (range !== expected.version) { + problems.push(`${field} names ${name}@${String(range)}, not ${expected.version}`); + } + } + } + return problems; +} + +const BUILT_FILE = /^dist\/(.+?)(?:\.d\.ts|\.js)(?:\.map)?$/u; + +/** + * What is wrong with a tarball's file list, or nothing. Every file is `package.json`, `LICENSE`, or + * under one of the manifest's `files` entries; nothing is a source, test, or configuration file; and + * every built file has the source it was compiled from, so stale output from a deleted source fails. + */ +export function tarballFileProblems( + files: readonly string[], + manifest: Readonly>, + hasSource: (relativeWithoutExtension: string) => boolean, +): string[] { + const problems: string[] = []; + const declared = Array.isArray(manifest.files) ? manifest.files.map(String) : []; + if (!files.includes("LICENSE")) problems.push("has no LICENSE"); + if (!files.includes("package.json")) problems.push("has no package.json"); + for (const file of files) { + if (file === "package.json" || file === "LICENSE") continue; + if (!declared.some((entry) => file === entry || file.startsWith(`${entry}/`))) { + problems.push(`ships ${file}, which its files field does not name`); + continue; + } + if ( + /(?:^|\/)(?:src|tests?|node_modules)\//u.test(file) || + /\.test\.[cm]?[jt]sx?$/u.test(file) + ) { + problems.push(`ships ${file}`); + continue; + } + const built = BUILT_FILE.exec(file); + if (file.startsWith("dist/") && (built === null || !hasSource(built[1] ?? ""))) { + problems.push(`ships ${file}, which no source produces`); + } + } + return problems; +} + +async function readJson(file: string): Promise> { + return JSON.parse(await readFile(file, "utf8")) as Record; +} + +async function filesUnder(directory: string): Promise { + const entries = await readdir(directory, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => + path.relative(directory, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"), + ) + .sort(); +} + +async function sourceExists(packageDirectory: string, relative: string): Promise { + for (const extension of [".ts", ".tsx"]) { + try { + await stat(path.join(packageDirectory, "src", `${relative}${extension}`)); + return true; + } catch {} + } + return false; +} + +export interface PackedPackage { + readonly name: string; + readonly tarball: string; + readonly files: number; + readonly bytes: number; +} + +async function pnpm(arguments_: readonly string[], cwd: string): Promise { + const { stdout } = await execFileAsync("pnpm", [...arguments_], { + cwd, + maxBuffer: 64 * 1024 * 1024, + }); + return stdout; +} + +async function packOne( + entry: PublicPackage, + version: string, + outDirectory: string, + scratch: string, +): Promise<{ packed: PackedPackage; problems: string[] }> { + const directory = path.join(repositoryRoot, entry.directory); + const report = JSON.parse( + await pnpm(["pack", "--json", "--pack-destination", outDirectory], directory), + ) as { filename: string }; + const tarball = report.filename; + const extracted = path.join(scratch, entry.name.replace("/", "+")); + await mkdir(extracted, { recursive: true }); + await execFileAsync("tar", ["-xzf", tarball, "-C", extracted]); + const root = path.join(extracted, "package"); + const files = await filesUnder(root); + const manifest = await readJson(path.join(root, "package.json")); + + const sources = new Map(); + for (const file of files) { + const built = BUILT_FILE.exec(file)?.[1]; + if (built !== undefined && !sources.has(built)) { + sources.set(built, await sourceExists(directory, built)); + } + } + const problems = [ + ...packedManifestProblems(manifest, { name: entry.name, version }), + ...tarballFileProblems(files, manifest, (relative) => sources.get(relative) === true), + ]; + // Nothing the package says may name the machine or checkout it was packed on. + for (const file of files) { + const text = (await readFile(path.join(root, file))).toString("latin1"); + for (const local of [repositoryRoot, homedir()]) { + if (text.includes(local)) problems.push(`${file} names a local path`); + } + } + return { + packed: { bytes: (await stat(tarball)).size, files: files.length, name: entry.name, tarball }, + problems: [...new Set(problems)], + }; +} + +/** Rebuilds the public set from clean output, packs it into `outDirectory`, and inspects it. */ +export async function packPublicPackages(outDirectory: string): Promise<{ + readonly packed: readonly PackedPackage[]; + readonly problems: readonly string[]; +}> { + const version = String((await readJson(path.join(repositoryRoot, "package.json"))).version); + await mkdir(outDirectory, { recursive: true }); + const out = await realpath(outDirectory); + + // Stale output from a deleted source would otherwise ship: the build only ever adds files, and + // Turbo's cache would restore whatever an earlier build left behind. + for (const entry of PUBLIC_PACKAGES) { + await rm(path.join(repositoryRoot, entry.directory, "dist"), { force: true, recursive: true }); + } + await pnpm( + [ + "exec", + "turbo", + "run", + "build", + "--force", + "--output-logs=errors-only", + ...PUBLIC_PACKAGES.map((entry) => `--filter=${entry.name}`), + ], + repositoryRoot, + ); + + const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-public-packages-")); + try { + const packed: PackedPackage[] = []; + const problems: string[] = []; + for (const entry of PUBLIC_PACKAGES) { + const result = await packOne(entry, version, out, scratch); + packed.push(result.packed); + problems.push(...result.problems.map((problem) => `${entry.name} ${problem}.`)); + } + return { packed, problems }; + } finally { + await rm(scratch, { force: true, recursive: true }); + } +} + +async function main(argv: readonly string[]): Promise { + if (argv.length !== 2 || argv[0] !== "--out" || !argv[1]) { + process.stderr.write("Usage: node scripts/pack-public-packages.ts --out \n"); + return 64; + } + try { + const { packed, problems } = await packPublicPackages(path.resolve(argv[1])); + for (const entry of packed) { + process.stdout.write( + `${path.basename(entry.tarball)}: ${entry.files} files, ${entry.bytes} bytes\n`, + ); + } + if (problems.length > 0) { + for (const problem of problems) process.stderr.write(`${problem}\n`); + return 1; + } + process.stdout.write(`Packed and inspected ${packed.length} public packages.\n`); + return 0; + } catch { + process.stderr.write("Packing the public packages failed while building or packing.\n"); + return 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await main(process.argv.slice(2)); +} diff --git a/scripts/test-standalone-module.sh b/scripts/test-standalone-module.sh new file mode 100755 index 0000000..c070199 --- /dev/null +++ b/scripts/test-standalone-module.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# The standalone module-development proof (ADR 0053): a module outside this repository, installed +# from locally packed @ekklesia/* tarballs, with no workspace link and no Ekklesia registry, must +# install, typecheck, build its backend and UI, package with the canonical packager, and verify with +# the platform's own verifier. +# +# bash scripts/test-standalone-module.sh +# +# Third-party packages (React, TypeScript, esbuild, ...) come from the public npm registry as usual. +# Nothing is published, and nothing outside a temporary directory is written except the packed +# public packages' rebuilt dist/ output. +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/ekklesia-standalone-XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail() { + echo "standalone module proof failed: $*" >&2 + exit 1 +} + +version="$(node -p 'require(process.argv[1]).version' "$repository_root/package.json")" + +echo "==> Packing and inspecting the public package set ($version)" +node "$repository_root/scripts/pack-public-packages.ts" --out "$work/tarballs" + +module="$work/standalone-proof" +cp -R "$repository_root/tests/fixtures/standalone-module" "$module" +cp "$repository_root/LICENSE" "$module/LICENSE" + +# A module repository's own pnpm settings: esbuild (used by the UI build and the packager) runs its +# install script. And, for this proof only, every @ekklesia/* package, the module's own and the ones +# they depend on, resolves to its packed tarball, exactly as it would to a published version. +{ + echo "allowBuilds:" + echo " esbuild: true" + echo "overrides:" + for tarball in "$work"/tarballs/*.tgz; do + name="$(tar -xzOf "$tarball" package/package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).name')" + echo " \"$name\": \"file:$tarball\"" + done +} >"$module/pnpm-workspace.yaml" + +echo "==> Installing the module from the tarballs" +(cd "$module" && pnpm install --reporter=append-only >"$work/install.log" 2>&1) || + { tail -40 "$work/install.log" >&2; fail "install"; } + +echo "==> Checking what the install resolved" +(cd "$module" && node --input-type=module - "$module" <<'EOF') +import { readdirSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const [moduleDirectory] = process.argv.slice(2); +const store = readdirSync(path.join(moduleDirectory, "node_modules", ".pnpm")); +const failures = []; +// The Module SDK and its dependencies never pull Drizzle into a module that does not use it. +if (store.some((entry) => entry.startsWith("drizzle-orm@"))) failures.push("drizzle-orm is installed"); +// One React, the module's own, shared by @ekklesia/ui. +const reacts = store.filter((entry) => /^react@/u.test(entry)); +if (reacts.length !== 1) failures.push(`${reacts.length} copies of react are installed`); +const fromModule = createRequire(path.join(moduleDirectory, "package.json")); +const ui = realpathSync(path.join(moduleDirectory, "node_modules", "@ekklesia", "ui", "package.json")); +const fromUi = createRequire(ui); +if (realpathSync(fromModule.resolve("react")) !== realpathSync(fromUi.resolve("react"))) { + failures.push("@ekklesia/ui resolves a different react"); +} +// Every Ekklesia package came from a tarball into the module's own store, never from a link. +for (const name of readdirSync(path.join(moduleDirectory, "node_modules", "@ekklesia"))) { + const real = realpathSync(path.join(moduleDirectory, "node_modules", "@ekklesia", name)); + if (!real.startsWith(path.join(moduleDirectory, "node_modules", ".pnpm") + path.sep)) { + failures.push(`@ekklesia/${name} is linked from outside the module`); + } +} +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); +} +EOF + +echo "==> Typechecking 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"; } + +# The packager needs no workspace file: the module's Node.js range is its own engines.node. The +# binary is run directly, because pnpm would re-resolve the module without the proof's overrides. +rm "$module/pnpm-workspace.yaml" + +echo "==> Packaging with the canonical packager" +(cd "$module" && ./node_modules/.bin/ekklesia-pack-module --module . --target any --out "$work/out" \ + --license-file LICENSE --source-revision 86ae907f59458508e3277666cdd214f5bd7c1078) +archive="$work/out/standalone-proof-0.1.0-any.tar.gz" + +echo "==> Verifying with the platform verifier" +(cd "$repository_root" && node core/dist/cli/verify-module-package.js "$archive" \ + --sha256-file "$archive.sha256" --extract-to "$work/verified") + +echo "==> Checking the package's own description" +node --input-type=module - "$work/verified" "$version" "$repository_root" "$work" <<'EOF' +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const [verified, version, repositoryRoot, work] = process.argv.slice(2); +const read = (file) => readFileSync(path.join(verified, file), "utf8"); +const metadata = JSON.parse(read("ekklesia.package.json")); +const failures = []; +const incorporates = metadata.incorporates ?? {}; +for (const name of ["@ekklesia/contracts", "@ekklesia/module-database", "@ekklesia/module-sdk"]) { + if (incorporates[name] !== version) failures.push(`incorporates ${name}@${incorporates[name]}`); +} +if (metadata.runtime?.node !== ">=24.11.0 <25") failures.push("runtime.node is not engines.node"); +if (metadata.transitional !== undefined) failures.push("records transitional database facts"); +const notices = read("THIRD_PARTY_NOTICES"); +if (notices.includes("@ekklesia/")) failures.push("credits Ekklesia as third-party"); +// The UI bundle holds one React, the module's own. +const reacts = notices.split("\n").filter((line) => /^react \d/u.test(line)); +if (reacts.length !== 1) failures.push(`credits ${reacts.length} copies of react`); +for (const entry of readdirSync(verified, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue; + const text = readFileSync(path.join(entry.parentPath, entry.name), "latin1"); + if (text.includes(repositoryRoot) || text.includes(work)) failures.push(`${entry.name} names a local path`); + if (/drizzle-orm/u.test(text) && entry.name.endsWith(".js")) failures.push(`${entry.name} bundles Drizzle`); +} +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); +} +console.log(`incorporates ${JSON.stringify(incorporates)}`); +EOF + +echo "Standalone module proof passed." diff --git a/templates/module/package.json b/templates/module/package.json index 5e476ab..1ed3120 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -4,6 +4,9 @@ "license": "AGPL-3.0-or-later", "private": true, "type": "module", + "engines": { + "node": ">=24.11.0 <25" + }, "files": [ "dist", "drizzle", diff --git a/templates/module/src/schema/tables.ts b/templates/module/src/schema/tables.ts index 63a4d71..7c66c77 100644 --- a/templates/module/src/schema/tables.ts +++ b/templates/module/src/schema/tables.ts @@ -1,4 +1,4 @@ -import { createModuleTable } from "@ekklesia/module-sdk"; +import { createModuleTable } from "@ekklesia/module-database/drizzle"; import { text, uuid } from "drizzle-orm/pg-core"; const moduleTable = createModuleTable("example"); diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index 021edd0..5d85035 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -27,7 +27,7 @@ export default defineModuleRuntime({ // make this choice consciously: 0 means "no body accepted" (a non-empty declared or streamed // body is rejected before this handler ever runs); a route that does accept a body declares // its own explicit byte maximum instead, up to the platform's ceiling - // (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`). + // (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/module-sdk`). maxBodyBytes: 0, async handler(_request, context) { const database = moduleDrizzle(context.services.database); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 0ee9298..7c0adad 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; +import type { ModuleDatabaseExecutor } from "@ekklesia/module-database"; import { ANONYMOUS_PRINCIPAL, type CapabilityId, EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_STORAGE, type ModuleConfiguration, - type ModuleDatabaseExecutor, type ModuleRouteDefinitionInput, type ModuleRouteHandlerContext, type ModuleRuntimeServices, diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 8eb56ea..c80181d 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -119,7 +119,7 @@ describe("repository dependency boundaries", () => { expect(result.violations).toEqual([]); }); - it("accepts only the module database adapter's consumer-selected Drizzle runtime peer", async () => { + it("accepts only the consumer-selected runtime peers: the database adapter's Drizzle and the shared UI's React", async () => { const result = await checkBoundaries(path.join(fixtures, "legal-consumer-runtime-peer")); expect(result.violations).toEqual([]); diff --git a/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/package.json b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/package.json new file mode 100644 index 0000000..a830345 --- /dev/null +++ b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ekklesia/ui", + "version": "0.0.0", + "type": "module", + "peerDependencies": { "react": "^19.2.0" }, + "devDependencies": { "react": "19.2.8" }, + "exports": { + ".": "./src/index.tsx" + }, + "ekklesia": { + "layer": "ui" + } +} diff --git a/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/src/index.tsx b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/src/index.tsx new file mode 100644 index 0000000..79d3678 --- /dev/null +++ b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/ui/src/index.tsx @@ -0,0 +1,6 @@ +import { useId } from "react"; + +export function Badge(props: { readonly label: string }) { + const id = useId(); + return {props.label}; +} diff --git a/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json index 54b9e7b..0fa15a8 100644 --- a/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json +++ b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json @@ -1,5 +1,5 @@ { - "name": "@ekklesia/ui", + "name": "@ekklesia/widgets", "version": "0.0.0", "type": "module", "peerDependencies": { "react": "19.2.8" }, diff --git a/tests/fixtures/standalone-module/drizzle/0000_initial.sql b/tests/fixtures/standalone-module/drizzle/0000_initial.sql new file mode 100644 index 0000000..a8737f0 --- /dev/null +++ b/tests/fixtures/standalone-module/drizzle/0000_initial.sql @@ -0,0 +1 @@ +CREATE TABLE "standalone_proof_notes" ("id" uuid PRIMARY KEY NOT NULL, "body" text NOT NULL); diff --git a/tests/fixtures/standalone-module/drizzle/meta/0000_snapshot.json b/tests/fixtures/standalone-module/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..0d927c2 --- /dev/null +++ b/tests/fixtures/standalone-module/drizzle/meta/0000_snapshot.json @@ -0,0 +1,6 @@ +{ + "version": "7", + "dialect": "postgresql", + "id": "20000000-0000-4000-8000-000000000001", + "prevId": "00000000-0000-0000-0000-000000000000" +} diff --git a/tests/fixtures/standalone-module/drizzle/meta/_ekklesia.json b/tests/fixtures/standalone-module/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..437e12a --- /dev/null +++ b/tests/fixtures/standalone-module/drizzle/meta/_ekklesia.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "moduleId": "standalone-proof", + "entries": [{ "tag": "0000_initial", "destructive": { "mode": "none" } }] +} diff --git a/tests/fixtures/standalone-module/drizzle/meta/_journal.json b/tests/fixtures/standalone-module/drizzle/meta/_journal.json new file mode 100644 index 0000000..9707f0f --- /dev/null +++ b/tests/fixtures/standalone-module/drizzle/meta/_journal.json @@ -0,0 +1,7 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { "idx": 0, "version": "7", "when": 1700000000000, "tag": "0000_initial", "breakpoints": true } + ] +} diff --git a/tests/fixtures/standalone-module/ekklesia.module.json b/tests/fixtures/standalone-module/ekklesia.module.json new file mode 100644 index 0000000..e292023 --- /dev/null +++ b/tests/fixtures/standalone-module/ekklesia.module.json @@ -0,0 +1,28 @@ +{ + "manifestVersion": 8, + "id": "standalone-proof", + "name": "Standalone Proof", + "description": "Proves a module builds, packages, and verifies from published Ekklesia packages alone.", + "version": "0.1.0", + "compatibility": { + "platform": "^0.1.0", + "moduleSdk": "^0.1.0" + }, + "database": { + "abi": 1 + }, + "capabilities": [], + "permissions": [ + { + "id": "standalone-proof.notes.read", + "baseline": "restricted", + "description": "Read notes." + } + ], + "entrypoints": { + "server": "./dist/server/index.js", + "schema": "./dist/schema/index.js", + "migrations": "./drizzle", + "ui": "./dist/ui/index.js" + } +} diff --git a/tests/fixtures/standalone-module/package.json b/tests/fixtures/standalone-module/package.json new file mode 100644 index 0000000..cfc0431 --- /dev/null +++ b/tests/fixtures/standalone-module/package.json @@ -0,0 +1,33 @@ +{ + "name": "@ekklesia-fixtures/standalone-proof", + "version": "0.1.0", + "description": "A module built outside the Ekklesia repository from packed @ekklesia/* tarballs. Not a workspace package: scripts/test-standalone-module.sh copies it elsewhere and installs it.", + "license": "AGPL-3.0-or-later", + "private": true, + "type": "module", + "engines": { + "node": ">=24.11.0 <25" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui", + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json" + }, + "dependencies": { + "@ekklesia/design-tokens": "^0.1.2", + "@ekklesia/module-database": "^0.1.2", + "@ekklesia/module-sdk": "^0.1.2", + "@ekklesia/module-ui-sdk": "^0.1.2", + "@ekklesia/ui": "^0.1.2", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@ekklesia/module-pack": "0.1.2", + "@ekklesia/module-ui-build": "^0.1.2", + "@ekklesia/typescript-config": "^0.1.2", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "typescript": "7.0.2" + } +} diff --git a/tests/fixtures/standalone-module/src/schema/index.ts b/tests/fixtures/standalone-module/src/schema/index.ts new file mode 100644 index 0000000..eb94c8b --- /dev/null +++ b/tests/fixtures/standalone-module/src/schema/index.ts @@ -0,0 +1,6 @@ +import { defineModuleSchemaMetadata } from "@ekklesia/module-database"; + +export default defineModuleSchemaMetadata({ + moduleId: "standalone-proof", + tables: [{ name: "standalone_proof_notes", foreignKeys: [] }], +}); diff --git a/tests/fixtures/standalone-module/src/server/index.ts b/tests/fixtures/standalone-module/src/server/index.ts new file mode 100644 index 0000000..c92126e --- /dev/null +++ b/tests/fixtures/standalone-module/src/server/index.ts @@ -0,0 +1,23 @@ +import { sql } from "@ekklesia/module-database"; +import { defineModuleRuntime } from "@ekklesia/module-sdk"; + +/** A Module Database ABI v1 module with no ORM: SQL through the executor Core provides. */ +export default defineModuleRuntime({ + database: { abi: 1 }, + id: "standalone-proof", + version: "0.1.0", + register({ http }) { + http.route({ + method: "GET", + path: "/notes", + permission: "standalone-proof.notes.read", + maxBodyBytes: 0, + async handler(_request, { services }) { + const result = await services.database.query( + sql`SELECT id, body FROM standalone_proof_notes ORDER BY id LIMIT ${50}`, + ); + return Response.json(result.rows); + }, + }); + }, +}); diff --git a/tests/fixtures/standalone-module/src/ui/index.tsx b/tests/fixtures/standalone-module/src/ui/index.tsx new file mode 100644 index 0000000..e512803 --- /dev/null +++ b/tests/fixtures/standalone-module/src/ui/index.tsx @@ -0,0 +1,23 @@ +import { designTokenVariableName } from "@ekklesia/design-tokens"; +import { defineModuleUi } from "@ekklesia/module-ui-sdk"; +import { EKKLESIA_UI_STYLESHEET, EmptyState, Panel } from "@ekklesia/ui"; +import { createRoot } from "react-dom/client"; + +const accent = `var(${designTokenVariableName("color.accent")})`; + +export default defineModuleUi({ + id: "standalone-proof", + version: "0.1.0", + styles: EKKLESIA_UI_STYLESHEET, + mount(container) { + const root = createRoot(container); + root.render( + +
+ +
+
, + ); + return () => root.unmount(); + }, +}); diff --git a/tests/fixtures/standalone-module/tsconfig.build.json b/tests/fixtures/standalone-module/tsconfig.build.json new file mode 100644 index 0000000..d5a42b9 --- /dev/null +++ b/tests/fixtures/standalone-module/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/tests/fixtures/standalone-module/tsconfig.json b/tests/fixtures/standalone-module/tsconfig.json new file mode 100644 index 0000000..21c21ff --- /dev/null +++ b/tests/fixtures/standalone-module/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@ekklesia/typescript-config/node.json", + "compilerOptions": { + "noEmit": true + }, + "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 new file mode 100644 index 0000000..4c82617 --- /dev/null +++ b/tests/fixtures/standalone-module/tsconfig.ui.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src/ui/**/*.tsx"] +} diff --git a/tests/public-packages.test.ts b/tests/public-packages.test.ts new file mode 100644 index 0000000..d529335 --- /dev/null +++ b/tests/public-packages.test.ts @@ -0,0 +1,265 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { + PUBLIC_PACKAGE_DISTRIBUTION, + PUBLIC_PACKAGES, + packedManifestProblems, + tarballFileProblems, +} from "../scripts/pack-public-packages.js"; + +/** + * The public module-development package boundary (ADR 0053): which packages may be published, what + * their manifests may say, and what the author-facing SDK exposes. The tarballs themselves are + * inspected by `pnpm packages:pack` and installed into a standalone module by + * `scripts/test-standalone-module.sh`. + */ + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +type Manifest = Record & { + name: string; + version: string; + private?: boolean; + scripts?: Record; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + optionalDependencies?: Record; + ekklesia?: Record; + files?: string[]; +}; + +async function readJson(file: string): Promise { + return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8")) as T; +} + +async function workspacePackages(): Promise< + Map +> { + const workspace = parse(await readFile(path.join(repositoryRoot, "pnpm-workspace.yaml"), "utf8")); + const packages = new Map(); + for (const pattern of workspace.packages as string[]) { + const directories = pattern.endsWith("/*") + ? ( + await readdir(path.join(repositoryRoot, pattern.slice(0, -2)), { + withFileTypes: true, + }).catch(() => []) + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => `${pattern.slice(0, -2)}/${entry.name}`) + : [pattern]; + for (const directory of directories) { + const manifest = await readJson(`${directory}/package.json`).catch(() => undefined); + if (manifest) packages.set(manifest.name, { directory, manifest }); + } + } + return packages; +} + +describe("the public package set", () => { + it("is exactly the packages that carry the distribution marker, and nothing else is publishable", async () => { + const packages = await workspacePackages(); + const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name)); + for (const entry of PUBLIC_PACKAGES) { + expect(packages.get(entry.name)?.directory, entry.name).toBe(entry.directory); + } + for (const [name, { manifest }] of packages) { + const marked = manifest.ekklesia?.distribution === PUBLIC_PACKAGE_DISTRIBUTION; + expect(marked, name).toBe(publicNames.has(name)); + // Internal packages stay private, so no publish can ever take them by accident. + expect(manifest.private === true, name).toBe(!publicNames.has(name)); + } + expect(packages.get("@ekklesia/core")?.manifest.private).toBe(true); + expect(packages.get("@ekklesia/module-ui-host")?.manifest.private).toBe(true); + expect(packages.get("@ekklesia/module-template")?.manifest.private).toBe(true); + }); + + it("uses one coordinated version, and depends only on other public packages through the workspace", async () => { + const platformVersion = (await readJson("package.json")).version; + const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name)); + for (const entry of PUBLIC_PACKAGES) { + const manifest = await readJson(`${entry.directory}/package.json`); + expect(manifest.version, entry.name).toBe(platformVersion); + expect(manifest.license, entry.name).toBe("AGPL-3.0-or-later"); + for (const field of ["dependencies", "peerDependencies", "optionalDependencies"] as const) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if (!name.startsWith("@ekklesia/")) continue; + expect(publicNames.has(name), `${entry.name} ${field} ${name}`).toBe(true); + // `pnpm pack` turns this into the exact coordinated version. + expect(range, `${entry.name} ${field} ${name}`).toBe("workspace:*"); + } + } + } + }); + + it("runs no monorepo-root command, or any other script, when a consumer installs or packs it", async () => { + for (const entry of PUBLIC_PACKAGES) { + const manifest = await readJson(`${entry.directory}/package.json`); + for (const script of ["preinstall", "install", "postinstall", "prepare", "prepack"]) { + expect(manifest.scripts?.[script], `${entry.name} ${script}`).toBeUndefined(); + } + expect(JSON.stringify(manifest.scripts ?? {}), entry.name).not.toContain("--workspace-root"); + } + }); + + it("keeps module-pack's own LICENSE identical to the repository's", async () => { + // pnpm adds the workspace LICENSE to a tarball only when no packed path looks like a licence, and + // module-pack ships `licenses/` and `license-supplements/`, so it carries its own copy. + expect(await readFile(path.join(repositoryRoot, "packages/module-pack/LICENSE"))).toEqual( + await readFile(path.join(repositoryRoot, "LICENSE")), + ); + }); +}); + +describe("the curated Module SDK", () => { + async function sdkExports(): Promise { + const source = await readFile( + path.join(repositoryRoot, "packages/module-sdk/src/index.ts"), + "utf8", + ); + expect(source).not.toMatch(/export\s*\*/u); + const names: string[] = []; + for (const block of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/gu)) { + for (const item of (block[1] ?? "").split(",")) { + const name = item + .replace(/^\s*type\s+/u, "") + .split(/\s+as\s+/u) + .pop() + ?.trim(); + if (name) names.push(name); + } + } + return names.sort(); + } + + it("exposes the author API only", async () => { + const names = await sdkExports(); + expect(names).toContain("defineModuleRuntime"); + expect(names).toContain("ModuleRouteHandlerContext"); + expect(names).toContain("parseModuleManifest"); + for (const name of names) { + // No identity or authorization internals, no Drizzle, and no legacy database facade. + expect(name).not.toMatch( + /Authoriz|Permission(?:Grant|Decision)|Identity|Session|Credential|Drizzle(?!MigrationJournal)|^ModuleDatabase$|^ModuleSchemaDefinition$|defineModuleSchema|createModuleTable|^parseEkklesia/u, + ); + } + }); + + it("does not make Drizzle a dependency of a module that does not use it", async () => { + const sdk = await readJson("packages/module-sdk/package.json"); + expect(Object.keys(sdk.dependencies ?? {})).toEqual(["@ekklesia/contracts"]); + expect(sdk.peerDependencies ?? {}).toEqual({}); + // Everything the SDK installs: Drizzle is at most an optional peer, and no runtime path reaches it. + for (const name of ["contracts", "module-database"]) { + const manifest = await readJson(`packages/${name}/package.json`); + expect(manifest.dependencies?.["drizzle-orm"], name).toBeUndefined(); + expect(manifest.optionalDependencies?.["drizzle-orm"], name).toBeUndefined(); + if (manifest.peerDependencies?.["drizzle-orm"]) { + expect(manifest.peerDependenciesMeta?.["drizzle-orm"]?.optional, name).toBe(true); + } + } + 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. + 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); + } + } + const contracts = await readJson("packages/contracts/package.json"); + expect(Object.keys(contracts.exports as object)).toEqual([".", "./module-package"]); + }); +}); + +describe("module authors' dependencies", () => { + it("never name @ekklesia/contracts directly", async () => { + const template = await readJson("templates/module/package.json"); + const fixture = await readJson("tests/fixtures/standalone-module/package.json"); + for (const manifest of [template, fixture]) { + expect(JSON.stringify(manifest), manifest.name).not.toContain("@ekklesia/contracts"); + } + const guide = await readFile( + path.join(repositoryRoot, "docs/module-development/README.md"), + "utf8", + ); + // Never shown as a dependency to add, and stated not to be one. + expect(guide).not.toMatch(/"@ekklesia\/contracts"\s*:/u); + expect(guide).toContain("`@ekklesia/contracts` is not a direct dependency."); + }); + + 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; + expect(devDependencies["@ekklesia/module-pack"]).toMatch(/^\d+\.\d+\.\d+$/u); + expect((fixture.engines as Record).node).toBeTruthy(); + }); +}); + +describe("@ekklesia/typescript-config", () => { + it("ships only its configurations, which extend each other by relative path", async () => { + const manifest = await readJson("packages/typescript-config/package.json"); + expect(manifest.files?.every((file) => /^[a-z-]+\.json$/u.test(file))).toBe(true); + for (const file of manifest.files ?? []) { + const config = await readJson<{ extends?: string }>(`packages/typescript-config/${file}`); + if (config.extends !== undefined) + expect(config.extends, file).toMatch(/^\.\/[a-z-]+\.json$/u); + } + }); +}); + +describe("the tarball inspection", () => { + const good = { + dependencies: { "@ekklesia/contracts": "0.1.2", zod: "4.4.3" }, + devDependencies: { "@ekklesia/core": "0.1.2" }, + ekklesia: { distribution: PUBLIC_PACKAGE_DISTRIBUTION, layer: "module-sdk" }, + files: ["dist"], + license: "AGPL-3.0-or-later", + name: "@ekklesia/module-sdk", + scripts: { build: "tsc" }, + version: "0.1.2", + }; + const expected = { name: "@ekklesia/module-sdk", version: "0.1.2" }; + + it("accepts a packed manifest that names only the coordinated train", () => { + expect(packedManifestProblems(good, expected)).toEqual([]); + }); + + it("refuses workspace ranges, privacy, missing markers, lifecycle scripts, and mixed trains", () => { + const cases: Record[] = [ + { ...good, dependencies: { "@ekklesia/contracts": "workspace:*" } }, + { ...good, private: true }, + { ...good, ekklesia: { layer: "module-sdk" } }, + { ...good, scripts: { prepack: "pnpm --workspace-root exec turbo run build" } }, + { ...good, scripts: { postinstall: "node setup.js" } }, + { ...good, dependencies: { "@ekklesia/contracts": "0.1.1" } }, + { ...good, peerDependencies: { "@ekklesia/core": "0.1.2" } }, + { ...good, version: "0.1.3" }, + ]; + for (const manifest of cases) { + expect(packedManifestProblems(manifest, expected), JSON.stringify(manifest)).not.toEqual([]); + } + }); + + it("accepts only declared built files with sources, the manifest, and a LICENSE", () => { + const hasSource = (file: string) => ["index", "http"].includes(file); + expect( + tarballFileProblems( + ["LICENSE", "package.json", "dist/index.js", "dist/index.d.ts", "dist/http.js.map"], + good, + hasSource, + ), + ).toEqual([]); + for (const files of [ + ["package.json", "dist/index.js"], + ["LICENSE", "package.json", "dist/removed.js"], + ["LICENSE", "package.json", "src/index.ts"], + ["LICENSE", "package.json", "tsconfig.json"], + ["LICENSE", "package.json", "dist/index.test.js"], + ]) { + expect(tarballFileProblems(files, good, hasSource), files.join(" ")).not.toEqual([]); + } + }); +}); -- 2.54.0 From aaa7ebfd8483641421f8ed13df061bfa7821beec Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 8 Oct 2026 07:40:43 -0500 Subject: [PATCH 2/2] fix: keep Drizzle out of every published declaration (ADR 0053) @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 --- apps/api/src/app.ts | 4 +- apps/api/src/database.ts | 3 +- apps/api/src/module-http-routes.ts | 6 +-- apps/api/src/server.ts | 7 +-- apps/api/tests/app.test.ts | 8 +-- apps/api/tests/dashboard-host-routes.test.ts | 6 +-- apps/api/tests/dashboard-routes.test.ts | 6 +-- apps/api/tests/health-routes.test.ts | 6 +-- .../tests/module-configuration-routes.test.ts | 6 +-- apps/api/tests/module-display-routes.test.ts | 6 +-- apps/api/tests/module-http-routes.test.ts | 18 +++---- .../api/tests/module-lifecycle-routes.test.ts | 6 +-- apps/api/tests/module-realtime-live.test.ts | 6 +-- apps/api/tests/module-ui-host-e2e.test.ts | 6 +-- apps/api/tests/module-ui-inventory.test.ts | 6 +-- apps/api/tests/module-ui-routes.test.ts | 10 ++-- core/src/modules/database.ts | 51 ++++++++++++++++++- core/src/modules/runtime.ts | 14 +++-- .../tests/module-database-type.test.ts | 17 ++++--- core/tests/module-realtime-runtime.test.ts | 9 ++-- core/tests/module-runtime.test.ts | 9 ++-- docs/development/api.md | 3 +- packages/contracts/package.json | 9 ---- packages/contracts/src/module-database.ts | 50 ++---------------- packages/contracts/src/module-http.ts | 25 ++++----- packages/contracts/src/module-runtime.ts | 17 +++---- pnpm-lock.yaml | 3 -- scripts/test-standalone-module.sh | 10 +++- .../fixtures/standalone-module/tsconfig.json | 8 ++- .../standalone-module/tsconfig.ui.json | 7 ++- tests/public-packages.test.ts | 26 ++++++++-- 31 files changed, 190 insertions(+), 178 deletions(-) rename packages/contracts/tests/module-database.test.ts => core/tests/module-database-type.test.ts (85%) 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; -- 2.54.0