# Capability Spec: i18n-backend-chained (i18n-backend-chained) **Package**: `@suaveplan/i18n-backend-chained` **Status**: PROPOSED — first delta; becomes canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: ChainedBackend implements the Backend interface The package SHALL export a `ChainedBackend` class that implements the `Backend` interface from `@suaveplan/types/i18n` (universal-tier; tier follows constituent backends). The class SHALL accept a required `ChainedBackendOptions` constructor argument carrying `{ backends, name?, mergeStrategy? }`. The capabilities matrix SHALL be derived from the union of child capabilities: `canRead: true`, `canReadMulti: true`, `canSave: backends.some(b => b.capabilities.canSave)`, `canTrackModification: backends.some(b => b.capabilities.canTrackModification)`, `canWatch: backends.some(b => b.capabilities.canWatch)`. Default `name` SHALL equal `"chained"`. Default `mergeStrategy` SHALL equal `"first-wins"`. #### Scenario: capabilities are derived from child union - **GIVEN** a `ChainedBackend` over `[memoryBackend(), httpBackend({...})]` (memory has canSave, http does not) - **WHEN** `chain.capabilities.canSave` is read - **THEN** the value equals `true` #### Scenario: default name is "chained" - **WHEN** `new ChainedBackend({ backends: [memoryBackend()] }).name` is read - **THEN** the value equals `"chained"` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"capabilities are derived from child union"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"default name is chained"` --- ### Requirement: read fallthrough on BACKEND_LOCALE_NOT_AVAILABLE `read(locale, namespace, signal?)` SHALL walk `backends` in registration order. The first backend whose `read` resolves SHALL win; subsequent backends SHALL be skipped. If a backend throws `I18nError(BACKEND_LOCALE_NOT_AVAILABLE)`, the chain SHALL fall through to the next backend. #### Scenario: fallthrough on BACKEND_LOCALE_NOT_AVAILABLE - **GIVEN** a `ChainedBackend` over `[emptyMemoryBackend, seededMemoryBackend]` - **WHEN** `chain.read("en", "common")` is awaited - **THEN** the result equals the seeded record AND `seededMemoryBackend.read` was called **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"fallthrough on BACKEND_LOCALE_NOT_AVAILABLE"` --- ### Requirement: read propagates BACKEND_READ_FAILED (no fallthrough) If any backend throws `I18nError(BACKEND_READ_FAILED)`, the chain SHALL propagate that error immediately and SHALL NOT continue fallthrough. Transport failures must not silently degrade. #### Scenario: BACKEND_READ_FAILED propagates immediately - **GIVEN** a `ChainedBackend` over `[failingHttpBackend, seededMemoryBackend]` where `failingHttpBackend.read` throws `BACKEND_READ_FAILED` - **WHEN** `chain.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` AND `seededMemoryBackend.read` was NOT called **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"BACKEND_READ_FAILED propagates immediately"` --- ### Requirement: All children miss throws BACKEND_LOCALE_NOT_AVAILABLE When every backend in a non-empty chain throws `BACKEND_LOCALE_NOT_AVAILABLE`, the chain SHALL throw `I18nError(BACKEND_LOCALE_NOT_AVAILABLE)`. This preserves catalog-layer fallthrough semantics one level up. #### Scenario: all children miss throws BACKEND_LOCALE_NOT_AVAILABLE - **GIVEN** a `ChainedBackend` over `[emptyMemory1, emptyMemory2]` - **WHEN** `chain.read("xx", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_LOCALE_NOT_AVAILABLE"` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"all children miss throws BACKEND_LOCALE_NOT_AVAILABLE"` --- ### Requirement: Empty chain throws CHAINED_BACKEND_ALL_FAILED The constructor's Zod schema SHALL reject `backends: []` with `INVALID_OPTIONS`. As defence-in-depth, if a runtime cast bypasses the schema and `backends.length === 0` is reached at `read` time, the call SHALL throw `I18nError` with code `I18N.CHAINED_BACKEND_ALL_FAILED`. This new error code (added to `I18N_CODES` in Wave 1b) disambiguates "chain misconfigured / empty" from the routine "all children miss" path so production teams can alert on misconfiguration without false positives from cache misses. #### Scenario: schema rejects empty backends - **WHEN** `new ChainedBackend({ backends: [] })` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"` #### Scenario: runtime empty chain throws CHAINED_BACKEND_ALL_FAILED - **GIVEN** a `ChainedBackend` constructed via `as unknown as ChainedBackendOptions` cast bypassing the schema with `backends: []` - **WHEN** `chain.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.CHAINED_BACKEND_ALL_FAILED"` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"schema rejects empty backends"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"runtime empty chain throws CHAINED_BACKEND_ALL_FAILED"` --- ### Requirement: Write-through cache after successful read from backend N After a successful `read` from backend at index N (where N > 0), the chain SHALL write the resolved record back to every preceding backend (indices 0..N-1) that implements `save`. The writes SHALL be best-effort: failures from any preceding backend SHALL be swallowed and SHALL NOT block the read return. #### Scenario: write-through populates cache layer - **GIVEN** a `ChainedBackend` over `[memoryCache, seededHttpBackend]` where memoryCache is empty and seededHttpBackend has the record - **WHEN** `chain.read("en", "common")` is awaited - **THEN** the read result equals the record AND `memoryCache.read("en", "common")` (post-call) returns the same record #### Scenario: write-through failure does not block read - **GIVEN** a `ChainedBackend` over `[brokenSaveBackend, seededMemory]` where `brokenSaveBackend.save` throws - **WHEN** `chain.read("en", "common")` is awaited - **THEN** the read returns the seeded record (no throw) **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"write-through populates cache layer"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"write-through failure does not block read"` --- ### Requirement: readMulti merges per strategy `readMulti(locale, namespaces, signal?)` SHALL fan out concurrently across all backends and merge the per-backend `Record` maps. With `mergeStrategy: "first-wins"` (default), earlier backends SHALL win on overlap; later backends SHALL fill gaps only. With `mergeStrategy: "last-wins"`, the merge SHALL favour later backends. Per-backend errors during fan-out SHALL be swallowed (the slot is omitted, not propagated). #### Scenario: first-wins prefers earlier backend - **GIVEN** a `ChainedBackend` with default `first-wins` over two backends each returning `{ common: {...} }` with different values - **WHEN** `chain.readMulti("en", ["common"])` is awaited - **THEN** the result `common` value comes from the earlier backend #### Scenario: last-wins prefers later backend - **GIVEN** a `ChainedBackend` with `last-wins` over two backends each returning `{ common: {...} }` with different values - **WHEN** `chain.readMulti("en", ["common"])` is awaited - **THEN** the result `common` value comes from the later backend #### Scenario: gaps are filled regardless of strategy - **GIVEN** a `ChainedBackend` whose first backend returns `{ a: {...} }` and second returns `{ b: {...} }` - **WHEN** `chain.readMulti("en", ["a", "b"])` is awaited - **THEN** the result contains both `a` and `b` **Verified by:** `packages/i18n/i18n-backend-chained/src/merge.test.ts::"first-wins prefers earlier backend"` AND `packages/i18n/i18n-backend-chained/src/merge.test.ts::"last-wins prefers later backend"` AND `packages/i18n/i18n-backend-chained/src/merge.test.ts::"gaps are filled regardless of strategy"` --- ### Requirement: save fan-out to every save-capable child `save(locale, namespace, record, signal?)` SHALL call `save` on every backend in the chain that implements it (sequential). Any failure SHALL surface as `I18nError(BACKEND_WRITE_FAILED)` with `cause` preserved. #### Scenario: save writes to every save-capable child - **GIVEN** a `ChainedBackend` over `[memoryA, memoryB]` - **WHEN** `chain.save("en", "common", { x: "y" })` is awaited - **THEN** both `memoryA.read("en", "common")` and `memoryB.read("en", "common")` return `{ x: "y" }` #### Scenario: save failure throws BACKEND_WRITE_FAILED - **GIVEN** a `ChainedBackend` whose first backend's `save` throws non-I18nError `Error("disk full")` - **WHEN** `chain.save("en", "common", { x: "y" })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_WRITE_FAILED"` AND `.cause.message === "disk full"` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"save writes to every save-capable child"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"save failure throws BACKEND_WRITE_FAILED"` --- ### Requirement: lastModified returns the first defined value across children `lastModified(locale, namespace)` SHALL walk children in order and return the first defined timestamp. Returns `undefined` when no child reports a timestamp. #### Scenario: lastModified returns first defined value - **GIVEN** a `ChainedBackend` whose first backend's `lastModified` returns `undefined` and second returns `4242` - **WHEN** `chain.lastModified("en", "common")` is awaited - **THEN** the result equals `4242` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"lastModified returns first defined value"` --- ### Requirement: init forward, dispose reverse-order `init(context)` SHALL call `init(context)` on every child in registration order (skipping children that do not implement it). `dispose()` SHALL call `dispose()` on every child in reverse registration order. Both lifecycle methods SHALL be safely callable multiple times. #### Scenario: init forwards in registration order - **GIVEN** a `ChainedBackend` over `[a, b, c]` where each child records the order of its `init` call - **WHEN** `chain.init(context)` is awaited - **THEN** the recorded order equals `["a", "b", "c"]` #### Scenario: dispose runs in reverse order - **GIVEN** a `ChainedBackend` over `[a, b, c]` where each child records the order of its `dispose` call - **WHEN** `chain.dispose()` is awaited - **THEN** the recorded order equals `["c", "b", "a"]` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"init forwards in registration order"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"dispose runs in reverse order"` --- ### Requirement: AbortSignal threading and cancellation Every async method (`read`, `readMulti`, `save`) SHALL accept an optional `signal?: AbortSignal` parameter and SHALL thread it into every child call (when the child accepts a signal parameter). When the signal is already aborted at call time, the chain SHALL throw `I18nError(I18N.BACKEND_TIMEOUT)` and SHALL NOT call any child. #### Scenario: pre-aborted signal aborts read with BACKEND_TIMEOUT - **GIVEN** an `AbortController` whose signal is already aborted - **WHEN** `chain.read("en", "common", { signal })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_TIMEOUT"` AND no child backend was called #### Scenario: signal threaded into child reads - **GIVEN** a `ChainedBackend` over a stub backend that records its received signal - **WHEN** `chain.read("en", "common", { signal })` is awaited - **THEN** the stub recorded the same signal instance **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"signal threaded into child reads"` --- ### Requirement: chainedBackend factory function with array shorthand The package SHALL export `chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend`. When an array is passed, it SHALL be wrapped as `{ backends: }`. When an options object is passed, it SHALL be forwarded to the constructor. #### Scenario: array shorthand wraps the array - **WHEN** `chainedBackend([memoryBackend()])` is called - **THEN** the result satisfies `result instanceof ChainedBackend` AND `result.name === "chained"` #### Scenario: options form forwards to constructor - **WHEN** `chainedBackend({ backends: [memoryBackend()], name: "custom" })` is called - **THEN** the result satisfies `result instanceof ChainedBackend` AND `result.name === "custom"` **Verified by:** `packages/i18n/i18n-backend-chained/src/factory.test.ts::"array shorthand wraps the array"` AND `packages/i18n/i18n-backend-chained/src/factory.test.ts::"options form forwards to constructor"` --- ### Requirement: writeThrough helper The package SHALL export `writeThrough(backends, successIndex, locale, namespace, record): Promise` from `./write-through`. The helper SHALL call `save` on every backend at indices `0..successIndex - 1` that implements `save` (best-effort; per-backend failures swallowed). #### Scenario: writeThrough writes only to preceding save-capable backends - **GIVEN** backends `[memoryA, httpReadOnly, memoryB]` (httpReadOnly has no save) and `successIndex: 2` - **WHEN** `writeThrough(backends, 2, "en", "common", { x: "y" })` is awaited - **THEN** memoryA received the save AND httpReadOnly was skipped AND memoryB was NOT called (it was the success backend) **Verified by:** `packages/i18n/i18n-backend-chained/src/write-through.test.ts::"writeThrough writes only to preceding save-capable backends"` --- ### Requirement: OpenTelemetry instrumentation including fallthrough counter The backend SHALL emit `i18n.backend.chained.read_duration_ms` (histogram), `i18n.backend.chained.read_total{outcome}` (counter), `i18n.backend.chained.read_errors_total{code}` (counter), `i18n.backend.chained.write_duration_ms` (histogram), `i18n.backend.chained.write_total{outcome}` (counter), and `i18n.backend.chained.fallthrough_total{from_backend,to_backend}` (counter; incremented on every `BACKEND_LOCALE_NOT_AVAILABLE`-driven fallthrough between adjacent links). #### Scenario: fallthrough increments fallthrough_total - **GIVEN** a `ChainedBackend` over `[emptyMemory, seededMemory]` with names `"a"` and `"b"` - **WHEN** `chain.read("en", "common")` is awaited successfully - **THEN** `i18n.backend.chained.fallthrough_total` increments with `from_backend="a"` AND `to_backend="b"` **Verified by:** `packages/i18n/i18n-backend-chained/src/backend.spec-conformance.test.ts::"fallthrough increments fallthrough_total"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can import only the surfaces they need: - `@suaveplan/i18n-backend-chained` — root barrel - `@suaveplan/i18n-backend-chained/backend` — `ChainedBackend` class only - `@suaveplan/i18n-backend-chained/factory` — `chainedBackend` factory only - `@suaveplan/i18n-backend-chained/write-through` — `writeThrough` helper only - `@suaveplan/i18n-backend-chained/merge` — `mergeReadMulti` helper only #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-backend-chained/src/index.test.ts::"every documented subpath resolves"` --- ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT depend on `@suaveplan/i18n` core nor on any other backend package. Consumers pass concrete backends in at construction. #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` #### Scenario: package.json depends on no other i18n backend - **WHEN** `package.json` `dependencies` is parsed - **THEN** the result does NOT contain `@suaveplan/i18n` AND does NOT contain `@suaveplan/i18n-backend-memory` AND does NOT contain `@suaveplan/i18n-backend-http` AND does NOT contain `@suaveplan/i18n-backend-storage` AND does NOT contain `@suaveplan/i18n-backend-fs-multifile` **Verified by:** `packages/i18n/i18n-backend-chained/src/index.test.ts::"package.json declares sideEffects false"` AND `packages/i18n/i18n-backend-chained/src/index.test.ts::"package.json depends on no other i18n backend"`