Files
2026-05-27 23:29:53 +01:00

17 KiB
Raw Permalink Blame History

Capability Spec Delta: i18n (i18n-storage-backends)

Package: @suaveplan/i18n Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 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 subsequent t('auth:login.title') returns 'Sign in'

Scenario: read failure maps to I18N.BACKEND_READ_FAILED

  • WHEN a backend's read rejects with a TypeError
  • THEN the catalog throws I18nError with code I18N.BACKEND_READ_FAILED and error.cause === TypeError

Scenario: missing readMulti falls back to N reads

  • WHEN MessageCatalog.preload(['en'], ['auth', 'common']) is called against a backend that omits readMulti
  • THEN read is 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' } } } } }) then read('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, then backend.read('en', 'auth')
  • THEN the resolved record deep-equals the saved value AND lastModified('en', 'auth') returns a number ≥ the timestamp before save

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 }) then read('en', 'auth') with fetchFn resolving to Response.json({ login: { title: 'Sign in' } })
  • THEN the resolved record equals { login: { title: 'Sign in' } } AND fetchFn was called with '/locales/en/auth.json'

Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE

  • WHEN fetchFn resolves with status 404
  • THEN the call rejects with I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) and context.status === 404

Scenario: HTTP 500 surfaces BACKEND_READ_FAILED

  • WHEN fetchFn resolves with status 500
  • THEN the call rejects with I18nError(I18N.BACKEND_READ_FAILED) and context.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-encoded TranslationRecord
  • single-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' }) and backend.save('en', 'auth', { login: { title: 'Sign in' } })
  • THEN MemoryStorage.get('i18n/en/auth.json') decodes to { login: { title: 'Sign in' } } AND backend.read('en', 'auth') returns the same record

Scenario: single-key-per-locale stores all namespaces under one key

  • WHEN layout: 'single-key-per-locale' and backend.save('en', 'auth', X) then backend.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' and backend.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 save calls msgpackEncode for serialisation and read calls msgpackDecode

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-supplied globPattern with {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.json contains { "login": { "title": "Sign in" } } and backend.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.json does not exist and backend.read('de', 'auth') is called
  • THEN the call rejects with I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE) AND context.path is the resolved file path

Scenario: watch emits loaded event on file change

  • WHEN watch: true and an external process writes a new value to <root>/en/auth.json
  • THEN the catalog emits loaded with { 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]) and memory.read('en', 'auth') resolves
  • THEN http.read is never called

Scenario: cache miss writes through to earlier backends

  • WHEN Chained([memory, http]), memory.read rejects with BACKEND_LOCALE_NOT_AVAILABLE, and http.read resolves with R
  • THEN the chained read resolves with R AND memory.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 read propagates 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 MemoryBackend carries seeds for en/auth and en/common and catalog.preload(['en'], ['auth', 'common']) is called
  • THEN t('auth:login.title') and t('common:cancel') resolve from the catalog without further backend calls

Scenario: invalidate forces re-read

  • WHEN catalog.preload(['en'], ['auth']) succeeds, then catalog.invalidate('en', 'auth'), then catalog.preload(['en'], ['auth']) is called again
  • THEN the backend's read was called twice for the (en, auth) pair

Scenario: concurrent preload calls dedup

  • WHEN catalog.preload(['en'], ['auth']) is called twice in immediate succession (no await between them)
  • THEN the backend's read is 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 auth namespace loaded and catalog.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 read rejects with BACKEND_READ_FAILED
  • THEN getAsync rejects with the same I18nError

Scenario: synchronous t remains unchanged under lazyLoad

  • WHEN lazyLoad: true and t('auth:login.title') is called for a not-yet-loaded namespace
  • THEN the synchronous return value is 'auth:login.title' (the key) AND the backend read was NOT triggered (only getAsync triggers 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: true and t('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.save rejects
  • THEN t() returns the default value AND no exception is thrown AND the OTel counter i18n.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 in MessageCatalog's in-memory store

Scenario: read records duration sample

  • WHEN StorageBackend.read('en', 'auth') is called once with an in-memory MeterProvider
  • THEN exactly one sample is recorded under i18n.backend.read.duration_ms with 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"