Skip to main content
Voyant is Drizzle-first. There is no framework-specific schema DSL layered on top of Drizzle, no custom ORM wrapper, and no schema metadata language. You write ordinary Drizzle table definitions, and the discipline in this page is about consistency, not ceremony. The framework adds exactly one schema-level concept that is not plain Drizzle, links across module boundaries, and that has its own page.

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 (or schema-core.ts) holds tables, columns, indexes, and constraints.
  • schema-relations.ts holds the Drizzle relations(...) declarations.
Tables first, relations second. Do not bury core table shape inside large relation files or scatter relation declarations through service code. 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:
1

Same package?

Use .references(() => other.id, { onDelete: ... }). It creates a real Postgres FK constraint. Done.
2

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/.
3

Required vertical extension?

In rare cases a cross-package FK is allowed (for example booking_product_details.booking_idbookings.id) because the extension package depends one-way on the parent and is never used without it. Document the exception in a code comment.
The reason is portability: a cross-package .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 into jsonb just because it is faster to write at first.
Bad fit for jsonbGood fit for jsonb
Reusable business entitiesMetadata
Fields you routinely filter or sort byShape-flexible edge data
Values that participate in relational constraintsIntegration payload snapshots
Data other modules treat as a stable contractProvider- or transport-specific extension fields
If the field is part of the stable domain contract, give it a real column. If it is edge metadata or a flexible integration payload, 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.
import { integer, pgTable, text } from "drizzle-orm/pg-core"

export const invoices = pgTable("invoices", {
  id: text("id").primaryKey().notNull(),
  // 12500 = 125.00 in the row's currency, never 125.0 as a float
  amount_minor: integer("amount_minor").notNull(),
  currency: text("currency").notNull(), // ISO 4217, for example "EUR"
})

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.
export type Booking = typeof bookings.$inferSelect
export type InsertBooking = typeof bookings.$inferInsert
Schema-owned records should export inferred select and insert types unless there is a strong reason to define a narrower DTO on top.

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 declare deletedAt 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.
import { and, eq } from "drizzle-orm"
import { whereActive } from "@voyant-travel/db/lifecycle"

await db
  .select()
  .from(bookings)
  .where(and(eq(bookings.organizationId, orgId), whereActive(bookings)))
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.
// Justified composite: filter by (channel, destination, purpose, status), then order latest.
uniqueIndex("uq_policy_versions_policy_version").on(policyId, version)
index("idx_storefront_verification_lookup").on(channel, destination, purpose, status)

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.
Do not fake search performance with ordinary btree indexes. ILIKE, multi-column text search, and related-table exists(...) text probing are not solved by spraying btree indexes across text columns. That is a search problem (a search-specific index strategy or a projection), or it is acceptable to stay baseline-grade until workload evidence justifies more. Say which.

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 (its modules, extensions, additionalSchemas, and starter-local schemas) into a committed drizzle.schemas.generated.ts that drizzle.config.ts imports. 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.
voyant db generate    # diff the manifest-derived schema → a new migration (timestamp prefix)
voyant db migrate     # apply pending migrations
voyant db studio      # open Drizzle Studio against the schema
voyant db push        # push the schema directly (dev convenience)
A few mechanics worth knowing:
  • 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 writes drizzle.links.generated.ts, referenced from the manifest’s schemas), then run voyant db generate so Drizzle owns their diff and snapshot. See Links.
  • A deployment’s custom-module tables are a separate source. Generate them with pnpm db:generate:deployment and apply them with pnpm 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.
voyant db doctor --fail-on-drift

The generated SCHEMA.md reference

Voyant regenerates a human-readable SCHEMA.md directly from the Drizzle table definitions, so the documented schema can never drift from the code.
pnpm generate:schema-docs   # regenerate SCHEMA.md from the Drizzle tables
Treat 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.