Skip to main content
Real travel data is relational: a person belongs to organizations, a booking references a quote, a product references a place. But Voyant modules are independently publishable packages, and a hard foreign key across a module boundary would force them to ship together. Links are how Voyant relates entities across modules without that coupling. This page explains why links exist, how you declare and materialize them, the policy that keeps them narrow, and how cross-module reads work. The rule is short:
Intra-domain foreign keys are fine. Cross-domain references must go through a link table.
Inside one module you use ordinary Drizzle .references() calls that create real Postgres foreign-key constraints. Across a module boundary you do not, for one concrete reason: a cross-package .references() forces schema co-installation. The consumer who installs one vertical but not the module that owns the referenced table cannot create that constraint, so the reference breaks the module-as-a-package boundary. A standalone vertical should not create a hard FK into another module’s canonical tables. Links solve this by keeping the wiring explicit at the deployment layer rather than baked into either module’s schema:
  • The source module exports a LinkableDefinition (for example personLinkable, productLinkable). It commits to being linkable, not to any particular partner.
  • The deployment declares the actual association with defineLink(...) in its src/links/ directory.
  • A generated pivot table materializes the association. Its lifecycle is owned by the deployment, not by either feature module.
So neither module imports the other’s schema, neither carries a hard dependency, and the association is mutable and discoverable at the layer that actually composes the app. You declare a link by pairing the two linkable definitions in the deployment’s src/links/.
// src/links/person-product.ts
import { defineLink } from "@voyant-travel/core"
import { personLinkable } from "@voyant-travel/relationships"
import { productLinkable } from "@voyant-travel/catalog"

export default defineLink(personLinkable, productLinkable)
defineLink is a typed contract from @voyant-travel/core. The current link model is pair-first: a materialized link row carries the left-side id, the right-side id, and row identity plus lifecycle timestamps. It supports one-to-one, one-to-many, many-to-one, many-to-many, and read-only externally-owned traversal, which covers the current baseline. You can scaffold the defineLink snippet with the CLI rather than writing it by hand. Each argument is a <module>.<entity> reference.
voyant generate link crm.person products.product --right-list
The --right-list flag (and related list flags) shapes the cardinality and the convenience list accessors generated for the link. A link row is a pair-first association record, not a generic business object with arbitrary edge payload. This is a deliberate constraint, and respecting it keeps the link runtime from drifting into an ad-hoc graph-metadata system.
Do not add a catch-all metadata payload to every link as the first answer to richer relationship needs. That produces weak ownership, unclear validation, hard-to-index queries, and relationship semantics that drift package by package.
When a relationship genuinely needs business fields of its own, the answer is not to enrich the link. It is to give the relationship an owner.
The relationship is…Use
Only “A is linked to B”, mutable, no fields of its ownThe pair-first link model (defineLink)
Carrying real product state (a role or label, a status, commercial config, lifecycle that affects behavior, or fields you must filter on)An owned relationship record: a module-owned table with explicit columns, API shape, validation, and indexes
The promotion threshold is “the relationship itself has meaningful state”, not “richer metadata might be convenient someday”. When you do promote, keep the first richer slice family-specific: one relationship family, one explicit owner, one concrete query and mutation surface, one documented validation and lifecycle model. Do not build a platform-wide graph-edge abstraction first. Links can still participate in the surrounding query path (traversal, lookup convenience), but the business fields live in the owned record, not behind what still looks like a plain pair-link. A declared link is just a contract until you generate its table. voyant db sync-links materializes the link tables, and the --emit-drizzle form folds them into the migration history so Drizzle owns their diff and snapshot.
voyant db sync-links                 # emit the link-table DDL
voyant db sync-links --emit-drizzle  # emit a generated Drizzle schema → drizzle.links.generated.ts
The recommended flow keeps link tables under the same migration discipline as everything else:
1

Declare the link

Add defineLink(...) in src/links/ (or scaffold it with voyant generate link).
2

Emit Drizzle definitions

Run voyant db sync-links --emit-drizzle. It writes drizzle.links.generated.ts, which the manifest’s schemas references.
3

Generate the migration

Run voyant db generate so Drizzle diffs the link tables into the regular migration history, rather than applying them out-of-band as raw DDL.
4

Apply

Run voyant db migrate.
The pivot tables live outside any module’s canonical tables, in neutral territory, which is exactly why the deployment owns them. The drift gate (voyant db doctor --fail-on-drift) checks that every declared link table is present in the latest snapshot, so a link you declared but never materialized fails CI.
A custom module’s schema should follow the same rule. When a deployment-local table in src/modules/<name>/schema.ts needs to reference a framework entity, use a plain text("person_id") column plus a defineLink in src/links, not a hard cross-module FK. The deployment migration source is applied after the framework bundle, so a hard FK would technically work, but the link keeps the module relocatable and matches house style.

Reading across modules: traversal and projections

Declaring a link tells the framework two entities can be related. Reading the combined graph at runtime is the query side. The default cross-module read model is application-layer traversal through the query graph (@voyant-travel/core/query and the link runtime), not precomputed tables:
  • base records come from each module’s own fetchers,
  • associations resolve through the shared link service,
  • linked records hydrate through registered target fetchers,
  • the final read shape is stitched in runtime code.
Assume this query-graph traversal is the contract until a specific read path proves it needs more. When traversal becomes a measured bottleneck, the answer is not automatic denormalization. First rule out simpler fixes: better indexes on the module-owned tables, cleaner fetcher behavior, a narrower query shape, or response-shaped caching. A cache is best-effort acceleration; a projection is a different thing. A projection is an explicit, owned, derived read model: denormalized read support that accelerates one expensive path. It is never a second source of truth. Canonical state still lives in the owning module’s tables. Introduce a projection only with one concrete, measured read path, and keep the first slice narrow: one target read model, one owner, one freshness contract, one rebuild and backfill path, one invalidation trigger.
Module routes should keep exposing business contracts. A caller should not be able to tell whether a result came from direct fetcher traversal, a cache, or a projection table. Projection internals stay behind shared runtime surfaces, never in public module contracts.
Framework-level projections should be rare and obvious, reserved for a genuinely shared platform read model that downstream apps would otherwise keep rebuilding. Public and storefront read paths (faceted discovery, denormalized catalog, availability or pricing search) are the most plausible future pressures, but they are not the current baseline, and if they arrive they must stay explicit about where freshness comes from and what is derived.

Next steps

Data models

Intra-module relations, money, indexes, and constraints.

Modules

Why modules stay isolated and how they compose.

Configuration

How declared links feed the manifest and migration bundle.

Command reference

voyant generate link and voyant db sync-links.