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

123 lines
12 KiB
Markdown

# `@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.