feat: add module schema discovery and validation
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-13 21:35:21 -05:00
parent 2465d9c1f7
commit 80b56b72b1
31 changed files with 1584 additions and 34 deletions
+56 -6
View File
@@ -10,12 +10,16 @@ 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",
@@ -74,7 +78,8 @@ without executing module code:
]
},
"entrypoints": {
"server": "./dist/server/index.js"
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js"
}
}
```
@@ -84,9 +89,10 @@ has its own exact semantic version because its public contract can evolve indepe
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 entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, remain inside the
package, and exist. Production never imports source TypeScript directly. Unknown manifest
properties are rejected, including presentation fields such as components or renderers.
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;
@@ -127,6 +133,44 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
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:
@@ -140,8 +184,14 @@ 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 entrypoints. Missing optional
dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting.
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