Files
Ekklesia/docs/module-development/README.md
T
ekklesia 80b56b72b1
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s
feat: add module schema discovery and validation
2026-07-13 21:35:21 -05:00

7.4 KiB

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:

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.

{
  "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:

{
  "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:

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:

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:

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.