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,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"`