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

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) → throw BACKEND_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 throw CHAINED_BACKEND_ALL_FAILED)
  • Mix of BACKEND_READ_FAILED and BACKEND_LOCALE_NOT_AVAILABLE → propagate the first BACKEND_READ_FAILED
  • Constructor receives an empty backends: [] array → throw CHAINED_BACKEND_ALL_FAILED immediately on first read (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 — ChainedBackend class implementing Backend from @suaveplan/types/i18n. Source: packages/i18n/i18n/src/core/backend/chained/chained-backend.ts L43-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 — ChainedBackendOptionsSchema Zod schema (validates non-empty backends array). Source: monolith chained-backend.schemas.ts (11 lines).
  • ADDED: src/factory.ts — chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend factory. 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 — emits i18n.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 — ChainedBackend class only
  • ./factory — chainedBackend factory only
  • ./write-through — writeThrough helper only
  • ./merge — mergeReadMulti helper only

Dependencies

  • @suaveplan/i18n-shared workspace:^ — for I18N_CODES, I18nError
  • @suaveplan/types workspace:^ — for Backend, BackendCapabilities, BackendInitContext, TranslationRecord from @suaveplan/types/i18n
  • @suaveplan/error workspace:^
  • @opentelemetry/api catalog:observability
  • tslib catalog:build
  • zod catalog:forms — for ChainedBackendOptionsSchema
  • No dep on @suaveplan/i18n core. No dep on any other backend package. Concrete backends are passed in at construction time as readonly 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_AVAILABLE triggers 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 MemoryBackend with 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 monolith chained-backend.test.ts (495 lines); covers fallthrough on BACKEND_LOCALE_NOT_AVAILABLE, propagation on BACKEND_READ_FAILED, write-through after successful read from backend N to backends 0..N-1, readMulti first-wins / last-wins merge, save writes to every save-capable child, lastModified returns first-defined value, init / dispose forward + reverse-order, capabilities derived from union, chainedBackend(array) array shorthand, chainedBackend(options) options form
  • packages/i18n/i18n-backend-chained/src/backend.spec-conformance.test.ts — verbatim port of monolith chained-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 returns ChainedBackend; options form returns ChainedBackend; default name === "chained"
  • packages/i18n/i18n-backend-chained/src/write-through.test.ts — writes only to backends with save; 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 strategy
  • packages/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_FAILED lands in @suaveplan/i18n-shared's I18N_CODES registry (Wave 1b prerequisite).
  • No downstream consumer changes needed in Wave 5 — the slim core (Wave 6) re-exports ChainedBackend and chainedBackend.
  • MANIFEST.md regenerated; CLAUDE.md package listing updated.