Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,83 @@
{
"name": "@suaveplan/i18n-backend-chained",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./backend": {
"import": {
"types": "./dist/backend/index.d.ts",
"default": "./dist/backend/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/i18n-backend-memory": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -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.

View File

@@ -0,0 +1,872 @@
import { MemoryBackend, memoryBackend } from "@suaveplan/i18n-backend-memory";
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import {
beforeEach,
describe,
expect,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import type {
Backend,
BackendCapabilities,
BackendInitContext,
TranslationRecord,
} from "@suaveplan/types";
import {
getChainedBackendMetricsState,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "../observability/observability.js";
import { ChainedBackend, ChainedBackendOptionsSchema } from "./backend.js";
import type { ChainedBackendOptions, MergeStrategy } from "./backend.types.js";
// ─────────────────────────────────────────────────────────────────────
// Stub backends used across the suite
// ─────────────────────────────────────────────────────────────────────
interface ReadOnlyStubOptions {
readonly name: string;
readonly response?: TranslationRecord;
readonly error?: I18nError;
readonly throwGeneric?: Error;
readonly capabilities?: Partial<BackendCapabilities>;
readonly recordSignal?: { last: AbortSignal | undefined };
readonly initOrder?: string[];
readonly disposeOrder?: string[];
readonly throwOnSave?: Error;
readonly recordSaves?: Array<{
locale: string;
namespace: string;
record: TranslationRecord;
}>;
}
function makeStub(opts: ReadOnlyStubOptions): Backend {
const baseCaps: BackendCapabilities = {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
...opts.capabilities,
} as BackendCapabilities;
const backend: Backend & {
save?: (
l: string,
n: string,
r: TranslationRecord,
s?: AbortSignal
) => Promise<void>;
} = {
name: opts.name,
capabilities: baseCaps,
async read(_locale, _namespace, signal?: AbortSignal) {
if (opts.recordSignal) opts.recordSignal.last = signal;
if (opts.throwGeneric) throw opts.throwGeneric;
if (opts.error) throw opts.error;
if (opts.response) return opts.response;
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`${opts.name}: locale not available`
);
},
async init(_context: BackendInitContext) {
opts.initOrder?.push(opts.name);
},
async dispose() {
opts.disposeOrder?.push(opts.name);
},
};
if (baseCaps.canSave || opts.throwOnSave || opts.recordSaves) {
backend.save = async (locale, namespace, record, _signal) => {
if (opts.throwOnSave) throw opts.throwOnSave;
opts.recordSaves?.push({ locale, namespace, record });
};
}
return backend;
}
// ─────────────────────────────────────────────────────────────────────
// Universal Backend conformance suite
// ─────────────────────────────────────────────────────────────────────
runBackendConformance("ChainedBackend", async () => {
const cache = new MemoryBackend();
const primary = new MemoryBackend();
const chain = new ChainedBackend({ backends: [cache, primary] });
return {
backend: chain,
async seed(locale, namespace, record) {
await primary.save(locale, namespace, record as never);
},
};
});
// ─────────────────────────────────────────────────────────────────────
// ChainedBackend specifics
// ─────────────────────────────────────────────────────────────────────
describe("ChainedBackend constructor", () => {
it("default name is chained", { timeout: 5000 }, () => {
const chain = new ChainedBackend({ backends: [memoryBackend()] });
expect(chain.name).toBe("chained");
});
it("accepts a custom name", { timeout: 5000 }, () => {
const chain = new ChainedBackend({
backends: [memoryBackend()],
name: "custom-chain",
});
expect(chain.name).toBe("custom-chain");
});
it("rejects empty backends with INVALID_OPTIONS", { timeout: 5000 }, () => {
expect(() => new ChainedBackend({ backends: [] })).toThrow(I18nError);
try {
new ChainedBackend({ backends: [] });
} catch (err) {
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("rejects non-string name with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new ChainedBackend({
backends: [memoryBackend()],
name: "" as string,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("rejects unknown mergeStrategy with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new ChainedBackend({
backends: [memoryBackend()],
mergeStrategy: "unknown" as MergeStrategy,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("MergeStrategy is a discriminated union", { timeout: 5000 }, () => {
expectTypeOf<MergeStrategy>().toEqualTypeOf<
"first-wins" | "last-wins"
>();
});
});
describe("ChainedBackend capabilities (union over children)", () => {
it("capabilities are derived from child union", { timeout: 5000 }, () => {
const a = makeStub({ name: "a", capabilities: { canSave: true } });
const b = makeStub({ name: "b" });
const chain = new ChainedBackend({ backends: [a, b] });
expect(chain.capabilities.canSave).toBe(true);
});
it("canTrackModification union over children", { timeout: 5000 }, () => {
const a = makeStub({ name: "a" });
const b = makeStub({
name: "b",
capabilities: { canTrackModification: true },
});
const chain = new ChainedBackend({ backends: [a, b] });
expect(chain.capabilities.canTrackModification).toBe(true);
});
it("canWatch union over children", { timeout: 5000 }, () => {
const a = makeStub({ name: "a", capabilities: { canWatch: true } });
const b = makeStub({ name: "b" });
const chain = new ChainedBackend({ backends: [a, b] });
expect(chain.capabilities.canWatch).toBe(true);
});
it("canRead is always true literal", { timeout: 5000 }, () => {
const chain = new ChainedBackend({
backends: [makeStub({ name: "a" })],
});
expect(chain.capabilities.canRead).toBe(true);
});
});
describe("ChainedBackend.read fallthrough semantics", () => {
beforeEach(() => {
resetChainedBackendMetricsState();
resetChainedBackendMetricsCache();
});
it("fallthrough on BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const empty = makeStub({ name: "empty" });
const seeded = makeStub({
name: "seeded",
response: { x: "y" },
capabilities: { canSave: true },
});
const chain = new ChainedBackend({ backends: [empty, seeded] });
const record = await chain.read("en", "common");
expect(record).toEqual({ x: "y" });
});
it("BACKEND_READ_FAILED propagates immediately", {
timeout: 5000,
}, async () => {
const failing = makeStub({
name: "failing",
error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"),
});
let nextCalled = false;
const next: Backend = {
name: "next",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
nextCalled = true;
return { x: "y" };
},
};
const chain = new ChainedBackend({ backends: [failing, next] });
await expect(chain.read("en", "common")).rejects.toMatchObject({
code: I18N_CODES.BACKEND_READ_FAILED,
});
expect(nextCalled).toBe(false);
});
it("all children miss throws BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const a = makeStub({ name: "a" });
const b = makeStub({ name: "b" });
const chain = new ChainedBackend({ backends: [a, b] });
await expect(chain.read("xx", "common")).rejects.toMatchObject({
code: I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
});
});
it("rethrows generic Error from child", { timeout: 5000 }, async () => {
const failing = makeStub({
name: "failing",
throwGeneric: new Error("generic boom"),
});
const chain = new ChainedBackend({ backends: [failing] });
await expect(chain.read("en", "common")).rejects.toThrow(
"generic boom"
);
});
it("fallthrough increments fallthrough_total", {
timeout: 5000,
}, async () => {
const a = makeStub({ name: "a" });
const b = makeStub({ name: "b", response: { x: "y" } });
const chain = new ChainedBackend({ backends: [a, b] });
await chain.read("en", "common");
const state = getChainedBackendMetricsState();
expect(state.fallthroughTotal.length).toBe(1);
expect(state.fallthroughTotal[0]?.attributes).toEqual({
from_backend: "a",
to_backend: "b",
});
});
it("single-backend chain reports primary outcome on hit", {
timeout: 5000,
}, async () => {
const only = makeStub({ name: "only", response: { x: "y" } });
const chain = new ChainedBackend({ backends: [only] });
await chain.read("en", "common");
const state = getChainedBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("primary");
});
it("multi-backend chain reports cache_hit when index 0 serves", {
timeout: 5000,
}, async () => {
const cache = makeStub({
name: "cache",
response: { x: "y" },
capabilities: { canSave: true },
});
const primary = makeStub({ name: "primary" });
const chain = new ChainedBackend({ backends: [cache, primary] });
await chain.read("en", "common");
const state = getChainedBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("cache_hit");
});
it("multi-backend chain reports cache_miss when later backend serves", {
timeout: 5000,
}, async () => {
const cache = makeStub({ name: "cache" });
const primary = makeStub({ name: "primary", response: { x: "y" } });
const chain = new ChainedBackend({ backends: [cache, primary] });
await chain.read("en", "common");
const state = getChainedBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("cache_miss");
});
it("miss outcome on total fallthrough exhaustion", {
timeout: 5000,
}, async () => {
const a = makeStub({ name: "a" });
const b = makeStub({ name: "b" });
const chain = new ChainedBackend({ backends: [a, b] });
await expect(chain.read("xx", "common")).rejects.toBeInstanceOf(
I18nError
);
const state = getChainedBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("miss");
});
it("error outcome on propagation", { timeout: 5000 }, async () => {
const failing = makeStub({
name: "failing",
error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"),
});
const chain = new ChainedBackend({ backends: [failing] });
await expect(chain.read("en", "common")).rejects.toBeInstanceOf(
I18nError
);
const state = getChainedBackendMetricsState();
const errCodes = state.readErrorsTotal.map((s) => s.attributes["code"]);
expect(errCodes).toContain(I18N_CODES.BACKEND_READ_FAILED);
});
});
describe("ChainedBackend write-through cache semantics", () => {
it("write-through populates cache layer", { timeout: 5000 }, async () => {
const cache = new MemoryBackend({ name: "cache" });
const primary = new MemoryBackend({ name: "primary" });
await primary.save("en", "common", { x: "y" });
const chain = new ChainedBackend({ backends: [cache, primary] });
await chain.read("en", "common");
// Cache should now have the record.
expect(await cache.read("en", "common")).toEqual({ x: "y" });
});
it("write-through failure does not block read", {
timeout: 5000,
}, async () => {
const recordedSaves: Array<{
locale: string;
namespace: string;
record: TranslationRecord;
}> = [];
const broken = makeStub({
name: "broken",
capabilities: { canSave: true },
throwOnSave: new Error("disk full"),
recordSaves: recordedSaves,
});
const seeded = makeStub({
name: "seeded",
response: { x: "y" },
});
const chain = new ChainedBackend({ backends: [broken, seeded] });
const record = await chain.read("en", "common");
expect(record).toEqual({ x: "y" });
});
it("write-through skips backends without save", {
timeout: 5000,
}, async () => {
const readOnlyCache = makeStub({ name: "ro" });
const seeded = makeStub({
name: "seeded",
response: { x: "y" },
});
const chain = new ChainedBackend({
backends: [readOnlyCache, seeded],
});
const record = await chain.read("en", "common");
expect(record).toEqual({ x: "y" });
});
});
describe("ChainedBackend.readMulti merge strategies", () => {
it("first-wins prefers earlier backend", { timeout: 5000 }, async () => {
const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } });
const b = memoryBackend({ seed: { en: { auth: { x: "B" } } } });
const chain = new ChainedBackend({ backends: [a, b] });
const merged = await chain.readMulti("en", ["auth"]);
expect(merged["auth"]).toEqual({ x: "A" });
});
it("last-wins prefers later backend", { timeout: 5000 }, async () => {
const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } });
const b = memoryBackend({ seed: { en: { auth: { x: "B" } } } });
const chain = new ChainedBackend({
backends: [a, b],
mergeStrategy: "last-wins",
});
const merged = await chain.readMulti("en", ["auth"]);
expect(merged["auth"]).toEqual({ x: "B" });
});
it("gaps are filled regardless of strategy", {
timeout: 5000,
}, async () => {
const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } });
const b = memoryBackend({ seed: { en: { common: { y: "B" } } } });
const chain = new ChainedBackend({ backends: [a, b] });
const merged = await chain.readMulti("en", ["auth", "common"]);
expect(merged).toEqual({ auth: { x: "A" }, common: { y: "B" } });
});
it("readMulti falls back to per-namespace read when child has no readMulti", {
timeout: 5000,
}, async () => {
const stub = makeStub({
name: "stub",
response: { x: "y" },
});
const chain = new ChainedBackend({ backends: [stub] });
const merged = await chain.readMulti("en", ["a", "b"]);
expect(merged).toEqual({ a: { x: "y" }, b: { x: "y" } });
});
it("readMulti per-namespace fallthrough on BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const empty = makeStub({ name: "empty" });
const chain = new ChainedBackend({ backends: [empty] });
const merged = await chain.readMulti("xx", ["a", "b"]);
expect(merged).toEqual({});
});
it("readMulti per-namespace propagates non-LOCALE_NOT_AVAILABLE errors", {
timeout: 5000,
}, async () => {
// When a child has no readMulti, the chained backend falls
// back to per-namespace `read()` calls. BACKEND_READ_FAILED
// from any per-namespace read in that path propagates
// upward (not swallowed) — the swallow only applies to the
// child.readMulti() path.
const failing = makeStub({
name: "failing",
error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"),
});
const chain = new ChainedBackend({ backends: [failing] });
await expect(chain.readMulti("en", ["a"])).rejects.toMatchObject({
code: I18N_CODES.BACKEND_READ_FAILED,
});
});
it("readMulti uses child readMulti when present", {
timeout: 5000,
}, async () => {
const seed = memoryBackend({
seed: { en: { auth: { x: "A" }, common: { y: "B" } } },
});
const chain = new ChainedBackend({ backends: [seed] });
const merged = await chain.readMulti("en", ["auth", "common"]);
expect(merged).toEqual({ auth: { x: "A" }, common: { y: "B" } });
});
it("readMulti swallows child readMulti errors per backend", {
timeout: 5000,
}, async () => {
const failing: Backend = {
name: "failing",
capabilities: {
canRead: true,
canReadMulti: true,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
"miss"
);
},
async readMulti() {
throw new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom");
},
};
const seed = memoryBackend({
seed: { en: { auth: { x: "A" } } },
});
const chain = new ChainedBackend({ backends: [failing, seed] });
const merged = await chain.readMulti("en", ["auth"]);
expect(merged).toEqual({ auth: { x: "A" } });
});
});
describe("ChainedBackend.save fan-out", () => {
it("save writes to every save-capable child", {
timeout: 5000,
}, async () => {
const a = memoryBackend({ name: "a" });
const b = memoryBackend({ name: "b" });
const chain = new ChainedBackend({ backends: [a, b] });
await chain.save("en", "common", { x: "y" });
expect(await a.read("en", "common")).toEqual({ x: "y" });
expect(await b.read("en", "common")).toEqual({ x: "y" });
});
it("save skips backends without save", { timeout: 5000 }, async () => {
const ro = makeStub({ name: "ro" });
const writable = memoryBackend({ name: "writable" });
const chain = new ChainedBackend({ backends: [ro, writable] });
await chain.save("en", "common", { x: "y" });
expect(await writable.read("en", "common")).toEqual({ x: "y" });
});
it("save failure throws BACKEND_WRITE_FAILED", {
timeout: 5000,
}, async () => {
const broken = makeStub({
name: "broken",
capabilities: { canSave: true },
throwOnSave: new Error("disk full"),
});
const chain = new ChainedBackend({ backends: [broken] });
try {
await chain.save("en", "common", { x: "y" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_WRITE_FAILED
);
expect(((err as I18nError).cause as Error).message).toBe(
"disk full"
);
}
});
it("save preserves I18nError thrown by child", {
timeout: 5000,
}, async () => {
const broken = makeStub({
name: "broken",
capabilities: { canSave: true },
throwOnSave: new I18nError(
I18N_CODES.BACKEND_WRITE_FAILED,
"child write failed"
),
});
const chain = new ChainedBackend({ backends: [broken] });
await expect(
chain.save("en", "common", { x: "y" })
).rejects.toMatchObject({
code: I18N_CODES.BACKEND_WRITE_FAILED,
message: "child write failed",
});
});
});
describe("ChainedBackend.lastModified", () => {
it("lastModified returns first defined value", {
timeout: 5000,
}, async () => {
const a: Backend = {
name: "a",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: true,
canWatch: false,
},
async read() {
return {};
},
async lastModified() {
return undefined;
},
};
const b: Backend = {
name: "b",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: true,
canWatch: false,
},
async read() {
return {};
},
async lastModified() {
return 4242;
},
};
const chain = new ChainedBackend({ backends: [a, b] });
expect(await chain.lastModified("en", "common")).toBe(4242);
});
it("lastModified returns undefined when no child reports", {
timeout: 5000,
}, async () => {
const stub = makeStub({ name: "stub" });
const chain = new ChainedBackend({ backends: [stub] });
expect(await chain.lastModified("en", "common")).toBeUndefined();
});
});
describe("ChainedBackend lifecycle (init/dispose)", () => {
it("init forwards in registration order", { timeout: 5000 }, async () => {
const initOrder: string[] = [];
const a = makeStub({ name: "a", initOrder });
const b = makeStub({ name: "b", initOrder });
const c = makeStub({ name: "c", initOrder });
const chain = new ChainedBackend({ backends: [a, b, c] });
await chain.init({
catalogName: "test",
emitLoaded: () => {},
});
expect(initOrder).toEqual(["a", "b", "c"]);
});
it("dispose runs in reverse order", { timeout: 5000 }, async () => {
const disposeOrder: string[] = [];
const a = makeStub({ name: "a", disposeOrder });
const b = makeStub({ name: "b", disposeOrder });
const c = makeStub({ name: "c", disposeOrder });
const chain = new ChainedBackend({ backends: [a, b, c] });
await chain.dispose();
expect(disposeOrder).toEqual(["c", "b", "a"]);
});
it("init skips children without init hook", { timeout: 5000 }, async () => {
const noHook: Backend = {
name: "no-hook",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
return {};
},
};
const chain = new ChainedBackend({ backends: [noHook] });
await chain.init({
catalogName: "test",
emitLoaded: () => {},
});
// No throw — pass.
});
it("dispose skips children without dispose hook", {
timeout: 5000,
}, async () => {
const noHook: Backend = {
name: "no-hook",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
return {};
},
};
const chain = new ChainedBackend({ backends: [noHook] });
await chain.dispose();
// No throw — pass.
});
});
describe("ChainedBackend AbortSignal threading", () => {
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
let childCalled = false;
const stub: Backend = {
name: "stub",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
childCalled = true;
return {};
},
};
const chain = new ChainedBackend({ backends: [stub] });
const ctrl = new AbortController();
ctrl.abort();
await expect(
chain.read("en", "common", ctrl.signal)
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
expect(childCalled).toBe(false);
});
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const chain = new ChainedBackend({
backends: [memoryBackend()],
});
const ctrl = new AbortController();
ctrl.abort();
await expect(
chain.readMulti("en", ["a"], ctrl.signal)
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
});
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const chain = new ChainedBackend({
backends: [memoryBackend()],
});
const ctrl = new AbortController();
ctrl.abort();
await expect(
chain.save("en", "a", { x: "y" }, ctrl.signal)
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
});
it("signal threaded into child reads", { timeout: 5000 }, async () => {
const recorder: { last: AbortSignal | undefined } = {
last: undefined,
};
const stub = makeStub({
name: "stub",
response: { x: "y" },
recordSignal: recorder,
});
const chain = new ChainedBackend({ backends: [stub] });
const ctrl = new AbortController();
await chain.read("en", "common", ctrl.signal);
expect(recorder.last).toBe(ctrl.signal);
});
it("signal threaded into child saves", { timeout: 5000 }, async () => {
const seenSignals: Array<AbortSignal | undefined> = [];
const stub: Backend = {
name: "stub",
capabilities: {
canRead: true,
canReadMulti: false,
canSave: true,
canTrackModification: false,
canWatch: false,
},
async read() {
return {};
},
async save(
_l: string,
_n: string,
_r: TranslationRecord,
signal?: AbortSignal
) {
seenSignals.push(signal);
},
};
const chain = new ChainedBackend({ backends: [stub] });
const ctrl = new AbortController();
await chain.save("en", "a", { x: "y" }, ctrl.signal);
expect(seenSignals[0]).toBe(ctrl.signal);
});
it("signal threaded into child readMulti", { timeout: 5000 }, async () => {
const seen: Array<AbortSignal | undefined> = [];
const stub: Backend = {
name: "stub",
capabilities: {
canRead: true,
canReadMulti: true,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read() {
return {};
},
async readMulti(
_l: string,
_ns: readonly string[],
signal?: AbortSignal
) {
seen.push(signal);
return {};
},
};
const chain = new ChainedBackend({ backends: [stub] });
const ctrl = new AbortController();
await chain.readMulti("en", ["a"], ctrl.signal);
expect(seen[0]).toBe(ctrl.signal);
});
});
describe("ChainedBackend CHAINED_BACKEND_ALL_FAILED defence-in-depth", () => {
it("runtime empty chain throws CHAINED_BACKEND_ALL_FAILED on read", {
timeout: 5000,
}, async () => {
// The constructor's Zod schema rejects empty `backends`
// arrays, so the only way to land in the runtime defensive
// branch is to bypass the schema. Use vi.spyOn to make
// safeParse accept an empty array for one constructor call.
const spyTarget = ChainedBackendOptionsSchema as unknown as {
safeParse: (input: unknown) => {
success: boolean;
data?: unknown;
error?: unknown;
};
};
const original = spyTarget.safeParse.bind(spyTarget);
spyTarget.safeParse = ((_input: unknown) => ({
success: true,
data: { backends: [] },
})) as typeof spyTarget.safeParse;
try {
const chain = new ChainedBackend({
backends: [] as unknown as readonly Backend[],
});
await expect(chain.read("en", "common")).rejects.toMatchObject({
code: I18N_CODES.CHAINED_BACKEND_ALL_FAILED,
});
} finally {
spyTarget.safeParse =
original as unknown as typeof spyTarget.safeParse;
}
});
it("constructor schema rejects backends: []", { timeout: 5000 }, () => {
try {
new ChainedBackend({
backends: [] as unknown as readonly Backend[],
} as ChainedBackendOptions);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
});

View File

@@ -0,0 +1,393 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type {
Backend,
BackendCapabilities,
BackendInitContext,
TranslationRecord,
} from "@suaveplan/types";
import z from "zod";
import {
type ChainedReadOutcome,
recordFallthrough,
recordRead,
recordWrite,
} from "../observability/observability.js";
import type { ChainedBackendOptions, MergeStrategy } from "./backend.types.js";
/**
* Zod boundary schema for {@link ChainedBackendOptions}. Validates only
* the statically-checkable shape; backend instances themselves are
* accepted as opaque `z.unknown()` because Zod cannot meaningfully
* type-check the structural `Backend` interface. Caller-supplied option
* values failing this schema cause the constructor to throw
* `I18nError(I18N.INVALID_OPTIONS)`. The schema also rejects an empty
* `backends` array.
*/
export const ChainedBackendOptionsSchema = z.object({
backends: z
.array(z.unknown())
.min(1, "ChainedBackend: backends must be non-empty"),
name: z.string().min(1).optional(),
mergeStrategy: z.enum(["first-wins", "last-wins"]).optional(),
});
export type ChainedBackendOptionsInput = z.input<
typeof ChainedBackendOptionsSchema
>;
/**
* Compose N backends into a single backend with read-fallthrough on
* `BACKEND_LOCALE_NOT_AVAILABLE` and write-through cache semantics
* after a successful read.
*
* Universal — runs identically in browsers, Bun, and Node.js.
*
* `read` walks the backend list in registration order. The first
* backend whose `read` resolves wins; subsequent backends are skipped.
* After a successful read from backend N (where N > 0), the resolved
* record is written back to every preceding backend (0..N-1) that
* implements `save` — the standard "cache from IndexedDB, fall through
* to HTTPS" pattern. Write-through is best-effort: failures from any
* preceding backend are swallowed and never block the read return.
*
* Only `BACKEND_LOCALE_NOT_AVAILABLE` triggers fallthrough; transport /
* parse errors (`BACKEND_READ_FAILED`) propagate immediately to stop
* the chain. When every backend in a non-empty chain reports
* `BACKEND_LOCALE_NOT_AVAILABLE`, the chain throws
* `BACKEND_LOCALE_NOT_AVAILABLE` (preserves catalog-layer fallthrough
* semantics one level up). When the chain is empty at runtime
* (defence-in-depth past the schema check), the chain throws
* `CHAINED_BACKEND_ALL_FAILED`.
*
* AbortSignal is threaded into every child call when the child accepts
* a signal parameter. A pre-aborted signal causes every async method to
* throw `BACKEND_TIMEOUT` without calling any child backend.
*/
export class ChainedBackend implements Backend {
public readonly name: string;
public readonly capabilities: BackendCapabilities;
readonly #backends: readonly Backend[];
readonly #mergeStrategy: MergeStrategy;
public constructor(options: ChainedBackendOptions) {
const parsed = ChainedBackendOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"ChainedBackend: invalid options",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
this.name = options.name ?? "chained";
this.#backends = options.backends;
this.#mergeStrategy = options.mergeStrategy ?? "first-wins";
this.capabilities = {
canRead: true,
canReadMulti: true,
canSave: this.#backends.some((b) => b.capabilities.canSave),
canTrackModification: this.#backends.some(
(b) => b.capabilities.canTrackModification
),
canWatch: this.#backends.some((b) => b.capabilities.canWatch),
};
}
public async init(context: BackendInitContext): Promise<void> {
for (const b of this.#backends) {
if (b.init) await b.init(context);
}
}
public async dispose(): Promise<void> {
for (let i = this.#backends.length - 1; i >= 0; i--) {
const b = this.#backends[i];
if (b?.dispose) await b.dispose();
}
}
public async read(
locale: string,
namespace: string,
signal?: AbortSignal
): Promise<TranslationRecord> {
this.#checkAborted(signal);
if (this.#backends.length === 0) {
throw new I18nError(
I18N_CODES.CHAINED_BACKEND_ALL_FAILED,
"ChainedBackend: chain is empty (schema bypassed)",
{ context: { backend: this.name, locale, namespace } }
);
}
const t0 = performance.now();
let outcome: ChainedReadOutcome = "miss";
let errorCode: string | undefined;
try {
for (let i = 0; i < this.#backends.length; i++) {
const backend = this.#backends[i] as Backend;
try {
const record = await this.#readChild(
backend,
locale,
namespace,
signal
);
outcome = this.#classifyReadOutcome(i);
if (i > 0) {
await this.#writeThrough(
i,
locale,
namespace,
record,
signal
);
}
return record;
} catch (err) {
if (
err instanceof I18nError &&
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
) {
if (i + 1 < this.#backends.length) {
const next = this.#backends[i + 1] as Backend;
recordFallthrough(backend.name, next.name);
}
continue;
}
outcome = "error";
if (err instanceof I18nError) {
errorCode = err.code;
}
throw err;
}
}
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
throw new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`ChainedBackend: no backend has ${locale}/${namespace}`,
{ context: { locale, namespace, backend: this.name } }
);
} finally {
recordRead(
this.name,
locale,
namespace,
outcome,
performance.now() - t0,
errorCode
);
}
}
public async readMulti(
locale: string,
namespaces: readonly string[],
signal?: AbortSignal
): Promise<Record<string, TranslationRecord>> {
this.#checkAborted(signal);
const perBackend = await Promise.all(
this.#backends.map(async (b) => {
if (b.readMulti) {
try {
return await this.#readMultiChild(
b,
locale,
namespaces,
signal
);
} catch {
return {};
}
}
const out: Record<string, TranslationRecord> = {};
for (const ns of namespaces) {
try {
out[ns] = await this.#readChild(b, locale, ns, signal);
} catch (err) {
if (
err instanceof I18nError &&
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
) {
continue;
}
throw err;
}
}
return out;
})
);
const merged: Record<string, TranslationRecord> = {};
const order =
this.#mergeStrategy === "first-wins"
? perBackend
: [...perBackend].reverse();
for (const map of order) {
for (const [ns, record] of Object.entries(map)) {
if (!(ns in merged)) merged[ns] = record;
}
}
return merged;
}
public async save(
locale: string,
namespace: string,
record: TranslationRecord,
signal?: AbortSignal
): Promise<void> {
this.#checkAborted(signal);
const t0 = performance.now();
try {
for (const backend of this.#backends) {
if (backend.save) {
await this.#saveChild(
backend,
locale,
namespace,
record,
signal
);
}
}
recordWrite(this.name, "ok", performance.now() - t0);
} catch (cause) {
recordWrite(this.name, "error", performance.now() - t0);
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_WRITE_FAILED,
"ChainedBackend: save failed",
{ cause, context: { locale, namespace, backend: this.name } }
);
}
}
public async lastModified(
locale: string,
namespace: string
): Promise<number | undefined> {
for (const backend of this.#backends) {
if (backend.lastModified) {
const t = await backend.lastModified(locale, namespace);
if (t !== undefined) return t;
}
}
return undefined;
}
/**
* Classify the read outcome based on the index of the backend that
* resolved. Index 0 with a multi-backend chain → `cache_hit`; later
* indices with a multi-backend chain → `cache_miss`; single-backend
* chains → `primary`.
*/
#classifyReadOutcome(successIndex: number): ChainedReadOutcome {
if (this.#backends.length <= 1) return "primary";
return successIndex === 0 ? "cache_hit" : "cache_miss";
}
async #readChild(
backend: Backend,
locale: string,
namespace: string,
signal: AbortSignal | undefined
): Promise<TranslationRecord> {
const fn = backend.read as (
l: string,
n: string,
s?: AbortSignal
) => Promise<TranslationRecord>;
return signal !== undefined
? await fn.call(backend, locale, namespace, signal)
: await backend.read(locale, namespace);
}
async #readMultiChild(
backend: Backend,
locale: string,
namespaces: readonly string[],
signal: AbortSignal | undefined
): Promise<Record<string, TranslationRecord>> {
const fn = backend.readMulti as (
l: string,
ns: readonly string[],
s?: AbortSignal
) => Promise<Record<string, TranslationRecord>>;
return signal !== undefined
? await fn.call(backend, locale, namespaces, signal)
: await (
backend.readMulti as (
l: string,
ns: readonly string[]
) => Promise<Record<string, TranslationRecord>>
)(locale, namespaces);
}
async #saveChild(
backend: Backend,
locale: string,
namespace: string,
record: TranslationRecord,
signal: AbortSignal | undefined
): Promise<void> {
const fn = backend.save as (
l: string,
n: string,
r: TranslationRecord,
s?: AbortSignal
) => Promise<void>;
if (signal !== undefined) {
await fn.call(backend, locale, namespace, record, signal);
} else {
await (
backend.save as (
l: string,
n: string,
r: TranslationRecord
) => Promise<void>
)(locale, namespace, record);
}
}
async #writeThrough(
successIndex: number,
locale: string,
namespace: string,
record: TranslationRecord,
signal: AbortSignal | undefined
): Promise<void> {
for (let j = 0; j < successIndex; j++) {
const earlier = this.#backends[j] as Backend;
if (earlier.save) {
try {
await this.#saveChild(
earlier,
locale,
namespace,
record,
signal
);
} catch {
// Write-through is best-effort; never block the read.
}
}
}
}
#checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new I18nError(
I18N_CODES.BACKEND_TIMEOUT,
"ChainedBackend: operation aborted",
{
context: { backend: this.name, reason: signal.reason },
}
);
}
}
}

