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.
|
||||
@@ -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"`
|
||||
90
openspec/archive/2026-05-09-i18n-backend-chained/tasks.md
Normal file
90
openspec/archive/2026-05-09-i18n-backend-chained/tasks.md
Normal 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/`
|
||||
Reference in New Issue
Block a user