Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,291 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-backend-chained (i18n-backend-chained)
**Package**: `@suaveplan/i18n-backend-chained`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: ChainedBackend implements the Backend interface
The package SHALL export a `ChainedBackend` class that implements the `Backend` interface from `@suaveplan/types/i18n` (universal-tier; tier follows constituent backends). The class SHALL accept a required `ChainedBackendOptions` constructor argument carrying `{ backends, name?, mergeStrategy? }`. The capabilities matrix SHALL be derived from the union of child capabilities: `canRead: true`, `canReadMulti: true`, `canSave: backends.some(b => b.capabilities.canSave)`, `canTrackModification: backends.some(b => b.capabilities.canTrackModification)`, `canWatch: backends.some(b => b.capabilities.canWatch)`. Default `name` SHALL equal `"chained"`. Default `mergeStrategy` SHALL equal `"first-wins"`.
#### Scenario: capabilities are derived from child union
- **GIVEN** a `ChainedBackend` over `[memoryBackend(), httpBackend({...})]` (memory has canSave, http does not)
- **WHEN** `chain.capabilities.canSave` is read
- **THEN** the value equals `true`
#### Scenario: default name is "chained"
- **WHEN** `new ChainedBackend({ backends: [memoryBackend()] }).name` is read
- **THEN** the value equals `"chained"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"capabilities are derived from child union"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"default name is chained"`
---
### Requirement: read fallthrough on BACKEND_LOCALE_NOT_AVAILABLE
`read(locale, namespace, signal?)` SHALL walk `backends` in registration order. The first backend whose `read` resolves SHALL win; subsequent backends SHALL be skipped. If a backend throws `I18nError(BACKEND_LOCALE_NOT_AVAILABLE)`, the chain SHALL fall through to the next backend.
#### Scenario: fallthrough on BACKEND_LOCALE_NOT_AVAILABLE
- **GIVEN** a `ChainedBackend` over `[emptyMemoryBackend, seededMemoryBackend]`
- **WHEN** `chain.read("en", "common")` is awaited
- **THEN** the result equals the seeded record AND `seededMemoryBackend.read` was called
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"fallthrough on BACKEND_LOCALE_NOT_AVAILABLE"`
---
### Requirement: read propagates BACKEND_READ_FAILED (no fallthrough)
If any backend throws `I18nError(BACKEND_READ_FAILED)`, the chain SHALL propagate that error immediately and SHALL NOT continue fallthrough. Transport failures must not silently degrade.
#### Scenario: BACKEND_READ_FAILED propagates immediately
- **GIVEN** a `ChainedBackend` over `[failingHttpBackend, seededMemoryBackend]` where `failingHttpBackend.read` throws `BACKEND_READ_FAILED`
- **WHEN** `chain.read("en", "common")` is awaited
- **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` AND `seededMemoryBackend.read` was NOT called
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"BACKEND_READ_FAILED propagates immediately"`
---
### Requirement: All children miss throws BACKEND_LOCALE_NOT_AVAILABLE
When every backend in a non-empty chain throws `BACKEND_LOCALE_NOT_AVAILABLE`, the chain SHALL throw `I18nError(BACKEND_LOCALE_NOT_AVAILABLE)`. This preserves catalog-layer fallthrough semantics one level up.
#### Scenario: all children miss throws BACKEND_LOCALE_NOT_AVAILABLE
- **GIVEN** a `ChainedBackend` over `[emptyMemory1, emptyMemory2]`
- **WHEN** `chain.read("xx", "common")` is awaited
- **THEN** the call throws `I18nError` with code `"I18N.BACKEND_LOCALE_NOT_AVAILABLE"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"all children miss throws BACKEND_LOCALE_NOT_AVAILABLE"`
---
### Requirement: Empty chain throws CHAINED_BACKEND_ALL_FAILED
The constructor's Zod schema SHALL reject `backends: []` with `INVALID_OPTIONS`. As defence-in-depth, if a runtime cast bypasses the schema and `backends.length === 0` is reached at `read` time, the call SHALL throw `I18nError` with code `I18N.CHAINED_BACKEND_ALL_FAILED`. This new error code (added to `I18N_CODES` in Wave 1b) disambiguates "chain misconfigured / empty" from the routine "all children miss" path so production teams can alert on misconfiguration without false positives from cache misses.
#### Scenario: schema rejects empty backends
- **WHEN** `new ChainedBackend({ backends: [] })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"`
#### Scenario: runtime empty chain throws CHAINED_BACKEND_ALL_FAILED
- **GIVEN** a `ChainedBackend` constructed via `as unknown as ChainedBackendOptions` cast bypassing the schema with `backends: []`
- **WHEN** `chain.read("en", "common")` is awaited
- **THEN** the call throws `I18nError` with code `"I18N.CHAINED_BACKEND_ALL_FAILED"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"schema rejects empty backends"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"runtime empty chain throws CHAINED_BACKEND_ALL_FAILED"`
---
### Requirement: Write-through cache after successful read from backend N
After a successful `read` from backend at index N (where N > 0), the chain SHALL write the resolved record back to every preceding backend (indices 0..N-1) that implements `save`. The writes SHALL be best-effort: failures from any preceding backend SHALL be swallowed and SHALL NOT block the read return.
#### Scenario: write-through populates cache layer
- **GIVEN** a `ChainedBackend` over `[memoryCache, seededHttpBackend]` where memoryCache is empty and seededHttpBackend has the record
- **WHEN** `chain.read("en", "common")` is awaited
- **THEN** the read result equals the record AND `memoryCache.read("en", "common")` (post-call) returns the same record
#### Scenario: write-through failure does not block read
- **GIVEN** a `ChainedBackend` over `[brokenSaveBackend, seededMemory]` where `brokenSaveBackend.save` throws
- **WHEN** `chain.read("en", "common")` is awaited
- **THEN** the read returns the seeded record (no throw)
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"write-through populates cache layer"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"write-through failure does not block read"`
---
### Requirement: readMulti merges per strategy
`readMulti(locale, namespaces, signal?)` SHALL fan out concurrently across all backends and merge the per-backend `Record<namespace, TranslationRecord>` maps. With `mergeStrategy: "first-wins"` (default), earlier backends SHALL win on overlap; later backends SHALL fill gaps only. With `mergeStrategy: "last-wins"`, the merge SHALL favour later backends. Per-backend errors during fan-out SHALL be swallowed (the slot is omitted, not propagated).
#### Scenario: first-wins prefers earlier backend
- **GIVEN** a `ChainedBackend` with default `first-wins` over two backends each returning `{ common: {...} }` with different values
- **WHEN** `chain.readMulti("en", ["common"])` is awaited
- **THEN** the result `common` value comes from the earlier backend
#### Scenario: last-wins prefers later backend
- **GIVEN** a `ChainedBackend` with `last-wins` over two backends each returning `{ common: {...} }` with different values
- **WHEN** `chain.readMulti("en", ["common"])` is awaited
- **THEN** the result `common` value comes from the later backend
#### Scenario: gaps are filled regardless of strategy
- **GIVEN** a `ChainedBackend` whose first backend returns `{ a: {...} }` and second returns `{ b: {...} }`
- **WHEN** `chain.readMulti("en", ["a", "b"])` is awaited
- **THEN** the result contains both `a` and `b`
**Verified by:** `packages/i18n/i18n-backend-chained/src/merge.test.ts::"first-wins prefers earlier backend"` AND `packages/i18n/i18n-backend-chained/src/merge.test.ts::"last-wins prefers later backend"` AND `packages/i18n/i18n-backend-chained/src/merge.test.ts::"gaps are filled regardless of strategy"`
---
### Requirement: save fan-out to every save-capable child
`save(locale, namespace, record, signal?)` SHALL call `save` on every backend in the chain that implements it (sequential). Any failure SHALL surface as `I18nError(BACKEND_WRITE_FAILED)` with `cause` preserved.
#### Scenario: save writes to every save-capable child
- **GIVEN** a `ChainedBackend` over `[memoryA, memoryB]`
- **WHEN** `chain.save("en", "common", { x: "y" })` is awaited
- **THEN** both `memoryA.read("en", "common")` and `memoryB.read("en", "common")` return `{ x: "y" }`
#### Scenario: save failure throws BACKEND_WRITE_FAILED
- **GIVEN** a `ChainedBackend` whose first backend's `save` throws non-I18nError `Error("disk full")`
- **WHEN** `chain.save("en", "common", { x: "y" })` is awaited
- **THEN** the call throws `I18nError` with code `"I18N.BACKEND_WRITE_FAILED"` AND `.cause.message === "disk full"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"save writes to every save-capable child"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"save failure throws BACKEND_WRITE_FAILED"`
---
### Requirement: lastModified returns the first defined value across children
`lastModified(locale, namespace)` SHALL walk children in order and return the first defined timestamp. Returns `undefined` when no child reports a timestamp.
#### Scenario: lastModified returns first defined value
- **GIVEN** a `ChainedBackend` whose first backend's `lastModified` returns `undefined` and second returns `4242`
- **WHEN** `chain.lastModified("en", "common")` is awaited
- **THEN** the result equals `4242`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"lastModified returns first defined value"`
---
### Requirement: init forward, dispose reverse-order
`init(context)` SHALL call `init(context)` on every child in registration order (skipping children that do not implement it). `dispose()` SHALL call `dispose()` on every child in reverse registration order. Both lifecycle methods SHALL be safely callable multiple times.
#### Scenario: init forwards in registration order
- **GIVEN** a `ChainedBackend` over `[a, b, c]` where each child records the order of its `init` call
- **WHEN** `chain.init(context)` is awaited
- **THEN** the recorded order equals `["a", "b", "c"]`
#### Scenario: dispose runs in reverse order
- **GIVEN** a `ChainedBackend` over `[a, b, c]` where each child records the order of its `dispose` call
- **WHEN** `chain.dispose()` is awaited
- **THEN** the recorded order equals `["c", "b", "a"]`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"init forwards in registration order"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"dispose runs in reverse order"`
---
### Requirement: AbortSignal threading and cancellation
Every async method (`read`, `readMulti`, `save`) SHALL accept an optional `signal?: AbortSignal` parameter and SHALL thread it into every child call (when the child accepts a signal parameter). When the signal is already aborted at call time, the chain SHALL throw `I18nError(I18N.BACKEND_TIMEOUT)` and SHALL NOT call any child.
#### Scenario: pre-aborted signal aborts read with BACKEND_TIMEOUT
- **GIVEN** an `AbortController` whose signal is already aborted
- **WHEN** `chain.read("en", "common", { signal })` is awaited
- **THEN** the call throws `I18nError` with code `"I18N.BACKEND_TIMEOUT"` AND no child backend was called
#### Scenario: signal threaded into child reads
- **GIVEN** a `ChainedBackend` over a stub backend that records its received signal
- **WHEN** `chain.read("en", "common", { signal })` is awaited
- **THEN** the stub recorded the same signal instance
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"` AND `packages/i18n/i18n-backend-chained/src/backend.test.ts::"signal threaded into child reads"`
---
### Requirement: chainedBackend factory function with array shorthand
The package SHALL export `chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend`. When an array is passed, it SHALL be wrapped as `{ backends: <array> }`. When an options object is passed, it SHALL be forwarded to the constructor.
#### Scenario: array shorthand wraps the array
- **WHEN** `chainedBackend([memoryBackend()])` is called
- **THEN** the result satisfies `result instanceof ChainedBackend` AND `result.name === "chained"`
#### Scenario: options form forwards to constructor
- **WHEN** `chainedBackend({ backends: [memoryBackend()], name: "custom" })` is called
- **THEN** the result satisfies `result instanceof ChainedBackend` AND `result.name === "custom"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/factory.test.ts::"array shorthand wraps the array"` AND `packages/i18n/i18n-backend-chained/src/factory.test.ts::"options form forwards to constructor"`
---
### Requirement: writeThrough helper
The package SHALL export `writeThrough(backends, successIndex, locale, namespace, record): Promise<void>` from `./write-through`. The helper SHALL call `save` on every backend at indices `0..successIndex - 1` that implements `save` (best-effort; per-backend failures swallowed).
#### Scenario: writeThrough writes only to preceding save-capable backends
- **GIVEN** backends `[memoryA, httpReadOnly, memoryB]` (httpReadOnly has no save) and `successIndex: 2`
- **WHEN** `writeThrough(backends, 2, "en", "common", { x: "y" })` is awaited
- **THEN** memoryA received the save AND httpReadOnly was skipped AND memoryB was NOT called (it was the success backend)
**Verified by:** `packages/i18n/i18n-backend-chained/src/write-through.test.ts::"writeThrough writes only to preceding save-capable backends"`
---
### Requirement: OpenTelemetry instrumentation including fallthrough counter
The backend SHALL emit `i18n.backend.chained.read_duration_ms` (histogram), `i18n.backend.chained.read_total{outcome}` (counter), `i18n.backend.chained.read_errors_total{code}` (counter), `i18n.backend.chained.write_duration_ms` (histogram), `i18n.backend.chained.write_total{outcome}` (counter), and `i18n.backend.chained.fallthrough_total{from_backend,to_backend}` (counter; incremented on every `BACKEND_LOCALE_NOT_AVAILABLE`-driven fallthrough between adjacent links).
#### Scenario: fallthrough increments fallthrough_total
- **GIVEN** a `ChainedBackend` over `[emptyMemory, seededMemory]` with names `"a"` and `"b"`
- **WHEN** `chain.read("en", "common")` is awaited successfully
- **THEN** `i18n.backend.chained.fallthrough_total` increments with `from_backend="a"` AND `to_backend="b"`
**Verified by:** `packages/i18n/i18n-backend-chained/src/backend.spec-conformance.test.ts::"fallthrough increments fallthrough_total"`
---
### Requirement: Subpath exports
The package SHALL declare subpath exports so consumers can import only the surfaces they need:
- `@suaveplan/i18n-backend-chained` — root barrel
- `@suaveplan/i18n-backend-chained/backend` — `ChainedBackend` class only
- `@suaveplan/i18n-backend-chained/factory` — `chainedBackend` factory only
- `@suaveplan/i18n-backend-chained/write-through` — `writeThrough` helper only
- `@suaveplan/i18n-backend-chained/merge` — `mergeReadMulti` helper only
#### Scenario: every subpath resolves
- **WHEN** every documented subpath is imported in a consumer test fixture
- **THEN** every import resolves with no `Module not found` errors
**Verified by:** `packages/i18n/i18n-backend-chained/src/index.test.ts::"every documented subpath resolves"`
---
### Requirement: Side-effect-free package
The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT depend on `@suaveplan/i18n` core nor on any other backend package. Consumers pass concrete backends in at construction.
#### Scenario: package.json declares sideEffects false
- **WHEN** `package.json` is parsed
- **THEN** the `sideEffects` field equals `false`
#### Scenario: package.json depends on no other i18n backend
- **WHEN** `package.json` `dependencies` is parsed
- **THEN** the result does NOT contain `@suaveplan/i18n` AND does NOT contain `@suaveplan/i18n-backend-memory` AND does NOT contain `@suaveplan/i18n-backend-http` AND does NOT contain `@suaveplan/i18n-backend-storage` AND does NOT contain `@suaveplan/i18n-backend-fs-multifile`
**Verified by:** `packages/i18n/i18n-backend-chained/src/index.test.ts::"package.json declares sideEffects false"` AND `packages/i18n/i18n-backend-chained/src/index.test.ts::"package.json depends on no other i18n backend"`

