17 KiB
Capability Spec Delta: i18n (i18n-storage-backends)
Package: @suaveplan/i18n
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Backend interface contract
The package SHALL expose a Backend interface that any external translation source can implement. The interface SHALL declare a stable name: string, a required read(locale, namespace) → Promise<TranslationRecord> method, and four optional methods: readMulti?(locale, namespaces) → Promise<Record<string, TranslationRecord>>, save?(locale, namespace, record) → Promise<void>, lastModified?(locale, namespace) → Promise<number | undefined>, and lifecycle hooks init?(catalog) / dispose?(). Implementations SHALL surface transport / parse failures as I18nError(I18N.BACKEND_READ_FAILED) (read path) or I18nError(I18N.BACKEND_WRITE_FAILED) (write path) with the underlying error attached as cause.
Scenario: read returns a TranslationRecord
- WHEN a registered
Backend.read('en', 'auth')resolves with{ login: { title: 'Sign in' } } - THEN the catalog stores it under
(en, auth)and a subsequentt('auth:login.title')returns'Sign in'
Scenario: read failure maps to I18N.BACKEND_READ_FAILED
- WHEN a backend's
readrejects with aTypeError - THEN the catalog throws
I18nErrorwith codeI18N.BACKEND_READ_FAILEDanderror.cause === TypeError
Scenario: missing readMulti falls back to N reads
- WHEN
MessageCatalog.preload(['en'], ['auth', 'common'])is called against a backend that omitsreadMulti - THEN
readis invoked once for each(locale, namespace)pair concurrently and the catalog merges the results
Verified by: packages/i18n/i18n/src/core/backend/backend.test.ts::"read populates catalog" AND packages/i18n/i18n/src/core/backend/backend.test.ts::"read failure surfaces BACKEND_READ_FAILED" AND packages/i18n/i18n/src/core/backend/backend.test.ts::"falls back to per-namespace read when readMulti is absent"
Requirement: MemoryBackend reference implementation
The package SHALL ship a MemoryBackend implementation backed by Map<locale, Map<namespace, TranslationRecord>>. It SHALL implement read, readMulti, save, and lastModified. Construction SHALL accept an optional seed: Record<locale, Record<namespace, TranslationRecord>> for tests and seeded defaults.
Scenario: seed is read on first request
- WHEN
new MemoryBackend({ seed: { en: { auth: { login: { title: 'Sign in' } } } } })thenread('en', 'auth') - THEN the resolved record deep-equals the seeded value
Scenario: save then read round-trips
- WHEN
backend.save('en', 'auth', { login: { title: 'Sign in' } })is called, thenbackend.read('en', 'auth') - THEN the resolved record deep-equals the saved value AND
lastModified('en', 'auth')returns a number ≥ the timestamp beforesave
Verified by: packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"reads seeded values" AND packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"save then read round-trips with lastModified"
Requirement: HttpBackend with templated load path
The package SHALL ship an HttpBackend that fetches translations from a URL constructed from a loadPath template containing {locale} and {namespace} placeholders. Defaults: fetchFn = globalThis.fetch, parsePayload = (res) => res.json(). A non-2xx HTTP response SHALL surface as I18nError(I18N.BACKEND_READ_FAILED) with context.status set to the HTTP status code. A 404 SHALL surface as I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) so chained backends can fall through.
Scenario: GET /locales/en/auth.json returns translations
- WHEN
new HttpBackend({ loadPath: '/locales/{locale}/{namespace}.json', fetchFn })thenread('en', 'auth')withfetchFnresolving toResponse.json({ login: { title: 'Sign in' } }) - THEN the resolved record equals
{ login: { title: 'Sign in' } }ANDfetchFnwas called with'/locales/en/auth.json'
Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE
- WHEN
fetchFnresolves with status 404 - THEN the call rejects with
I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)andcontext.status === 404
Scenario: HTTP 500 surfaces BACKEND_READ_FAILED
- WHEN
fetchFnresolves with status 500 - THEN the call rejects with
I18nError(I18N.BACKEND_READ_FAILED)andcontext.status === 500
Verified by: packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"fetches templated URL" AND packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"404 surfaces LOCALE_NOT_AVAILABLE" AND packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"5xx surfaces BACKEND_READ_FAILED"
Requirement: StorageBackend wraps any AbstractStorage
The package SHALL ship a StorageBackend that adapts any AbstractStorage instance from @suaveplan/storage into a Backend. It SHALL support three storage layouts via a layout option:
single-key-per-namespace(default): key =${prefix}/${locale}/${namespace}.json, value = JSON-encodedTranslationRecordsingle-key-per-locale: key =${prefix}/${locale}.json, value =Record<namespace, TranslationRecord>flat-keys: key =${prefix}:${locale}:${namespace}:${dotPath}, value = the leaf string
Codec SHALL be pluggable via encode(record): Uint8Array / decode(bytes): TranslationRecord (default: JSON.stringify + TextEncoder / TextDecoder + JSON.parse).
Scenario: single-key-per-namespace round-trips through MemoryStorage
- WHEN
new StorageBackend({ storage: new MemoryStorage(), layout: 'single-key-per-namespace', prefix: 'i18n' })andbackend.save('en', 'auth', { login: { title: 'Sign in' } }) - THEN
MemoryStorage.get('i18n/en/auth.json')decodes to{ login: { title: 'Sign in' } }ANDbackend.read('en', 'auth')returns the same record
Scenario: single-key-per-locale stores all namespaces under one key
- WHEN
layout: 'single-key-per-locale'andbackend.save('en', 'auth', X)thenbackend.save('en', 'common', Y) - THEN
MemoryStorage.get('i18n/en.json')decodes to{ auth: X, common: Y }
Scenario: flat-keys writes one storage key per leaf
- WHEN
layout: 'flat-keys'andbackend.save('en', 'auth', { login: { title: 'Sign in' } }) - THEN
MemoryStorage.get('i18n:en:auth:login.title')decodes to'Sign in'
Scenario: codec swap to MessagePack
- WHEN
new StorageBackend({ storage, encode: msgpackEncode, decode: msgpackDecode })is constructed - THEN
savecallsmsgpackEncodefor serialisation andreadcallsmsgpackDecode
Verified by: packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-namespace round-trip" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-locale shape" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"flat-keys shape" AND packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"codec swap to MessagePack"
Requirement: MultiFileFsBackend reads JSON files in a per-namespace tree
The package SHALL ship a MultiFileFsBackend (Node/Bun-only; pure ESM dynamic-import of node:fs/promises) that loads <root>/<locale>/<namespace>.json from disk. It SHALL support three layouts:
nested(default):<root>/<locale>/<namespace>.json(e.g.locales/en/auth.json)flat:<root>/<locale>.<namespace>.json(e.g.locales/en.auth.json)glob: caller-suppliedglobPatternwith{locale}and{namespace}placeholders
When watch: true, it SHALL use node:fs/promises.watch to detect changes and emit a loaded event via the catalog event bus on each successful reload. Optional peer-dependency parsers SHALL be supported when present: yaml (for .yaml/.yml), gettext-parser (for .po), json5 (for .json5).
Scenario: nested layout loads namespace files
- WHEN
<root>/en/auth.jsoncontains{ "login": { "title": "Sign in" } }andbackend.read('en', 'auth')is called - THEN the resolved record equals the file's parsed content
Scenario: missing file maps to LOCALE_NOT_AVAILABLE
- WHEN
<root>/de/auth.jsondoes not exist andbackend.read('de', 'auth')is called - THEN the call rejects with
I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)ANDcontext.pathis the resolved file path
Scenario: watch emits loaded event on file change
- WHEN
watch: trueand an external process writes a new value to<root>/en/auth.json - THEN the catalog emits
loadedwith{ locale: 'en', namespace: 'auth', backend: 'multi-file-fs' }within 500 ms
Verified by: packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"nested layout loads namespace files" AND packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"missing file maps to LOCALE_NOT_AVAILABLE" AND packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"watch emits loaded event on file change"
Requirement: ChainedBackend composes N backends with write-through cache
The package SHALL ship a ChainedBackend that composes a non-empty ordered list of backends. read(locale, ns) SHALL walk the list in order; the first backend whose read resolves wins. After a successful read, the resolved record SHALL be written through to every preceding backend whose save is implemented (the standard "cache from IndexedDB, fall through to HTTPS" pattern). readMulti SHALL fan out concurrently and merge per the configured mergeStrategy: 'first-wins' | 'last-wins' (default 'first-wins').
Scenario: cache hit short-circuits the chain
- WHEN
Chained([memory, http])andmemory.read('en', 'auth')resolves - THEN
http.readis never called
Scenario: cache miss writes through to earlier backends
- WHEN
Chained([memory, http]),memory.readrejects withBACKEND_LOCALE_NOT_AVAILABLE, andhttp.readresolves withR - THEN the chained
readresolves withRANDmemory.save('en', 'auth', R)is called exactly once
Scenario: BACKEND_READ_FAILED does not fall through
- WHEN the first backend rejects with
BACKEND_READ_FAILED(a transport / parse error, not a 404-equivalent) - THEN the chained
readpropagates the error without calling subsequent backends
Verified by: packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"cache hit short-circuits" AND packages/i18n/i18n/src/core/backend/chained/chained-backend.spec-conformance.test.ts::"cache miss writes through" AND packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"BACKEND_READ_FAILED does not fall through"
Requirement: MessageCatalog accepts and routes through registered backends
MessageCatalog SHALL accept backend?: Backend (single) or backends?: Backend[] (auto-wrapped in ChainedBackend) at construction. When a backend is registered, MessageCatalog.preload(locales, namespaces?) → Promise<void> SHALL bulk-load via readMulti when supported and via concurrent read calls otherwise. Repeated preload calls for the same (locale, namespace) pair SHALL be deduplicated via the existing in-flight Promise map. MessageCatalog.invalidate(locale?, namespace?) → void SHALL drop the in-memory cache for the affected pairs so the next read re-fetches.
Scenario: preload populates catalog via readMulti
- WHEN a
MemoryBackendcarries seeds foren/authanden/commonandcatalog.preload(['en'], ['auth', 'common'])is called - THEN
t('auth:login.title')andt('common:cancel')resolve from the catalog without further backend calls
Scenario: invalidate forces re-read
- WHEN
catalog.preload(['en'], ['auth'])succeeds, thencatalog.invalidate('en', 'auth'), thencatalog.preload(['en'], ['auth'])is called again - THEN the backend's
readwas called twice for the(en, auth)pair
Scenario: concurrent preload calls dedup
- WHEN
catalog.preload(['en'], ['auth'])is called twice in immediate succession (noawaitbetween them) - THEN the backend's
readis called exactly once
Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"preload populates via readMulti" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"invalidate forces re-read" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"concurrent preload calls dedup"
Requirement: getAsync triggers in-flight backend load for missing namespace
When lazyLoad: true is set (default when a backend is registered) and MessageCatalog.getAsync(key, locale?) is called for a key in a namespace not yet present, the catalog SHALL trigger a backend read for that (locale, namespace) and resolve the returned Promise once the read completes (success or fallback). The synchronous get(key) and t(key) SHALL continue to behave as today: return undefined / the key string when the namespace is absent, even with lazyLoad: true. This separation SHALL be enforced so existing synchronous call sites are unchanged.
Scenario: getAsync resolves to backend value
- WHEN the catalog has no
authnamespace loaded andcatalog.getAsync('auth:login.title')is called - THEN the backend's
read('en', 'auth')is invoked AND the returned Promise resolves to{ value: 'Sign in', source: 'catalog', ... }
Scenario: getAsync surfaces backend errors
- WHEN the backend's
readrejects withBACKEND_READ_FAILED - THEN
getAsyncrejects with the sameI18nError
Scenario: synchronous t remains unchanged under lazyLoad
- WHEN
lazyLoad: trueandt('auth:login.title')is called for a not-yet-loaded namespace - THEN the synchronous return value is
'auth:login.title'(the key) AND the backendreadwas NOT triggered (onlygetAsynctriggers backend calls)
Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync resolves to backend value" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync surfaces backend errors" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"synchronous t never triggers backend reads"
Requirement: saveMissing writes back through registered backend
When saveMissing: true is passed in catalog options and a backend with a save method is registered, MessageCatalog SHALL invoke backend.save(locale, namespace, { ...existing, [key]: defaultValue }) whenever a key miss occurs. If the backend lacks save or the call rejects, MessageCatalog SHALL emit an OTel counter sample (i18n.backend.write.total{outcome=error}) but SHALL NOT throw — saveMissing is best-effort by design.
Scenario: missing key triggers save
- WHEN
saveMissing: trueandt('auth:new.unknown.key', { defaultValue: 'Default' })is called for a key absent from the catalog - THEN the backend's
save('en', 'auth', record)is called once with the merged record containing the new key
Scenario: save rejection does not throw
- WHEN
backend.saverejects - THEN
t()returns the default value AND no exception is thrown AND the OTel counteri18n.backend.write.total{outcome=error}increments by 1
Verified by: packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"saveMissing triggers backend.save" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"save rejection does not throw"
Requirement: Backend OTel instrumentation
All backend operations SHALL emit OTel telemetry built via getMeter('suaveplan.i18n'):
- Histogram
i18n.backend.read.duration_ms— labels:backend.name,locale,namespace,outcome(hit | miss | error) - Counter
i18n.backend.read.total— labels:backend.name,outcome - Counter
i18n.backend.write.total— labels:backend.name,outcome - Gauge
i18n.backend.cache.size— observable callback returning the count of(locale, namespace)pairs currently held inMessageCatalog's in-memory store
Scenario: read records duration sample
- WHEN
StorageBackend.read('en', 'auth')is called once with an in-memoryMeterProvider - THEN exactly one sample is recorded under
i18n.backend.read.duration_mswith attributes{ backend.name: 'storage', locale: 'en', namespace: 'auth', outcome: 'hit' }
Scenario: cache size gauge reflects catalog state
- WHEN
catalog.preload(['en', 'fr'], ['auth'])succeeds, then the OTel gauge is observed - THEN the observed value is
2(one pair per locale × namespace)
Verified by: packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"records read.duration_ms sample" AND packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"cache.size gauge reflects state"