i18n
| Subpath | @noy-db/hub/i18n |
| Factory | withI18n() |
| Layer | with-shape |
| Cluster | D — 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)andi18nText({ languages, required })field descriptorsresolveI18nText(value, locale, fallback?)helper — resolve a single value- Per-call
{ locale, fallback }oncollection.get()/list()/query()reads
Behavior when NOT opted in
applyI18nLocale(under the hood) returns the record unchanged — locale resolution is silently skippedvalidateI18nTextValuethrows when a collection declaredi18nFields(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,exportare all enforced — guard / derivationctx.vaultreads resolve under their own layer policy (guarddefaults to the lenient'substitute'). Themvlayer fires for materialized views that declare{ i18nLocale, i18nFields }— UNION (group-key i18n fields resolve before the unified-row bucketing) and query-form (resolved inGroupedReduction.runbeforegroupAndReduce); grouping a raw i18n field without a locale throwsLocaleNotSpecifiedError(group by adictKey/staticDictcode 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. Thejoinlayer resolves a joined right-side i18n field to the query locale —query().join(...).toArray({ locale })(or the vault default; raw when locale-less). Theexportlayer fires forexportStream/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):
- Auto-translate (existing)
computeExemptFills— reads the prior record's marker; classifies each locale slot as authored, exempt round-tripped fill, or cleared fill- Script enforcement — checks authored slots only; unchanged
round-tripped fills are exempt (so Thai text in an
enfill slot does not trigger a Latin-locale script rejection on a second write) - Required-validation — authored floor only; fills do not satisfy
required - Densify — fill empty slots, record marker, refresh changed-source fills
- 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 norename()— 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>Labelresolves even under a locale-less read — the property a locale-less app needs anddictKeycannot provide. WithoutdisplayLocaleit behaves likedictKey(no label until a locale is active). When a locale is active, it honorsonMissing/substituteexactly likedictKey. The locale gate is otherwise unchanged: ani18nText-only or plain-dictKeycollection still returns the raw record on a locale-less read. - Read-only by construction.
vault.dictionary(staticName)throwsStaticDictReadonlyError— a static dict has no mutation surface. - Put-time code validation. A stored code outside
tablethrowsUnknownDictCodeError(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, ordisplayLocalewhen 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 throwMissingTranslationError 'auto'translate fields call out to theplaintextTranslatorcallback set atcreateNoydb-time- Dictionary
rename()rewrites every collection record that points at the old key — O(records) per rename
See also
- lookup — the reference-field feature
dictKey()/staticDict()alias onto - via — the field-feature composer shared by all field declarations
- Vaults & collections — the vault/collection model this service builds on
- Accounting app — an invoicing app using
dictKey/i18nTextend to end - history — versions dictionary and record changes when opted in alongside
i18n - Services — the full opt-in catalog
- Proven by showcases
09-with-i18nand94-with-i18n-hardening.