12 KiB
@suaveplan/i18n-backend-chained — chained-fallback backend orchestrator
Package: @suaveplan/i18n-backend-chained (NEW)
Status: PROPOSED
Type: feature (new universal-tier package; verbatim port from monolith with one new error code)
Date: 2026-05-05
Wave: 5
Design doc: openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md
Path: packages/i18n/i18n-backend-chained/
Why
The Wave 5 backend split lifts each Backend implementation from the @suaveplan/i18n monolith. The chained backend composes N backends into a single Backend with read-fallthrough (try backend 0; on BACKEND_LOCALE_NOT_AVAILABLE, try backend 1; …) and write-through cache semantics (after a successful read from backend N, write the record back to backends 0..N-1 that implement save). It is the canonical wiring for offline-first apps: MemoryBackend → StorageBackend(OPFSStorage) → HttpBackend. Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain (do not silently degrade); only BACKEND_LOCALE_NOT_AVAILABLE triggers fallthrough.
Because the chained backend orchestrates other backends rather than implementing storage itself, it has zero workspace deps beyond the standard set. Consumers pass concrete backends in at construction time. This avoids a Wave 5 dep cycle (chained → memory → chained) and keeps the chained backend a pure orchestrator.
The Backend interface itself moves to @suaveplan/types/i18n in Wave 1. @suaveplan/i18n-backend-chained implements Backend from that location and accepts other Backend-shaped instances at construction. Runtime atoms come from @suaveplan/i18n-shared.
New error code
The chained backend SHALL throw I18N.CHAINED_BACKEND_ALL_FAILED when every backend in the chain fails. This is a new code added to the I18N_CODES registry in Wave 1b (@suaveplan/i18n-shared). The existing monolith currently re-throws BACKEND_LOCALE_NOT_AVAILABLE when all backends miss; the split formalises the distinction:
- All backends report
BACKEND_LOCALE_NOT_AVAILABLE(no source has the data) → throwBACKEND_LOCALE_NOT_AVAILABLE(preserves catalog fallthrough semantics for the catalog layer one level up) - One or more backends throw
BACKEND_READ_FAILED(transport failure) → propagate the first failure (do NOT throwCHAINED_BACKEND_ALL_FAILED) - Mix of
BACKEND_READ_FAILEDandBACKEND_LOCALE_NOT_AVAILABLE→ propagate the firstBACKEND_READ_FAILED - Constructor receives an empty
backends: []array → throwCHAINED_BACKEND_ALL_FAILEDimmediately on firstread(or reject at construction, whichever the spec selects below)
The new code disambiguates the "no backends configured" failure mode from the routine "all backends miss" path so production teams can alert on chain-misconfiguration without false positives from cache misses.
What changes
New package: @suaveplan/i18n-backend-chained
- ADDED:
packages/i18n/i18n-backend-chained/package scaffold (package.json, tsconfig.json, vite.config.ts, README.md). - ADDED:
src/backend.ts—ChainedBackendclass implementingBackendfrom@suaveplan/types/i18n. Source:packages/i18n/i18n/src/core/backend/chained/chained-backend.tsL43-L230. Capabilities are derived from the union of child capabilities (canRead: true,canReadMulti: true,canSave: any-child-canSave,canTrackModification: any-child-canTrackModification,canWatch: any-child-canWatch). - ADDED:
src/backend.types.ts—ChainedBackendOptions,MergeStrategy. Source: monolith L15-L28. - ADDED:
src/backend.schemas.ts—ChainedBackendOptionsSchemaZod schema (validates non-emptybackendsarray). Source: monolithchained-backend.schemas.ts(11 lines). - ADDED:
src/factory.ts—chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackendfactory. Source: monolith L233-L242. - ADDED:
src/write-through.ts—writeThrough(backends, successIndex, locale, namespace, record): Promise<void>extracted from#writeThrough(best-effort save to every preceding backend). Source: monolith L213-L229. - ADDED:
src/merge.ts—mergeReadMulti(perBackend, strategy): Record<string, TranslationRecord>extracted from the inline merge body (first-wins / last-wins). Source: monolith L164-L174. - ADDED:
src/observability.ts— emitsi18n.backend.chained.read_duration_ms,i18n.backend.chained.read_total{outcome},i18n.backend.chained.read_errors_total{code},i18n.backend.chained.write_duration_ms,i18n.backend.chained.write_total{outcome},i18n.backend.chained.fallthrough_total{from_backend,to_backend}(counter; tracks how often each chain link fell through). - ADDED:
src/backend.test.ts,src/backend.spec-conformance.test.ts,src/factory.test.ts,src/write-through.test.ts,src/merge.test.ts,src/index.test.ts— Vitest suites verbatim-ported from monolith plus new spec-conformance suites. - ADDED:
src/backend.md,src/factory.md,src/write-through.md,src/merge.md— co-located docs. - ADDED:
src/index.ts— explicit named re-exports.
Subpath exports
.— root barrel./backend—ChainedBackendclass only./factory—chainedBackendfactory only./write-through—writeThroughhelper only./merge—mergeReadMultihelper only
Dependencies
@suaveplan/i18n-shared workspace:^— forI18N_CODES,I18nError@suaveplan/types workspace:^— forBackend,BackendCapabilities,BackendInitContext,TranslationRecordfrom@suaveplan/types/i18n@suaveplan/error workspace:^@opentelemetry/api catalog:observabilitytslib catalog:buildzod catalog:forms— forChainedBackendOptionsSchema- No dep on
@suaveplan/i18ncore. No dep on any other backend package. Concrete backends are passed in at construction time asreadonly Backend[].
Tier
Universal. The chained backend itself is platform-neutral; the tier of any concrete chain depends on the constituent backends (e.g. chaining HttpBackend + MultiFileFsBackend produces a server-tier chain).
Out of scope
- Removing the chained backend from
packages/i18n/i18n/src/core/backend/chained/— happens in Wave 6. - Promise.race read strategy (return whichever backend resolves first) — explicit non-goal; the chained backend is sequential by design so the cache-fallthrough semantics are deterministic.
- Conditional fallthrough on arbitrary error codes — out of scope; only
BACKEND_LOCALE_NOT_AVAILABLEtriggers fallthrough. Consumers who want richer semantics wrap the chain in a custom orchestrator. - Negative caching (record that a backend missed and skip it for some TTL) — out of scope; consumers wire
MemoryBackendwith sentinel values if they want this.
Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
ChainedBackend class + capabilities (union over children) |
packages/i18n/i18n/src/core/backend/chained/chained-backend.ts |
L43-L71 | Verbatim port; rewire imports |
init (forward to every child) |
same file | L73-L77 | Verbatim port |
dispose (reverse-order forward to every child) |
same file | L79-L84 | Verbatim port |
read (sequential fallthrough on BACKEND_LOCALE_NOT_AVAILABLE; propagate other errors) |
same file | L86-L130 | Verbatim port; modified: when chain exhausts on empty backends, throw new code I18N.CHAINED_BACKEND_ALL_FAILED |
readMulti (concurrent fan-out + merge per strategy) |
same file | L132-L175 | Verbatim port |
save (write to every save-capable child) |
same file | L177-L198 | Verbatim port |
lastModified (first defined value across children) |
same file | L200-L211 | Verbatim port |
#writeThrough (lifted to src/write-through.ts) |
same file | L213-L229 | Extracted |
Inline merge logic (lifted to src/merge.ts) |
same file | L164-L174 | Extracted |
ChainedBackendOptions, MergeStrategy types |
same file | L15-L28 | Verbatim port to backend.types.ts |
chainedBackend factory (array shorthand + options form) |
same file | L233-L242 | Verbatim port to factory.ts |
ChainedBackendOptionsSchema |
packages/i18n/i18n/src/core/backend/chained/chained-backend.schemas.ts |
full file (11 lines) | Verbatim port |
recordRead / recordWrite instrumentation pattern |
packages/i18n/i18n/src/observability/backend-metrics.ts |
full file | Reimplemented inline; metric names re-prefixed i18n.backend.chained. |
Requirements with no source line
| Requirement | Justification |
|---|---|
New I18N.CHAINED_BACKEND_ALL_FAILED error code |
Disambiguates "all backends miss" (BACKEND_LOCALE_NOT_AVAILABLE) from "chain misconfigured / empty" (the new code) — the monolith conflates these and production teams cannot alert on misconfiguration without false positives. The code is added to @suaveplan/i18n-shared's I18N_CODES registry as part of Wave 1b. |
i18n.backend.chained.fallthrough_total{from_backend,to_backend} counter |
Operability — production teams need per-link fallthrough rates to size cache layers correctly |
Subpath exports ./backend, ./factory, ./write-through, ./merge |
Tree-shaking + reuse — write-through and merge helpers are reusable in custom orchestrators |
AbortSignal parameter on read / readMulti / save |
Cancellation parity; signal SHALL be threaded into every child call |
Monolith-source surprise (flag for follow-up)
The monolith ChainedBackend.read currently throws BACKEND_LOCALE_NOT_AVAILABLE at L116-L120 when all backends miss. The new spec preserves that behaviour for "all children miss" but adds CHAINED_BACKEND_ALL_FAILED for the "empty backends array" misconfiguration path. The constructor's existing Zod schema rejects empty arrays via INVALID_OPTIONS, so CHAINED_BACKEND_ALL_FAILED is reachable only if the schema is bypassed (e.g. consumers cast around the type system). Consider folding both signals under CHAINED_BACKEND_ALL_FAILED in a follow-up if the catalog-layer fallthrough semantics permit; for this delta the conservative split is to keep BACKEND_LOCALE_NOT_AVAILABLE for the routine-miss case.
Verification approach
Each requirement carries a **Verified by:** line in the spec delta. Concrete test files:
packages/i18n/i18n-backend-chained/src/backend.test.ts— verbatim port of monolithchained-backend.test.ts(495 lines); covers fallthrough onBACKEND_LOCALE_NOT_AVAILABLE, propagation onBACKEND_READ_FAILED, write-through after successful read from backend N to backends 0..N-1,readMultifirst-wins / last-wins merge,savewrites to every save-capable child,lastModifiedreturns first-defined value,init/disposeforward + reverse-order, capabilities derived from union,chainedBackend(array)array shorthand,chainedBackend(options)options formpackages/i18n/i18n-backend-chained/src/backend.spec-conformance.test.ts— verbatim port of monolithchained-backend.spec-conformance.test.ts(102 lines) plus new requirements (CHAINED_BACKEND_ALL_FAILED, AbortSignal, fallthrough counter)packages/i18n/i18n-backend-chained/src/factory.test.ts— array form returnsChainedBackend; options form returnsChainedBackend; defaultname === "chained"packages/i18n/i18n-backend-chained/src/write-through.test.ts— writes only to backends withsave; failures from earlier backends do NOT block; failures are swallowed (best-effort)packages/i18n/i18n-backend-chained/src/merge.test.ts— first-wins picks earlier; last-wins picks later; gaps filled from later regardless of strategypackages/i18n/i18n-backend-chained/src/index.test.ts— every documented public export resolves through every documented subpath
Impact
- New universal-tier package — joins the i18n family under
packages/i18n/. - New error code
I18N.CHAINED_BACKEND_ALL_FAILEDlands in@suaveplan/i18n-shared'sI18N_CODESregistry (Wave 1b prerequisite). - No downstream consumer changes needed in Wave 5 — the slim core (Wave 6) re-exports
ChainedBackendandchainedBackend. MANIFEST.mdregenerated;CLAUDE.mdpackage listing updated.