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
@@ -0,0 +1,88 @@
# 0007: Module-owned Drizzle schemas and table isolation
- Status: Accepted
- Date: 2026-07-13
## Context
Ekklesia needs to collect database declarations from independently installed modules without
giving Core feature-specific knowledge or allowing modules to couple through private database
structures. Schema declarations must be inspectable before any future migration or database
startup work, and their physical names must remain globally collision-resistant in a shared
PostgreSQL deployment.
The module manifest already has an optional `entrypoints.schema` field. Production module loading
already requires compiled ESM artifacts and complete static discovery before executable code is
imported.
## Decision
A module that owns tables declares a compiled ESM `.js` or `.mjs` schema entrypoint in
`ekklesia.module.json`, normally `./dist/schema/index.js`. The entrypoint default-exports a
`defineModuleSchema` definition containing only a module ID ownership marker and a table record.
The ID must match the already-discovered manifest. The schema entrypoint contains declarations
only, performs no I/O, opens no database connection, and has no top-level side effects beyond
constructing Drizzle metadata.
All module tables use Drizzle's PostgreSQL dialect in the platform-managed default/public
PostgreSQL schema. Modules cannot select custom PostgreSQL schemas. A module owns only the table
objects exported by its own schema definition. Schema definitions contain physical base tables,
not query aliases that could disguise another table under an owned name.
Physical table names use this mandatory namespace:
```text
<module ID with hyphens converted to underscores>_<lowercase snake-case local name>
```
For example, module `care-team` creates local table `members` as `care_team_members`.
Identifiers must be lowercase ASCII snake case and no longer than PostgreSQL's 63-character
identifier limit. `createModuleTable(moduleId)` applies this transformation for authors, while Core
validates the resulting physical names independently. Every table object and every physical table
name may appear only once in the aggregate.
Foreign keys may target self-referential or other exported tables owned by the same module. Every
source column must belong to the declaring table, and every target column in a simple or compound
key must belong to one exported target table. A foreign key may not target a table owned by another
module, even when a module or capability dependency exists. A target with the same namespace that
is omitted from the owning module's schema definition is also invalid.
Modules may not import another module's schema entrypoint, tables, columns, relations, or other
private code. Cross-module data relationships use opaque identifiers and public capabilities,
events, or APIs. The repository boundary checker enforces literal source/package imports, and Core
schema validation independently rejects cross-module foreign-key metadata.
Core schema discovery first completes ordinary module discovery for the entire installed graph. If
that fails, no schema entrypoint is imported. It then imports optional schema entrypoints in
dependency-first module order, validates every definition and table, and publishes a frozen
aggregate only if all modules succeed. Module contributions and table records are explicitly
sorted, and failures are structured, deterministic, and stripped of caught exception messages,
stacks, URLs, causes, and machine-specific paths.
Atomicity here means no schema code runs before complete static discovery and no partial aggregate
is published. ESM evaluation itself cannot be undone, which is why schema entrypoints must remain
side-effect-free.
## Alternatives
- Source TypeScript entrypoints were rejected because production schema discovery must consume the
same built artifacts that deployment uses.
- PostgreSQL schema-per-module isolation was rejected for this slice because table prefixes already
provide explicit ownership without adding schema search-path and migration-filtering complexity.
- Cross-module foreign keys were rejected because they couple module installation, upgrades,
deletion, and migration ordering through private storage details.
- Inferring tables from all named module exports was rejected because an explicit default
definition provides one reviewable ownership boundary and deterministic aggregation.
- A central table registry was rejected because cloning a module into `modules/` must remain enough
to install its declarations.
## Consequences
Module authors must use names short enough for the complete namespaced identifier to fit within 63
characters. Modules with no tables omit `entrypoints.schema`. Custom PostgreSQL schemas, views,
enums, sequences, and richer relation metadata can receive explicit contracts later rather than
being accepted implicitly now.
This decision discovers and validates Drizzle table metadata only. It does not create a database,
execute migrations, compare live schema state, or define upgrade, downgrade, uninstall, or
destructive-change policy. Those migration decisions remain a later Phase 3 slice.
+1
View File
@@ -15,3 +15,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
- [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
+3 -2
View File
@@ -62,8 +62,9 @@ Implemented now: the Phase 1 repository and infrastructure foundation plus publi
static versioned manifests, independent capability versions and dependency ranges, automatic
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
template.
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
Drizzle tables to a deterministic aggregate without connecting to a database.
Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth,
Target architecture, not yet implemented: migration generation or execution, Better Auth,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+4 -3
View File
@@ -32,7 +32,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- Workspace package names are unique and shared packages declare a recognized architectural role.
- Circular source and workspace-package dependencies are rejected.
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
direct table imports and foreign keys.
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
columns, relations, schema entrypoint, or migration internals.
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
rejects every currently machine-checkable repository rule even before production packages fill
@@ -40,8 +41,8 @@ every directory. The UI guard rejects component, view, template, stylesheet, sha
common UI-framework dependencies inside modules. This deny list is defense in depth; package
review remains required when adopting a new frontend technology.
Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata
and are intentionally deferred to the Phase 3 schema validator.
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
targets, and physical table namespaces in compiled Drizzle metadata.
## Generated code