View File

@@ -0,0 +1,90 @@
# Tasks — `@suaveplan/i18n-backend-chained` (chained-fallback backend orchestrator)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## 1. Spec authoring
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3, §6, §6.1)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
- [x] 1.3 Read source: `packages/i18n/i18n/src/core/backend/chained/chained-backend.ts`, `chained-backend.test.ts`, `chained-backend.spec-conformance.test.ts`, `chained-backend.schemas.ts`, `chained-backend.md`, plus `packages/i18n/i18n/src/core/backend/backend.types.ts`
- [x] 1.4 Confirm Wave 1b (`@suaveplan/i18n-shared`) registry includes `I18N.CHAINED_BACKEND_ALL_FAILED` so this package can throw it without a registry-change dependency
- [x] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.6 Fill `## Source mapping` table with verified line ranges from monolith
- [x] 1.7 Draft `specs/i18n-backend-chained/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.8 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.9 `openspec change validate i18n-backend-chained` exits with no errors
## 2. Package scaffold
- [x] 2.1 `mkdir -p packages/i18n/i18n-backend-chained/src`
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-backend-chained`, version `0.1.0`, type `module`, sideEffects `false`. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`. **No** workspace dep on any other backend package.
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`)
- [x] 2.6 Add `package.json` `exports` map with subpaths `.`, `./backend`, `./factory`, `./write-through`, `./merge`
## 3. Implementation
- [x] 3.1 Author `src/backend.types.ts` — port `ChainedBackendOptions` and `MergeStrategy` from monolith L15-L28 verbatim
- [x] 3.2 Author `src/backend.schemas.ts` — port `ChainedBackendOptionsSchema` Zod schema verbatim (11 lines); validates `backends` is a non-empty array
- [x] 3.3 Author `src/write-through.ts` — extract `#writeThrough` logic into a standalone `writeThrough(backends, successIndex, locale, namespace, record): Promise<void>` (best-effort; swallows errors)
- [x] 3.4 Author `src/merge.ts` — extract inline merge logic from `readMulti` into a standalone `mergeReadMulti(perBackend, strategy): Record<string, TranslationRecord>`
- [x] 3.5 Author `src/observability.ts` — inline equivalents of `recordRead` / `recordWrite`; export `i18n.backend.chained.read_duration_ms`, `i18n.backend.chained.write_duration_ms` histograms; counters `read_total{outcome}`, `read_errors_total{code}`, `write_total{outcome}`, `fallthrough_total{from_backend,to_backend}` (low-cardinality — backends identified by their `.name`)
- [x] 3.6 Author `src/backend.ts` — port `ChainedBackend` class from monolith L43-L230; rewire imports; use `writeThrough` and `mergeReadMulti` helpers; emit metrics including the new `fallthrough_total` counter on every `BACKEND_LOCALE_NOT_AVAILABLE`-driven `continue`; add optional `signal?: AbortSignal` parameter on `read` / `readMulti` / `save` and thread it into every child call; throw `I18nError(I18N.CHAINED_BACKEND_ALL_FAILED)` if `backends.length === 0` is reached at runtime (defence-in-depth past the schema check)
## 4. Factory function
- [x] 4.1 Author `src/factory.ts` — `chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend` supporting both array shorthand and full options object (per monolith L233-L242)
## 5. Documentation (sibling `.md` per implementation file)
- [x] 5.1 `src/backend.md` — port from `packages/i18n/i18n/src/core/backend/chained/chained-backend.md`; ≥200 words; document fallthrough semantics, write-through cache pattern, BACKEND_LOCALE_NOT_AVAILABLE vs CHAINED_BACKEND_ALL_FAILED distinction
- [x] 5.2 `src/factory.md` — ≥200 words; basic + advanced examples (array shorthand vs options object)
- [x] 5.3 `src/write-through.md` — best-effort semantics; per-backend save-capability gating
- [x] 5.4 `src/merge.md` — first-wins vs last-wins examples; gap-filling behaviour
- [x] 5.5 `src/observability.md` — emitted metrics + units; `fallthrough_total` cardinality (= number of unique chain links observed)
- [x] 5.6 `README.md` `## Modules` section links every `.md`
## 6. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
- [x] 6.1 `src/backend.test.ts` — port every test from `packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts` (495 lines); same `it(...)` titles for conformance traceability; cover fallthrough, propagation of `BACKEND_READ_FAILED`, write-through, readMulti merge strategies, save fan-out, lastModified first-defined, init/dispose lifecycle, capabilities union
- [x] 6.2 `src/backend.spec-conformance.test.ts` — port from monolith spec-conformance suite (102 lines); add new requirements (CHAINED_BACKEND_ALL_FAILED on empty backends, AbortSignal cancellation, fallthrough_total counter emission)
- [x] 6.3 `src/factory.test.ts` — array form returns `ChainedBackend`; options form returns `ChainedBackend`; default `name === "chained"`
- [x] 6.4 `src/write-through.test.ts` — writes only to backends with `save`; saves at indices 0..N-1; never writes to the success backend; failures swallowed (best-effort)
- [x] 6.5 `src/merge.test.ts` — first-wins picks earlier; last-wins picks later; gaps filled from later
- [x] 6.6 `src/index.test.ts` — every documented public export resolves through `.`, `./backend`, `./factory`, `./write-through`, `./merge`
- [x] 6.7 Add an AbortSignal cancellation test — pre-aborted signal causes `read` to throw `I18nError(I18N.BACKEND_TIMEOUT)`; signal threaded into child reads (verify via stub backend that records its received signal)
- [x] 6.8 Add a `CHAINED_BACKEND_ALL_FAILED` test — bypass schema validation via `as unknown as ChainedBackendOptions` cast and pass `backends: []`; runtime throw on first `read` carries the new code
- [x] 6.9 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 6.10 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-backend-chained/src/` returning zero hits
- [x] 6.11 No `// biome-ignore` suppressions (CLAUDE.md §9)
- [x] 6.12 No `export *` in any non-root barrel (CLAUDE.md §10)
## 7. Pre-archive verification (5-gate triple)
- [x] 7.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-backend-chained` — green
- [x] 7.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-backend-chained` — 100/100/100/100
- [x] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-backend-chained` — exit 0
- [x] 7.5 `bunx turbo build --filter=@suaveplan/i18n-backend-chained` — green
## 8. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 8.1 Generics threaded — `Backend[]` correctly typed; `MergeStrategy` discriminator narrowed exhaustively
- [x] 8.2 Zod schemas wired at the public boundary — `ChainedBackendOptionsSchema` parses constructor input AND rejects empty `backends` array with `INVALID_OPTIONS`
- [x] 8.3 Every error code thrown by at least one production path: `INVALID_OPTIONS` (constructor), `BACKEND_LOCALE_NOT_AVAILABLE` (all children miss with non-empty chain), `CHAINED_BACKEND_ALL_FAILED` (defence-in-depth empty-chain throw), `BACKEND_READ_FAILED` (propagation from child), `BACKEND_WRITE_FAILED` (save fan-out failure), `BACKEND_TIMEOUT` (signal aborted)
- [x] 8.4 Strict-mode tested — exhaustive switch on `MergeStrategy` enforced via `expectTypeOf`
- [x] 8.5 OTel histograms emitted from production paths: `i18n.backend.chained.read_duration_ms`, `i18n.backend.chained.write_duration_ms`; counters `read_total`, `read_errors_total`, `write_total`, `fallthrough_total{from_backend,to_backend}`
- [x] 8.6 Lifecycle dedup — `init` is idempotent (re-calling forwards to children but does not duplicate state); `dispose` is idempotent and reverse-order
- [x] 8.7 No retroactive tick — every box reflects work done in this change
## 9. Manifest + ecosystem awareness
- [x] 9.1 Add `@suaveplan/i18n-backend-chained` entry to root `CLAUDE.md` package listing
- [x] 9.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
- [x] 9.3 Append `@suaveplan/i18n-backend-chained` to `STATUS.md` Completed list
## 10. Archive
- [x] 10.1 Fold spec delta into `openspec/specs/i18n-backend-chained/spec.md` (NEW capability spec — first delta becomes canonical)
- [x] 10.2 Move `openspec/changes/i18n-backend-chained/` → `openspec/archive/<yyyy-mm-dd>-i18n-backend-chained/`