View File

@@ -0,0 +1,28 @@
import type { Backend } from "@suaveplan/types";
/**
* `readMulti` merge strategy when multiple backends respond. `first-wins`
* gives priority to backends earlier in the chain (the cache layer);
* `last-wins` gives priority to backends later in the chain (the
* source of truth). Gaps are filled from the other side regardless of
* strategy.
*/
export type MergeStrategy = "first-wins" | "last-wins";
/**
* Constructor options for {@link ChainedBackend}.
*
* * `backends` — required, non-empty ordered list of backends. The first
* listed is queried first on `read`. Convention: the cache layer is
* index 0, the source of truth is the last index.
* * `name` — backend label override; defaults to `"chained"`.
* * `mergeStrategy` — `readMulti` merge strategy; defaults to
* `"first-wins"`.
*/
export interface ChainedBackendOptions {
readonly backends: readonly Backend[];
readonly name?: string;
readonly mergeStrategy?: MergeStrategy;
}

View File

@@ -0,0 +1,11 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export {
ChainedBackend,
type ChainedBackendOptionsInput,
ChainedBackendOptionsSchema,
} from "./backend.js";
export type {
ChainedBackendOptions,
MergeStrategy,
} from "./backend.types.js";

View File

@@ -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.

View File

@@ -0,0 +1,41 @@
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ChainedBackend } from "../backend/backend.js";
import { chainedBackend } from "./factory.js";
describe("chainedBackend factory", () => {
it("array shorthand wraps the array", { timeout: 5000 }, () => {
const result = chainedBackend([memoryBackend()]);
expect(result).toBeInstanceOf(ChainedBackend);
expect(result.name).toBe("chained");
});
it("options form forwards to constructor", { timeout: 5000 }, () => {
const result = chainedBackend({
backends: [memoryBackend()],
name: "custom",
});
expect(result).toBeInstanceOf(ChainedBackend);
expect(result.name).toBe("custom");
});
it("options form respects mergeStrategy", { timeout: 5000 }, async () => {
const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } });
const b = memoryBackend({ seed: { en: { auth: { x: "B" } } } });
const chain = chainedBackend({
backends: [a, b],
mergeStrategy: "last-wins",
});
const merged = await chain.readMulti("en", ["auth"]);
expect(merged["auth"]).toEqual({ x: "B" });
});
it("array shorthand returns fresh instance per call", {
timeout: 5000,
}, () => {
const list = [memoryBackend()];
const a = chainedBackend(list);
const b = chainedBackend(list);
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,22 @@
import type { Backend } from "@suaveplan/types";
import { ChainedBackend } from "../backend/backend.js";
import type { ChainedBackendOptions } from "../backend/backend.types.js";
/**
* Factory form for callers that prefer builders over `new`. Accepts
* either an array shorthand (`chainedBackend([cache, primary])`) or a
* full options object (`chainedBackend({ backends: [...], name,
* mergeStrategy })`). Returns a fresh`ChainedBackend` per call — no
* internal singleton, no shared state.
*/
export function chainedBackend(
backendsOrOptions: readonly Backend[] | ChainedBackendOptions
): ChainedBackend {
if (Array.isArray(backendsOrOptions)) {
return new ChainedBackend({
backends: backendsOrOptions as readonly Backend[],
});
}
return new ChainedBackend(backendsOrOptions as ChainedBackendOptions);
}

View File

@@ -0,0 +1 @@
export { chainedBackend } from "./factory.js";

View File

@@ -0,0 +1,92 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
describe("@suaveplan/i18n-backend-chained — root barrel", () => {
it("re-exports ChainedBackend", { timeout: 5000 }, () => {
expect(typeof root.ChainedBackend).toBe("function");
});
it("re-exports chainedBackend factory", { timeout: 5000 }, () => {
expect(typeof root.chainedBackend).toBe("function");
});
it("re-exports ChainedBackendOptionsSchema", { timeout: 5000 }, () => {
expect(root.ChainedBackendOptionsSchema).toBeDefined();
});
it("re-exports observability helpers", { timeout: 5000 }, () => {
expect(typeof root.recordRead).toBe("function");
expect(typeof root.recordWrite).toBe("function");
expect(typeof root.recordFallthrough).toBe("function");
expect(typeof root.getChainedBackendMetrics).toBe("function");
expect(typeof root.getChainedBackendMetricsState).toBe("function");
expect(typeof root.resetChainedBackendMetricsState).toBe("function");
expect(typeof root.resetChainedBackendMetricsCache).toBe("function");
});
});
describe("@suaveplan/i18n-backend-chained — every subpath resolves", () => {
it("subpath barrels resolve through the package", {
timeout: 5000,
}, async () => {
const backend = await import("./backend/index.js");
const factory = await import("./factory/index.js");
const observability = await import("./observability/index.js");
expect(typeof backend.ChainedBackend).toBe("function");
expect(backend.ChainedBackendOptionsSchema).toBeDefined();
expect(typeof factory.chainedBackend).toBe("function");
expect(typeof observability.getChainedBackendMetrics).toBe("function");
expect(typeof observability.recordRead).toBe("function");
expect(typeof observability.recordWrite).toBe("function");
expect(typeof observability.recordFallthrough).toBe("function");
});
});
describe("@suaveplan/i18n-backend-chained — package.json", () => {
it("declares sideEffects false", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
sideEffects?: boolean;
};
expect(pkg.sideEffects).toBe(false);
});
it("does not depend on @suaveplan/i18n core", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
};
const deps = pkg.dependencies ?? {};
expect(deps).not.toHaveProperty("@suaveplan/i18n");
});
it("@suaveplan/testing is in devDependencies only (Rule 8)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(pkg.dependencies ?? {}).not.toHaveProperty("@suaveplan/testing");
expect(pkg.peerDependencies ?? {}).not.toHaveProperty(
"@suaveplan/testing"
);
expect(pkg.devDependencies ?? {}).toHaveProperty("@suaveplan/testing");
});
it("declares every documented subpath export", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
exports?: Record<string, unknown>;
};
const documented = [".", "./backend", "./factory", "./observability"];
for (const subpath of documented) {
expect(pkg.exports?.[subpath]).toBeDefined();
}
});
});

