207 lines
7.4 KiB
Markdown
207 lines
7.4 KiB
Markdown
# Module development
|
|
|
|
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
|
|
it never owns components, styles, layouts, renderers, navigation, or other UI.
|
|
|
|
## Required package shape
|
|
|
|
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
|
|
strict semantic version, and the module layer marker:
|
|
|
|
```text
|
|
modules/example/
|
|
├── dist/schema/index.js # when the module owns tables
|
|
├── dist/server/index.js
|
|
├── ekklesia.module.json
|
|
├── package.json
|
|
├── src/schema/index.ts # when the module owns tables
|
|
└── src/server/index.ts
|
|
```
|
|
|
|
The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`.
|
|
|
|
```json
|
|
{
|
|
"type": "module",
|
|
"ekklesia": {
|
|
"layer": "module"
|
|
}
|
|
}
|
|
```
|
|
|
|
The package version must match the manifest and runtime-definition versions. Internal package
|
|
dependencies use `workspace:` ranges and supported public package entrypoints.
|
|
|
|
## Canonical manifest
|
|
|
|
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
|
without executing module code:
|
|
|
|
```json
|
|
{
|
|
"manifestVersion": 2,
|
|
"id": "calendar",
|
|
"name": "Calendar",
|
|
"version": "0.1.0",
|
|
"compatibility": {
|
|
"platform": "^0.1.0",
|
|
"moduleSdk": "^0.1.0"
|
|
},
|
|
"dependencies": {
|
|
"modules": {
|
|
"required": {
|
|
"people": "^0.1.0"
|
|
}
|
|
},
|
|
"capabilities": {
|
|
"required": {
|
|
"people.records": "^2.0.0"
|
|
},
|
|
"optional": {
|
|
"media.items": ">=1.1.0 <2.0.0"
|
|
}
|
|
}
|
|
},
|
|
"capabilities": [
|
|
{
|
|
"id": "calendar.events",
|
|
"version": "1.0.0"
|
|
}
|
|
],
|
|
"permissions": ["calendar.events.read"],
|
|
"storage": {
|
|
"buckets": [
|
|
{
|
|
"id": "assets",
|
|
"description": "Private event assets owned by Calendar."
|
|
}
|
|
]
|
|
},
|
|
"entrypoints": {
|
|
"server": "./dist/server/index.js",
|
|
"schema": "./dist/schema/index.js"
|
|
}
|
|
}
|
|
```
|
|
|
|
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
|
|
has its own exact semantic version because its public contract can evolve independently from the
|
|
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
|
may be absent; if it is installed, its capability version must be compatible.
|
|
|
|
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`,
|
|
remain inside the package, and exist. Production never imports source TypeScript directly. Modules
|
|
with no tables omit `entrypoints.schema`. Unknown manifest properties are rejected, including
|
|
presentation fields such as components or renderers.
|
|
|
|
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
|
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
|
module code must not configure SeaweedFS or another S3 client directly.
|
|
|
|
## Runtime server definition
|
|
|
|
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
|
|
|
```ts
|
|
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
|
|
|
export default defineModuleRuntime({
|
|
id: "calendar",
|
|
version: "0.1.0",
|
|
|
|
register({ capabilities }) {
|
|
capabilities.provide("calendar.events", calendarEvents);
|
|
},
|
|
|
|
async start({ capabilities }) {
|
|
const people = capabilities.resolve("people.records");
|
|
// Acquire runtime resources here.
|
|
},
|
|
|
|
async stop() {
|
|
// Release runtime resources here.
|
|
}
|
|
});
|
|
```
|
|
|
|
ID and version are intentional build-artifact identity markers and must match the manifest. Core
|
|
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
|
|
own capabilities and the capability dependencies declared in its manifest. The runtime context is
|
|
deliberately limited to the module ID and capability access.
|
|
|
|
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
|
|
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
|
close them in `stop`.
|
|
|
|
## Drizzle schema definition
|
|
|
|
A module with tables exports a declaration-only schema definition from `src/schema/index.ts`:
|
|
|
|
```ts
|
|
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
|
|
import { integer, text } from "drizzle-orm/pg-core";
|
|
|
|
const moduleTable = createModuleTable("calendar");
|
|
|
|
export const events = moduleTable("events", {
|
|
id: integer("id").primaryKey(),
|
|
title: text("title").notNull()
|
|
});
|
|
|
|
export default defineModuleSchema({
|
|
moduleId: "calendar",
|
|
tables: { events }
|
|
});
|
|
```
|
|
|
|
`createModuleTable` 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. Core validates the built metadata even when a module does not use
|
|
the 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.
|
|
The `moduleId` is an ownership marker and must match the static manifest. Export physical base
|
|
tables, never query aliases.
|
|
|
|
Foreign keys may reference self or another exported table from the same module. They may not target
|
|
another module or an omitted private table. Every source column must belong to the declaring table,
|
|
and all columns in a compound target must belong to the same exported target table. Modules must
|
|
never import another module's tables, columns, relations, schema entrypoint, or migration files.
|
|
Store opaque identifiers and integrate through public capabilities, events, or APIs instead.
|
|
|
|
## Discovery and lifecycle
|
|
|
|
From the repository root, run:
|
|
|
|
```bash
|
|
pnpm install
|
|
pnpm modules:validate
|
|
pnpm verify
|
|
```
|
|
|
|
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
|
|
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
|
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
|
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
|
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
|
|
identifier sorting.
|
|
|
|
After complete static discovery succeeds, validation loads optional schema entrypoints
|
|
dependency-first and rejects invalid definitions, unnamespaced or duplicate tables, custom
|
|
PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when
|
|
every schema is valid. This slice does not connect to PostgreSQL or execute migrations.
|
|
|
|
Core completes discovery before importing any server entrypoint. It then loads and verifies every
|
|
runtime definition before running hooks, registers all modules dependency-first into an unpublished
|
|
staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later
|
|
module fails to start, Core stops already-started modules in reverse order and reports any cleanup
|
|
failures without leaking caught exceptions or machine paths. A failing `start` hook must clean up
|
|
its own partial work before rejecting.
|
|
|
|
## Start from the template
|
|
|
|
Copy `templates/module` to `modules/<your-module-id>` and follow its README. No central registry edit
|
|
is needed: the workspace glob, build, discovery, and runtime loading all find the new package.
|