Initial Commit
This commit is contained in:
45
packages/localization/i18n/i18n-backend-chained/README.md
Normal file
45
packages/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.
|
||||
|
||||
83
packages/localization/i18n/i18n-backend-chained/package.json
Normal file
83
packages/localization/i18n/i18n-backend-chained/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { chainedBackend } from "./factory.js";
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
26
packages/localization/i18n/i18n-backend-chained/src/index.ts
Normal file
26
packages/localization/i18n/i18n-backend-chained/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
ChainedBackendMetricSample,
|
||||
ChainedBackendMetricsState,
|
||||
ChainedReadOutcome,
|
||||
ChainedWriteOutcome,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getChainedBackendMetrics,
|
||||
getChainedBackendMetricsState,
|
||||
recordFallthrough,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetChainedBackendMetricsCache,
|
||||
resetChainedBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user