first commit
This commit is contained in:
45
docs/localization/i18n/i18n-backend-chained/README.md
Normal file
45
docs/localization/i18n/i18n-backend-chained/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @suaveplan/i18n-backend-chained - Universal chained backend orchestrator for the @suaveplan/i18n family. Composes N backends into a single Backend with read-fallthrough on BACKEND_LOCALE_NOT_AVAILABLE, write-through cache semantics after a successful read, and concurrent fan-out for readMulti with first-wins / last-wins merge. Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain. Emits OpenTelemetry instrumentation under i18n.backend.chained.* including a cache_hit/cache_miss/primary outcome attribute on the read counter.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`, write-through cache semantics after a successful read, and concurrent fan-out for `readMulti` with `first-wins` (default) or `last-wins` merge strategy.
|
||||
|
||||
The chained backend is the canonical wiring for offline-first applications: register an in-memory or storage cache at index 0 and a network source of truth (`HttpBackend`) at the last index. The chain checks the cache first, falls through on miss, and 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 not a feature.
|
||||
|
||||
OpenTelemetry instrumentation is emitted under `i18n.backend.chained.*`. The read counter carries an extended outcome attribute (`cache_hit | cache_miss | primary`) on top of the base `hit | miss | error` set so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
| Module | Path | Doc |
|
||||
|---|---|---|
|
||||
| `ChainedBackend` class + Zod schema | `src/backend/backend.ts` | [`backend.md`](./src/backend/backend.md) |
|
||||
| `chainedBackend` factory | `src/factory/factory.ts` | [`factory.md`](./src/factory/factory.md) |
|
||||
| OTel instruments + state ring | `src/observability/observability.ts` | [`observability.md`](./src/observability/observability.md) |
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/i18n-backend-chained` — root barrel
|
||||
- `@suaveplan/i18n-backend-chained/backend` — `ChainedBackend` class + types + schema
|
||||
- `@suaveplan/i18n-backend-chained/factory` — `chainedBackend` factory
|
||||
- `@suaveplan/i18n-backend-chained/observability` — OTel instruments + state ring
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/i18n-backend-chained
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see repository root.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# `ChainedBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `chainedBackend` factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`chainedBackend` is a builder-style alternative to `new ChainedBackend(options)`. It returns a fresh `ChainedBackend` per call with no internal singleton or shared state. The factory accepts either an array shorthand for the common "just give me this list of backends" case or a full options object when callers want to override `name` or `mergeStrategy`.
|
||||
|
||||
This separation between class constructor and factory function lets consumers write composable code (`pipe(loadConfig, mapBackends, chainedBackend)`) without sprinkling `new` keywords through their initialisation pipeline. It also pairs naturally with the `httpBackend` / `memoryBackend` / `storageBackend` factories from sibling packages so an entire i18n backend chain can be expressed as a single nested function call.
|
||||
|
||||
## Features
|
||||
|
||||
- Array shorthand: `chainedBackend([memoryBackend(), httpBackend({...})])` — uses default `name: "chained"` and `mergeStrategy: "first-wins"`.
|
||||
- Options form: `chainedBackend({ backends, name, mergeStrategy })` — passes options straight through to the constructor.
|
||||
- No mutation of the input array — the factory wraps the array in a new options object before calling the constructor.
|
||||
- Returns a fresh `ChainedBackend` per call (no caching, no singleton).
|
||||
- Throws `I18nError(INVALID_OPTIONS)` if the resulting options fail Zod validation (empty `backends` array, etc.).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const chain = chainedBackend([
|
||||
memoryBackend(),
|
||||
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
|
||||
]);
|
||||
|
||||
await chain.read("en", "auth");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const chain = chainedBackend({
|
||||
backends: [
|
||||
memoryBackend({ name: "memory-cache" }),
|
||||
httpBackend({
|
||||
name: "https-source",
|
||||
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
|
||||
}),
|
||||
],
|
||||
name: "production-chain",
|
||||
mergeStrategy: "last-wins",
|
||||
});
|
||||
|
||||
chain.name; // "production-chain"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend`
|
||||
|
||||
- `backendsOrOptions` — either an array of backends (array shorthand) or a full `ChainedBackendOptions` object. The function uses `Array.isArray` to discriminate at runtime.
|
||||
|
||||
Returns a fresh `ChainedBackend` instance. Throws `I18nError(INVALID_OPTIONS)` on validation failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a thin wrapper around `new ChainedBackend(options)`. All validation happens in the constructor's Zod schema; the factory itself adds no logic beyond array-vs-options discrimination.
|
||||
- The factory is the recommended ergonomic surface for application code; the class is exported for libraries that need to subclass or extend the chain. Both surfaces produce identical `ChainedBackend` instances.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Chained-backend observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns every OpenTelemetry instrument and the in-memory state ring used by `@suaveplan/i18n-backend-chained`. Production code paths in `ChainedBackend` call `recordRead`, `recordWrite`, and `recordFallthrough`; tests reset and inspect the in-memory state ring via `resetChainedBackendMetricsState` and `getChainedBackendMetricsState`. The instruments are built lazily on first call so consumers without an OTel `MeterProvider` registered pay zero overhead.
|
||||
|
||||
The emitted metric prefix is `i18n.backend.chained.*`, and every counter and histogram carries a `backend` attribute so multi-chain deployments can disambiguate per-instance metrics. The chained backend's `read_total` counter additionally carries an `outcome` attribute drawn from the extended set `hit | miss | error | cache_hit | cache_miss | primary` — the latter three values are the chained-backend's contribution beyond the base outcome set used by leaf backends.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy meter construction; idempotent `getChainedBackendMetrics()`.
|
||||
- Six instruments: `read_duration_ms` (histogram), `read_total{outcome}` (counter), `read_errors_total{code}` (counter), `write_duration_ms` (histogram), `write_total{outcome}` (counter), `fallthrough_total{from_backend, to_backend}` (counter).
|
||||
- In-memory state ring for spec-conformance tests — avoids standing up a full SDK pipeline.
|
||||
- `recordRead` / `recordWrite` / `recordFallthrough` are the only API surface production code calls.
|
||||
- `resetChainedBackendMetricsState` clears the ring; `resetChainedBackendMetricsCache` drops cached instruments so a fresh meter can be built (test-only).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getChainedBackendMetrics,
|
||||
recordRead,
|
||||
} from "@suaveplan/i18n-backend-chained/observability";
|
||||
|
||||
// Lazy-build instruments on first use.
|
||||
const ins = getChainedBackendMetrics();
|
||||
|
||||
// Production hook — emit a hit on the cache layer at index 0.
|
||||
recordRead("chained", "en", "auth", "cache_hit", 0.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getChainedBackendMetricsState,
|
||||
recordFallthrough,
|
||||
recordWrite,
|
||||
resetChainedBackendMetricsCache,
|
||||
resetChainedBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-chained/observability";
|
||||
|
||||
resetChainedBackendMetricsCache();
|
||||
resetChainedBackendMetricsState();
|
||||
|
||||
recordWrite("chained", "ok", 1.23);
|
||||
recordFallthrough("memory-cache", "https-source");
|
||||
|
||||
const state = getChainedBackendMetricsState();
|
||||
state.writeTotal.length; // 1
|
||||
state.fallthroughTotal[0]?.attributes; // { from_backend: "memory-cache", to_backend: "https-source" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getChainedBackendMetrics(): CachedInstruments` — lazy build / cached fetch.
|
||||
- `recordRead(backend, locale, namespace, outcome, durationMs, errorCode?)` — emit one read sample.
|
||||
- `recordWrite(backend, outcome, durationMs)` — emit one write sample.
|
||||
- `recordFallthrough(fromBackend, toBackend)` — emit one fallthrough sample.
|
||||
- `getChainedBackendMetricsState(): ChainedBackendMetricsState` — read the in-memory state ring.
|
||||
- `resetChainedBackendMetricsState(): void` — clear the ring.
|
||||
- `resetChainedBackendMetricsCache(): void` — drop cached instruments (test-only).
|
||||
- `ChainedReadOutcome` — `"hit" | "miss" | "error" | "cache_hit" | "cache_miss" | "primary"`.
|
||||
- `ChainedWriteOutcome` — `"ok" | "error"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `fallthrough_total` counter is bounded in cardinality by the number of unique chain links observed in production — typically O(N) for a chain of N backends, never proliferating beyond the registered set. Consumers configuring per-tenant chains should keep `from_backend` / `to_backend` low-cardinality (avoid embedding tenant ids in backend names).
|
||||
- The in-memory state ring grows unbounded in long-running tests. Always call `resetChainedBackendMetricsState()` between cases.
|
||||
- The OTel `Counter` and `Histogram` instruments are no-ops when no `MeterProvider` is registered — the default. Production wiring registers a SDK + exporter at process start.
|
||||
Reference in New Issue
Block a user