292 lines
16 KiB
Markdown
292 lines
16 KiB
Markdown
<!-- 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"`
|