feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-21 08:09:30 -05:00
parent 37f54edd89
commit 88c28d75c9
30 changed files with 2947 additions and 48 deletions
+80 -13
View File
@@ -10,15 +10,24 @@ strict semantic version, and the module layer marker:
```text
modules/example/
├── drizzle/ # when the module has migration history
│ ├── 0000_initial.sql
│ └── meta/
│ ├── 0000_snapshot.json
│ ├── _ekklesia.json
│ └── _journal.json
├── dist/schema/index.js # when the module owns tables
├── dist/server/index.js
├── drizzle.config.ts # when generating migrations
├── ekklesia.module.json
├── package.json
├── src/schema/index.ts # when the module owns tables
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
└── src/server/index.ts
```
The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`.
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
module with no migration history omits `entrypoints.migrations` and needs no placeholder files.
```json
{
@@ -79,7 +88,8 @@ without executing module code:
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js"
"schema": "./dist/schema/index.js",
"migrations": "./drizzle"
}
}
```
@@ -89,10 +99,11 @@ 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 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.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
and exist. Production never imports source TypeScript directly. Modules with no tables omit
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`. 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;
@@ -135,18 +146,27 @@ close them in `stop`.
## Drizzle schema definition
A module with tables exports a declaration-only schema definition from `src/schema/index.ts`:
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`:
```ts
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
import { integer, text } from "drizzle-orm/pg-core";
import { integer, pgTableCreator, text } from "drizzle-orm/pg-core";
const moduleTable = createModuleTable("calendar");
const moduleTable = pgTableCreator((localName) => `calendar_${localName}`);
export const events = moduleTable("events", {
id: integer("id").primaryKey(),
title: text("title").notNull()
});
```
The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module
SDK ownership marker:
```ts
import { defineModuleSchema } from "@ekklesia/module-sdk";
import { events } from "./tables.js";
export { events } from "./tables.js";
export default defineModuleSchema({
moduleId: "calendar",
@@ -154,11 +174,12 @@ export default defineModuleSchema({
});
```
`createModuleTable` converts the module ID to a snake-case namespace and prefixes every local table
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. Core validates the built metadata even when a module does not use
the helper.
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.
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.
@@ -171,6 +192,45 @@ and all columns in a compound target must belong to the same exported target tab
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.
## Drizzle migrations
Module migrations use the stable Drizzle Kit `0.31.10` PostgreSQL format. Pin that exact version in
the module, set `migrations.prefix` to `index`, and generate from the module's own source schema:
```ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema/tables.ts",
out: "./drizzle",
migrations: { prefix: "index" },
breakpoints: true
});
```
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
executing the ESM Module SDK wrapper. `src/schema/index.ts` remains the production compiled schema
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not
declared twice.
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
explicitly declare either `{"mode":"none"}` or
`{"mode":"approval-required","reason":"..."}`. Do not add an `approved` flag; approval will be
an external Core-owned record bound to the migration checksum.
Migration identities are `<module-id>:<generated-tag>`. Core preserves Drizzle journal order inside
each module and dependency-first discovery order between modules. It calculates SHA-256 from the
exact SQL bytes, so changing whitespace or line endings changes the checksum. Never edit an
already-applied migration; the future database history will detect checksum changes. Add a new
forward migration instead. Downgrade and uninstall migrations are not supported.
A module may migrate only its own objects. SQL is intentionally treated as opaque: offline
validation checks the artifact and metadata contract but cannot prove SQL ownership or classify
destructive behavior. Review those properties explicitly. Core remains the only future migration
executor.
## Discovery and lifecycle
From the repository root, run:
@@ -178,6 +238,7 @@ From the repository root, run:
```bash
pnpm install
pnpm modules:validate
pnpm migrations:validate
pnpm verify
```
@@ -193,6 +254,12 @@ dependency-first and rejects invalid definitions, unnamespaced or duplicate tabl
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.
`pnpm migrations:validate` performs complete static discovery first, then reads every declared
migration directory without importing module code or connecting to PostgreSQL. It validates native
Drizzle journals and snapshot chains, Ekklesia policy metadata, ordering, inventory, paths, and
checksums atomically. Its concise report lists every installed module and migration in deterministic
order.
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