feat: add module schema discovery and validation
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user