stable

i18n

Multi-locale records via a shared label dictionary (dictKey) or per-record translated text (i18nText), resolved to the caller's locale at read time.
Subpath@noy-db/hub/i18n
FactorywithI18n()
Layerwith-shape
ClusterD — Data Shape
LOC cost~854 (off-bundle when not opted in)

What it does

i18n lets a collection store one value per record and return the right locale's version of it on read, without your application code branching on language anywhere. It covers two complementary patterns: dictKey — a stable code ('paid', 'draft') that resolves against a shared, per-vault label dictionary — for enum-like fields where the set of values is small and known; and i18nText — a { locale: string } map stored directly on the record — for free-form translated content (descriptions, notes). Locale resolution happens after decryption, at read time, so the record on disk always holds every locale you wrote; only the value handed back from get()/list() changes shape. The label dictionary itself is stored as an encrypted collection inside the vault, and is versioned/audit-logged like any other collection when the history service (withHistory()) is also opted in — that's not a property of i18n alone.

When you need it

  • Apps that ship in more than one language
  • Enum-like fields (status, taxClass, country) where labels differ by locale
  • Per-record translatable content (descriptions, comments, free-form notes)
  • Auto-translate hooks for missing locale fills

Prerequisites

  • None — withI18n() plus the per-collection dictionary declarations shown below; no other strategy or package is involved.

Opt in

import { createNoydb, dictKey, i18nText } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { withI18n } from '@noy-db/hub/i18n'

const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  i18nStrategy: withI18n(),
})

Usage

Seed the shared label dictionary, declare a collection's dictKey/i18nText fields, then read the same record back at different locales:

const vault = await db.openVault('firm')
await vault.dictionary('status').putAll({
  draft: { en: 'Draft', th: 'ฉบับร่าง' },
  paid:  { en: 'Paid',  th: 'ชำระแล้ว' },
})

interface Invoice {
  id: string
  status: string
  description: Record<string, string>
}

const invoices = vault.collection<Invoice>('invoices', {
  dictKeyFields: {
    status: dictKey('status', ['draft', 'paid'] as const),
  },
  i18nFields: {
    description: i18nText({ languages: ['en', 'th'], required: 'all' }),
  },
})

await invoices.put('inv-1', {
  id: 'inv-1',
  status: 'paid',
  description: { en: 'Consulting hours', th: 'ค่าที่ปรึกษา' },
})

await invoices.get('inv-1', { locale: 'th' })
// → { id: 'inv-1', status: 'paid', statusLabel: 'ชำระแล้ว', description: 'ค่าที่ปรึกษา' }

await invoices.get('inv-1', { locale: 'en' })
// → { id: 'inv-1', status: 'paid', statusLabel: 'Paid', description: 'Consulting hours' }

await invoices.get('inv-1', { locale: 'raw' })
// → { id: 'inv-1', status: 'paid', description: { en: 'Consulting hours', th: 'ค่าที่ปรึกษา' } }
// 'raw' skips resolution: dictKey fields get no <field>Label, i18nText fields keep their full locale map

The stored record never changes — status is always the code, description is always the full locale map. Only the shape of what get()/list() return depends on the locale you pass: a resolved string for description, plus a <field>Label virtual field for each dictKey.

API

  • vault.dictionary(name).putAll(...) / put(...) / get(...) / delete(...) / list() / rename(...)
  • dictKey(name, keys) and i18nText({ languages, required }) field descriptors
  • resolveI18nText(value, locale, fallback?) helper — resolve a single value
  • Per-call { locale, fallback } on collection.get() / list() / query() reads

