ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
4.1 KiB
Ekklesia module template
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (GET /records, declared in register) that reads that table through
context.services.database — the platform-provided ModuleDatabase facade, never a
module-constructed Drizzle instance or a raw pg client. Migration execution and storage
provisioning are intentionally not included.
It also ships an optional React UI (src/ui/), compiled to the entrypoints.ui artifact its
manifest declares by pnpm build
(ADR 0023). UI is a choice, not
a requirement: to keep a module backend-only, delete src/ui/, tsconfig.ui.json,
tsconfig.ui-test.json, tests/module-ui.test.ts, the ui entrypoint from the manifest, and the
React and module-UI dependencies. Everything else keeps working, and ekklesia-build-module-ui
simply reports that there is nothing to build.
No host mounts a module UI yet — the Dashboard shell does not exist. tests/module-ui.test.ts builds
the artifact from source, validates it with parseModuleUiExport, mounts it into a real DOM, and
unmounts it, which is what proves the pipeline today. See
Declaring a UI entrypoint.
To create a module inside this repository:
- Copy this directory to
modules/<module-id>. - Rename the package in
package.jsonand removeprivatewhen it is ready for publication. - Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in
ekklesia.module.json. - Keep the manifest capability versions and dependency ranges aligned with the contracts the module actually implements and consumes.
- Replace
src/server/index.tswith backend behavior only. ItsdefineModuleRuntimeID and version must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP routes only inregister, against a permission the manifest already declares;start,stop, and every route handler — neverregister— receivecontext.services.database, a typedModuleDatabaseview over the platform's one PostgreSQL pool. - Replace the pure Drizzle declarations in
src/schema/tables.tsand keep its table prefix aligned with the module ID. Keep thedefineModuleSchemaownership wrapper insrc/schema/index.tsaligned too, and never import another module's schema. - Update
drizzle.config.ts, generate forward migrations withpnpm migration:generate --name=lowercase_namefrom the module directory, and review the SQL. Keep each generated tag indrizzle/meta/_ekklesia.json, classify it asnoneorapproval-required, and change that file'smoduleIdwhen renaming the template. - Run
pnpm install,pnpm build,pnpm modules:validate,pnpm migrations:validate, andpnpm verifyfrom the repository root. The declared production entrypoint is the compileddist/server/index.js, not source TypeScript; the schema entrypoint is likewise compiled todist/schema/index.js.
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
Keep imports side-effect-free, register capabilities in register, acquire resources in start,
and release them in stop. Never construct a Drizzle instance, read DATABASE_URL, or reach for a
raw pg client — context.services.database is the only supported way to query this module's own
tables, and it is never available during register.
The migration directory uses the pinned stable Drizzle Kit 0.31.10 PostgreSQL journal and
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
validation treats SQL as opaque bytes, calculates SHA-256 from the exact contents, and never opens a
database or executes SQL. Destructive declarations are review metadata, not automatic SQL
classification or approval.