Initial Commit
This commit is contained in:
122
openspec/archive/2026-05-09-i18n-backend-chained/proposal.md
Normal file
122
openspec/archive/2026-05-09-i18n-backend-chained/proposal.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# `@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.
|
||||
Reference in New Issue
Block a user