16 KiB
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. (Seeopenspec/AGENTS.mdSection 13.)
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
ChainedBackendover[memoryBackend(), httpBackend({...})](memory has canSave, http does not) - WHEN
chain.capabilities.canSaveis read - THEN the value equals
true
Scenario: default name is "chained"
- WHEN
new ChainedBackend({ backends: [memoryBackend()] }).nameis 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
ChainedBackendover[emptyMemoryBackend, seededMemoryBackend] - WHEN
chain.read("en", "common")is awaited - THEN the result equals the seeded record AND
seededMemoryBackend.readwas 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
ChainedBackendover[failingHttpBackend, seededMemoryBackend]wherefailingHttpBackend.readthrowsBACKEND_READ_FAILED - WHEN
chain.read("en", "common")is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_READ_FAILED"ANDseededMemoryBackend.readwas 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
ChainedBackendover[emptyMemory1, emptyMemory2] - WHEN
chain.read("xx", "common")is awaited - THEN the call throws
I18nErrorwith 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
I18nErrorwith code"I18N.INVALID_OPTIONS"
Scenario: runtime empty chain throws CHAINED_BACKEND_ALL_FAILED
- GIVEN a
ChainedBackendconstructed viaas unknown as ChainedBackendOptionscast bypassing the schema withbackends: [] - WHEN
chain.read("en", "common")is awaited - THEN the call throws
I18nErrorwith 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
ChainedBackendover[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
ChainedBackendover[brokenSaveBackend, seededMemory]wherebrokenSaveBackend.savethrows - 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
ChainedBackendwith defaultfirst-winsover two backends each returning{ common: {...} }with different values - WHEN
chain.readMulti("en", ["common"])is awaited - THEN the result
commonvalue comes from the earlier backend
Scenario: last-wins prefers later backend
- GIVEN a
ChainedBackendwithlast-winsover two backends each returning{ common: {...} }with different values - WHEN
chain.readMulti("en", ["common"])is awaited - THEN the result
commonvalue comes from the later backend
Scenario: gaps are filled regardless of strategy
- GIVEN a
ChainedBackendwhose first backend returns{ a: {...} }and second returns{ b: {...} } - WHEN
chain.readMulti("en", ["a", "b"])is awaited - THEN the result contains both
aandb
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
ChainedBackendover[memoryA, memoryB] - WHEN
chain.save("en", "common", { x: "y" })is awaited - THEN both
memoryA.read("en", "common")andmemoryB.read("en", "common")return{ x: "y" }
Scenario: save failure throws BACKEND_WRITE_FAILED
- GIVEN a
ChainedBackendwhose first backend'ssavethrows non-I18nErrorError("disk full") - WHEN
chain.save("en", "common", { x: "y" })is awaited - THEN the call throws
I18nErrorwith 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
ChainedBackendwhose first backend'slastModifiedreturnsundefinedand second returns4242 - 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
ChainedBackendover[a, b, c]where each child records the order of itsinitcall - WHEN
chain.init(context)is awaited - THEN the recorded order equals
["a", "b", "c"]
Scenario: dispose runs in reverse order
- GIVEN a
ChainedBackendover[a, b, c]where each child records the order of itsdisposecall - 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
AbortControllerwhose signal is already aborted - WHEN
chain.read("en", "common", { signal })is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_TIMEOUT"AND no child backend was called
Scenario: signal threaded into child reads
- GIVEN a
ChainedBackendover 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 ChainedBackendANDresult.name === "chained"
Scenario: options form forwards to constructor
- WHEN
chainedBackend({ backends: [memoryBackend()], name: "custom" })is called - THEN the result satisfies
result instanceof ChainedBackendANDresult.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) andsuccessIndex: 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
ChainedBackendover[emptyMemory, seededMemory]with names"a"and"b" - WHEN
chain.read("en", "common")is awaited successfully - THEN
i18n.backend.chained.fallthrough_totalincrements withfrom_backend="a"ANDto_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—ChainedBackendclass only@suaveplan/i18n-backend-chained/factory—chainedBackendfactory only@suaveplan/i18n-backend-chained/write-through—writeThroughhelper only@suaveplan/i18n-backend-chained/merge—mergeReadMultihelper only
Scenario: every subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Scenario: package.json depends on no other i18n backend
- WHEN
package.jsondependenciesis parsed - THEN the result does NOT contain
@suaveplan/i18nAND does NOT contain@suaveplan/i18n-backend-memoryAND does NOT contain@suaveplan/i18n-backend-httpAND does NOT contain@suaveplan/i18n-backend-storageAND 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"