experimentalsince 0.3.0-pre.9

lookup Reference fields over enum, dictionary, and first-class collection dimensions

The lookup()/enumOf()/dict() field feature: a field stores a canonical code and resolves against a backing dimension, with reference integrity.
Subpath@noy-db/hub (root export — lookup, enumOf (also exported as enum), dict, UnknownLookupKeyError, DictKeyInUseError)
Wiringno with*() strategy — declare lookupFields: { field: lookup(...) } (or viaFields: { field: via(lookup(...)) }) on vault.collection()
Kindfield 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:

TierFactoryBackingVocabulary defaultSyncs?
enumenumOf(['a','b'] as const)none — inline keys, no storeclosedn/a (no store)
dictdict('status')reserved _dict_status micro-collection (vault.dictionary('status'))openyes — see below
matrixlookup('countries')a first-class collection (vault.collection('countries'))openyes (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 keys union 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() (and dict()) default to 'open' — declaring keys on dictKey() never enforced membership, and still doesn't; write-time refusal is exclusively the native vocabulary: 'closed' opt-in.
  • staticDict() is closed by a different, older mechanism — its own validateCodes option (default true), throwing UnknownDictCodeError, 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
  • restrict refuses the delete before any mutation with DictKeyInUseError.
  • cascade tombstones/deletes every referencing record.
  • nullify clears the referencing field via an ordinary put().

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:

  • restrict fails closed: the delete/forget refuses with RestrictRefUnresolvableError (carrying dimension, key, and the referencing edge as "collection.field"), since it cannot prove the row is unreferenced.
  • cascade / nullify proceed, but the edge that could not be propagated is never dropped silently — it is reported on a lookup:propagation-residue vault event ({ vault, dimension, key, residue }, each residue entry formatted dimension: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.

OptionTypeDefaultPurpose
keystring'id'Backing-row field holding the canonical key.
altKeysreadonly 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.
sortBystring—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.
keysreadonly string[]—Declared key list (enum/dict tiers).
tableRecord<string, Record<string, string>>—Static tier's in-code key → locale → label table.
displayLocalestring—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

ErrorThrown when
UnknownLookupKeyErrorA write supplies a key (after altKey normalization) that a vocabulary: 'closed' field's backing dimension doesn't contain.
DictKeyInUseErrorA backing dictionary/collection row still referenced by a restrict lookup field is deleted or forgotten. Carries dictionaryName, usedBy (the referencing collection), and count.
RestrictRefUnresolvableErrorA 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>Label until it is opened (only the default key: 'id' has an on-demand fallback). altKeys additionally refuses lazy ({ prefetch: false }) backing collections at write time with ValidationError.
  • Declared sortBy without displayLocale silently sorts by code when the label is locale-keyed — heed the declare-time console.warn, or use orderBy(..., { by: 'label' }).
  • restrict is a behavior change for declared lookup fields relative to the alias era: DictKeyInUseError existed 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 the lookup block extends
  • Services overview · Orientation map