lookup Reference fields over enum, dictionary, and first-class collection dimensions
| Subpath | @noy-db/hub (root export — lookup, enumOf (also exported as enum), dict, UnknownLookupKeyError, DictKeyInUseError) |
| Wiring | no with*() strategy — declare lookupFields: { field: lookup(...) } (or viaFields: { field: via(lookup(...)) }) on vault.collection() |
| Kind | field feature — declared per field through the via surface |
What it does
A lookup field stores a stable code ('US', 'paid') and resolves everything else — display
label, membership, ordering, reference integrity — against a backing dimension. One
declaration covers all three places such dimensions live: inline in code (enumOf()), in a
per-vault editable dictionary (dict(), the i18n _dict_* machinery), or in a
first-class collection of the vault itself (lookup() — a "matrix" dimension like a
countries collection, with as many columns as you like).
Beyond code→label resolution it adds three things codes alone can't do: altKeys (alternate
identifiers that normalize to the canonical key on write — an ISO3 country code, a phone call
prefix), closed vocabulary (write-time refusal of unknown keys, checked against the
dimension's live rows), and reference semantics (restrict / cascade / nullify when a
still-referenced backing row is deleted or forgotten).
The canonical example — a countries matrix
The reference pattern for lookup dimensions is a first-class countries collection: ISO2 as the
canonical key, ISO3 and call prefix as alternate keys, localized names — and sparse, populated
only with what you use (add a row for a country the day you first need it; no schema migration,
no 249-row seed):
import { lookup } from '@noy-db/hub'
interface Country { id: string; iso2: string; iso3: string; callPrefix: string; name: Record<string, string> }
interface Order { id: string; country: string }
const countries = vault.collection<Country>('countries', {})
await countries.put('row-US', { id: 'row-US', iso2: 'US', iso3: 'USA', callPrefix: '+1',
name: { en: 'United States', th: 'สหรัฐอเมริกา' } })
await countries.put('row-TH', { id: 'row-TH', iso2: 'TH', iso3: 'THA', callPrefix: '+66',
name: { en: 'Thailand', th: 'ประเทศไทย' } })
const orders = vault.collection<Order>('orders', {
lookupFields: {
country: lookup('countries', {
key: 'iso2', // the canonical key FIELD — not the row's storage id
altKeys: ['iso3', 'callPrefix'], // candidate values that normalize to `key`
present: { label: 'name', by: 'locale' }, // <field>Label resolves row.name[locale]
sortBy: 'name',
backing: 'collection',
vocabulary: 'closed',
}),
},
})
Note the row's own storage id ('row-US') is deliberately not the canonical key ('US'):
key names a field on the backing row. Every lookup mechanism — membership, altKey
normalization, sorting, join dressing — keys by row[key], never by the row's storage id.
Writes then normalize any accepted identifier to the canonical key:
await orders.put('o1', { id: 'o1', country: 'USA' }) // ISO3 candidate
;(await orders.get('o1'))?.country // → 'US' — normalized on ingest
await orders.put('o2', { id: 'o2', country: '+66' }) // call-prefix candidate
;(await orders.get('o2'))?.country // → 'TH'
And unknown keys are refused at write time under vocabulary: 'closed':
await orders.put('o3', { id: 'o3', country: 'ZZ' })
// → rejects with UnknownLookupKeyError
The three tiers
All three factories build the same descriptor shape; backing decides where the dimension lives:
| Tier | Factory | Backing | Vocabulary default | Syncs? |
|---|---|---|---|---|
| enum | enumOf(['a','b'] as const) | none — inline keys, no store | closed | n/a (no store) |
| dict | dict('status') | reserved _dict_status micro-collection (vault.dictionary('status')) | open | yes — see below |
| matrix | lookup('countries') | a first-class collection (vault.collection('countries')) | open | yes (ordinary collection sync) |
lookup(dimension, opts) is the one factory that can construct any tier
(backing: 'static' | 'reserved' | 'collection', default 'collection') — enumOf() and
dict() are fixed-tier conveniences, and the i18n spellings dictKey() /
staticDict() compile onto the same descriptor shape (see "dictKey and staticDict are aliases"
below).
altKeys — alternate identifiers normalize on write
altKeys names backing-row fields whose values are accepted as write-time candidates and
normalized to the canonical key — synchronously, from an in-memory index built over the backing
rows already open in this session (no store read per put()). Because the index is
session-local, the backing collection must be open in eager mode (the default) for matrix-tier
altKeys; declaring altKeys on a matrix field whose backing collection was opened
{ prefetch: false } (lazy) makes put() throw ValidationError naming the field and dimension.
The dict() tier has no altKeys option — its dictionary cache is always synchronous, so the
restriction never applies.
A candidate value may be a string or a number — both coerce to the same string index entry (a
numeric 1 and the string '1' are interchangeable). Each candidate must be claimed by at most
one backing row: two rows resolving to the same candidate — across the string/number boundary
included — throw ValidationError.
Vocabulary — open vs closed, and why sparse dimensions are safe
vocabulary: 'closed' checks membership against the backing dimension's actual current rows,
live — not a hardcoded universe of "real" codes. A legitimate ISO2 code that simply hasn't been
populated in this vault is refused exactly like a fake one:
// Only 'US' and 'TH' rows exist — 'ZA' is a real ISO2 code, just not populated here.
await orders.put('o4', { id: 'o4', country: 'ZA' })
// → rejects with UnknownLookupKeyError
That is what makes populate-only-what-you-use dimensions sound: membership is defined by your data, not by an external standard.
Per-tier detail:
- Matrix tier has no declared key list at all — membership is purely the backing collection's live rows.
- Dict tier closed membership is the declared
keysunion the reserved dictionary's live rows: a declared key is known even before any dictionary row for it exists, and a live row for an undeclared key is known too. dictKey()(anddict()) default to'open'— declaring keys ondictKey()never enforced membership, and still doesn't; write-time refusal is exclusively the nativevocabulary: 'closed'opt-in.staticDict()is closed by a different, older mechanism — its ownvalidateCodesoption (defaulttrue), throwingUnknownDictCodeError, unchanged.
Cold-session caveat. The live-rows side of the check reads a cache materialized during this
session. A freshly opened vault (or one straight after a pull) that hasn't yet opened the
matrix-tier backing collection — or warmed the dict-tier dictionary cache — sees an empty
live-rows set, so a vocabulary: 'closed' field refuses even a genuinely valid, already-persisted
key until the dimension is hydrated. Open the backing collection first (matrix tier, eager mode),
or await vault.dictionary(name).list() (dict tier). This fails safe — it refuses rather than
accepts unverified keys — but is easy to trip over on a fresh instance.
Array-valued fields — element-wise normalization
A lookup field whose value is itself an array (tags: ['USA', '+66']) normalizes element by
element — altKey normalization and closed-vocabulary membership run per element, against the same
backing index the scalar case uses:
const orders = vault.collection<{ id: string; tags: string[] }>('orders-tagged', {
lookupFields: { tags: lookup('countries', { key: 'iso2', altKeys: ['iso3', 'callPrefix'] }) },
})
await orders.put('o1', { id: 'o1', tags: ['USA', '+66'] })
;(await orders.get('o1'))?.tags // → ['US', 'TH'] — each element normalized to the canonical key
Under vocabulary: 'closed', the write is refused on the first unknown element:
const closed = vault.collection<{ id: string; tags: string[] }>('orders-closed', {
lookupFields: { tags: lookup('countries', { key: 'iso2', vocabulary: 'closed' }) },
})
await closed.put('o1', { id: 'o1', tags: ['ZZZ', 'nope'] })
// → rejects with UnknownLookupKeyError
Detail rules: normalization rewrites values in place, never cardinality — duplicate elements are
kept, not deduplicated; a non-string element (number, object, null) is skipped, exactly as the
scalar branch skips a non-string value; an empty array is a no-op even under closed vocabulary.
This behaves identically at a dotted, non-wildcard path ('meta.tags' for a nested
{ meta: { tags: [...] } } shape).
Presentation — <field>Label, in reads and joins
Reading with a locale resolves a <field>Label sibling on the same record, sourced from the
backing row's present.label field (locale-keyed when present.by: 'locale'). Joining to a
collection that declares a lookup field dresses the joined side too, matrix tier included:
const rows = shipments.query().join('orderId', { as: 'order' }).toArray({ locale: 'th' })
rows[0].order.countryLabel // 'สหรัฐอเมริกา' — resolved through the join
Direct (non-join) reads of a matrix-tier field resolve <field>Label the same way — keyed by
descriptor.key through the same session-local snapshot the join path uses — so a custom canonical
key like key: 'iso2' above dresses correctly on a plain read, not only through a join. That
snapshot shares the cold-session requirement: the backing dimension must be open and populated in
this session (the cold-session caveat above). Only the default key: 'id' tier keeps a live
on-demand .get() fallback on a snapshot
miss; for any custom key the snapshot is the sole source, so a cold dimension yields no label
until it is hydrated.
Sorting by the resolved label
Two independent channels:
// (a) declared: sortBy + displayLocale — a plain orderBy() sorts by the label at the
// field's own fixed locale:
lookup('countries', { key: 'iso2', present: { label: 'name', by: 'locale' },
sortBy: 'name', backing: 'collection', displayLocale: 'en' })
orders.query().orderBy('country', 'asc').toArray()
// (b) per-call: orderBy(..., { by: 'label' }) resolves at the query's own locale —
// no displayLocale needed, and two locales genuinely produce two different
// orders for the same rows (the labels collate differently per language):
orders.query().orderBy('country', 'asc', { by: 'label' }).toArray({ locale: 'en' })
orders.query().orderBy('country', 'asc', { by: 'label' }).toArray({ locale: 'th' })
Silent degrade if you skip displayLocale. A sortBy field whose present.by is
locale-keyed but has no displayLocale does not throw — a plain orderBy() degrades to raw
stored-code order with no query-time warning. lookup() emits a one-time console.warn at
declare time when it detects this combination; watch for it, or use channel (b), which needs no
displayLocale at all.
Reference semantics — restrict (default), cascade, nullify
A lookup field is a constrained reference to its backing dimension. Deleting (or forgetting) a
backing row that is still referenced follows the field's onDelete:
import { dict } from '@noy-db/hub'
const tickets = vault.collection<{ id: string; status: string }>('tickets', {
lookupFields: { status: dict('status') }, // onDelete defaults to 'restrict'
})
await vault.dictionary('status').put('paid', { en: 'Paid' })
await tickets.put('t1', { id: 't1', status: 'paid' })
// restrict (default) — refuses, naming the referencing collection and count:
await vault.dictionary('status').delete('paid')
// → rejects with DictKeyInUseError
// err.dictionaryName === 'status'; err.usedBy === 'tickets'; err.count === 1
await tickets.delete('t1') // retire the reference first
await vault.dictionary('status').delete('paid') // now succeeds
restrictrefuses the delete before any mutation withDictKeyInUseError.cascadetombstones/deletes every referencing record.nullifyclears the referencing field via an ordinaryput().
The matrix tier behaves identically against a first-class collection — countries.delete(...)
throws, cascades, or nullifies the same way — and restrict matches against row[key], not the
row's storage id, so the non-default-key recipe above is fully covered.
vault.forget() composes the same way: restrict refuses the forget call
itself before any shred while references exist; cascade/nullify propagate after the shred and
are reported on the forget result (lookupReferencesCascaded / lookupReferencesNullified /
lookupReferencesResidue).
To change (rather than delete) a dictionary key that records already reference, use
vault.dictionary(name).rename(oldKey, newKey) — it rewrites every referencing record to the new
key in one operation, and is honored even when the referencing field declares vocabulary: 'closed' (mid-rename both the old and new keys count as members).
A plain delete of a dictionary/collection row that no declared lookup field references is
completely unaffected — onDelete only fires for dimensions a lookupFields /
via(lookup(...)) declaration actually points at.
When a backing row's key can't be read
A matrix dimension with a custom key resolves each reference by reading row[key] off the
backing row. If that row is corrupted — the key field missing, or holding a non-scalar value —
the reference can't be resolved, and the two policies diverge:
restrictfails closed: the delete/forget refuses withRestrictRefUnresolvableError(carryingdimension,key, and thereferencingedge as"collection.field"), since it cannot prove the row is unreferenced.cascade/nullifyproceed, but the edge that could not be propagated is never dropped silently — it is reported on alookup:propagation-residuevault event ({ vault, dimension, key, residue }, each residue entry formatteddimension:key:collection.field, e.g.'countries:row-broken:orders.country').
A row whose key is present and scalar is unaffected — this concerns only corrupted backing rows.
Reserved-tier sync — dictionaries travel
Reserved _dict_* collections participate in sync: vault.dictionary(name)
writes enter the dirty log and dispatch like any other write, and pull() applies remote
dictionary rows through the ordinary path via an explicit reserved-prefix registry (every other_-prefixed internal namespace keeps its skip semantics). Dictionary deletes travel as
version-ordered delete markers, so a deleted
key cannot be silently resurrected by a stale peer's next push. Before 0.3.0-pre.9, dictionary
writes bypassed sync entirely and only crossed devices via backup/bundle export.
describe() — the normalized lookup block
A native lookup()/enumOf()/dict() field contributes a normalized lookup block to
collection.describe() — alongside, not replacing, the legacy dict
block:
orders.describe().fields.find((f) => f.key === 'country')?.lookup
// {
// dimension: 'countries', backing: 'collection', vocabulary: 'closed', key: 'iso2',
// altKeys: ['iso3', 'callPrefix'], present: { label: 'name', by: 'locale' },
// sortBy: 'name', onDelete: 'restrict',
// }
widget derives to 'select' whenever a lookup block is present. The statically-known key set
appears only where one exists (enum tier, or a dict tier with declared keys) — a matrix-tier
field has no keys (membership lives in the backing collection's rows), and a bare enumOf()
field omits dimension entirely (it has no backing store).
dictKey and staticDict are aliases
The i18n spellings dictKey() / staticDict() are aliases of this
feature's descriptor shape — not deprecated, fully supported: internally they build the
equivalent descriptor (dictKey() ≙ the reserved tier, staticDict() ≙ the static tier) and
their stored envelopes, type/widget/dict describe() output, and .join() dressing are
byte-identical to the native equivalent. Two differences remain, by design: the aliases do not
emit the new describe() lookup block, and they do not gain the new capabilities — altKeys,
vocabulary: 'closed' refusal, the matrix tier, and restrict/cascade/nullify semantics
belong to the native lookup()/enumOf()/dict() spellings only.
Late-attach — declaring lookup fields after construction
lookupFields can be declared on a later vault.collection(name, …) call against a collection
that is already open, not only at first construction — a "late attach". The fields go live at once:
closed-vocabulary enforcement, altKey normalization, and <field>Label dressing (direct and
through joins) all take effect immediately, and the reference-graph edges (onDelete) register on
attach. Enforcement is not retroactive — records written before the attach keep whatever value
they already held, since they never ran altKey ingest or the closed-vocabulary check.
The matrix tier has one precondition: the backing dimension must already be open in this session,
in eager mode. A late matrix attach against a dimension that is unopened (or was opened
{ prefetch: false }) throws ValidationError naming the field and dimension; open the backing
collection first and the same attach succeeds. The enum, static, and reserved (dict()) tiers
attach with no such precondition.
If a late-attached field collides with a field already owned by a different via family (for
example attaching a lookup onto a field already declared through moneyFields), the whole call
throws ValidationError naming the field and both families — the attach is all-or-nothing.
API reference
lookup(dimension, opts?)
The general factory — any tier via backing.
| Option | Type | Default | Purpose |
|---|---|---|---|
key | string | 'id' | Backing-row field holding the canonical key. |
altKeys | readonly string[] | — | Backing-row fields whose values normalize to key on write. Matrix tier requires the backing collection open in eager mode. |
vocabulary | 'open' | 'closed' | 'open' | 'closed' refuses unknown keys at write time (UnknownLookupKeyError). |
present | { label: string; by?: string } | — | Which backing-row field supplies <field>Label; by: 'locale' makes it locale-keyed. |
sortBy | string | — | Backing-row field a plain orderBy() sorts by (needs displayLocale when locale-keyed). |
backing | 'static' | 'reserved' | 'collection' | 'collection' | The tier. |
onDelete | 'restrict' | 'cascade' | 'nullify' | 'restrict' | Reference semantics on delete/forget of a referenced backing row. |
keys | readonly string[] | — | Declared key list (enum/dict tiers). |
table | Record<string, Record<string, string>> | — | Static tier's in-code key → locale → label table. |
displayLocale | string | — | Fixed locale for declared-sortBy ordering and locale-less label reads. |
onMissing / substitute / labels | — | — | Label-resolution policy, shared with the i18n engine. |
enumOf(keys) (also exported as enum)
Inline closed enum — no backing store, no dimension name, vocabulary: 'closed',
onDelete: 'restrict'. enumOf(['draft', 'paid'] as const) types the field to the literal
union.
dict(dimension, opts?)
Reserved-tier dictionary field — backed by vault.dictionary(dimension)'s _dict_<dimension>
collection, vocabulary: 'open' by default. Accepts keys, vocabulary, present, onDelete,
onMissing, substitute. No altKeys (the dictionary cache is always synchronous, so none of
the matrix tier's hydration constraints apply — and none of its altKey support either).
Errors
| Error | Thrown when |
|---|---|
UnknownLookupKeyError | A write supplies a key (after altKey normalization) that a vocabulary: 'closed' field's backing dimension doesn't contain. |
DictKeyInUseError | A backing dictionary/collection row still referenced by a restrict lookup field is deleted or forgotten. Carries dictionaryName, usedBy (the referencing collection), and count. |
RestrictRefUnresolvableError | A restrict delete/forget cannot resolve a referenced backing row's compare-key (its key field is missing or non-scalar on a corrupted row), so it fails closed. Carries dimension, key, and referencing ("collection.field"). |
Limits & gotchas
- Matrix-tier altKeys, closed vocabulary, and label presentation need a hydrated backing
collection — see the cold-session caveat above; with a custom
key, a cold dimension yields no<field>Labeluntil it is opened (only the defaultkey: 'id'has an on-demand fallback).altKeysadditionally refuses lazy ({ prefetch: false }) backing collections at write time withValidationError. - Declared
sortBywithoutdisplayLocalesilently sorts by code when the label is locale-keyed — heed the declare-timeconsole.warn, or useorderBy(..., { by: 'label' }). restrictis a behavior change for declared lookup fields relative to the alias era:DictKeyInUseErrorexisted and was documented long before it was ever thrown; a delete of a still-referenced dictionary row now actually refuses. Undeclared dimensions are unaffected.
See also
- via — the field-feature surface this binding plugs into
- i18n —
dictKey()/staticDict(), dictionaries, and the label-resolution engine - foreign-refs — record-to-record foreign keys (
ref()/refArray()); lookup fields are code-to-dimension references with label resolution on top - sync — delete markers and how reserved dictionaries replicate
- forget — how reference semantics gate or propagate an erasure
- field-metadata — the
describe()surface thelookupblock extends - Services overview · Orientation map