feat: add module migration contracts and validation
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 0008: Module-owned migration contracts and offline validation
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-21
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0007 gives each module exclusive ownership of its namespaced Drizzle tables, but it does not
|
||||
define how those tables evolve. Ekklesia needs a cloneable module to carry its migration history
|
||||
without a central registry, and Core needs to inspect every installed history deterministically
|
||||
before a future runner is allowed to touch PostgreSQL.
|
||||
|
||||
Drizzle Kit already generates PostgreSQL SQL files, a journal, and schema snapshots. Replacing that
|
||||
format would separate module authors from supported Drizzle tooling. At the same time, Drizzle's
|
||||
journal does not express Ekklesia's destructive-change policy or module ownership boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
### Ownership and packaging
|
||||
|
||||
Each module owns its migration history and may create, alter, or remove only database objects that
|
||||
it owns. A dependency on another module does not grant permission to migrate that module's tables,
|
||||
indexes, constraints, sequences, types, or other private database objects. Cross-module foreign
|
||||
keys and imports of another module's private schema or migrations remain prohibited by ADR 0007.
|
||||
|
||||
A module with migrations declares the optional manifest field
|
||||
`entrypoints.migrations`, normally as `./drizzle`. It points to a packaged directory inside the
|
||||
module, not executable code. A module with no migrations omits the field. Static module discovery
|
||||
must validate the directory for every installed module before migration files are inspected. The
|
||||
directory is included in the module package so cloning a module into `modules/`, installing, and
|
||||
building remains sufficient; no central migration registry is introduced.
|
||||
|
||||
### Supported Drizzle format and workflow
|
||||
|
||||
This slice pins Drizzle Kit `0.31.10`, matching the stable pre-v1 format compatible with the
|
||||
repository's Drizzle ORM `0.45.2`. A module configures PostgreSQL, its source schema, an output
|
||||
directory, index-prefixed migrations, and statement breakpoints. Authors generate migrations with
|
||||
the module's `migration:generate` script, review the generated SQL and snapshots, add the required
|
||||
Ekklesia policy entry, and run `pnpm migrations:validate` from the repository root.
|
||||
|
||||
The accepted directory format is:
|
||||
|
||||
```text
|
||||
drizzle/
|
||||
├── 0000_initial.sql
|
||||
└── meta/
|
||||
├── 0000_snapshot.json
|
||||
├── _ekklesia.json
|
||||
└── _journal.json
|
||||
```
|
||||
|
||||
`_journal.json` and indexed snapshots are the native Drizzle Kit PostgreSQL version-7 metadata.
|
||||
Every journal entry has a corresponding `<tag>.sql` file and `<index>_snapshot.json`. Extra SQL or
|
||||
snapshot files and unsupported file types are invalid. Files and the `meta` directory must be real
|
||||
entries within the declared directory; symbolic links are rejected rather than followed.
|
||||
|
||||
Ekklesia adds strict `meta/_ekklesia.json` policy metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"moduleId": "example",
|
||||
"entries": [
|
||||
{
|
||||
"tag": "0000_initial",
|
||||
"destructive": { "mode": "none" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The sidecar has exactly one entry for each journal entry in the same order. A destructive migration
|
||||
uses `{"mode":"approval-required","reason":"..."}`. The source tree never records an
|
||||
`approved` boolean: a future approval record must be external to the module and bound to the
|
||||
module ID, migration identity, and checksum so editing the migration invalidates approval.
|
||||
|
||||
### Identity, checksums, and ordering
|
||||
|
||||
Within a module, `_journal.json` is authoritative for ordering. Indices start at zero, are
|
||||
contiguous, and match each indexed tag; timestamps strictly increase; snapshot IDs form the native
|
||||
Drizzle predecessor chain. The validator does not repair or re-sort an invalid journal.
|
||||
|
||||
Between modules, Core uses the dependency-first order produced by complete static module
|
||||
discovery, with its deterministic identifier tie-breaking. It then preserves journal order within
|
||||
each module.
|
||||
|
||||
A migration's stable identity is `<module ID>:<Drizzle tag>`, for example
|
||||
`example:0000_initial`. Core calculates a lowercase SHA-256 checksum from the exact SQL file bytes.
|
||||
Line endings, whitespace, breakpoint comments, and final newlines therefore affect the checksum.
|
||||
SQL must be non-empty valid UTF-8, but it is not normalized. The source metadata does not duplicate
|
||||
the calculated checksum. When execution exists, Core's applied database history is the authority
|
||||
for comparing the stored applied checksum with current packaged bytes and rejecting edits to an
|
||||
already-applied migration.
|
||||
|
||||
### Destructive changes and SQL limits
|
||||
|
||||
Migrations are forward-only for now. There are no downgrade or uninstall migrations. A migration
|
||||
declared `approval-required` must never be silently applied; offline validation reports that state,
|
||||
while the future runner will require an explicit external approval contract before execution.
|
||||
This slice neither grants approval nor executes SQL.
|
||||
|
||||
The ownership and destructive declarations are enforceable author contracts and review inputs,
|
||||
not claims that the offline validator can prove arbitrary SQL safe. SQL comments, quoted and
|
||||
schema-qualified identifiers, procedural bodies, dynamic SQL, extension behavior, and
|
||||
`search_path` changes make simplistic regular-expression classification incomplete and
|
||||
misleading. The validator deliberately treats SQL as opaque bytes and does not claim to detect
|
||||
cross-module access or destructive statements. Stronger enforcement, if added, requires a real
|
||||
PostgreSQL-aware model and must state its limits.
|
||||
|
||||
### Atomic validation and future execution
|
||||
|
||||
Offline migration discovery begins only after complete static module discovery succeeds. It opens
|
||||
no PostgreSQL connection, imports no schema or server entrypoint, and executes no SQL. It validates
|
||||
the full inventory, metadata, ordering, identities, paths, snapshots, policy, and checksums for all
|
||||
modules, then publishes one frozen aggregate. Any issue prevents publication of every module's
|
||||
result. Errors contain deterministic codes, module IDs, and logical metadata paths only; caught
|
||||
exception messages, SQL text, URLs, secrets, machine paths, causes, stacks, and partial results are
|
||||
not exposed.
|
||||
|
||||
Migration execution remains exclusively controlled by Core. A later ADR and slice will define the
|
||||
database history schema, pending/applied comparison, advisory locking, transaction boundaries,
|
||||
approval lookup, failure recovery, and execution semantics.
|
||||
|
||||
The selected format follows Drizzle Kit's stable
|
||||
[`_journal.json` and snapshot handling](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/utils.ts),
|
||||
its [migration generation flow](https://github.com/drizzle-team/drizzle-orm/blob/drizzle-kit%400.31.10/drizzle-kit/src/cli/commands/migrate.ts),
|
||||
and Drizzle ORM's [SQL reading and SHA-256 behavior](https://github.com/drizzle-team/drizzle-orm/blob/0.45.2/drizzle-orm/src/migrator.ts).
|
||||
|
||||
## Alternatives
|
||||
|
||||
- A custom migration language was rejected because it would abandon supported Drizzle generation
|
||||
and metadata without improving runtime safety.
|
||||
- Expected checksums in packaged migration policy metadata were rejected because they duplicate the
|
||||
artifact being checked and do not prove whether an edited migration was already applied. Applied
|
||||
database history will provide that authority.
|
||||
- Timestamp-only or filename-only identity was rejected because independently installed modules
|
||||
can legitimately use the same generated tag and timestamp.
|
||||
- Lexicographically sorting migration files was rejected because Drizzle's journal is the native
|
||||
ordered history and must be internally consistent.
|
||||
- Automatically classifying SQL with regular expressions was rejected because it would imply an
|
||||
ownership and destructive-change guarantee the implementation cannot provide.
|
||||
- Module-controlled execution was rejected because ordering, history, locking, approval, and
|
||||
failure behavior are platform-wide invariants owned by Core.
|
||||
|
||||
## Consequences
|
||||
|
||||
Module authors commit generated SQL, native Drizzle metadata, and the Ekklesia policy sidecar. They
|
||||
must review ownership and destructive impact explicitly. Modules with no database history remain
|
||||
valid and need no placeholder directory. Offline validation gives CI a stable inventory and exact
|
||||
checksums without requiring PostgreSQL, but intentionally does not prove SQL semantics or predict
|
||||
whether a migration will succeed against a particular live database.
|
||||
@@ -16,3 +16,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
|
||||
- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted
|
||||
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
|
||||
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
||||
|
||||
@@ -63,8 +63,11 @@ static versioned manifests, independent capability versions and dependency range
|
||||
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
|
||||
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
|
||||
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
|
||||
Drizzle tables to a deterministic aggregate without connecting to a database.
|
||||
Drizzle tables to a deterministic aggregate without connecting to a database. Optional module-owned
|
||||
Drizzle migration directories now produce an atomic dependency-ordered offline inventory with
|
||||
validated metadata, explicit destructive-change declarations, and exact SHA-256 checksums.
|
||||
|
||||
Target architecture, not yet implemented: migration generation or execution, Better Auth,
|
||||
Target architecture, not yet implemented: transactional migration execution and applied history,
|
||||
Better Auth,
|
||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||
|
||||
@@ -42,7 +42,9 @@ common UI-framework dependencies inside modules. This deny list is defense in de
|
||||
review remains required when adopting a new frontend technology.
|
||||
|
||||
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
||||
targets, and physical table namespaces in compiled Drizzle metadata.
|
||||
targets, and physical table namespaces in compiled Drizzle metadata. Offline migration validation
|
||||
checks each module's packaged Drizzle inventory and policy contract, while deliberately making no
|
||||
claim that opaque SQL ownership can be proven without PostgreSQL-aware analysis.
|
||||
|
||||
## Generated code
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pnpm format:check # formatting without mutation
|
||||
pnpm lint # Biome recommended rules
|
||||
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
|
||||
pnpm migrations:validate # offline module migration inventory and exact checksums
|
||||
pnpm typecheck # strict TypeScript
|
||||
pnpm test # Vitest
|
||||
pnpm build # Turborepo package build graph
|
||||
@@ -35,6 +36,11 @@ must produce the compiled JavaScript entrypoints declared by their static manife
|
||||
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||
|
||||
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
|
||||
checks every declared stable Drizzle migration directory. It reports dependency-first module
|
||||
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
|
||||
executing SQL. Modules with no migration history are valid.
|
||||
|
||||
CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate
|
||||
CI job runs the live infrastructure smoke test so code-only local verification does not require
|
||||
Docker. The explicit suffix avoids pnpm's built-in `ci` install command.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user