feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s

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 <[email protected]>
This commit is contained in:
ekklesia
2026-10-08 07:28:54 -05:00
co-authored by Claude Opus 5.5
parent 9426c83109
commit 89d5d0f2da
63 changed files with 2320 additions and 237 deletions
+6 -4
View File
@@ -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.
+123 -17
View File
@@ -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`,