89 lines
5.0 KiB
Markdown
89 lines
5.0 KiB
Markdown
# 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.
|