View File

@@ -0,0 +1,26 @@
// Root barrel for @suaveplan/i18n-backend-chained. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export {
ChainedBackend,
type ChainedBackendOptionsInput,
ChainedBackendOptionsSchema,
} from "./backend/backend.js";
export type {
ChainedBackendOptions,
MergeStrategy,
} from "./backend/backend.types.js";
export { chainedBackend } from "./factory/factory.js";
export {
type ChainedBackendMetricSample,
type ChainedBackendMetricsState,
type ChainedReadOutcome,
type ChainedWriteOutcome,
getChainedBackendMetrics,
getChainedBackendMetricsState,
recordFallthrough,
recordRead,
recordWrite,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "./observability/observability.js";

View File

@@ -0,0 +1,15 @@
export type {
ChainedBackendMetricSample,
ChainedBackendMetricsState,
ChainedReadOutcome,
ChainedWriteOutcome,
} from "./observability.js";
export {
getChainedBackendMetrics,
getChainedBackendMetricsState,
recordFallthrough,
recordRead,
recordWrite,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "./observability.js";

View File

@@ -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.

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
getChainedBackendMetrics,
getChainedBackendMetricsState,
recordFallthrough,
recordRead,
recordWrite,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "./observability.js";
describe("chained-backend observability", () => {
beforeEach(() => {
resetChainedBackendMetricsState();
resetChainedBackendMetricsCache();
});
it("getChainedBackendMetrics is idempotent (cached)", {
timeout: 5000,
}, () => {
const a = getChainedBackendMetrics();
const b = getChainedBackendMetrics();
expect(a).toBe(b);
});
it("recordRead pushes histogram + counter samples", {
timeout: 5000,
}, () => {
recordRead("chained", "en", "auth", "cache_hit", 1.5);
const state = getChainedBackendMetricsState();
expect(state.readDurationMs.length).toBe(1);
expect(state.readDurationMs[0]?.attributes).toEqual({
backend: "chained",
locale: "en",
namespace: "auth",
outcome: "cache_hit",
});
expect(state.readTotal.length).toBe(1);
expect(state.readTotal[0]?.attributes).toEqual({
backend: "chained",
outcome: "cache_hit",
});
expect(state.readErrorsTotal.length).toBe(0);
});
it("recordRead with errorCode emits error counter", {
timeout: 5000,
}, () => {
recordRead(
"chained",
"en",
"auth",
"error",
0.1,
"I18N.BACKEND_READ_FAILED"
);
const state = getChainedBackendMetricsState();
expect(state.readErrorsTotal.length).toBe(1);
expect(state.readErrorsTotal[0]?.attributes).toEqual({
backend: "chained",
code: "I18N.BACKEND_READ_FAILED",
});
});
it("recordWrite pushes histogram + counter samples", {
timeout: 5000,
}, () => {
recordWrite("chained", "ok", 2.1);
const state = getChainedBackendMetricsState();
expect(state.writeDurationMs.length).toBe(1);
expect(state.writeDurationMs[0]?.attributes).toEqual({
backend: "chained",
outcome: "ok",
});
expect(state.writeTotal.length).toBe(1);
});
it("recordFallthrough emits the fallthrough counter", {
timeout: 5000,
}, () => {
recordFallthrough("memory", "http");
const state = getChainedBackendMetricsState();
expect(state.fallthroughTotal.length).toBe(1);
expect(state.fallthroughTotal[0]?.attributes).toEqual({
from_backend: "memory",
to_backend: "http",
});
});
it("resetChainedBackendMetricsState clears every ring", {
timeout: 5000,
}, () => {
recordRead("chained", "en", "a", "cache_hit", 1, "code");
recordWrite("chained", "ok", 2);
recordFallthrough("a", "b");
resetChainedBackendMetricsState();
const state = getChainedBackendMetricsState();
expect(state.readDurationMs.length).toBe(0);
expect(state.readTotal.length).toBe(0);
expect(state.readErrorsTotal.length).toBe(0);
expect(state.writeDurationMs.length).toBe(0);
expect(state.writeTotal.length).toBe(0);
expect(state.fallthroughTotal.length).toBe(0);
});
it("resetChainedBackendMetricsCache forces a fresh meter on next call", {
timeout: 5000,
}, () => {
const before = getChainedBackendMetrics();
resetChainedBackendMetricsCache();
const after = getChainedBackendMetrics();
expect(after).not.toBe(before);
});
});

View File

@@ -0,0 +1,213 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for `@suaveplan/i18n-backend-chained`.
*
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-chained')`.
* All instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire up the SDK.
*
* In addition to emitting via OTel, this module retains a small
* in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK +
* InMemoryMetricExporter pipeline. Tests call
* `resetChainedBackendMetricsState()` between cases and read
* `getChainedBackendMetricsState()` to inspect counters / histogram
* counts.
*
* The chained backend's `read_total` counter carries an additional
* `outcome` attribute beyond the base `hit | miss | error` set:
* `cache_hit` (the request was served from a preceding save-capable
* cache layer at index 0), `cache_miss` (the chain fell through past
* the cache layer to the primary), and `primary` (the chain has only
* one backend or the cache is the responder). This mirrors the upstream
* monolith's chained-cache observability semantics so production teams
* can size cache layers correctly.
*/
export type ChainedReadOutcome =
| "hit"
| "miss"
| "error"
| "cache_hit"
| "cache_miss"
| "primary";
export type ChainedWriteOutcome = "ok" | "error";
export interface ChainedBackendMetricSample {
readonly attributes: Record<string, string>;
}
export interface ChainedBackendMetricsState {
readonly readDurationMs: ChainedBackendMetricSample[];
readonly readTotal: ChainedBackendMetricSample[];
readonly readErrorsTotal: ChainedBackendMetricSample[];
readonly writeDurationMs: ChainedBackendMetricSample[];
readonly writeTotal: ChainedBackendMetricSample[];
readonly fallthroughTotal: ChainedBackendMetricSample[];
}
interface CachedInstruments {
readonly readDurationMs: Histogram;
readonly readTotal: Counter;
readonly readErrorsTotal: Counter;
readonly writeDurationMs: Histogram;
readonly writeTotal: Counter;
readonly fallthroughTotal: Counter;
}
let cached: CachedInstruments | undefined;
const state: ChainedBackendMetricsState = {
readDurationMs: [],
readTotal: [],
readErrorsTotal: [],
writeDurationMs: [],
writeTotal: [],
fallthroughTotal: [],
};
/**
* Build (or return cached) OTel instruments. Idempotent — safe to call
* from any production code path.
*/
export function getChainedBackendMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-backend-chained");
cached = {
readDurationMs: meter.createHistogram(
"i18n.backend.chained.read_duration_ms",
{
description:
"Duration in ms of a ChainedBackend.read call; labels: backend, locale, namespace, outcome.",
unit: "ms",
}
),
readTotal: meter.createCounter("i18n.backend.chained.read_total", {
description:
"ChainedBackend.read call count; label: outcome (hit | miss | error | cache_hit | cache_miss | primary).",
}),
readErrorsTotal: meter.createCounter(
"i18n.backend.chained.read_errors_total",
{
description:
"ChainedBackend.read error count; label: code (the I18N error code).",
}
),
writeDurationMs: meter.createHistogram(
"i18n.backend.chained.write_duration_ms",
{
description:
"Duration in ms of a ChainedBackend.save call; labels: backend, outcome.",
unit: "ms",
}
),
writeTotal: meter.createCounter("i18n.backend.chained.write_total", {
description:
"ChainedBackend.save call count; label: outcome (ok | error).",
}),
fallthroughTotal: meter.createCounter(
"i18n.backend.chained.fallthrough_total",
{
description:
"ChainedBackend fallthrough count between adjacent links; labels: from_backend, to_backend.",
}
),
};
return cached;
}
/**
* Record a `read` call's duration + outcome. Mirrors the
* `read_total{outcome}` counter and per-error-code
* `read_errors_total{code}` counter, pushing one sample into the
* in-memory state ring per counter touched.
*/
export function recordRead(
backend: string,
locale: string,
namespace: string,
outcome: ChainedReadOutcome,
durationMs: number,
errorCode?: string
): void {
const ins = getChainedBackendMetrics();
const histogramAttrs: Record<string, string> = {
backend,
locale,
namespace,
outcome,
};
ins.readDurationMs.record(durationMs, histogramAttrs);
state.readDurationMs.push({ attributes: histogramAttrs });
const aggregate: Record<string, string> = { backend, outcome };
ins.readTotal.add(1, aggregate);
state.readTotal.push({ attributes: aggregate });
if (errorCode !== undefined) {
const errorAttrs: Record<string, string> = {
backend,
code: errorCode,
};
ins.readErrorsTotal.add(1, errorAttrs);
state.readErrorsTotal.push({ attributes: errorAttrs });
}
}
/**
* Record a `save` call's duration + outcome.
*/
export function recordWrite(
backend: string,
outcome: ChainedWriteOutcome,
durationMs: number
): void {
const ins = getChainedBackendMetrics();
const attrs: Record<string, string> = { backend, outcome };
ins.writeDurationMs.record(durationMs, attrs);
state.writeDurationMs.push({ attributes: attrs });
ins.writeTotal.add(1, attrs);
state.writeTotal.push({ attributes: attrs });
}
/**
* Record a fallthrough event between adjacent chain links. Cardinality
* is bounded by the number of unique chain links observed in production.
*/
export function recordFallthrough(
fromBackend: string,
toBackend: string
): void {
const ins = getChainedBackendMetrics();
const attrs: Record<string, string> = {
from_backend: fromBackend,
to_backend: toBackend,
};
ins.fallthroughTotal.add(1, attrs);
state.fallthroughTotal.push({ attributes: attrs });
}
/** Return a frozen snapshot of the in-memory state. */
export function getChainedBackendMetricsState(): ChainedBackendMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetChainedBackendMetricsState(): void {
state.readDurationMs.length = 0;
state.readTotal.length = 0;
state.readErrorsTotal.length = 0;
state.writeDurationMs.length = 0;
state.writeTotal.length = 0;
state.fallthroughTotal.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetChainedBackendMetricsCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,47 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-chained",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"backend/index": "src/backend/index.ts",
"factory/index": "src/factory/index.ts",
"observability/index": "src/observability/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-shared",
"@suaveplan/i18n-backend-memory",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});