Relationships
Companions:
- CORE-DOCUMENT-STORAGE.md — the foundational EAV layer relations read and write against (
store_relationis one of the seven typed stores). - CLIENT-SDK.md —
@byline/clientis where most relation reads land; the populate /WithPopulatedpatterns are documented there too. - COLLECTIONS.md —
pickercolumn definitions for relation-picker rows, and theuseAsTitlefield used by populate's default projection. - DOCUMENT-PATHS.md —
pathlives in a dedicatedbyline_document_pathstable keyed by(document_id, locale). Used by relation filters (where: { category: { path: 'news' } }) and locale-resolved per request. - AUTHN-AUTHZ.md — populate threads
RequestContextsobeforeRead/afterReadapply to populated targets. - FILE-MEDIA-UPLOADS.md — the
Mediacollection plus a relation pointing at it is the canonical "shared media library" pattern. - RICHTEXT.md — document links inside richtext field values are a second consumer of the relation envelope.
Overview
A relation in Byline is a typed reference from one document to another. Relations are first-class citizens of the storage layer (store_relation is one of the seven typed store_* tables) and a first-class field type (RelationField). Every relation carries the target's logical id and collection id, plus optional link metadata (relationship_type, cascade_delete).
Today's surface is intentionally focused:
- Single-target relations.
hasManyis deferred. One field stores at most one relation. - Cross-collection only. A relation's target lives in some collection's
documentVersions. There is no in-place "embed". - Read-time population. Relations are stored as references; population happens on read, in batches, with depth-bounded recursion safety.
- Two consumer surfaces. A typed
RelationFieldon a collection schema, and document links / inline images embedded inside richtext field values. Both flow through the same envelope and the sameReadContext.
Three things round out the picture in code:
- Populate —
populateDocumentsinpackages/core/src/services/populate.tswalks a reconstructed document's relation leaves, batch-fetches the targets, and embeds them in place. - ReadContext — request-scoped recursion guard (visited set, read budget, depth clamp) that survives across nested populate walks and
afterReadhook re-entry. - Relation envelope — a single shape that every relation leaf narrows through, regardless of whether it's unpopulated, populated, unresolved, or cycle-suppressed.
The first production consumer is News → featureImage → Media (apps/webapp/byline/collections/news/schema.ts). The full pipeline — picker → patch → write → reconstruct → populate → API preview — exercises end-to-end any time an editor sets a feature image.
Quick reference
Each entry is the minimal shape for one task. The "Edit" line tells you which file you actually change; the link at the end points at the deeper architecture section.
1. Declare a relation field
type: 'relation' + targetCollection (the target's path string). displayField is the hint populate uses for picker rows and default projection; optional marks the field nullable.
Edit: apps/webapp/byline/collections/<name>/schema.ts
{ name: 'category', label: 'Category', type: 'relation', targetCollection: 'news-categories', displayField: 'name',}
{ name: 'featureImage', label: 'Feature Image', type: 'relation', targetCollection: 'media', displayField: 'title', optional: true,}The picker UI, populate's default projection, and the where-clause parser all read targetCollection and displayField.
2. Mark a collection as link-target for richtext
linksInEditor: true on the CollectionDefinition makes the collection's documents appear as picker options in every richtext editor's link plugin. Requires useAsTitle so the picker has a label.
Edit: apps/webapp/byline/collections/<name>/schema.ts
export const Pages = defineCollection({ path: 'pages', useAsTitle: 'title', linksInEditor: true, // ← pickable from richtext links fields: [/* … */],})A single boolean on the collection rather than an allowedCollections list per richtext field — simpler, and revisitable if a real use case ever needs per-editor restriction.
3. Filter on a relation target's columns
Nested-object sub-wheres compile to a depth-scoped EXISTS against store_relation joined to the target's current_(published_)documents view. path is locale-resolved; status is the target version's column.
Edit: any read call site (server fn, loader, script).
// News whose category's path is 'press'where: { category: { path: 'press' } }
// News whose category's parent's path is 'editorial' — 2-hopwhere: { category: { parent: { path: 'editorial' } } }
// Field filter on the target collection (not a top-level column)where: { category: { slug: 'press' } }
// status / path inside a combinator are still top-level column filterswhere: { $or: [{ author: { id: actor.id } }, { status: 'published' }] }status / path inside a nested sub-clause resolve as document metadata at the inner depth (via DocumentColumnFilter), not as field filters — so a target collection that declares a path field will not see those clauses resolve there; rename to slug if that ever bites.
4. Read with populate: true (default projection)
populate: true walks every relation field and applies the default projection — document-row metadata (document_id, path, status, created_at, updated_at) plus the schema-declared useAsTitle field. Depth defaults to 1 when a populate map is present.
Edit: any read call site.
const news = await client.collection('news').find({ populate: true,})Cheap, predictable, no per-relation configuration.
5. Read with populate: '*' (full projection, recursive)
'*' returns the full target document (every field), and it propagates — every relation reached by the walk is also fully populated, up to depth.
Edit: any read call site.
const news = await client.collection('news').find({ populate: '*', depth: 3,})The right shape when you genuinely want the whole tree. For most production reads, named relations with '*' per-leaf is the sweet spot (recipe 6).
6. Read named relations with per-relation projection
populate: { name: '*' } per relation, picked individually. This is the pattern the news module uses.
Edit: apps/webapp/src/modules/news/list.ts (and similar).
const news = await client.collection('news').find<NewsListFields>({ where: data.category ? { category: { path: data.category } } : undefined, sort: { publishedOn: 'desc' }, populate: { category: '*', featureImage: '*' }, page: data.page, pageSize: data.pageSize, status: preview ? 'any' : 'published',})The per-leaf forms compose:
populate: { category: true }— default projection (lighter)populate: { category: '*' }— full document at this leaf onlypopulate: { category: { select: ['name', 'path'] } }— explicit field listpopulate: { author: { populate: { department: true } } }— go one level deeper
7. Walk deeper (depth: 2 + nested populate)
depth: 1 is the default with a populate map; depth: 0 disables. To walk further, raise depth and supply nested populate: { … } shapes for the deeper levels.
Edit: any read call site.
const news = await client.collection('news').find({ populate: { featureImage: true, author: { populate: { department: true } }, }, depth: 2,})Programmatic cap is 8 (clamped by ReadContext.maxDepth); admin API preview cap is 3.
→ ReadContext — recursion safety
8. Handle the four envelope states
Every relation leaf — pre-populate, post-populate, missing target, cycle — narrows through the same base shape. Discriminate on _resolved and _cycle.
Edit: any consumer rendering relation values.
function renderRelation(rel: RelatedDocumentValue) { if (rel._cycle) return <span>(cycle suppressed)</span> if (rel._resolved === false) return <span>(deleted or hidden)</span> if (rel._resolved && rel.document) { return <a href={rel.document.path}>{rel.document.fields.title}</a> } // Raw reference — populate didn't run for this leaf. return <a href={`/admin/doc/${rel.target_document_id}`}>unresolved</a>}Link metadata (relationship_type, cascade_delete) survives population — it stays on the envelope after the target is attached.
→ The relation envelope — four states, one shape
9. Type populated relations with WithPopulated
Schema-derived field types treat relation slots as the unpopulated wire shape (RelatedDocumentValue). WithPopulated<Fields, 'name', TargetFields> overlays the populated envelope so result.fields.<name>?.document?.fields.<field> is fully typed.
Edit: the server-fn that calls populate.
import type { WithPopulated } from '@byline/client'import type { MediaFields } from '~/collections/media/schema.js'import type { NewsFields } from '~/collections/news/schema.js'import type { NewsCategoryFields } from '~/collections/news-categories/schema.js'
type NewsListFields = WithPopulated< WithPopulated<NewsFields, 'category', NewsCategoryFields>, 'featureImage', MediaFields>
await client.collection('news').find<NewsListFields>({ populate: { category: '*', featureImage: '*' },})The wrapper is purely at the type level — a matching populate: { … } at the call site is still required for the runtime envelope to actually be populated.
→ CLIENT-SDK.md — Typing populated relations
10. Status awareness through populate
Populate honours the same readMode rule as direct reads. A public reader (@byline/client, defaulting to readMode: 'published') sees populated targets through current_published_documents — a draft over a previously-published target does not leak through populate. Admin code paths use readMode: 'any' and see the latest version regardless of status.
Edit: the server-fn (the status / preview gate is per call).
// Public read — populated targets pass through current_published_documentsawait client.collection('news').find({ populate: { featureImage: '*' }, status: 'published', // default; explicit here for clarity})
// Admin read or preview — see latest regardless of publish stateawait client.collection('news').find({ populate: { featureImage: '*' }, status: 'any',})This applies through every depth level — a populated target is itself read through the same source view as the calling document.
→ Status awareness through populate
11. Thread ReadContext through a custom afterRead hook
afterRead hooks that perform their own reads must thread the existing readContext through. Otherwise: re-reads cost a fresh DB round-trip, the cycle guard breaks, and afterRead may fire twice on the same document in one request.
Edit: the hook in your collection's schema.
import { defineCollection } from '@byline/core'
export const Posts = defineCollection({ path: 'posts', hooks: { afterRead: async ({ doc, requestContext, readContext, client }) => { // Hook reads another doc — thread `readContext` through: const related = await client.collection('media').findById( doc.fields.featureImageId, { _readContext: readContext } // ← preserves visited set + read budget ) doc.fields.featureImage = related }, }, fields: [/* … */],})ReadContext carries the visited set, the afterReadFired set, the read budget, and the depth clamp. Threading it preserves "each document materialises at most once per logical request."
→ ReadContext — recursion safety
Architecture
Data model
RelationField schema:
{ name: 'featureImage', label: 'Feature Image', type: 'relation', targetCollection: 'media', // path of the target collection displayField: 'title', // optional; falls back to first text field optional: true,}targetCollection is the source-of-truth string the picker, populate, and the where-clause parser all consult. displayField is a hint for picker rows and populate's default projection — populate always includes it implicitly so widget summaries keep working even if a caller's select doesn't ask for it.
Storage row. store_relation has the same row identity as the other store tables (document_version_id, locale, path) plus the relation-specific columns:
store_relation document_version_id uuid locale text path text -- e.g. 'featureImage', 'content.0.body.0.relatedDoc' target_document_id uuid -- the relation's target target_collection_id uuid -- the target's collection (DB UUID) relationship_type text -- nullable; opaque to core cascade_delete boolean -- nullable; not yet acted onThe flatten/reconstruct pipeline in packages/db-postgres/src/modules/storage/storage-utils.ts round-trips the reference shape: a RelationField's value at write time is { target_document_id, target_collection_id, relationship_type?, cascade_delete? }, and that's exactly what's reconstructed on read before populate runs.
The relation envelope — four states, one shape
Every relation leaf — pre-populate, post-populate, missing target, cycle — narrows through the same base shape (RelatedDocumentValue). The discriminators _resolved / _cycle / document identify which of the four states the leaf is in:
// Unpopulated — no populate pass ran, or this leaf wasn't in scope{ target_document_id, target_collection_id, relationship_type?, cascade_delete? }
// Populated — target fetched and attached{ ..., _resolved: true, document: { ...fetched target } }
// Unresolved — target not found (deleted, soft-deleted, or filtered out){ ..., _resolved: false }
// Cycle — target already materialised earlier in this request{ ..., _resolved: true, _cycle: true }Two rules fall out of this shape:
- Same narrowing logic everywhere.
if (v._cycle) { … } else if (v._resolved === false) { … } else if (v._resolved && v.document) { … } else { /* raw ref */ }works at every relation leaf, no matter where it sits in the document tree. - Link metadata survives population.
relationship_typeandcascade_deletestay on the envelope after the target is attached — not thrown away on success.
The unresolved-target shape and the cycle shape stay distinct so callers can tell "not fetched because deleted" from "not fetched because of cycle" — the failure mode and the recovery affordance are different.
The populate pipeline
// packages/core/src/services/populate.tsexport async function populateDocuments(opts: { db: IDbAdapter collections: CollectionDefinition[] collectionId: string // source collection documents: Array<Record<string, any>> populate?: PopulateMap | true | '*' depth?: number // default 1 when populate is set; 0 disables locale?: string readMode?: 'published' | 'any' readContext?: ReadContext // request-scoped guard requestContext?: RequestContext // for beforeRead / afterRead}): Promise<void>Mutates documents in place. find* results are freshly-shaped copies, so this never aliases storage state.
Algorithm — batch-by-depth. For every depth level:
- Walk each document's
fieldsagainst itsCollectionDefinition, recursing throughgroup/array/blocksto collect every relation leaf that matches the populate map. - Group the collected leaves by
target_collection_id. - Batch fetch each group via
IDocumentQueries.getDocumentsByDocumentIds()— one DB round-trip per target collection per depth level. Selective field loading is wired through this call, so the batch projects only the fields the populate map asked for (plus the target'sdisplayField). - Replace each leaf in place with the populated, unresolved, or cycle-marked envelope.
- Recurse to the next depth if
depth > 1, using the populate map's nestedpopulate: { ... }as the next level's spec.
The batch-per-target-collection-per-depth shape is what keeps populate cheap. A 20-document × 3-relation × 2-depth fan-out is six total DB round-trips, not 120 — see the storage benchmark where populateDocuments depth-2 stays flat at ~3 ms across all scales.
The populate DSL — two axes. Populate options compose two orthogonal axes: scope (which relations to walk) and projection (what fields to return per target).
// Scope at the top level:populate: true // every relation field, default projectionpopulate: '*' // every relation field, full projection, recursivepopulate: { featureImage: ... } // named relations only
// Projection at each leaf:populate: { featureImage: true } // default projectionpopulate: { featureImage: '*' } // full document, propagatespopulate: { featureImage: { select: ['title', 'altText'] } } // explicitpopulate: { author: { populate: { department: true } } } // deeper levelThe default projection is the document row metadata that's always free (document_id, path, status, created_at, updated_at) plus the schema-declared useAsTitle field (fallback: first text field). '*' wins when any leaf in a batch requests it — projection composes across leaves of the same target collection.
useAsTitle lives on CollectionDefinition. The default projection is schema-aware without a UI dependency. useAsTitle was deliberately placed on CollectionDefinition (server-safe) rather than on CollectionAdminConfig (admin-only) so populate, afterRead consumers, and any future access-control consumer can read a document's identity without taking a UI runtime dependency. Django's Model.__str__ is the analogue.
CollectionAdminConfig retains a separate picker?: ColumnDefinition[] slot that drives rich row rendering in the relation-picker modal (e.g. thumbnail + title + status for Media). It's distinct from columns (which drives list-view rendering); formatters are reusable across both. See COLLECTIONS.md § Columns and picker.
Status awareness through populate
Populate honours the same readMode rule as direct reads. When a public reader (@byline/client, defaulting to readMode: 'published') populates a relation, the target is fetched through current_published_documents — so a draft over a previously-published target does not leak through populate. Admin code paths use readMode: 'any' (the adapter default) and see the latest version regardless of status. See Quick Reference recipe 10 for the call-site idiom.
ReadContext — recursion safety
A populate walk that ignored the rest of the request would still cycle the moment afterRead started doing its own reads. Byline's response is a request-scoped ReadContext that survives across nested populate walks and across afterRead re-entry.
export interface ReadContext { visited: Set<string> // ${target_collection_id}:${document_id} keys readCount: number // monotonic; throws ERR_READ_BUDGET_EXCEEDED on overflow maxReads: number // default 500 maxDepth: number // default 8 (caps `depth`) afterReadFired: Set<string> // each doc runs through afterRead at most once per request beforeReadCache: ... // beforeRead predicate cache, see AUTHN-AUTHZ.md}
export function createReadContext(overrides?: Partial<ReadContext>): ReadContextThree enforcement points:
- Populate walk. Each populate level pre-filters target IDs against
visited. Already-visited IDs become the_cycle: truestub instead of a re-fetch. Keys are${collection_id}:${document_id}so two collections that somehow shared a UUID stay distinct. - Read budget. Each materialised document increments
readCount. CrossingmaxReadsthrowsERR_READ_BUDGET_EXCEEDEDcarrying the partial result so the caller can decide whether to surface or degrade. Cheap defensive insurance against a malformed graph or a buggy hook. - afterRead once-per-doc-per-request.
afterReadFired: Set<string>enforces "each document runs throughafterReadat most once per logical request." A hook re-reading a doc that's already invisitedshort-circuits with the cached materialised value — no second pass, no second hook fire. The single most important semantic rule, and the reasonReadContextwas wired ahead of the hook rather than retrofitted.
Threading rules:
- Top-level reads create a fresh ReadContext. External callers never see it — public signatures stay context-free.
- CollectionHandle accepts a private _readContext? opt-in for hook re-entry. Hooks that call
client.collection(...).find(...)thread the same context through; subsequent reads of an already-visited document are short-circuited. - Populate and afterRead always share one context per request. A document linked through both a relation field and a richtext document link would otherwise cost two materialisations and two hook fires; sharing the context collapses them to one.
AsyncLocalStorage is a future option for carrying ReadContext implicitly. The explicit parameter is the source of truth today; AsyncLocalStorage can layer over it later without breaking the contract.
Relation filters
Relations are queryable. The where clause supports nested-object sub-wheres against a relation field's target — see Quick Reference recipe 3 for the call-site shape.
The compiler in packages/core/src/query/parse-where.ts recognises nested-object sub-wheres on relation fields and emits RelationFilter entries with recursive nested: DocumentFilter[]. The Postgres adapter's buildFilterExists dispatches on filter kind and emits a depth-scoped nested EXISTS through store_relation joined to the target's current(_published)_documents view.
Two consequences worth flagging:
- readMode propagates through the filter predicate. A public-reader query for
where: { author: { id: 'X' } }only matches when there is a published version of the author — no draft leaks at the target side either. - Nested-object DSL was chosen over Payload-style dot notation (
'author.id': 'X'). Dot notation collides with Byline's internal EAV path notation and doesn't absorb the futurehasManyquantifiers (some,every,none).
The relation field admin widget
Editing a relation field uses two components in packages/ui/src/fields/relation/:
- relation-field.tsx — the in-form widget. Renders a compact summary card via
RelationSummary/RelationDisplaywhen set, plus Remove and Change buttons. When empty, renders a "Select…" button. - relation-picker.tsx — the modal that opens on Select / Change. Lists documents from the
targetCollectionvia the host'sgetCollectionDocumentsserver fn, with search and pagination. Single-select for the currenthasMany: falsemodel.
Selection flows through the standard setFieldValue → FieldSetPatch pipeline — no new patch family. The patch contract is field.set with value = { target_document_id, target_collection_id }; field.clear on Remove. Both already supported by setFieldValue.
Display-field resolution inside the picker: field.displayField → first top-level text field on the resolved target definition → path → document_id.
Failure mode for an unknown target collection: if the target collection isn't registered, the widget renders an inline error ("Relation field {name} targets unknown collection {targetCollection}") and a disabled picker button. No throw.
The Zod schema builder emits a typed object for relation fields:
z.object({ target_document_id: z.string().uuid(), target_collection_id: z.string().uuid(), relationship_type: z.string().optional(), cascade_delete: z.boolean().optional(),}).nullable()The old z.any() catch-all is gone — the picker's contract is enforced at form-validate time.
Richtext document links
A second application of the relationship primitive: links to other Byline documents inside a richtext field value, plus inline-image references to media documents. Two paired Lexical plugins consume the same DocumentRelation envelope this doc defines.
The full present-state strategy — how the link and inline-image modals embed picked targets at picker time, the on-save server walker that canonicalises document.path via CollectionDefinition.buildDocumentPath, the persisted Lexical JSON shapes, the embedRelationsOnSave / populateRelationsOnRead field-level flags, and the embed / populate adapter contracts — lives in RICHTEXT.md → Relations — embed and populate.
One eligibility flag stays here because it lives on CollectionDefinition, not on the editor adapter:
export const Pages: CollectionDefinition = { path: 'pages', linksInEditor: true, // ← this collection is pickable from richtext fields: [...],}A collection becomes pickable from every richtext editor's link picker when its definition declares linksInEditor: true. Deliberately a single boolean on the collection rather than an allowedCollections list per richtext field — simpler, and revisitable if a real use case ever needs per-editor restriction.
Admin API preview depth selector
The admin "API" view at apps/webapp/src/routes/(byline)/admin/collections/$collection/$id/api.tsx ships a depth selector (0–3) in the ViewMenu. Changing it threads ?depth=N through loaderDeps, so each depth is a distinct cache entry. The user-facing cap is 3 — strict enough to prevent a curious editor from accidentally DOSing the preview on a wide graph; the programmatic client cap is 8 (ReadContext.maxDepth).
The admin server fn (packages/host-tanstack-start/src/server-fns/collections/get.ts) calls populateDocuments directly through @byline/core/services rather than through @byline/client — admin code paths historically did not depend on the client SDK at runtime. (Admin reads now flow through CollectionHandle for the beforeRead track; the populate-direct call sits inside CollectionHandle.findById on that path.)
Demo wiring — News → Media
The first production relation is in the News collection (apps/webapp/byline/collections/news/schema.ts):
{ name: 'featureImage', label: 'Feature Image', type: 'relation', targetCollection: 'media', displayField: 'title', optional: true,}…and the seeded News documents in apps/webapp/byline/seeds/documents.ts reference existing Media items. Editing a news item shows the picker; saving writes through the standard form-state pipeline; reloading the API preview at depth 1 shows the populated Media envelope (including the file's variants array — see FILE-MEDIA-UPLOADS.md); depth 2 walks into Media's own field set; deleting the referenced Media item and reloading at depth 1 shows the _resolved: false placeholder rather than a crash.
Risks worth tracking
- Field-tree walker drift. Three walkers exist today — the flatten/reconstruct walker (
storage-utils.ts),walkRelationLeavesinpopulate.ts, and theafterReadwalker indocument-read.ts. They all need to recurse throughgroup/array/blocksthe same way. If they diverge, relations inside compound fields populate incorrectly. The right time to extract a sharedwalkFieldTree(fields, data, visitor)into@byline/coreis the next time anything needs to touch a walker (a new compound type,hasMany, a new collection hook). Premature extraction now would over-fit on three known consumers; one more consumer makes the right shape obvious. - Wide depth × wide fan-out IN(...) lists. 20 docs × 5 relations × 3 depth is still capped at three round-trips per target collection, but the IN list itself can grow. The integration test asserts the batch-per-level expectation; an explicit query-count regression would catch a silent fanout-per-leaf change.
- cascade_delete round-trips but is not acted on. The column persists; nothing reads it. Cascading deletes belongs to the future write-path work and shares design questions with relation-integrity scanning.
- No relation column formatter in list views. List views currently render
target_document_idas a string for relation fields. A formatter that resolves touseAsTitleis a small, independent piece of work — useful but out of scope here.
Future phases of work
The current surface is feature-complete for the single-target / read-time-populate model. The phases below are concrete, planned tracks that build on it. Each is independent — none blocks the others, and the hasMany track is the largest by scope.
Phase — hasMany relations
hasMany: true on RelationField is the single biggest planned addition to this area. It changes four things in concert:
- Schema.
RelationFieldgrowshasMany?: boolean. The field's value type becomes an array of envelopes rather than a single envelope (or null). The Zod schema builder emits an array-of-object shape underhasMany. - Storage. Multiple
store_relationrows per(document_version_id, locale, path)rather than at most one. Thepathcarries an array index for ordering (tags.0,tags.1, …);flattenFieldSetDataalready supports this via array recursion. No new store table. - Populate output. Each leaf in the array narrows through the same envelope as today. The four states (unpopulated, populated, unresolved, cycle) apply per-element. A single deleted target inside a
hasManyfield becomes one_resolved: falseenvelope at its position; sibling elements are unaffected. - where quantifiers. Today's nested-object sub-where (
{ author: { id: 'X' } }) means "the author's id equals X." WithhasMany, the question splits: some of the targets, every one, or none. The DSL grows$some/$every/$nonequantifiers:where: { tags: { $some: { name: 'urgent' } } }where: { authors: { $every: { isStaff: true } } }parse-where.tsrecognises the quantifier as a wrapper around the existing nested-DocumentFilter[], and the Postgres adapter compiles each quantifier to a differentEXISTS/NOT EXISTS/ count-comparison shape overstore_relationjoined to the target'scurrent(_published)_documentsview.
The picker UX is the largest UI piece. Add / remove / reorder over a list, with the same target-collection / search / pagination affordances as the single-select picker. The patch surface is unchanged in spirit (field.set with the new array value); whether to introduce richer per-element patches (relation.add, relation.remove, relation.reorder) is an open Phase decision — the analogue of how blocks have block.add / block.replace / block.remove rather than always replacing the whole array.
Phase — cascade-delete acted on
The cascade_delete flag round-trips today but is not acted on. A future write-path pass walks relations to deleted targets and applies the policy:
cascade_delete: true→ hard-delete the referencing relation rows (and possibly the document itself, depending on context).cascade_delete: false→ leave the relation row in place; reads see_resolved: false.cascade_delete: 'restrict'(a future third value) → refuse the target delete withERR_REFERENTIAL_INTEGRITYlisting the referrers.
This shares design questions with the broader integrity-scanning track below — both need to walk every relation that points at a given target, efficiently, on a soft-delete event. A reverse index on (target_collection_id, target_document_id) already exists on store_relation for this purpose.
Phase — cross-document link integrity job
A periodic admin command that scans richtext fields and store_relation rows for links to deleted or unresolvable targets, then surfaces them in a "broken links" admin view. The natural shape: a server fn that reuses populate's missing-target detection (_resolved: false) but materialises the result as a list of (linking_document, target_id, target_collection) rows for triage. Bulk "fix" affordances (re-link, remove the link, replace the link) follow as separate user-facing actions.
Phase — bulk "refresh embedded links" command
For richtext links in embedRelationsOnSave: true mode (the default), embedded attributes drift when a target's title / path / altText / image variants change. A bulk command would walk every richtext value in a chosen collection (or the whole installation), re-resolve each link's target, and re-embed the cached fields in place — without bumping documentVersions. Useful when staleness compounds (e.g. after a bulk title rename) and a per-document re-save isn't practical.
Phase — relation column formatter
List views currently render target_document_id as a string for relation cells. A formatter that resolves to the target's useAsTitle (with the picker's displayField fallback chain) is small, self-contained, and independent of the other phases. Worth doing alongside hasMany so the formatter handles "A, B, +3 more" from the start rather than being retrofitted.
Out of scope (not currently planned)
- Anchor / fragment targeting inside richtext links — pointing a link at a specific heading inside the target document. Editor-feature work, orthogonal to the storage shape.
- Mixed-mode richtext links (per-link choice between embed and populate inside the same editor, rather than per-field). Almost certainly not worth the complexity until a real use case demands it.
- AsyncLocalStorage for ReadContext. Could replace the explicit
_readContextparameter onCollectionHandlelater. Not blocking anything.
Code map
Concern | Location |
|
|
|
|
|
|
|
|
|
|
|
|
Field-type → store-table mapping |
|
Relation |
|
Postgres |
|
|
|
Zod schema for relation |
|
Relation field admin widget |
|
Admin API preview depth selector |
|
Admin |
|
|
|
Richtext document links + embed / populate strategy | |
Reference relation field |
|
Reference list reading populated relations |
|
Reference detail reading populated relations |
|
Integration tests |
|