Behavior when NOT opted in

  • applyI18nLocale (under the hood) returns the record unchanged — locale resolution is silently skipped
  • validateI18nTextValue throws when a collection declared i18nFields (misconfiguration: declared but didn't opt in)
  • vault.dictionary(name) throws with a pointer to @noy-db/hub/i18n

Hardening (per-layer policy, substitute, script, dictKey parity)

Optional, opt-in extensions on i18nText() / dictKey(). Fields that don't set them behave exactly as before (zero breaking change) — a plain i18nText({ languages, required }) with no extra options resolves and validates exactly as shown in Opt-in.

Here is what the record on disk and the value returned from get() look like as each option kicks in, using a notes collection with a hardened body field:

interface Note { id: string; body: Record<string, string> }

const notes = vault.collection<Note>('notes', {
  i18nFields: {
    body: i18nText({
      languages: ['th', 'en'], required: 'any',
      substitute: ['en', 'th', 'any'],
      onMissing: 'substitute',
      smartSubstitute: true,
      script: 'auto',
      densifyOnWrite: true,
    }),
  },
})

await notes.put('n1', { id: 'n1', body: { en: 'Site visit scheduled' } })

Only en was authored, but densifyOnWrite fills the empty th slot at write time from the substitute chain, so the stored record is already dense:

await notes.get('n1', { locale: 'th' })
// → { id: 'n1', body: 'Site visit scheduled' }
// th was densify-filled at write time, so this resolves directly —
// no substitute walk needed on read

await notes.get('n1', { locale: 'raw' })
// → { id: 'n1', body: { en: 'Site visit scheduled', th: 'Site visit scheduled' } }
// the full locale map as stored — densify made both slots non-empty

await notes.i18nProvenance('n1')
// → { body: ['th'] }
// th was auto-filled; en is the only authored slot

Every read (get, list, raw included) strips the internal fill-tracking marker — i18nProvenance() is the only way to see which locales were filled rather than authored.

onMissing + substitute — missing-locale policy. When a field is resolved to an absent locale, onMissing decides: 'substitute' (walk the declared substitute chain, then null), 'null', or 'throw' (the default — today's behavior). onMissing may be a scalar or a per-layer map (read/guard/join/mv/derivation/export); the guard layer defaults to lenient 'substitute'. A caller-supplied fallback at read time overrides the declared substitute.

Smart substitute. smartSubstitute: true makes the 'substitute' fallback script-aware: after the explicit substitute chain misses, it picks the available locale whose primary script is nearest the target — same script first, then Latin (broadly readable), then any other — instead of an arbitrary first-non-empty value. So a missing Thai label falls back to another Thai (or a Latin) translation rather than, say, an Arabic one. Default false.

i18nText({
  languages: ['th', 'en'], required: 'any',
  substitute: ['en', 'th', 'any'],
  onMissing: { read: 'substitute', mv: 'throw', derivation: 'null' },
  smartSubstitute: true,   // nearest-script fallback
})

Current wiring (ALL layers): read (get/list), guard, derivation, mv, join, export are all enforced — guard / derivation ctx.vault reads resolve under their own layer policy (guard defaults to the lenient 'substitute'). The mv layer fires for materialized views that declare { i18nLocale, i18nFields } — UNION (group-key i18n fields resolve before the unified-row bucketing) and query-form (resolved in GroupedReduction.run before groupAndReduce); grouping a raw i18n field without a locale throws LocaleNotSpecifiedError (group by a dictKey/ staticDict code instead, and resolve the label at read). i18n fields merely carried through an MV stay raw — declare them on the output collection and they resolve per-reader at read time. The join layer resolves a joined right-side i18n field to the query locale — query().join(...).toArray({ locale }) (or the vault default; raw when locale-less). The export layer fires for exportStream/exportJSON({ resolveLabels }) — records collapse to the export locale (i18nText to the string, dict <field>Labels resolved), with the dictionary snapshot omitted.

Script enforcement (script, onScriptViolation). Write-time per-locale validation. script: 'auto' infers allowed Unicode scripts with asymmetric Latin tolerance: non-Latin locales also allow Latin (so '9/9 อาคาร TCM ถนนรัชดาภิเษก' passes), while Latin locales reject other scripts (so Thai text in an en slot is rejected). Common/Inherited/Mark (digits, combining marks) are always allowed. Violations 'reject' (default, ScriptViolationError), 'filter' (strip), or 'warn'. Tighten a field with an explicit set: script: { th: ['Thai'] }.

densifyOnWrite — eager slot fill at write time. With densifyOnWrite: true on an i18nText field, every empty declared-language slot is filled from the field's substitute chain at write time — using substitute ?? ['any'] (first non-empty value when no explicit chain is declared) — so the stored locale map is always dense and downstream layers (join, MV, derivation, export) never hit a hole.

Which slots were auto-filled is recorded in an internal _i18nFilled marker that is encrypted with the record and stripped from all reads (get, list, raw). The only sanctioned way to inspect it is:

await collection.i18nProvenance(id)
// → e.g. { name: ['en'] }  — or undefined when nothing was filled

Declaring the field:

i18nText({
  languages: ['th', 'en'], required: 'any',
  substitute: ['th', 'any'],
  densifyOnWrite: true,
})

Write sequence (for a densify-enabled field):

  1. Auto-translate (existing)
  2. computeExemptFills — reads the prior record's marker; classifies each locale slot as authored, exempt round-tripped fill, or cleared fill
  3. Script enforcement — checks authored slots only; unchanged round-tripped fills are exempt (so Thai text in an en fill slot does not trigger a Latin-locale script rejection on a second write)
  4. Required-validation — authored floor only; fills do not satisfy required
  5. Densify — fill empty slots, record marker, refresh changed-source fills
  6. Encrypt and persist

Refresh and no-clobber. Densify re-runs from the current authored slots on every write, so correcting a source value automatically refreshes its derived slots (e.g. fixing th re-densifies en). If a user authors a real value into a formerly-filled slot the marker for that locale is cleared and the value is treated as authored from that point on. A value byte-identical to the existing fill remains classified as a fill (value-equality provenance limitation).

Mutual exclusion. densifyOnWrite: true combined with an explicitonMissing: 'throw' — scalar or any per-layer value — is a configuration error (densify fills every hole, making a throw policy unreachable). Declaring densifyOnWrite without an explicit onMissing is fine; the default'throw' simply never fires.

Limitation. Supports single-leaf i18n fields only. Array-wildcard paths (contacts[].name) are skipped, mirroring auto-translate.

i18n:script-violation event channel. The 'warn' and 'filter' script-enforcement modes collect ScriptWarning[] objects; previously those warnings were silently discarded. The typed event channel surfaces them:

// Payload shape (ScriptWarning = { field, locale, expected, sample })
interface NoydbEventMap {
  'i18n:script-violation': {
    vault: string; collection: string; id: string
    mode: 'warn' | 'filter'
    warning: ScriptWarning
  }
}

db.on('i18n:script-violation', (e) => {
  // audit; e.mode === 'filter' means characters were dropped from e.warning.sample
})

One event is emitted per warning per write. The event fires for 'warn' (value stored as-is) and 'filter' (disallowed characters stripped — the event is the only signal that stored data was mutated by script enforcement). 'reject' throws ScriptViolationError and emits nothing (the caller sees the error directly). No new subscription surface — the existing NoydbEventEmitter API (db.on/db.off) is used.

I18nMap<Langs, Required> type helper. Types the stored locale-map from the required mode so the compiler forces you to handle an absent optional locale (string | undefined) instead of silently reading undefined — the right fix for bare .th access at raw-reading (identity/guard/export) layers. 'all' (default) → every locale required; 'any' → every locale optional; readonly L[] → listed required, rest optional.

type Lang = 'th' | 'en'
interface Contact {
  name: I18nMap<Lang, 'any'>        // { th?: string; en?: string }
  legalName: I18nMap<Lang, ['th']>  // { th: string; en?: string }
}

dictKey parity. dictKey() accepts the same onMissing/substitute (default stays 'null' = legacy omit-on-miss). An array-of-keys field resolves to [{ key, label }] pair objects; a wildcard path (contacts[].title) adds a per-element sibling <leaf>Label. The stable key is preserved through resolution, so identity operations (groupBy, dedup) bind to the key even when labels collapse across locales (Mr./Ms. → คุณ).

Label sort. Identity binding stays on the code, but when you want results ordered by the human label, opt in per-call: query().orderBy('status', 'asc', { by: 'label' }) sorts a dictKey/staticDict field by its resolved label at the query locale (toArray({ locale }), or a staticDict displayLocale when locale-less), falling back to the code when no label resolves. The default orderBy('status') still sorts by the stored code.

staticDict (code-provided dictionary)

staticDict(name, table, opts?) is a sibling to dictKey for closed, defined-in-code, identical-across-vaults enums (honorific, civil-status, gender, religion, status…). The labels live in the in-code table; the record stores only the stable code. Drop it into the same dictKeyFields slot:

interface Worker { id: string; civilStatus: string }

const workers = vault.collection<Worker>('workers', {
  dictKeyFields: {
    department:  dictKey('department', ['ops', 'fin'] as const),  // per-vault, editable
    civilStatus: staticDict('civilStatus', {                      // code-defined
      adultMale:   { th: 'นาย',    en: 'Mr'  },
      adultFemale: { th: 'นาง',    en: 'Mrs' },
    }, { displayLocale: 'th' }),
  },
})

await workers.put('w1', { id: 'w1', civilStatus: 'adultMale' }) // stores only the code

await workers.get('w1')
// → { id: 'w1', civilStatus: 'adultMale', civilStatusLabel: 'นาย' }
// locale-less read: civilStatusLabel still resolves via displayLocale

await workers.get('w1', { locale: 'en' })
// → { id: 'w1', civilStatus: 'adultMale', civilStatusLabel: 'Mr' }
// an active locale wins over displayLocale

How it differs from dictKey:

  • No _dict_* collection and no rename() — labels are code constants, so there is no per-vault encrypted copy and no O(records) migration. A label change is a code deploy.
  • Hybrid resolution. With a configured displayLocale, a <field>Label resolves even under a locale-less read — the property a locale-less app needs and dictKey cannot provide. Without displayLocale it behaves like dictKey (no label until a locale is active). When a locale is active, it honors onMissing/substitute exactly like dictKey. The locale gate is otherwise unchanged: an i18nText-only or plain-dictKey collection still returns the raw record on a locale-less read.
  • Read-only by construction. vault.dictionary(staticName) throws StaticDictReadonlyError — a static dict has no mutation surface.
  • Put-time code validation. A stored code outside table throws UnknownDictCodeError (opt out per descriptor with { validateCodes: false }).
  • Query parity. groupBy(field) buckets by the stable code; the code-table-backed source resolves { by: 'label' } at the active locale, or displayLocale when the query is locale-less.

Migrating from a userland helper needs zero record change — the record already stores the code; only the field descriptor changes.

Relation to the lookup feature

Since 0.3.0-pre.9, dictKey() and staticDict() are aliases of the lookup feature's descriptor shape — dictKey() matching its reserved-dictionary tier, staticDict() its static tier. This is not a deprecation: both spellings stay fully supported, keep compiling onto the i18n binding, and their stored envelopes, describe() output, and .join() dressing are byte-identical to their native equivalents. What the native lookup()/enumOf()/dict() spellings add on top — alternate-key normalization, closed-vocabulary write refusal, a first-class collection ("matrix") tier, and restrict/cascade/nullify reference semantics on delete/forget — is exclusive to them; see lookup. Reserved _dict_* dictionaries themselves now also replicate through sync, with dictionary deletes traveling as delete markers.

Both declarations can also be spelled through the via composer (viaFields: { status: via(dictKey('status', ['paid'] as const)) }) with identical internals.

Pairs well with

  • aggregate — groupBy(dictKeyField) resolves labels in the result on render
  • history — locale changes are versioned

Edge cases & limits

  • Required locales declared in i18nText({ required: 'all' }) are enforced on put — missing translations throw MissingTranslationError
  • 'auto' translate fields call out to the plaintextTranslator callback set at createNoydb-time
  • Dictionary rename() rewrites every collection record that points at the old key — O(records) per rename

See also