# `ChainedBackend` _(no submodules)_ ## Purpose `ChainedBackend` composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE` and write-through cache semantics after a successful read. It is the canonical wiring for offline-first applications: register a fast cache (in-memory `MemoryBackend` or `StorageBackend(OPFSStorage)`) at index 0 and a source-of-truth backend (`HttpBackend`) at the last index. The chain checks the cache first, falls through to the next backend on a cache miss, then writes the resolved record back to the cache so subsequent reads avoid the network round-trip. Transport failures (`BACKEND_READ_FAILED`) propagate immediately to stop the chain — silent degradation across an HTTP outage is a bug, not a feature. Only `BACKEND_LOCALE_NOT_AVAILABLE` (the resource is genuinely missing from a backend) triggers fallthrough. ## Features - Read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; immediate propagation on `BACKEND_READ_FAILED`. - Write-through cache after a successful read from index N to indices 0..N-1 (best-effort; per-backend save failures are swallowed and never block the read return). - `readMulti` concurrent fan-out across every backend with `first-wins` (default) or `last-wins` merge strategy; gaps are filled from the other side regardless of strategy. - `save` fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED` with `cause` preserved. - `lastModified` returns the first defined timestamp across children. - `init` forwards in registration order; `dispose` runs in reverse order — both safely callable multiple times. - Capabilities matrix derived from the union of child capabilities (`canRead: true` always; `canSave`, `canTrackModification`, `canWatch` follow the union). - AbortSignal threading on `read`, `readMulti`, and `save`. A pre-aborted signal throws `BACKEND_TIMEOUT` without calling any child. - OpenTelemetry instrumentation under `i18n.backend.chained.*`. The read counter carries an outcome attribute of `cache_hit | cache_miss | primary` so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates. - New `CHAINED_BACKEND_ALL_FAILED` error code thrown defensively when the chain is empty at runtime (the constructor's Zod schema already rejects empty arrays at construction time). ## Usage: Basic Example ```ts import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend"; import { memoryBackend } from "@suaveplan/i18n-backend-memory"; import { httpBackend } from "@suaveplan/i18n-backend-http"; const chain = new ChainedBackend({ backends: [ memoryBackend(), httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }), ], }); const record = await chain.read("en", "auth"); // On miss the http backend served the request; the memory backend now // caches the record so subsequent reads hit memory. ``` ## Usage: Advanced Example ```ts import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend"; import { memoryBackend } from "@suaveplan/i18n-backend-memory"; import { httpBackend } from "@suaveplan/i18n-backend-http"; const controller = new AbortController(); const chain = new ChainedBackend({ backends: [ memoryBackend({ name: "memory-cache" }), httpBackend({ name: "https-source", loadPath: "https://i18n.example.com/{locale}/{namespace}.json", }), ], name: "offline-first", mergeStrategy: "first-wins", }); // AbortSignal threading: the chain throws BACKEND_TIMEOUT immediately // when the signal is already aborted, and threads the signal into // every child call when the child accepts it. const merged = await chain.readMulti( "en", ["auth", "common", "billing"], controller.signal ); // Capabilities come from the union of children — memory has canSave, // http does not, so the chain reports canSave: true. chain.capabilities.canSave; // true ``` ## API Reference ### `class ChainedBackend implements Backend` #### Constructor `new ChainedBackend(options: ChainedBackendOptions)` - `options.backends: readonly Backend[]` — required, non-empty. - `options.name?: string` — defaults to `"chained"`. - `options.mergeStrategy?: "first-wins" | "last-wins"` — defaults to `"first-wins"`. Throws `I18nError(INVALID_OPTIONS)` when `backends` is empty or any field fails validation. #### Instance methods - `read(locale, namespace, signal?): Promise` — sequential fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; propagates `BACKEND_READ_FAILED` immediately; throws `BACKEND_TIMEOUT` on a pre-aborted signal; throws `CHAINED_BACKEND_ALL_FAILED` when the chain is empty at runtime. - `readMulti(locale, namespaces, signal?): Promise>` — concurrent fan-out + merge per strategy; per-backend errors are swallowed. - `save(locale, namespace, record, signal?): Promise` — fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED`. - `lastModified(locale, namespace): Promise` — first defined timestamp across children. - `init(context): Promise` — forwards in registration order. - `dispose(): Promise` — reverse-order forward. ### `ChainedBackendOptionsSchema` Zod schema validating the constructor options shape. Rejects empty `backends` arrays. ## Implementation Notes - The `read` outcome attribute is computed by `#classifyReadOutcome(successIndex)`. Single-backend chains report `primary`; multi-backend chains report `cache_hit` (index 0 served the request) or `cache_miss` (a later backend served the request after fallthrough). On total miss, the outcome is `miss`; on `BACKEND_READ_FAILED` propagation, the outcome is `error`. - `init` and `dispose` are safe to call multiple times — both methods iterate the children and forward the lifecycle event without keeping internal state. - `writeThrough` is intentionally best-effort: a failing cache layer should never block the read return. Failures are swallowed silently. Production observability comes from the `i18n.backend.chained.write_total{outcome=error}` counter incremented by the `save` path (not the write-through path). - The `Backend` interface in `@suaveplan/types` does not declare a `signal` parameter on `read` / `readMulti` / `save`, but concrete backends (HTTP, sodium-backed remote) accept one. The chained backend threads `signal` to children via a structural cast — children that ignore the third argument receive normal positional arguments only.