# 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` method, and four optional methods: `readMulti?(locale, namespaces) → Promise>`, `save?(locale, namespace, record) → Promise`, `lastModified?(locale, namespace) → Promise`, 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>`. It SHALL implement `read`, `readMulti`, `save`, and `lastModified`. Construction SHALL accept an optional `seed: Record>` 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` - `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 `//.json` from disk. It SHALL support three layouts: - `nested` (default): `//.json` (e.g. `locales/en/auth.json`) - `flat`: `/..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** `/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** `/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 `/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` 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"`