Files
suaveplan/packages/localization/i18n/i18n-backend-chained/src/backend/backend.md
2026-05-27 23:47:53 +01:00

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 on BACKEND_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).
  • readMulti concurrent fan-out across every backend with first-wins (default) or last-wins merge strategy; gaps are filled from the other side regardless of strategy.
  • save fan-out to every save-capable child; failures surface as BACKEND_WRITE_FAILED with cause preserved.
  • lastModified returns the first defined timestamp across children.
  • init forwards in registration order; dispose runs in reverse order — both safely callable multiple times.
  • Capabilities matrix derived from the union of child capabilities (canRead: true always; canSave, canTrackModification, canWatch follow the union).
  • AbortSignal threading on read, readMulti, and save. A pre-aborted signal throws BACKEND_TIMEOUT without calling any child.
  • OpenTelemetry instrumentation under i18n.backend.chained.*. The read counter carries an outcome attribute of cache_hit | cache_miss | primary so production teams can size cache layers correctly. A fallthrough_total{from_backend, to_backend} counter tracks per-link fallthrough rates.
  • New CHAINED_BACKEND_ALL_FAILED error 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 on BACKEND_LOCALE_NOT_AVAILABLE; propagates BACKEND_READ_FAILED immediately; throws BACKEND_TIMEOUT on a pre-aborted signal; throws CHAINED_BACKEND_ALL_FAILED when 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 as BACKEND_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 read outcome attribute is computed by #classifyReadOutcome(successIndex). Single-backend chains report primary; multi-backend chains report cache_hit (index 0 served the request) or cache_miss (a later backend served the request after fallthrough). On total miss, the outcome is miss; on BACKEND_READ_FAILED propagation, the outcome is error.
  • init and dispose are safe to call multiple times — both methods iterate the children and forward the lifecycle event without keeping internal state.
  • writeThrough is intentionally best-effort: a failing cache layer should never block the read return. Failures are swallowed silently. Production observability comes from the i18n.backend.chained.write_total{outcome=error} counter incremented by the save path (not the write-through path).
  • The Backend interface in @suaveplan/types does not declare a signal parameter on read / readMulti / save, but concrete backends (HTTP, sodium-backed remote) accept one. The chained backend threads signal to children via a structural cast — children that ignore the third argument receive normal positional arguments only.