6.5 KiB
ChainedBackend
(no submodules)
Purpose
ChainedBackend composes N backends into a single Backend with read-fallthrough on BACKEND_LOCALE_NOT_AVAILABLE and write-through cache semantics after a successful read. It is the canonical wiring for offline-first applications: register a fast cache (in-memory MemoryBackend or StorageBackend(OPFSStorage)) at index 0 and a source-of-truth backend (HttpBackend) at the last index. The chain checks the cache first, falls through to the next backend on a cache miss, then writes the resolved record back to the cache so subsequent reads avoid the network round-trip.
Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain — silent degradation across an HTTP outage is a bug, not a feature. Only BACKEND_LOCALE_NOT_AVAILABLE (the resource is genuinely missing from a backend) triggers fallthrough.
Features
- Read-fallthrough on
BACKEND_LOCALE_NOT_AVAILABLE; immediate propagation onBACKEND_READ_FAILED. - Write-through cache after a successful read from index N to indices 0..N-1 (best-effort; per-backend save failures are swallowed and never block the read return).
readMulticoncurrent fan-out across every backend withfirst-wins(default) orlast-winsmerge strategy; gaps are filled from the other side regardless of strategy.savefan-out to every save-capable child; failures surface asBACKEND_WRITE_FAILEDwithcausepreserved.lastModifiedreturns the first defined timestamp across children.initforwards in registration order;disposeruns in reverse order — both safely callable multiple times.- Capabilities matrix derived from the union of child capabilities (
canRead: truealways;canSave,canTrackModification,canWatchfollow the union). - AbortSignal threading on
read,readMulti, andsave. A pre-aborted signal throwsBACKEND_TIMEOUTwithout calling any child. - OpenTelemetry instrumentation under
i18n.backend.chained.*. The read counter carries an outcome attribute ofcache_hit | cache_miss | primaryso production teams can size cache layers correctly. Afallthrough_total{from_backend, to_backend}counter tracks per-link fallthrough rates. - New
CHAINED_BACKEND_ALL_FAILEDerror code thrown defensively when the chain is empty at runtime (the constructor's Zod schema already rejects empty arrays at construction time).
Usage: Basic Example
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = new ChainedBackend({
backends: [
memoryBackend(),
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
],
});
const record = await chain.read("en", "auth");
// On miss the http backend served the request; the memory backend now
// caches the record so subsequent reads hit memory.
Usage: Advanced Example
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const controller = new AbortController();
const chain = new ChainedBackend({
backends: [
memoryBackend({ name: "memory-cache" }),
httpBackend({
name: "https-source",
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
}),
],
name: "offline-first",
mergeStrategy: "first-wins",
});
// AbortSignal threading: the chain throws BACKEND_TIMEOUT immediately
// when the signal is already aborted, and threads the signal into
// every child call when the child accepts it.
const merged = await chain.readMulti(
"en",
["auth", "common", "billing"],
controller.signal
);
// Capabilities come from the union of children — memory has canSave,
// http does not, so the chain reports canSave: true.
chain.capabilities.canSave; // true
API Reference
class ChainedBackend implements Backend
Constructor
new ChainedBackend(options: ChainedBackendOptions)
options.backends: readonly Backend[]— required, non-empty.options.name?: string— defaults to"chained".options.mergeStrategy?: "first-wins" | "last-wins"— defaults to"first-wins".
Throws I18nError(INVALID_OPTIONS) when backends is empty or any field fails validation.
Instance methods
read(locale, namespace, signal?): Promise<TranslationRecord>— sequential fallthrough onBACKEND_LOCALE_NOT_AVAILABLE; propagatesBACKEND_READ_FAILEDimmediately; throwsBACKEND_TIMEOUTon a pre-aborted signal; throwsCHAINED_BACKEND_ALL_FAILEDwhen the chain is empty at runtime.readMulti(locale, namespaces, signal?): Promise<Record<string, TranslationRecord>>— concurrent fan-out + merge per strategy; per-backend errors are swallowed.save(locale, namespace, record, signal?): Promise<void>— fan-out to every save-capable child; failures surface asBACKEND_WRITE_FAILED.lastModified(locale, namespace): Promise<number | undefined>— first defined timestamp across children.init(context): Promise<void>— forwards in registration order.dispose(): Promise<void>— reverse-order forward.
ChainedBackendOptionsSchema
Zod schema validating the constructor options shape. Rejects empty backends arrays.
Implementation Notes
- The
readoutcome attribute is computed by#classifyReadOutcome(successIndex). Single-backend chains reportprimary; multi-backend chains reportcache_hit(index 0 served the request) orcache_miss(a later backend served the request after fallthrough). On total miss, the outcome ismiss; onBACKEND_READ_FAILEDpropagation, the outcome iserror. initanddisposeare safe to call multiple times — both methods iterate the children and forward the lifecycle event without keeping internal state.writeThroughis intentionally best-effort: a failing cache layer should never block the read return. Failures are swallowed silently. Production observability comes from thei18n.backend.chained.write_total{outcome=error}counter incremented by thesavepath (not the write-through path).- The
Backendinterface in@suaveplan/typesdoes not declare asignalparameter onread/readMulti/save, but concrete backends (HTTP, sodium-backed remote) accept one. The chained backend threadssignalto children via a structural cast — children that ignore the third argument receive normal positional arguments only.