Modules own their tables
A module owns the tables that define its domain: its canonical records, the local foreign keys between its own tables, its local indexes and constraints, and the Drizzle relations its service layer uses. If a table represents the canonical state of one module, keep it inside that module. Keep table shape and relations in separate files so each is easy to scan:schema.ts(orschema-core.ts) holds tables, columns, indexes, and constraints.schema-relations.tsholds the Drizzlerelations(...)declarations.
Relations inside a boundary; links across one
Inside one module, model relationships directly in SQL with real foreign keys and Drizzle relations. Across a module boundary, do not add a direct foreign key and do not import another module’s schema to create a hard relational dependency. Use a link instead, and read the combined graph through query traversal.Intra-domain FKs are fine. Cross-domain FKs must go through a link table.Concretely, when you add a column that points at another table:
Same package?
Use
.references(() => other.id, { onDelete: ... }). It creates a real Postgres FK constraint. Done.Cross-package, deployment-managed association?
Add a plain
text("foo_id") column (indexed where appropriate), export a *Linkable from each side, and declare defineLink(...) in the consuming deployment’s src/links/..references() forces schema co-installation, so a consumer who installs a vertical but not the module that owns the referenced table cannot create the constraint. That breaks the module-as-a-package boundary.
Column and type conventions
Prefer typed columns over generic blobs
Use real typed columns for stable business fields: identifiers, statuses, dates, amounts, codes, booleans, and structured enums. Do not push stable domain fields intojsonb just because it is faster to write at first.
Bad fit for jsonb | Good fit for jsonb |
|---|---|
| Reusable business entities | Metadata |
| Fields you routinely filter or sort by | Shape-flexible edge data |
| Values that participate in relational constraints | Integration payload snapshots |
| Data other modules treat as a stable contract | Provider- or transport-specific extension fields |
jsonb is acceptable.
Money is integer minor units plus a currency
Money is never a float. Store amounts as integer minor units (cents, bani, pence) alongside an explicit currency code, so arithmetic is exact and the unit is unambiguous.Expose inferred row and input types
Drizzle’s inferred types are the source of truth for row and insert shapes. Export them from the schema file so service and route code stay aligned with the schema instead of drifting into hand-written parallel types.Be intentional about nullability
Nullability should communicate domain meaning, not implementation indecision. Use a nullable column when the value is genuinely optional, becomes available later in the lifecycle, or does not exist for all variants of the record. Do not use nullable columns to dodge a validation or lifecycle decision.Soft delete
Tables that declaredeletedAt are filtered automatically by createCrudService(...): list, count, listAndCount, and retrieve return active rows only by default, and you pass includeDeleted: true to opt back in (recycle bins, audit reports, reconciliation). For ad-hoc queries that bypass the CRUD service, compose whereActive(table) from @voyant-travel/db/lifecycle into the WHERE clause.
whereActive(table) returns undefined for tables without deletedAt, so and(other, undefined) collapses cleanly and the helper is safe to apply unconditionally.
The index and constraint policy
Indexes and constraints earn their place by serving a real query shape or enforcing a real invariant. Voyant stays Drizzle-first here too: this is disciplined authoring, not an automated optimizer.Start from query shape
An index should exist because a real read path needs it, and that path should be visible in a service query, a route handler, a workflow query, or a daemon reconciliation pass. When you propose an index, name the concrete query shape it supports.Composite versus single-column
Prefer one honest composite index over several decorative single-column ones when a query repeatedly filters by the same column group (an equality filter followed by a stable sort, or a scoped lookup with a dominant selector tuple). Keep single-column indexes while a table has several independent selectors and no one combination dominates yet, for example a list endpoint with many optional filters. Promote to a composite only when one combined pattern proves hotter than the rest. Do not spray every pairwise combination speculatively.Unique and check constraints
Use a unique constraint for a stable business invariant that is always invalid to violate regardless of caller, for example one version per parent ((policyId, version)), one code per product, or one external reference per source-object tuple. Use a check constraint for a row-local correctness rule that is stable and cheap: non-negative amounts, bounded percentages, valid date ordering when both dates exist. If violating the rule is universally invalid and independent of workflow context, the database should own it, not only service code.
Partial indexes, and the search trap
Use a partial index only when the predicate is stable, the subset is frequently queried, and the subset is materially smaller than the full table (active records, pending work, published rows). Do not add one for every boolean or nullable field.Schema discipline
A few rules keep per-module schemas shippable as independent packages and keep the aggregate consistent.- Put stable local invariants in the database. If violating an invariant would always be invalid, enforce it with a constraint rather than relying only on service validation.
- Cross-module references go through links, never cross-package FKs. This is the same boundary rule as above, and it is what keeps each module independently installable.
- Packages declare schema; apps own migrations. A package exports its tables; the starter or app owns
drizzle.config.ts, the migration directories, and the generated SQL. This keeps final composition in one place and avoids package-level migration collisions when modules are assembled together. - Never hand-edit the schema list. A deployment’s schema set is derived from
voyant.config.ts(itsmodules,extensions,additionalSchemas, and starter-localschemas) into a committeddrizzle.schemas.generated.tsthatdrizzle.config.tsimports. To change it, edit the manifest and regenerate, do not edit the generated file.
Generating migrations
Migrations live in the app, derived from the manifest. The CLI proxies Drizzle Kit so the schema set always comes from the resolved manifest.- New migrations use timestamp prefixes by default, so concurrently-authored migrations never collide on a sequential index. Pre-existing sequential migrations stay as they are, and pre-existing duplicate prefixes are baselined rather than rewritten.
- Cross-module link tables are folded into the migration history. Generate their Drizzle definitions with
voyant db sync-links --emit-drizzle(which writesdrizzle.links.generated.ts, referenced from the manifest’sschemas), then runvoyant db generateso Drizzle owns their diff and snapshot. See Links. - A deployment’s custom-module tables are a separate source. Generate them with
pnpm db:generate:deploymentand apply them withpnpm db:migrate; the collector applies the framework bundle first, then the deployment source.
The drift gate
voyant db doctor --fail-on-drift gates CI. It cross-checks manifest resolvability, schema parity (the generated schema matches the manifest), generated-manifest freshness, duplicate prefixes against the baseline, and that every declared link table is present in the latest snapshot. Run it before you push.
The generated SCHEMA.md reference
Voyant regenerates a human-readableSCHEMA.md directly from the Drizzle table definitions, so the documented schema can never drift from the code.
SCHEMA.md as a generated artifact: read it to understand the data model, but change the Drizzle definitions and regenerate rather than editing it by hand.
Next steps
Links
Relate entities across module boundaries without coupling schemas.
Modules
How a module owns its tables, services, and routes.
Configuration
How the manifest derives the schema set and migration bundle.
Command reference
Every
voyant db command and its flags.