Files
suaveplan/openspec/specs/i18n-backend-chained/spec.md
2026-05-27 23:29:53 +01:00

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. (See openspec/AGENTS.md Section 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 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"