Initial Commit
This commit is contained in:
31
packages/localization/i18n/README.md
Normal file
31
packages/localization/i18n/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# `i18n`
|
||||
|
||||
Internationalization core, backends, detectors, and locale resolution.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `i18n`
|
||||
|
||||
19 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/i18n`](./i18n/README.md) | Slim integration core for the i18n family. After the Wave 6 modular split, this package owns the `MessageCatalog`, the `createI18n` / `I18nInstance` plugin slo… |
|
||||
| [`@suaveplan/i18n-backend-chained`](./i18n-backend-chained/README.md) | Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOC… |
|
||||
| [`@suaveplan/i18n-backend-fs-multifile`](./i18n-backend-fs-multifile/README.md) | Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage. |
|
||||
| [`@suaveplan/i18n-backend-http`](./i18n-backend-http/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to `BACKEND_LOCALE_NOT… |
|
||||
| [`@suaveplan/i18n-backend-memory`](./i18n-backend-memory/README.md) | Universal in-memory translation backend for the `@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace, TranslationRecord>>` plus a modification l… |
|
||||
| [`@suaveplan/i18n-backend-storage`](./i18n-backend-storage/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by any `AbstractStorage` implementation from `@suaveplan/storage`. Implements the f… |
|
||||
| [`@suaveplan/i18n-detector`](./i18n-detector/README.md) | Universal locale-detection chain for the `@suaveplan/i18n` family. Ships the chain orchestrator, the BCP-47 default negotiator, the `getTextDirection` / `… |
|
||||
| [`@suaveplan/i18n-formatter-collator`](./i18n-formatter-collator/README.md) | Wrapper around `Intl.Collator` for the `@suaveplan/i18n` family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-fir… |
|
||||
| [`@suaveplan/i18n-formatter-currency`](./i18n-formatter-currency/README.md) | Locale-aware currency formatting backed by `Intl.NumberFormat` with `style: "currency"`. Wave 3 of the 14-package i18n modular split. Provides both a class (`C… |
|
||||
| [`@suaveplan/i18n-formatter-date`](./i18n-formatter-date/README.md) | Wrapper around `Intl.DateTimeFormat` for the `@suaveplan/i18n` family — 8-preset table (short/medium/long/full × date+time/time-only/date-only), range renderin… |
|
||||
| [`@suaveplan/i18n-formatter-display-name`](./i18n-formatter-display-name/README.md) | Wrapper around `Intl.DisplayNames` for the `@suaveplan/i18n` family — locale-aware language, region, script, currency, and calendar display names. Universal-ti… |
|
||||
| [`@suaveplan/i18n-formatter-list`](./i18n-formatter-list/README.md) | Locale-aware list joining backed by `Intl.ListFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`ListFormatter`) and a standalone he… |
|
||||
| [`@suaveplan/i18n-formatter-number`](./i18n-formatter-number/README.md) | Locale-aware number formatting backed by `Intl.NumberFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`NumberFormatter`) and a stan… |
|
||||
| [`@suaveplan/i18n-formatter-relative-time`](./i18n-formatter-relative-time/README.md) | Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`RelativeTimeFor… |
|
||||
| [`@suaveplan/i18n-formatter-segmenter`](./i18n-formatter-segmenter/README.md) | Wrapper around `Intl.Segmenter` for the `@suaveplan/i18n` family — locale-aware text segmentation by grapheme, word, or sentence. Compound emoji count as a sin… |
|
||||
| [`@suaveplan/i18n-icu-parser`](./i18n-icu-parser/README.md) | ICU MessageFormat parser, `{{var}}` interpolation engine, and 9-language CLDR pluralization for the `@suaveplan/i18n` family. Universal leaf package — dep… |
|
||||
| [`@suaveplan/i18n-shared`](./i18n-shared/README.md) | Shared runtime atoms for the entire `@suaveplan/i18n*` package family — locale constants, BCP-47 normalisation map, RTL set, locale descriptors, interpolation… |
|
||||
| [`@suaveplan/locale-data`](./locale-data/README.md) | Static ISO 3166-1 country data and ISO 639-1 language data with typed lookup functions, OTel instrumentation, and Zod schemas. |
|
||||
| [`@suaveplan/react-i18n`](./react-i18n/README.md) | React bindings for `@suaveplan/i18n`. Ships the `<TranslationProvider>`, twelve hooks, four headless components, the class-component HOC, the nine built-in loc… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/localization/i18n/_intro.md
Normal file
1
packages/localization/i18n/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Internationalization core, backends, detectors, and locale resolution.
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
# @suaveplan/i18n-backend-fs-multifile - Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
|
||||
|
||||
## Summary
|
||||
|
||||
> **Server-tier package** — uses `node:fs/promises`. For browser consumers, use
|
||||
> [`@suaveplan/i18n-backend-http`](../i18n-backend-http) or
|
||||
> [`@suaveplan/i18n-backend-storage`](../i18n-backend-storage).
|
||||
|
||||
Server-tier translation backend for the `@suaveplan/i18n` family. Reads
|
||||
locale + namespace catalogs from the local filesystem in three wire layouts,
|
||||
optionally watches the root directory for changes, and emits `loaded` events
|
||||
into the catalog event bus so React Suspense / HMR consumers refresh without
|
||||
polling. JSON is built in; YAML and JSON5 are auto-detected via dynamic
|
||||
`import()` so the package stays lean and the consumer picks the parser.
|
||||
|
||||
The backend implements the universal `Backend` contract from
|
||||
`@suaveplan/types` — capabilities matrix `{ canRead, canReadMulti: false,
|
||||
canSave, canTrackModification, canWatch }` — and conforms to the
|
||||
`runBackendConformance` harness shipped from
|
||||
`@suaveplan/i18n-shared/testing`. Errors are surfaced via `I18nError` from
|
||||
`@suaveplan/i18n-shared`: `ENOENT`/`ENOTDIR` map to
|
||||
`BACKEND_LOCALE_NOT_AVAILABLE` (so a chained-backend can fall through to
|
||||
the next layer), other errno codes map to `BACKEND_READ_FAILED`, and a
|
||||
pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT` without
|
||||
touching the filesystem. OpenTelemetry instruments are emitted under the
|
||||
`i18n.backend.fs_multifile.*` prefix with a `layout` attribute.
|
||||
|
||||
<!-- 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 | Doc |
|
||||
|---|---|
|
||||
| `MultiFileFsBackend` class + Zod options schema | [`src/backend/backend.md`](./src/backend/backend.md) |
|
||||
| `multiFileFsBackend()` factory | [`src/factory/factory.md`](./src/factory/factory.md) |
|
||||
| OTel instruments + state ring | [`src/observability/observability.md`](./src/observability/observability.md) |
|
||||
|
||||
## Quick example
|
||||
|
||||
```ts
|
||||
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
|
||||
|
||||
const backend = multiFileFsBackend({ root: "./locales", layout: "nested" });
|
||||
const record = await backend.read("en", "auth");
|
||||
```
|
||||
|
||||
See the linked module docs for the advanced examples (watch mode, glob
|
||||
layout, YAML peer detection, AbortSignal cancellation).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-fs-multifile",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"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/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,125 @@
|
||||
# `backend.ts` — `MultiFileFsBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Server-tier translation `Backend` that reads and writes
|
||||
`<root>/<locale>/<namespace>.<ext>` (or one of the alternate `flat` /
|
||||
`glob` layouts) on the local filesystem via `node:fs/promises`. The
|
||||
class implements the universal `Backend` contract from
|
||||
`@suaveplan/types` and conforms to the
|
||||
`runBackendConformance` harness shipped from
|
||||
`@suaveplan/i18n-shared/testing`. It exists as its own package — and
|
||||
not part of the universal `@suaveplan/i18n` core — because the
|
||||
default code path performs `await import("node:fs/promises")` which
|
||||
fails at module evaluation in browsers and non-Node runtimes that do
|
||||
not expose Node built-ins.
|
||||
|
||||
## Features
|
||||
|
||||
- Three wire layouts: `nested` (default), `flat`, `glob` (with
|
||||
`{locale}` / `{namespace}` substitution into a caller-supplied
|
||||
pattern).
|
||||
- `read` / `save` / `lastModified` over a `MultiFileFsLike` shim that
|
||||
defaults to `node:fs/promises` and is fully injectable for tests.
|
||||
- JSON built in. YAML and JSON5 are auto-detected via dynamic
|
||||
`import()` so the package stays free of mandatory parser deps.
|
||||
- Optional `fs.watch` loop emits `loaded` events into the
|
||||
`BackendInitContext.emitLoaded` channel for HMR / Suspense refresh.
|
||||
- Pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT`
|
||||
without touching the filesystem. ENOENT / ENOTDIR fall through as
|
||||
`BACKEND_LOCALE_NOT_AVAILABLE`; other errno codes surface as
|
||||
`BACKEND_READ_FAILED`.
|
||||
- Zod-validated options at the public boundary — bad `layout` /
|
||||
missing `globPattern` / non-string `name` rejected as
|
||||
`INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
|
||||
|
||||
const backend = new MultiFileFsBackend({ root: "./locales" });
|
||||
const record = await backend.read("en", "auth");
|
||||
// → { login: { title: "Sign in" } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
|
||||
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "./locales",
|
||||
layout: "glob",
|
||||
globPattern: "{locale}/{namespace}.json",
|
||||
extension: "json",
|
||||
watch: true,
|
||||
});
|
||||
|
||||
await backend.init({
|
||||
catalogName: "app",
|
||||
emitLoaded: (locale, ns) => {
|
||||
console.log(`reloaded ${locale}/${ns}`);
|
||||
},
|
||||
});
|
||||
|
||||
// AbortSignal short-circuits with BACKEND_TIMEOUT.
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await backend.read("en", "auth", ctrl.signal); // throws I18N.BACKEND_TIMEOUT
|
||||
|
||||
// YAML peer auto-detection — install `yaml` to enable.
|
||||
const yamlBackend = new MultiFileFsBackend({
|
||||
root: "./locales",
|
||||
extension: "yaml",
|
||||
});
|
||||
const fr = await yamlBackend.read("fr", "auth");
|
||||
|
||||
await backend.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MultiFileFsBackend(options: MultiFileFsBackendOptions)`:
|
||||
Constructs and validates options. Throws
|
||||
`I18nError(I18N.INVALID_OPTIONS)` on Zod failure or when
|
||||
`layout: "glob"` is supplied without `globPattern`.
|
||||
- `read(locale, namespace, signal?)`: returns the parsed
|
||||
`TranslationRecord`. Throws
|
||||
`BACKEND_LOCALE_NOT_AVAILABLE` on ENOENT/ENOTDIR,
|
||||
`BACKEND_READ_FAILED` on other fs / parse / unknown-extension /
|
||||
missing-peer failures, `BACKEND_TIMEOUT` on a pre-aborted signal.
|
||||
- `save(locale, namespace, record, signal?)`: ensures the parent
|
||||
directory and writes the encoded payload. Throws
|
||||
`BACKEND_WRITE_FAILED` on writeFile failure or unsupported
|
||||
extension; `BACKEND_TIMEOUT` on pre-aborted signal.
|
||||
- `lastModified(locale, namespace)`: returns `mtimeMs` or `undefined`
|
||||
(never throws).
|
||||
- `init(context)`: stores the `BackendInitContext` and starts the
|
||||
`fs.watch` loop when `watch: true`.
|
||||
- `dispose()`: aborts the watcher; safe to call multiple times.
|
||||
- `MultiFileFsBackendOptionsSchema`: exported Zod schema for the
|
||||
options bag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- A leading source-comment marks the package as server-tier so
|
||||
consumers do not accidentally land it in a browser bundle. Wave 6's
|
||||
slim universal `@suaveplan/i18n` core does NOT re-export this
|
||||
package.
|
||||
- The `#getFs` method dynamically imports `node:fs/promises` only
|
||||
when no `fs` shim was injected, keeping injected-fs tests
|
||||
hermetic and avoiding the cost when consumers supply their own
|
||||
filesystem.
|
||||
- `parsePayload` routes JSON through built-in `JSON.parse`. Unknown
|
||||
extensions throw `BACKEND_READ_FAILED` with the extension name in
|
||||
the error message and context. Peer parsers (`yaml`, `json5`) are
|
||||
loaded via dynamic `import()`; missing peers surface as
|
||||
`BACKEND_READ_FAILED` with `{ peer }` in the context so consumers
|
||||
can install the right package.
|
||||
- `joinPath` collapses leading and trailing slashes so
|
||||
`root: ""` (relative to cwd) and `root: "/abs"` both produce well-formed
|
||||
paths.
|
||||
@@ -0,0 +1,939 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getMultiFileFsBackendMetricsState,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "../observability/observability.js";
|
||||
import { MultiFileFsBackend } from "./backend.js";
|
||||
|
||||
let testRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRoot = await mkdtemp(join(tmpdir(), "i18n-mfs-"));
|
||||
resetMultiFileFsBackendMetricsCache();
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (testRoot) await rm(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Universal Backend conformance suite (real tmpdir; nested layout)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
runBackendConformance("MultiFileFsBackend", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "i18n-mfs-conf-"));
|
||||
const backend = new MultiFileFsBackend({ root });
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// MultiFileFsBackend specifics — nested layout
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend nested layout", () => {
|
||||
it("loads <root>/<locale>/<namespace>.json", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(
|
||||
join(testRoot, "en", "auth.json"),
|
||||
JSON.stringify({ login: { title: "Sign in" } })
|
||||
);
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
const record = await backend.read("en", "auth");
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
|
||||
it("nested layout reads expected path", { timeout: 5000 }, async () => {
|
||||
const seen: string[] = [];
|
||||
const fs = {
|
||||
readFile: async (p: string) => {
|
||||
seen.push(p);
|
||||
return new TextEncoder().encode(
|
||||
JSON.stringify({ hello: "Hi" })
|
||||
);
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/tmp/i18n",
|
||||
layout: "nested",
|
||||
fs,
|
||||
});
|
||||
const record = await backend.read("en", "common");
|
||||
expect(record).toEqual({ hello: "Hi" });
|
||||
expect(seen[0]).toBe("/tmp/i18n/en/common.json");
|
||||
});
|
||||
|
||||
it("ENOENT maps to BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
try {
|
||||
await backend.read("de", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid JSON maps to BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.json"), "not-json");
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("array JSON rejects as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.json"), "[]");
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
});
|
||||
|
||||
it("save creates the locale directory and round-trips", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await backend.save("fr", "common", { ok: "Ok" });
|
||||
const record = await backend.read("fr", "common");
|
||||
expect(record).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("save creates parent directory", { timeout: 5000 }, async () => {
|
||||
const mkdirCalls: Array<{ path: string; recursive?: boolean }> = [];
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {},
|
||||
mkdir: async (p: string, opts?: { recursive?: boolean }) => {
|
||||
mkdirCalls.push({ path: p, recursive: opts?.recursive });
|
||||
},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
|
||||
await backend.save("en", "common", { x: "y" });
|
||||
expect(mkdirCalls).toEqual([{ path: "/tmp/i18n/en", recursive: true }]);
|
||||
});
|
||||
|
||||
it("writeFile failure throws BACKEND_WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {
|
||||
throw new Error("disk full");
|
||||
},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
|
||||
try {
|
||||
await backend.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 encoder", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "yaml",
|
||||
});
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("lastModified returns mtime-ms after save", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
expect(t).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lastModified returns mtime", { timeout: 5000 }, async () => {
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 4242 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
|
||||
expect(await backend.lastModified("en", "common")).toBe(4242);
|
||||
});
|
||||
|
||||
it("lastModified swallows stat failure", { timeout: 5000 }, async () => {
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => {
|
||||
throw new Error("stat failed");
|
||||
},
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for missing file", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// MultiFileFsBackend specifics — flat layout
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend flat layout", () => {
|
||||
it("loads <root>/<locale>.<namespace>.json", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writeFile(
|
||||
join(testRoot, "en.auth.json"),
|
||||
JSON.stringify({ ok: "Ok" })
|
||||
);
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
layout: "flat",
|
||||
});
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("flat layout reads expected path", { timeout: 5000 }, async () => {
|
||||
const seen: string[] = [];
|
||||
const fs = {
|
||||
readFile: async (p: string) => {
|
||||
seen.push(p);
|
||||
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/tmp/i18n",
|
||||
layout: "flat",
|
||||
fs,
|
||||
});
|
||||
await backend.read("en", "common");
|
||||
expect(seen[0]).toBe("/tmp/i18n/en.common.json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// MultiFileFsBackend specifics — glob layout
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend glob layout", () => {
|
||||
it("substitutes {locale} and {namespace} in pattern", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "fr"), { recursive: true });
|
||||
await writeFile(
|
||||
join(testRoot, "fr", "auth.fr.json"),
|
||||
JSON.stringify({ ok: "Ok-fr" })
|
||||
);
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
layout: "glob",
|
||||
globPattern: "{locale}/{namespace}.{locale}.json",
|
||||
});
|
||||
expect(await backend.read("fr", "auth")).toEqual({ ok: "Ok-fr" });
|
||||
});
|
||||
|
||||
it("glob layout requires globPattern", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new MultiFileFsBackend({ root: testRoot, layout: "glob" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("glob layout substitutes placeholders", { timeout: 5000 }, async () => {
|
||||
const seen: string[] = [];
|
||||
const fs = {
|
||||
readFile: async (p: string) => {
|
||||
seen.push(p);
|
||||
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/tmp",
|
||||
layout: "glob",
|
||||
globPattern: "i18n/{namespace}.{locale}.json",
|
||||
fs,
|
||||
});
|
||||
await backend.read("en", "common");
|
||||
expect(seen[0]).toBe("/tmp/i18n/common.en.json");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Capabilities + extension support
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend capabilities", () => {
|
||||
it("capabilities matrix follows watch option", { timeout: 5000 }, () => {
|
||||
const a = new MultiFileFsBackend({ root: testRoot, watch: true });
|
||||
expect(a.capabilities.canWatch).toBe(true);
|
||||
const b = new MultiFileFsBackend({ root: testRoot, watch: false });
|
||||
expect(b.capabilities.canWatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiFileFsBackend extension support", () => {
|
||||
it("rejects unknown extension as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.toml"), "ok = 'Ok'");
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "toml",
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("yaml extension parses via the yaml peer when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.yaml"), "ok: Ok\n");
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "yaml",
|
||||
});
|
||||
try {
|
||||
const record = await backend.read("en", "auth");
|
||||
expect(record).toEqual({ ok: "Ok" });
|
||||
} catch (err) {
|
||||
// If yaml peer is not installed, the surface error must
|
||||
// identify the missing peer.
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
expect(String((err as I18nError).message)).toContain("yaml");
|
||||
}
|
||||
});
|
||||
|
||||
it("yml extension routes through yaml peer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.yml"), "ok: Ok\n");
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "yml",
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toContain("yaml");
|
||||
}
|
||||
});
|
||||
|
||||
it("yaml peer returning non-record payload surfaces BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.yaml"), '"just a string"\n');
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "yaml",
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
// Either "non-record" (peer present) or "yaml" (peer missing)
|
||||
// — both are valid spec outcomes.
|
||||
const msg = String((err as I18nError).message);
|
||||
expect(/non-record|yaml/.test(msg)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("json5 extension routes through the parseWithPeer dispatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await mkdir(join(testRoot, "en"), { recursive: true });
|
||||
await writeFile(join(testRoot, "en", "auth.json5"), "{ ok: 'Ok', }\n");
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "json5",
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
expect(String((err as I18nError).message)).toContain("json5");
|
||||
}
|
||||
});
|
||||
|
||||
it("save rejects non-JSON extension as BACKEND_WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
extension: "yaml",
|
||||
});
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("save accepts json5 extension (encodes as JSON)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writes: Array<{ p: string; data: Uint8Array | string }> = [];
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async (p: string, data: Uint8Array | string) => {
|
||||
writes.push({ p, data });
|
||||
},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/tmp",
|
||||
extension: "json5",
|
||||
fs,
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(writes.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Watch lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend watch mode", () => {
|
||||
it("init starts watcher when watch=true", { timeout: 5000 }, async () => {
|
||||
const events: Array<{
|
||||
locale: string;
|
||||
namespace: string;
|
||||
ctx: unknown;
|
||||
}> = [];
|
||||
const iter = (async function* () {
|
||||
yield { filename: "en/common.json" };
|
||||
})();
|
||||
const fs = {
|
||||
readFile: async () =>
|
||||
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 1 }),
|
||||
watch: () => iter,
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/virtual",
|
||||
watch: true,
|
||||
fs,
|
||||
});
|
||||
await backend.init({
|
||||
catalogName: "test",
|
||||
emitLoaded: (locale, namespace, ctx) => {
|
||||
events.push({ locale, namespace, ctx });
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await backend.dispose();
|
||||
expect(events).toEqual([
|
||||
{
|
||||
locale: "en",
|
||||
namespace: "common",
|
||||
ctx: { backend: "multi-file-fs" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits loaded event when a watched file changes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: Array<{ locale: string; namespace: string }> = [];
|
||||
const fakeIter = (async function* () {
|
||||
yield { filename: "en/auth.json" };
|
||||
})();
|
||||
|
||||
const fs = {
|
||||
readFile: async () =>
|
||||
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 1 }),
|
||||
watch: () => fakeIter,
|
||||
};
|
||||
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/virtual",
|
||||
watch: true,
|
||||
fs,
|
||||
});
|
||||
await backend.init({
|
||||
catalogName: "test",
|
||||
emitLoaded: (locale, namespace) => {
|
||||
events.push({ locale, namespace });
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await backend.dispose();
|
||||
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
|
||||
});
|
||||
|
||||
it("watch ignores null filenames and unmatchable patterns", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: unknown[] = [];
|
||||
const iter = (async function* () {
|
||||
yield { filename: null };
|
||||
yield { filename: "noslash.json" };
|
||||
})();
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
watch: () => iter,
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/virtual",
|
||||
watch: true,
|
||||
fs,
|
||||
});
|
||||
await backend.init({
|
||||
catalogName: "x",
|
||||
emitLoaded: () => events.push({}),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await backend.dispose();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("watch parses flat-layout filenames", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: Array<{ locale: string; namespace: string }> = [];
|
||||
const iter = (async function* () {
|
||||
yield { filename: "en.auth.json" };
|
||||
yield { filename: "no-extension" };
|
||||
})();
|
||||
const fs = {
|
||||
readFile: async () =>
|
||||
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
watch: () => iter,
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/virtual",
|
||||
layout: "flat",
|
||||
watch: true,
|
||||
fs,
|
||||
});
|
||||
await backend.init({
|
||||
catalogName: "test",
|
||||
emitLoaded: (locale, namespace) => {
|
||||
events.push({ locale, namespace });
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await backend.dispose();
|
||||
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
|
||||
});
|
||||
|
||||
it("watch parser ignores unrelated filenames in nested layout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const events: unknown[] = [];
|
||||
const iter = (async function* () {
|
||||
yield { filename: "unrelated.txt" };
|
||||
})();
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
watch: () => iter,
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "/virtual",
|
||||
watch: true,
|
||||
fs,
|
||||
});
|
||||
await backend.init({
|
||||
catalogName: "test",
|
||||
emitLoaded: (...args) => events.push(args),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await backend.dispose();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await backend.dispose();
|
||||
await backend.dispose();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("init with watch=false does not start a watcher", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await backend.init({ catalogName: "x", emitLoaded: () => {} });
|
||||
await backend.dispose();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// FS error classification
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend filesystem error classification", () => {
|
||||
it("non-ENOENT errno (e.g. EACCES) maps to BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = {
|
||||
readFile: async () => {
|
||||
const err = new Error("permission denied") as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_READ_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ENOTDIR maps to BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = {
|
||||
readFile: async () => {
|
||||
const err = new Error("not a directory") as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "ENOTDIR";
|
||||
throw err;
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Constructor option validation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend constructor", () => {
|
||||
it("rejects malformed options shape via Zod (e.g. layout: bogus)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
const bad: unknown = "bogus";
|
||||
new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
layout: bad as "nested",
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty name with INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new MultiFileFsBackend({ root: testRoot, name: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("default name is multi-file-fs", { timeout: 5000 }, () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
expect(backend.name).toBe("multi-file-fs");
|
||||
});
|
||||
|
||||
it("accepts empty root (paths relative to cwd)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let mkdirCalls = 0;
|
||||
let writePath: string | undefined;
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async (p: string) => {
|
||||
writePath = p;
|
||||
},
|
||||
mkdir: async () => {
|
||||
mkdirCalls++;
|
||||
},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: "",
|
||||
layout: "glob",
|
||||
globPattern: "{namespace}.{locale}.json",
|
||||
fs,
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(writePath).toBe("auth.en.json");
|
||||
expect(mkdirCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AbortSignal cancellation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend AbortSignal threading", () => {
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let touched = false;
|
||||
const fs = {
|
||||
readFile: async () => {
|
||||
touched = true;
|
||||
return new Uint8Array();
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
backend.read("en", "auth", ctrl.signal)
|
||||
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
|
||||
expect(touched).toBe(false);
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let touched = false;
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {
|
||||
touched = true;
|
||||
},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
backend.save("en", "auth", { ok: "Ok" }, ctrl.signal)
|
||||
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
|
||||
expect(touched).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Observability — layout attribute on metrics
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MultiFileFsBackend observability", () => {
|
||||
it("read emits layout attribute", { timeout: 5000 }, async () => {
|
||||
await writeFile(
|
||||
join(testRoot, "en.common.json"),
|
||||
JSON.stringify({ ok: "Ok" })
|
||||
);
|
||||
const backend = new MultiFileFsBackend({
|
||||
root: testRoot,
|
||||
layout: "flat",
|
||||
});
|
||||
await backend.read("en", "common");
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const flat = state.readTotal.find(
|
||||
(s) => s.attributes["layout"] === "flat"
|
||||
);
|
||||
expect(flat).toBeDefined();
|
||||
expect(flat?.attributes["outcome"]).toBe("hit");
|
||||
});
|
||||
|
||||
it("write emits layout attribute on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const okWrite = state.writeTotal.find(
|
||||
(s) =>
|
||||
s.attributes["layout"] === "nested" &&
|
||||
s.attributes["outcome"] === "ok"
|
||||
);
|
||||
expect(okWrite).toBeDefined();
|
||||
});
|
||||
|
||||
it("write emits layout attribute on error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = {
|
||||
readFile: async () => new Uint8Array(),
|
||||
writeFile: async () => {
|
||||
throw new Error("disk full");
|
||||
},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/tmp/i", fs });
|
||||
await expect(
|
||||
backend.save("en", "auth", { ok: "Ok" })
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const errWrite = state.writeTotal.find(
|
||||
(s) => s.attributes["outcome"] === "error"
|
||||
);
|
||||
expect(errWrite).toBeDefined();
|
||||
expect(errWrite?.attributes["layout"]).toBe("nested");
|
||||
});
|
||||
|
||||
it("read miss records BACKEND_LOCALE_NOT_AVAILABLE error code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MultiFileFsBackend({ root: testRoot });
|
||||
await expect(backend.read("xx", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
|
||||
expect(codes).toContain(I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE);
|
||||
});
|
||||
|
||||
it("read error records BACKEND_READ_FAILED code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = {
|
||||
readFile: async () => {
|
||||
const err = new Error("perm") as Error & { code: string };
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
},
|
||||
writeFile: async () => {},
|
||||
mkdir: async () => {},
|
||||
stat: async () => ({ mtimeMs: 0 }),
|
||||
};
|
||||
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
|
||||
expect(codes).toContain(I18N_CODES.BACKEND_READ_FAILED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
// Server-tier package — uses node:fs/promises. Browser consumers: use
|
||||
// @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
|
||||
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
BackendInitContext,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead, recordWrite } from "../observability/observability.js";
|
||||
import type {
|
||||
MultiFileFsBackendOptions,
|
||||
MultiFileFsLayout,
|
||||
MultiFileFsLike,
|
||||
} from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link MultiFileFsBackendOptions}. Validates
|
||||
* the statically-checkable shape only — `fs` is accepted as opaque
|
||||
* `z.unknown()` because the `MultiFileFsLike` contract is structural.
|
||||
*/
|
||||
export const MultiFileFsBackendOptionsSchema = z.object({
|
||||
root: z.string(),
|
||||
layout: z.enum(["nested", "flat", "glob"]).optional(),
|
||||
globPattern: z.string().optional(),
|
||||
extension: z.string().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
watch: z.boolean().optional(),
|
||||
fs: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type MultiFileFsBackendOptionsInput = z.input<
|
||||
typeof MultiFileFsBackendOptionsSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Server-tier filesystem-backed translation source.
|
||||
*
|
||||
* Reads `<root>/<locale>/<namespace>.<ext>` (`nested`),
|
||||
* `<root>/<locale>.<namespace>.<ext>` (`flat`), or a caller-supplied
|
||||
* `globPattern` (`glob`). Optional peer parsers `yaml` and `json5` are
|
||||
* auto-detected via dynamic import; an unknown extension or a missing
|
||||
* peer surfaces as `BACKEND_READ_FAILED` with the peer name in the
|
||||
* error context.
|
||||
*
|
||||
* `fs.watch` is consulted only when `watch: true`. The watcher emits
|
||||
* `loaded` events through `BackendInitContext.emitLoaded` so React
|
||||
* Suspense / HMR consumers can refresh without polling.
|
||||
*
|
||||
* AbortSignal is honoured on `read` and `save`: a pre-aborted signal
|
||||
* short-circuits with `BACKEND_TIMEOUT` without touching the
|
||||
* filesystem.
|
||||
*/
|
||||
export class MultiFileFsBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities;
|
||||
|
||||
readonly #root: string;
|
||||
readonly #layout: MultiFileFsLayout;
|
||||
readonly #globPattern: string | undefined;
|
||||
readonly #extension: string;
|
||||
readonly #watch: boolean;
|
||||
readonly #fs: MultiFileFsLike | "default";
|
||||
#initContext: BackendInitContext | undefined;
|
||||
#watchAbort: AbortController | undefined;
|
||||
|
||||
public constructor(options: MultiFileFsBackendOptions) {
|
||||
const parsed = MultiFileFsBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"MultiFileFsBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (options.layout === "glob" && !options.globPattern) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"MultiFileFsBackend: globPattern is required when layout='glob'"
|
||||
);
|
||||
}
|
||||
this.name = options.name ?? "multi-file-fs";
|
||||
this.#root = options.root;
|
||||
this.#layout = options.layout ?? "nested";
|
||||
this.#globPattern = options.globPattern;
|
||||
this.#extension = options.extension ?? "json";
|
||||
this.#watch = options.watch ?? false;
|
||||
this.#fs = options.fs ?? "default";
|
||||
this.capabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: false,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: this.#watch,
|
||||
};
|
||||
}
|
||||
|
||||
public async init(context: BackendInitContext): Promise<void> {
|
||||
this.#initContext = context;
|
||||
if (this.#watch) {
|
||||
await this.#startWatch();
|
||||
}
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
this.#watchAbort?.abort();
|
||||
this.#watchAbort = undefined;
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
let outcome: "hit" | "miss" | "error" = "hit";
|
||||
let errorCode: string | undefined;
|
||||
const path = this.#resolvePath(locale, namespace);
|
||||
try {
|
||||
const fs = await this.#getFs();
|
||||
const bytes = await fs.readFile(path).catch((err: unknown) => {
|
||||
throw classifyFsError(err, path);
|
||||
});
|
||||
return await parsePayload(bytes, this.#extension, path);
|
||||
} catch (err) {
|
||||
// Every throw from readFile / parsePayload is wrapped in
|
||||
// `I18nError` upstream — `classifyFsError` covers fs errors
|
||||
// and `parsePayload` covers parse / encoding errors.
|
||||
const i18n = err as I18nError;
|
||||
if (i18n.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE) {
|
||||
outcome = "miss";
|
||||
} else {
|
||||
outcome = "error";
|
||||
}
|
||||
errorCode = i18n.code;
|
||||
throw err;
|
||||
} finally {
|
||||
recordRead(
|
||||
this.name,
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
outcome,
|
||||
performance.now() - t0,
|
||||
errorCode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#checkAborted(signal);
|
||||
const path = this.#resolvePath(locale, namespace);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const fs = await this.#getFs();
|
||||
await this.#ensureDir(fs, path);
|
||||
await fs.writeFile(path, encodePayload(record, this.#extension));
|
||||
recordWrite(this.name, this.#layout, "ok", performance.now() - t0);
|
||||
} catch (cause) {
|
||||
recordWrite(
|
||||
this.name,
|
||||
this.#layout,
|
||||
"error",
|
||||
performance.now() - t0
|
||||
);
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED,
|
||||
`MultiFileFsBackend: write failed for ${path}`,
|
||||
{ cause, context: { path, backend: this.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
const path = this.#resolvePath(locale, namespace);
|
||||
try {
|
||||
const fs = await this.#getFs();
|
||||
const stat = await fs.stat(path);
|
||||
return stat.mtimeMs;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#resolvePath(locale: string, namespace: string): string {
|
||||
if (this.#layout === "glob" && this.#globPattern) {
|
||||
const pattern = this.#globPattern
|
||||
.replaceAll("{locale}", locale)
|
||||
.replaceAll("{namespace}", namespace);
|
||||
return joinPath(this.#root, pattern);
|
||||
}
|
||||
if (this.#layout === "flat") {
|
||||
return joinPath(
|
||||
this.#root,
|
||||
`${locale}.${namespace}.${this.#extension}`
|
||||
);
|
||||
}
|
||||
return joinPath(this.#root, locale, `${namespace}.${this.#extension}`);
|
||||
}
|
||||
|
||||
async #ensureDir(fs: MultiFileFsLike, path: string): Promise<void> {
|
||||
const slashIdx = path.lastIndexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
await fs.mkdir(path.slice(0, slashIdx), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async #getFs(): Promise<MultiFileFsLike> {
|
||||
if (this.#fs !== "default") return this.#fs;
|
||||
const mod = (await import("node:fs/promises")) as unknown as {
|
||||
readFile: (path: string) => Promise<Buffer>;
|
||||
writeFile: (
|
||||
path: string,
|
||||
data: Uint8Array | string
|
||||
) => Promise<void>;
|
||||
mkdir: (
|
||||
path: string,
|
||||
options?: { recursive?: boolean }
|
||||
) => Promise<string | undefined>;
|
||||
stat: (path: string) => Promise<{ mtimeMs: number }>;
|
||||
watch: (path: string) => AsyncIterable<{ filename: string | null }>;
|
||||
};
|
||||
return {
|
||||
readFile: async (p) => new Uint8Array(await mod.readFile(p)),
|
||||
writeFile: async (p, data) => {
|
||||
await mod.writeFile(p, data);
|
||||
},
|
||||
mkdir: async (p, opts) => {
|
||||
await mod.mkdir(p, opts);
|
||||
},
|
||||
stat: (p) => mod.stat(p),
|
||||
watch: mod.watch,
|
||||
};
|
||||
}
|
||||
|
||||
async #startWatch(): Promise<void> {
|
||||
const fs = await this.#getFs();
|
||||
const watch = fs.watch as (
|
||||
p: string
|
||||
) => AsyncIterable<{ filename: string | null }>;
|
||||
this.#watchAbort = new AbortController();
|
||||
// Spawn the watcher; don't await — it runs for the lifetime of
|
||||
// the backend. Errors from the iterator are intentionally
|
||||
// swallowed.
|
||||
void this.#runWatchLoop(watch(this.#root));
|
||||
}
|
||||
|
||||
async #runWatchLoop(
|
||||
iter: AsyncIterable<{ filename: string | null }>
|
||||
): Promise<void> {
|
||||
for await (const event of iter) {
|
||||
const parsed = this.#parseEventPath(event.filename);
|
||||
if (parsed && this.#initContext) {
|
||||
this.#initContext.emitLoaded(parsed.locale, parsed.namespace, {
|
||||
backend: this.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#parseEventPath(
|
||||
filename: string | null
|
||||
): { locale: string; namespace: string } | undefined {
|
||||
if (!filename) return undefined;
|
||||
if (this.#layout === "nested") {
|
||||
const m = filename.match(
|
||||
new RegExp(`^([^/]+)/(.+)\\.${this.#extension}$`)
|
||||
);
|
||||
if (m) return { locale: m[1] as string, namespace: m[2] as string };
|
||||
}
|
||||
if (this.#layout === "flat") {
|
||||
const m = filename.match(
|
||||
new RegExp(`^([^.]+)\\.(.+)\\.${this.#extension}$`)
|
||||
);
|
||||
if (m) return { locale: m[1] as string, namespace: m[2] as string };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"MultiFileFsBackend: operation aborted",
|
||||
{ context: { backend: this.name, reason: signal.reason } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(...parts: string[]): string {
|
||||
return parts
|
||||
.filter((p) => p && p.length > 0)
|
||||
.map((p, i) =>
|
||||
i === 0
|
||||
? p.replace(/\/+$/, "")
|
||||
: p.replace(/^\/+/, "").replace(/\/+$/, "")
|
||||
)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function classifyFsError(err: unknown, path: string): I18nError {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`MultiFileFsBackend: file not found ${path}`,
|
||||
{ cause: err, context: { path } }
|
||||
);
|
||||
}
|
||||
return new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`MultiFileFsBackend: read failed for ${path}`,
|
||||
{ cause: err, context: { path } }
|
||||
);
|
||||
}
|
||||
|
||||
async function parsePayload(
|
||||
bytes: Uint8Array,
|
||||
extension: string,
|
||||
path: string
|
||||
): Promise<TranslationRecord> {
|
||||
const ext = extension.toLowerCase();
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
if (ext === "json") return parseJson(text, path);
|
||||
if (ext === "json5") return parseWithPeer("json5", text, path);
|
||||
if (ext === "yaml" || ext === "yml")
|
||||
return parseWithPeer("yaml", text, path);
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`MultiFileFsBackend: unsupported file extension '${extension}'`,
|
||||
{ context: { path, extension } }
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson(text: string, path: string): TranslationRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error("payload is not an object record");
|
||||
}
|
||||
return parsed as TranslationRecord;
|
||||
} catch (cause) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`MultiFileFsBackend: JSON parse failed for ${path}`,
|
||||
{ cause, context: { path } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseWithPeer(
|
||||
peerName: string,
|
||||
text: string,
|
||||
path: string
|
||||
): Promise<TranslationRecord> {
|
||||
let mod: { [key: string]: unknown };
|
||||
try {
|
||||
mod = (await import(/* @vite-ignore */ peerName)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch (cause) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`MultiFileFsBackend: peer dependency '${peerName}' is not installed`,
|
||||
{ cause, context: { path, peer: peerName } }
|
||||
);
|
||||
}
|
||||
const fn = mod["parse"] as (input: string) => unknown;
|
||||
const parsed = fn(text);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`MultiFileFsBackend: ${peerName} returned non-record payload`,
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
return parsed as TranslationRecord;
|
||||
}
|
||||
|
||||
function encodePayload(
|
||||
record: TranslationRecord,
|
||||
extension: string
|
||||
): Uint8Array {
|
||||
const ext = extension.toLowerCase();
|
||||
if (ext === "json" || ext === "json5") {
|
||||
return new TextEncoder().encode(JSON.stringify(record, null, 2));
|
||||
}
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED,
|
||||
`MultiFileFsBackend: write not supported for extension '${extension}'`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Wire-layout discriminator for {@link MultiFileFsBackend}. The default
|
||||
* (`"nested"`) reads `<root>/<locale>/<namespace>.<ext>`, `"flat"`
|
||||
* reads `<root>/<locale>.<namespace>.<ext>`, and `"glob"` substitutes
|
||||
* caller-supplied `{locale}` / `{namespace}` placeholders into a
|
||||
* pattern joined to `root`.
|
||||
*/
|
||||
export type MultiFileFsLayout = "nested" | "flat" | "glob";
|
||||
|
||||
/**
|
||||
* Minimal `node:fs/promises` shape this backend depends on. Inject
|
||||
* for tests or to swap in a different filesystem implementation
|
||||
* (memfs, in-memory sandbox, etc.). Defaults to `node:fs/promises` via
|
||||
* dynamic import.
|
||||
*/
|
||||
export interface MultiFileFsLike {
|
||||
readFile(path: string): Promise<Uint8Array>;
|
||||
writeFile(path: string, data: Uint8Array | string): Promise<void>;
|
||||
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
stat(path: string): Promise<{ mtimeMs: number }>;
|
||||
watch?(path: string): AsyncIterable<{ filename: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for {@link MultiFileFsBackend}.
|
||||
*/
|
||||
export interface MultiFileFsBackendOptions {
|
||||
/** Root directory containing the locale tree. */
|
||||
readonly root: string;
|
||||
/** Wire layout. Defaults to `'nested'`. */
|
||||
readonly layout?: MultiFileFsLayout;
|
||||
/**
|
||||
* Required when `layout: 'glob'`. Pattern with `{locale}` /
|
||||
* `{namespace}` placeholders, e.g.
|
||||
* `'i18n/{namespace}.{locale}.json'`.
|
||||
*/
|
||||
readonly globPattern?: string;
|
||||
/** File extension (without dot). Defaults to `'json'`. */
|
||||
readonly extension?: string;
|
||||
/** Optional backend name. Defaults to `'multi-file-fs'`. */
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Enable file-watch mode. When `true`, the backend emits `loaded`
|
||||
* events via `BackendInitContext.emitLoaded` whenever a file
|
||||
* changes.
|
||||
*/
|
||||
readonly watch?: boolean;
|
||||
/** Inject a filesystem shim. Defaults to `node:fs/promises`. */
|
||||
readonly fs?: MultiFileFsLike;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MultiFileFsBackend,
|
||||
type MultiFileFsBackendOptionsInput,
|
||||
MultiFileFsBackendOptionsSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
MultiFileFsBackendOptions,
|
||||
MultiFileFsLayout,
|
||||
MultiFileFsLike,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# `factory.ts` — `multiFileFsBackend()`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Builder-style entry point that returns a fresh `MultiFileFsBackend`
|
||||
per call. Functionally identical to `new MultiFileFsBackend(options)`,
|
||||
but reads naturally in pipelines and configuration objects where the
|
||||
caller is composing a backend chain alongside `memoryBackend()`,
|
||||
`storageBackend()`, `httpBackend()`, and `chainedBackend()` — every
|
||||
backend in the family ships a matching factory function for symmetry.
|
||||
|
||||
The factory has no internal state — every invocation returns a brand-new
|
||||
instance. Options are validated by the same Zod schema as the
|
||||
constructor; bad options throw `I18nError(I18N.INVALID_OPTIONS)`. There
|
||||
is no caching, no singleton, and no hidden configuration: the factory
|
||||
exists purely to flip the calling style from `new C(opts)` to
|
||||
`factory(opts)` for ergonomic chain composition.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `MultiFileFsBackend` on every call.
|
||||
- Forwards all options unchanged into the class constructor.
|
||||
- Same validation surface — bad options throw `INVALID_OPTIONS`.
|
||||
- Zero shared state, zero side effects.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
|
||||
|
||||
const backend = multiFileFsBackend({ root: "./locales" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { chainedBackend } from "@suaveplan/i18n-backend-chained";
|
||||
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
// Server-side: hot in-memory cache → fall through to disk on miss.
|
||||
const chain = chainedBackend([
|
||||
memoryBackend(),
|
||||
multiFileFsBackend({ root: "./locales", watch: true }),
|
||||
]);
|
||||
|
||||
await chain.init({
|
||||
catalogName: "app",
|
||||
emitLoaded: () => {},
|
||||
});
|
||||
|
||||
const record = await chain.read("en", "auth");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend`:
|
||||
returns a new `MultiFileFsBackend` instance. Throws
|
||||
`I18nError(I18N.INVALID_OPTIONS)` on schema failure or when
|
||||
`layout: "glob"` is supplied without `globPattern`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a thin wrapper — the line count is intentional. The
|
||||
factory exists for symmetry with the rest of the i18n backend
|
||||
family, where every concrete backend ships both a class export and
|
||||
a `<thing>Backend()` factory function.
|
||||
- The factory is server-tier — declaring this package's `engines.node`
|
||||
at the package boundary covers the factory and the class equally.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { MultiFileFsBackend } from "../backend/backend.js";
|
||||
import { multiFileFsBackend } from "./factory.js";
|
||||
|
||||
let testRoot: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testRoot = await mkdtemp(join(tmpdir(), "i18n-mfs-factory-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (testRoot) await rm(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("multiFileFsBackend factory", () => {
|
||||
it("factory returns MultiFileFsBackend instance", { timeout: 5000 }, () => {
|
||||
const b = multiFileFsBackend({ root: testRoot });
|
||||
expect(b).toBeInstanceOf(MultiFileFsBackend);
|
||||
});
|
||||
|
||||
it("factory defaults name to multi-file-fs", { timeout: 5000 }, () => {
|
||||
const b = multiFileFsBackend({ root: testRoot });
|
||||
expect(b.name).toBe("multi-file-fs");
|
||||
});
|
||||
|
||||
it("factory honours custom name", { timeout: 5000 }, () => {
|
||||
const b = multiFileFsBackend({ root: testRoot, name: "custom" });
|
||||
expect(b.name).toBe("custom");
|
||||
});
|
||||
|
||||
it("factory propagates INVALID_OPTIONS for bad layout", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
const bad: unknown = "bogus";
|
||||
multiFileFsBackend({
|
||||
root: testRoot,
|
||||
layout: bad as "nested",
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("factory rejects glob layout without globPattern", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
multiFileFsBackend({ root: testRoot, layout: "glob" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("factory returns a fresh instance per call", { timeout: 5000 }, () => {
|
||||
const a = multiFileFsBackend({ root: testRoot });
|
||||
const b = multiFileFsBackend({ root: testRoot });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MultiFileFsBackend } from "../backend/backend.js";
|
||||
import type { MultiFileFsBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `MultiFileFsBackend` per call — no internal singleton, no
|
||||
* shared state. Equivalent to `new MultiFileFsBackend(options)`.
|
||||
*/
|
||||
export function multiFileFsBackend(
|
||||
options: MultiFileFsBackendOptions
|
||||
): MultiFileFsBackend {
|
||||
return new MultiFileFsBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { multiFileFsBackend } from "./factory.js";
|
||||
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
);
|
||||
const BACKEND_TS_PATH = fileURLToPath(
|
||||
new URL("./backend/backend.ts", import.meta.url)
|
||||
);
|
||||
|
||||
describe("@suaveplan/i18n-backend-fs-multifile — root barrel", () => {
|
||||
it("re-exports MultiFileFsBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.MultiFileFsBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports multiFileFsBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.multiFileFsBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports MultiFileFsBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.MultiFileFsBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.recordWrite).toBe("function");
|
||||
expect(typeof root.getMultiFileFsBackendMetrics).toBe("function");
|
||||
expect(typeof root.getMultiFileFsBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetMultiFileFsBackendMetricsState).toBe(
|
||||
"function"
|
||||
);
|
||||
expect(typeof root.resetMultiFileFsBackendMetricsCache).toBe(
|
||||
"function"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-fs-multifile — 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.MultiFileFsBackend).toBe("function");
|
||||
expect(backend.MultiFileFsBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.multiFileFsBackend).toBe("function");
|
||||
expect(typeof observability.getMultiFileFsBackendMetrics).toBe(
|
||||
"function"
|
||||
);
|
||||
expect(typeof observability.recordRead).toBe("function");
|
||||
expect(typeof observability.recordWrite).toBe("function");
|
||||
});
|
||||
|
||||
it("every documented subpath resolves", { 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-fs-multifile — package.json", () => {
|
||||
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
sideEffects?: boolean;
|
||||
};
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("package.json declares engines.node and omits browser field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
engines?: { node?: string };
|
||||
browser?: unknown;
|
||||
};
|
||||
expect(pkg.engines?.node).toBe(">=20");
|
||||
expect(pkg.browser).toBeUndefined();
|
||||
});
|
||||
|
||||
it("yaml and json5 are optional peer deps not direct deps", {
|
||||
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("yaml");
|
||||
expect(deps).not.toHaveProperty("json5");
|
||||
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("src/backend.ts carries the server-tier marker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = readFileSync(BACKEND_TS_PATH, "utf-8");
|
||||
expect(src).toContain("Server-tier package");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-fs-multifile. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MultiFileFsBackend,
|
||||
type MultiFileFsBackendOptionsInput,
|
||||
MultiFileFsBackendOptionsSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
MultiFileFsBackendOptions,
|
||||
MultiFileFsLayout,
|
||||
MultiFileFsLike,
|
||||
} from "./backend/backend.types.js";
|
||||
export { multiFileFsBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getMultiFileFsBackendMetrics,
|
||||
getMultiFileFsBackendMetricsState,
|
||||
type MultiFileFsBackendMetricSample,
|
||||
type MultiFileFsBackendMetricsState,
|
||||
type MultiFileFsReadOutcome,
|
||||
type MultiFileFsWriteOutcome,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
MultiFileFsBackendMetricSample,
|
||||
MultiFileFsBackendMetricsState,
|
||||
MultiFileFsReadOutcome,
|
||||
MultiFileFsWriteOutcome,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getMultiFileFsBackendMetrics,
|
||||
getMultiFileFsBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,92 @@
|
||||
# `observability.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Self-contained OpenTelemetry instrument set for the
|
||||
`@suaveplan/i18n-backend-fs-multifile` package. The module wires every
|
||||
public counter and histogram emitted by `MultiFileFsBackend` and exposes
|
||||
an in-memory state ring used by spec-conformance tests so production
|
||||
code paths can be exercised without standing up a full OTel SDK.
|
||||
|
||||
The module is loaded lazily by `getMultiFileFsBackendMetrics()` — the
|
||||
first call to any production path (`recordRead`, `recordWrite`) builds a
|
||||
single shared `Meter` and caches the resulting `Histogram`/`Counter`
|
||||
instances. Subsequent calls hit the cache. When no `MeterProvider` is
|
||||
registered (the default in production), every instrument is a no-op,
|
||||
keeping the cost path-neutral when telemetry is off.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazily-built histograms and counters under the
|
||||
`i18n.backend.fs_multifile.*` prefix.
|
||||
- Five instruments: `read_duration_ms`, `read_total`,
|
||||
`read_errors_total`, `write_duration_ms`, `write_total`.
|
||||
- Every emission carries a `backend` label plus a `layout` label
|
||||
(`nested` / `flat` / `glob`) so consumers can size watchers per
|
||||
layout.
|
||||
- In-memory state ring (`getMultiFileFsBackendMetricsState`,
|
||||
`resetMultiFileFsBackendMetricsState`) so spec-conformance tests can
|
||||
assert exact attribute payloads without an exporter.
|
||||
- `resetMultiFileFsBackendMetricsCache` test hook to drop the cached
|
||||
instruments between cases when a different MeterProvider is required.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordRead,
|
||||
recordWrite,
|
||||
} from "@suaveplan/i18n-backend-fs-multifile/observability";
|
||||
|
||||
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.42);
|
||||
recordWrite("multi-file-fs", "nested", "ok", 0.31);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMultiFileFsBackendMetricsState,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-fs-multifile/observability";
|
||||
|
||||
resetMultiFileFsBackendMetricsCache();
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
|
||||
// ... run code that hits the backend ...
|
||||
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
|
||||
expect(outcomes).toContain("hit");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRead(backend, locale, namespace, layout, outcome, durationMs, errorCode?)`:
|
||||
emits the `read_duration_ms` histogram and `read_total` counter; if
|
||||
`errorCode` is supplied, also increments `read_errors_total{code,layout}`.
|
||||
- `recordWrite(backend, layout, outcome, durationMs)`: emits the
|
||||
`write_duration_ms` histogram and `write_total` counter.
|
||||
- `getMultiFileFsBackendMetrics()`: lazy build / return cached
|
||||
instruments — idempotent.
|
||||
- `getMultiFileFsBackendMetricsState()`: return the in-memory state
|
||||
ring snapshot.
|
||||
- `resetMultiFileFsBackendMetricsState()`: clear the in-memory state
|
||||
ring.
|
||||
- `resetMultiFileFsBackendMetricsCache()`: drop the cached instruments
|
||||
so a fresh MeterProvider can take over (test-only escape hatch).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `layout` attribute is mandatory on every counter and histogram so
|
||||
operators can split SLOs per wire layout — `glob` patterns tend to
|
||||
hide expensive resolutions behind dynamic substitution.
|
||||
- The state ring is unbounded by design and is the test-only path. The
|
||||
`resetMultiFileFsBackendMetricsState` helper is the supported way to
|
||||
prevent ring growth across tests.
|
||||
- The OTel meter is named `suaveplan.i18n-backend-fs-multifile` so
|
||||
consumers can apply per-package views.
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getMultiFileFsBackendMetrics,
|
||||
getMultiFileFsBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("MultiFileFsBackend metrics — instrument lifecycle", () => {
|
||||
it("getMultiFileFsBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMultiFileFsBackendMetricsCache();
|
||||
const a = getMultiFileFsBackendMetrics();
|
||||
const b = getMultiFileFsBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetMultiFileFsBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getMultiFileFsBackendMetrics();
|
||||
resetMultiFileFsBackendMetricsCache();
|
||||
const b = getMultiFileFsBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiFileFsBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.42);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "hit",
|
||||
layout: "nested",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "hit",
|
||||
layout: "nested",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
recordRead(
|
||||
"multi-file-fs",
|
||||
"en",
|
||||
"auth",
|
||||
"flat",
|
||||
"miss",
|
||||
0.42,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "miss",
|
||||
layout: "flat",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
layout: "flat",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records error outcome", { timeout: 5000 }, () => {
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
recordRead(
|
||||
"multi-file-fs",
|
||||
"en",
|
||||
"auth",
|
||||
"glob",
|
||||
"error",
|
||||
0.42,
|
||||
"I18N.BACKEND_READ_FAILED"
|
||||
);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "error",
|
||||
layout: "glob",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
code: "I18N.BACKEND_READ_FAILED",
|
||||
layout: "glob",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiFileFsBackend metrics — recordWrite", () => {
|
||||
it("records duration + total on ok", { timeout: 5000 }, () => {
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
recordWrite("multi-file-fs", "nested", "ok", 0.42);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.writeDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "ok",
|
||||
layout: "nested",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.writeTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "ok",
|
||||
layout: "nested",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records duration + total on error", { timeout: 5000 }, () => {
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
recordWrite("multi-file-fs", "flat", "error", 0.42);
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
backend: "multi-file-fs",
|
||||
outcome: "error",
|
||||
layout: "flat",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiFileFsBackend metrics — state ring helpers", () => {
|
||||
it("resetMultiFileFsBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.1);
|
||||
recordRead(
|
||||
"multi-file-fs",
|
||||
"en",
|
||||
"auth",
|
||||
"nested",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
recordWrite("multi-file-fs", "nested", "ok", 0.1);
|
||||
resetMultiFileFsBackendMetricsState();
|
||||
const state = getMultiFileFsBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
expect(state.writeDurationMs).toEqual([]);
|
||||
expect(state.writeTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
import type { MultiFileFsLayout } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-fs-multifile`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-fs-multifile')`.
|
||||
* 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
|
||||
* `resetMultiFileFsBackendMetricsState()` between cases and read
|
||||
* `getMultiFileFsBackendMetricsState()` to inspect counters /
|
||||
* histogram counts.
|
||||
*/
|
||||
|
||||
export type MultiFileFsReadOutcome = "hit" | "miss" | "error";
|
||||
export type MultiFileFsWriteOutcome = "ok" | "error";
|
||||
|
||||
export interface MultiFileFsBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MultiFileFsBackendMetricsState {
|
||||
readonly readDurationMs: MultiFileFsBackendMetricSample[];
|
||||
readonly readTotal: MultiFileFsBackendMetricSample[];
|
||||
readonly readErrorsTotal: MultiFileFsBackendMetricSample[];
|
||||
readonly writeDurationMs: MultiFileFsBackendMetricSample[];
|
||||
readonly writeTotal: MultiFileFsBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
readonly writeDurationMs: Histogram;
|
||||
readonly writeTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: MultiFileFsBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
writeDurationMs: [],
|
||||
writeTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to
|
||||
* call from any production code path.
|
||||
*/
|
||||
export function getMultiFileFsBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-fs-multifile");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.fs_multifile.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MultiFileFsBackend.read call; labels: backend, locale, namespace, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.fs_multifile.read_total", {
|
||||
description:
|
||||
"MultiFileFsBackend.read call count; labels: backend, outcome (hit | miss | error), layout.",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.fs_multifile.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"MultiFileFsBackend.read error count; labels: backend, code, layout.",
|
||||
}
|
||||
),
|
||||
writeDurationMs: meter.createHistogram(
|
||||
"i18n.backend.fs_multifile.write_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MultiFileFsBackend.save call; labels: backend, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
writeTotal: meter.createCounter(
|
||||
"i18n.backend.fs_multifile.write_total",
|
||||
{
|
||||
description:
|
||||
"MultiFileFsBackend.save call count; labels: backend, outcome (ok | error), layout.",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{backend,outcome,layout}` counter and per-error-code
|
||||
* `read_errors_total{backend,code,layout}` counter, pushing one
|
||||
* sample into the in-memory state ring per counter touched.
|
||||
*/
|
||||
export function recordRead(
|
||||
backend: string,
|
||||
locale: string,
|
||||
namespace: string,
|
||||
layout: MultiFileFsLayout,
|
||||
outcome: MultiFileFsReadOutcome,
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const ins = getMultiFileFsBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
backend,
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
layout,
|
||||
};
|
||||
ins.readDurationMs.record(durationMs, histogramAttrs);
|
||||
state.readDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { backend, outcome, layout };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs: Record<string, string> = {
|
||||
backend,
|
||||
code: errorCode,
|
||||
layout,
|
||||
};
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `save` call's duration + outcome. Mirrors the
|
||||
* `write_total{backend,outcome,layout}` counter into the in-memory
|
||||
* state ring.
|
||||
*/
|
||||
export function recordWrite(
|
||||
backend: string,
|
||||
layout: MultiFileFsLayout,
|
||||
outcome: MultiFileFsWriteOutcome,
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = getMultiFileFsBackendMetrics();
|
||||
const attrs: Record<string, string> = { backend, outcome, layout };
|
||||
ins.writeDurationMs.record(durationMs, attrs);
|
||||
state.writeDurationMs.push({ attributes: attrs });
|
||||
ins.writeTotal.add(1, attrs);
|
||||
state.writeTotal.push({ attributes: attrs });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getMultiFileFsBackendMetricsState(): MultiFileFsBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetMultiFileFsBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
state.writeDurationMs.length = 0;
|
||||
state.writeTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetMultiFileFsBackendMetricsCache(): 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,48 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-fs-multifile",
|
||||
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/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
"yaml",
|
||||
"json5",
|
||||
/^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: 10000,
|
||||
},
|
||||
});
|
||||
96
packages/localization/i18n/i18n-backend-http/README.md
Normal file
96
packages/localization/i18n/i18n-backend-http/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @suaveplan/i18n-backend-http - Universal translation backend backed by injectable fetch. Read-only; maps HTTP 404 to BACKEND_LOCALE_NOT_AVAILABLE (chain fallthrough) and HTTP 5xx / network / parse failures to BACKEND_READ_FAILED (chain stop). Pluggable parsePayload + customHeaders + retry policy + AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.http.*. Zero runtime dependencies on @suaveplan/i18n core.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal translation backend for the `@suaveplan/i18n` family,
|
||||
backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to
|
||||
`BACKEND_LOCALE_NOT_AVAILABLE` (the chained-backend fallthrough signal)
|
||||
and HTTP 5xx, network failures, and parse failures to
|
||||
`BACKEND_READ_FAILED` (a chain-stopping signal that prevents silent
|
||||
degradation when the transport is broken). Pluggable `parsePayload`,
|
||||
`customHeaders`, `requestOptions`, `crossDomain` shorthand, retry-policy
|
||||
injection (structurally compatible with `@suaveplan/retry`'s
|
||||
`RetryPolicy`), `AbortSignal` cancellation, and OpenTelemetry
|
||||
instrumentation under `i18n.backend.http.*`. Zero runtime dependency on
|
||||
`@suaveplan/i18n` core.
|
||||
|
||||
<!-- 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)_
|
||||
## Why this exists
|
||||
|
||||
The Wave 5 modular split lifts each backend out of the
|
||||
`@suaveplan/i18n` monolith into its own sibling so consumers can install
|
||||
only the backends they need. The HTTP backend is the canonical
|
||||
production source for browser-tier and edge consumers fetching
|
||||
translations from a CDN, an internal i18n service, or a pre-signed
|
||||
storage URL. Keeping it in its own package avoids dragging
|
||||
`@suaveplan/storage` (storage backend) or `node:fs` (multifile-fs
|
||||
backend) into browser bundles.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-backend-http
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const backend = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
});
|
||||
|
||||
const record = await backend.read("en", "auth");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Module |
|
||||
| --- | --- |
|
||||
| `@suaveplan/i18n-backend-http/backend` | [`HttpBackend`](./src/backend/backend.md) |
|
||||
| `@suaveplan/i18n-backend-http/factory` | [`httpBackend`](./src/factory/factory.md) |
|
||||
| `@suaveplan/i18n-backend-http/observability` | [OTel instruments](./src/observability/observability.md) |
|
||||
|
||||
## Capabilities
|
||||
|
||||
```ts
|
||||
{
|
||||
canRead: true,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
}
|
||||
```
|
||||
|
||||
## Error semantics
|
||||
|
||||
- HTTP 404 → `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` — chained
|
||||
backend SHOULD fall through.
|
||||
- HTTP 5xx / non-2xx non-404 → `I18nError(I18N.BACKEND_READ_FAILED)` —
|
||||
chained backend MUST propagate.
|
||||
- Network failure (`TypeError` from `fetch`) →
|
||||
`I18nError(I18N.BACKEND_READ_FAILED)`.
|
||||
- AbortError mid-flight → `I18nError(I18N.BACKEND_TIMEOUT)`.
|
||||
- Pre-aborted `signal` → `I18nError(I18N.BACKEND_TIMEOUT)` (no
|
||||
network request issued).
|
||||
- Parse failure → `I18nError(I18N.BACKEND_READ_FAILED)`.
|
||||
- Invalid options → `I18nError(I18N.INVALID_OPTIONS)` at construction.
|
||||
|
||||
## Tier
|
||||
|
||||
Universal — runs in browsers, Bun, and Node.js >= 18. On older Node
|
||||
runtimes lacking `globalThis.fetch`, `defaultFetch()` returns a stub
|
||||
that throws `BACKEND_READ_FAILED` with an explicit message instructing
|
||||
consumers to inject `fetchFn`.
|
||||
|
||||
82
packages/localization/i18n/i18n-backend-http/package.json
Normal file
82
packages/localization/i18n/i18n-backend-http/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-http",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal translation backend backed by injectable fetch. Read-only; maps HTTP 404 to BACKEND_LOCALE_NOT_AVAILABLE (chain fallthrough) and HTTP 5xx / network / parse failures to BACKEND_READ_FAILED (chain stop). Pluggable parsePayload + customHeaders + retry policy + AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.http.*. Zero runtime dependencies on @suaveplan/i18n core.",
|
||||
"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/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,119 @@
|
||||
# `HttpBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`HttpBackend` is the canonical browser-tier translation source for the
|
||||
`@suaveplan/i18n` family. It fetches `<loadPath>` per `(locale,
|
||||
namespace)` via an injectable `fetchFn` (defaulting to
|
||||
`globalThis.fetch`) and parses the response body via an injectable
|
||||
`parsePayload` (defaulting to a JSON-object parser that rejects arrays,
|
||||
null, and primitive payloads). HTTP 404 maps to
|
||||
`BACKEND_LOCALE_NOT_AVAILABLE` — the chained-backend fallthrough signal
|
||||
— while HTTP 5xx, network failures, and parse failures map to
|
||||
`BACKEND_READ_FAILED`, the chain-stopping signal that prevents silent
|
||||
degradation when the transport itself is broken.
|
||||
|
||||
The backend is read-only by design (`canSave: false`,
|
||||
`canTrackModification: false`, `canWatch: false`). Consumers needing
|
||||
write-back wire `MultiFileFsBackend` or `StorageBackend` behind the
|
||||
catalog instead. Universal — runs identically in browsers, Bun,
|
||||
and Node.js >= 18; older Node runtimes get a clear
|
||||
`BACKEND_READ_FAILED` from `defaultFetch()` instructing them to inject
|
||||
`fetchFn`.
|
||||
|
||||
## Features
|
||||
|
||||
- 5-flag capability matrix `{ canRead: true, canReadMulti: false,
|
||||
canSave: false, canTrackModification: false, canWatch: false }`.
|
||||
- URL templating via `{locale}` / `{namespace}` placeholders with
|
||||
`encodeURIComponent` per segment.
|
||||
- Pluggable `parsePayload` accepts non-JSON bodies (YAML, MessagePack,
|
||||
schema-validated JSON).
|
||||
- Pluggable `customHeaders` builder per request.
|
||||
- `crossDomain: true` shorthand for `credentials: "include"` +
|
||||
`mode: "cors"` (preserves explicit overrides in `requestOptions`).
|
||||
- Optional `retry?: HttpBackendRetry` (structurally compatible with
|
||||
`@suaveplan/retry`'s `RetryPolicy`) wraps every fetch.
|
||||
- Optional `signal?: AbortSignal` — pre-aborted signals throw
|
||||
`BACKEND_TIMEOUT` without issuing a network request; mid-flight
|
||||
AbortError surfaces as `BACKEND_TIMEOUT`; the signal is threaded into
|
||||
the underlying `RequestInit` so `fetch` can cancel mid-flight.
|
||||
- Zod boundary validation throws `INVALID_OPTIONS` at construction.
|
||||
- Per-call OTel instrumentation under `i18n.backend.http.*`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HttpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
});
|
||||
|
||||
const record = await backend.read("en", "auth");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HttpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.yaml",
|
||||
crossDomain: true,
|
||||
customHeaders: (locale) => ({ "x-locale": locale }),
|
||||
parsePayload: async (res) => parseYaml(await res.text()),
|
||||
retry: {
|
||||
run: async (op) => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
return await op();
|
||||
} catch (err) {
|
||||
if (i === 2) throw err;
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 2_000);
|
||||
const record = await backend.read("en", "auth", ctrl.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class HttpBackend implements Backend`
|
||||
|
||||
- `new HttpBackend(options: HttpBackendOptions)` — Zod-validated;
|
||||
throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input.
|
||||
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
|
||||
fetches and parses the URL. Throws `BACKEND_LOCALE_NOT_AVAILABLE` on
|
||||
404; `BACKEND_READ_FAILED` on 5xx / network / parse failures;
|
||||
`BACKEND_TIMEOUT` on signal abort.
|
||||
|
||||
### Helpers
|
||||
|
||||
- `buildUrl(loadPath, locale, namespace): string` — substitutes
|
||||
`{locale}` and `{namespace}` with `encodeURIComponent`-applied
|
||||
values.
|
||||
- `defaultParsePayload: ParsePayloadFn` — JSON object parser. Rejects
|
||||
arrays, null, and primitives as `BACKEND_READ_FAILED`.
|
||||
- `HttpBackendOptionsSchema: z.ZodObject` — Zod boundary schema.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class delegates to a private `#fetchOnce` helper that classifies
|
||||
network failures: `AbortError` / `ABORT_ERR` codes surface as
|
||||
`BACKEND_TIMEOUT` (so consumers can distinguish cancellation from
|
||||
transport faults), and `I18nError(I18N.BACKEND_TIMEOUT)` thrown from
|
||||
inside `fetchFn` (e.g. an injected fake) propagates unchanged.
|
||||
Everything else from the network surface — `TypeError`, refused
|
||||
connections, DNS failures, CORS preflights — maps to
|
||||
`BACKEND_READ_FAILED`. Capability flags stay `false` for write paths
|
||||
because the HTTP transport has no canonical `PUT` semantics across
|
||||
CDNs; consumers needing write-back wire a different backend.
|
||||
@@ -0,0 +1,649 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildUrl,
|
||||
defaultParsePayload,
|
||||
HttpBackend,
|
||||
HttpBackendOptionsSchema,
|
||||
} from "./backend.js";
|
||||
|
||||
function makeFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||
const fn: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
};
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
// ── Conformance harness ──────────────────────────────────────────────
|
||||
runBackendConformance("HttpBackend", async () => {
|
||||
const { fn } = makeFetch((url) => {
|
||||
if (url.includes("/missing") || url.includes("/xx/")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
return { backend };
|
||||
});
|
||||
|
||||
describe("HttpBackend specifics", () => {
|
||||
it("capabilities matrix is read-only", { timeout: 5000 }, () => {
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: makeFetch(() => new Response("{}")).fn,
|
||||
});
|
||||
expect(backend.capabilities).toEqual({
|
||||
canRead: true,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("default name is http", { timeout: 5000 }, () => {
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: makeFetch(() => new Response("{}")).fn,
|
||||
});
|
||||
expect(backend.name).toBe("http");
|
||||
});
|
||||
|
||||
it("custom name override", { timeout: 5000 }, () => {
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: makeFetch(() => new Response("{}")).fn,
|
||||
name: "cdn",
|
||||
});
|
||||
expect(backend.name).toBe("cdn");
|
||||
});
|
||||
|
||||
it("substitutes {locale} and {namespace} in loadPath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ login: { title: "Sign in" } }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(calls[0]?.url).toBe("/i18n/en/auth.json");
|
||||
});
|
||||
|
||||
it("URL-encodes locale and namespace path segments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() => new Response(JSON.stringify({}), { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
await backend.read("en-US", "auth/v2");
|
||||
expect(calls[0]?.url).toBe("/i18n/en-US/auth%2Fv2.json");
|
||||
});
|
||||
|
||||
it("200 OK returns parsed JSON", { timeout: 5000 }, async () => {
|
||||
const { fn } = makeFetch(
|
||||
() => new Response(JSON.stringify({ hello: "Hi" }), { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
expect(await backend.read("en", "common")).toEqual({ hello: "Hi" });
|
||||
});
|
||||
|
||||
it("404 maps to BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(() => new Response("", { status: 404 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({ status: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
it("500 maps to BACKEND_READ_FAILED", { timeout: 5000 }, async () => {
|
||||
const { fn } = makeFetch(() => new Response("", { status: 500 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
expect((err as I18nError).context).toMatchObject({ status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
it("network failure maps to BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn: typeof fetch = async () => {
|
||||
throw new TypeError("network unreachable");
|
||||
};
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("payload parse failure maps to BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(
|
||||
() => new Response("not json", { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
expect((err as I18nError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("parsePayload may throw I18nError directly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
parsePayload: async () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_READ_FAILED",
|
||||
"custom parser rejection"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
expect((err as I18nError).message).toContain(
|
||||
"custom parser rejection"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("custom parsePayload is invoked", { timeout: 5000 }, async () => {
|
||||
const { fn } = makeFetch(
|
||||
() =>
|
||||
new Response("login.title=Sign in\n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
})
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.properties",
|
||||
fetchFn: fn,
|
||||
parsePayload: async (response) => {
|
||||
const text = await response.text();
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx > 0)
|
||||
out[line.slice(0, idx).trim()] = line
|
||||
.slice(idx + 1)
|
||||
.trim();
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
"login.title": "Sign in",
|
||||
});
|
||||
});
|
||||
|
||||
it("customHeaders is invoked", { timeout: 5000 }, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() => new Response("{}", { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
customHeaders: (locale, namespace) => ({
|
||||
"x-locale": locale,
|
||||
"x-namespace": namespace,
|
||||
}),
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(calls[0]?.init?.headers).toEqual({
|
||||
"x-locale": "en",
|
||||
"x-namespace": "auth",
|
||||
});
|
||||
});
|
||||
|
||||
it("crossDomain sets credentials and mode", { timeout: 5000 }, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() => new Response("{}", { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
crossDomain: true,
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
expect(calls[0]?.init?.mode).toBe("cors");
|
||||
});
|
||||
|
||||
it("requestOptions credentials are preserved when crossDomain is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() => new Response("{}", { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
crossDomain: true,
|
||||
requestOptions: { credentials: "same-origin" },
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(calls[0]?.init?.credentials).toBe("same-origin");
|
||||
});
|
||||
|
||||
it("retry.run wraps the fetch operation", { timeout: 5000 }, async () => {
|
||||
let attempts = 0;
|
||||
const fn: typeof fetch = async () => {
|
||||
attempts++;
|
||||
if (attempts < 2) throw new TypeError("transient");
|
||||
return new Response("{}", { status: 200 });
|
||||
};
|
||||
let runCalls = 0;
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
retry: {
|
||||
run: async (op) => {
|
||||
runCalls++;
|
||||
try {
|
||||
return await op();
|
||||
} catch {
|
||||
return await op();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(runCalls).toBe(1);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects non-object JSON payloads as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(
|
||||
() => new Response('"a string"', { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects array JSON payloads as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(
|
||||
() => new Response("[1, 2, 3]", { status: 200 })
|
||||
);
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid options throw INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new HttpBackend({
|
||||
loadPath: "",
|
||||
fetchFn: makeFetch(() => new Response("")).fn,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid loadPath type throws INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new HttpBackend({
|
||||
loadPath: 42 as unknown as string,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth", ctrl.signal);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
|
||||
}
|
||||
});
|
||||
|
||||
it("mid-flight AbortError surfaces as BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn: typeof fetch = async () => {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
};
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
|
||||
}
|
||||
});
|
||||
|
||||
it("mid-flight ABORT_ERR code surfaces as BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn: typeof fetch = async () => {
|
||||
const err = Object.assign(new Error("aborted"), {
|
||||
code: "ABORT_ERR",
|
||||
});
|
||||
throw err;
|
||||
};
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
|
||||
}
|
||||
});
|
||||
|
||||
it("fetchFn rethrowing I18nError(BACKEND_TIMEOUT) propagates unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn: typeof fetch = async () => {
|
||||
throw new I18nError("I18N.BACKEND_TIMEOUT", "synthetic timeout");
|
||||
};
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_TIMEOUT");
|
||||
expect((err as I18nError).message).toContain("synthetic timeout");
|
||||
}
|
||||
});
|
||||
|
||||
it("AbortSignal is threaded into RequestInit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn, calls } = makeFetch(
|
||||
() => new Response("{}", { status: 200 })
|
||||
);
|
||||
const ctrl = new AbortController();
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
await backend.read("en", "auth", ctrl.signal);
|
||||
expect(calls[0]?.init?.signal).toBe(ctrl.signal);
|
||||
});
|
||||
|
||||
it("uses default fetch fallback when globalThis.fetch is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = globalThis.fetch;
|
||||
(globalThis as { fetch: typeof fetch | undefined }).fetch = undefined;
|
||||
try {
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
});
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses real globalThis.fetch when no fetchFn supplied (smoke)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
});
|
||||
expect(backend.name).toBe("http");
|
||||
});
|
||||
|
||||
it("retry policy receives signal when one is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
|
||||
const ctrl = new AbortController();
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
retry: {
|
||||
run: async (op, opts) => {
|
||||
receivedSignal = opts?.signal;
|
||||
return op();
|
||||
},
|
||||
},
|
||||
});
|
||||
await backend.read("en", "auth", ctrl.signal);
|
||||
expect(receivedSignal).toBe(ctrl.signal);
|
||||
});
|
||||
|
||||
it("customHeaders throwing I18nError propagates with original code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
customHeaders: () => {
|
||||
throw new I18nError(
|
||||
"I18N.INVALID_OPTIONS",
|
||||
"header build rejected"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("customHeaders rejection propagates as caught error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fn } = makeFetch(() => new Response("{}", { status: 200 }));
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
customHeaders: () => {
|
||||
throw new Error("header build failed");
|
||||
},
|
||||
});
|
||||
await expect(backend.read("en", "auth")).rejects.toThrow(
|
||||
"header build failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("non-Error thrown from fetchFn maps to BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fn: typeof fetch = async () => {
|
||||
const x: unknown = "not an error instance";
|
||||
throw x;
|
||||
};
|
||||
const backend = new HttpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: fn,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("HttpBackendOptionsSchema parses a minimal valid input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = HttpBackendOptionsSchema.safeParse({
|
||||
loadPath: "/x/{locale}/{namespace}.json",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUrl helper", () => {
|
||||
it("substitutes both placeholders", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
buildUrl("/p/{locale}/{namespace}.json", "en-GB", "common")
|
||||
).toBe("/p/en-GB/common.json");
|
||||
});
|
||||
|
||||
it("URL-encodes special characters", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
buildUrl("/p/{locale}/{namespace}.json", "en GB", "errors/auth")
|
||||
).toBe("/p/en%20GB/errors%2Fauth.json");
|
||||
});
|
||||
|
||||
it("substitutes every placeholder occurrence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
buildUrl("/p/{locale}/{locale}/{namespace}.json", "en", "common")
|
||||
).toBe("/p/en/en/common.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultParsePayload helper", () => {
|
||||
it("accepts a JSON object payload", { timeout: 5000 }, async () => {
|
||||
const response = new Response(JSON.stringify({ hello: "Hi" }), {
|
||||
status: 200,
|
||||
});
|
||||
expect(await defaultParsePayload(response, "en", "common")).toEqual({
|
||||
hello: "Hi",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects array payload", { timeout: 5000 }, async () => {
|
||||
const response = new Response("[1,2,3]", { status: 200 });
|
||||
try {
|
||||
await defaultParsePayload(response, "en", "common");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects null payload", { timeout: 5000 }, async () => {
|
||||
const response = new Response("null", { status: 200 });
|
||||
await expect(
|
||||
defaultParsePayload(response, "en", "common")
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
});
|
||||
|
||||
it("rejects primitive payload", { timeout: 5000 }, async () => {
|
||||
const response = new Response('"hi"', { status: 200 });
|
||||
await expect(
|
||||
defaultParsePayload(response, "en", "common")
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead } from "../observability/observability.js";
|
||||
import type {
|
||||
CustomHeadersFn,
|
||||
HttpBackendOptions,
|
||||
HttpBackendRetry,
|
||||
ParsePayloadFn,
|
||||
} from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link HttpBackendOptions}. Validates only the
|
||||
* statically-checkable shape; injected functions (`fetchFn`,
|
||||
* `parsePayload`, `customHeaders`, `retry`) are accepted as opaque
|
||||
* `z.unknown()` because Zod cannot meaningfully type-check function
|
||||
* arity. Caller-supplied option values failing this schema cause the
|
||||
* constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export const HttpBackendOptionsSchema = z.object({
|
||||
loadPath: z
|
||||
.string()
|
||||
.min(1, "loadPath must contain at least the {locale} placeholder"),
|
||||
name: z.string().min(1).optional(),
|
||||
fetchFn: z.unknown().optional(),
|
||||
parsePayload: z.unknown().optional(),
|
||||
customHeaders: z.unknown().optional(),
|
||||
requestOptions: z
|
||||
.object({
|
||||
method: z.string().optional(),
|
||||
credentials: z.enum(["omit", "same-origin", "include"]).optional(),
|
||||
mode: z
|
||||
.enum(["cors", "no-cors", "same-origin", "navigate"])
|
||||
.optional(),
|
||||
cache: z
|
||||
.enum([
|
||||
"default",
|
||||
"no-store",
|
||||
"reload",
|
||||
"no-cache",
|
||||
"force-cache",
|
||||
"only-if-cached",
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
retry: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type HttpBackendOptionsInput = z.input<typeof HttpBackendOptionsSchema>;
|
||||
|
||||
/**
|
||||
* Default JSON parser — accepts a JSON object payload, rejects arrays,
|
||||
* null, and primitive values as `BACKEND_READ_FAILED` (translation
|
||||
* records are always objects in the contract).
|
||||
*/
|
||||
export const defaultParsePayload: ParsePayloadFn = async (response) => {
|
||||
const json = (await response.json()) as unknown;
|
||||
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
"HttpBackend: payload is not a TranslationRecord object"
|
||||
);
|
||||
}
|
||||
return json as TranslationRecord;
|
||||
};
|
||||
|
||||
/**
|
||||
* Substitute `{locale}` and `{namespace}` placeholders in `loadPath`,
|
||||
* URL-encoding each segment via `encodeURIComponent`.
|
||||
*/
|
||||
export function buildUrl(
|
||||
loadPath: string,
|
||||
locale: string,
|
||||
namespace: string
|
||||
): string {
|
||||
return loadPath
|
||||
.replaceAll("{locale}", encodeURIComponent(locale))
|
||||
.replaceAll("{namespace}", encodeURIComponent(namespace));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default `fetch` binding. When `globalThis.fetch` is
|
||||
* unavailable (older Node, non-Node environments without DOM), returns
|
||||
* a stub that throws `BACKEND_READ_FAILED` with a clear message
|
||||
* prompting consumers to inject `fetchFn` explicitly.
|
||||
*/
|
||||
function defaultFetch(): typeof fetch {
|
||||
if (typeof globalThis.fetch !== "function") {
|
||||
return (async () => {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
"HttpBackend: globalThis.fetch is unavailable; supply fetchFn"
|
||||
);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-backed translation source.
|
||||
*
|
||||
* Universal — runs in browsers, Bun, and Node.js >= 18. Read-only
|
||||
* by design (`canSave: false`). Maps HTTP 404 to
|
||||
* `BACKEND_LOCALE_NOT_AVAILABLE` (the chained-backend fallthrough
|
||||
* signal); HTTP 5xx, network failures, and parse failures to
|
||||
* `BACKEND_READ_FAILED` (a chain-stopping signal — transport failures
|
||||
* must propagate, not silently degrade). Pluggable `parsePayload`
|
||||
* accepts non-JSON payloads (YAML, MessagePack, schema-validating
|
||||
* JSON). Optional injectable `retry` policy wraps every fetch.
|
||||
* Optional `signal?: AbortSignal` parameter on `read` throws
|
||||
* `BACKEND_TIMEOUT` on pre-aborted signals and is threaded into the
|
||||
* underlying `RequestInit` so `fetch` can cancel mid-flight.
|
||||
*/
|
||||
export class HttpBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
readonly #loadPath: string;
|
||||
readonly #fetchFn: typeof fetch;
|
||||
readonly #parsePayload: ParsePayloadFn;
|
||||
readonly #customHeaders: CustomHeadersFn | undefined;
|
||||
readonly #requestOptions: RequestInit | undefined;
|
||||
readonly #crossDomain: boolean;
|
||||
readonly #retry: HttpBackendRetry | undefined;
|
||||
|
||||
public constructor(options: HttpBackendOptions) {
|
||||
const parsed = HttpBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"HttpBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.name = options.name ?? "http";
|
||||
this.#loadPath = options.loadPath;
|
||||
this.#fetchFn = options.fetchFn ?? defaultFetch();
|
||||
this.#parsePayload = options.parsePayload ?? defaultParsePayload;
|
||||
this.#customHeaders = options.customHeaders;
|
||||
this.#requestOptions = options.requestOptions;
|
||||
this.#crossDomain = options.crossDomain ?? false;
|
||||
this.#retry = options.retry;
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
const url = buildUrl(this.#loadPath, locale, namespace);
|
||||
let outcome: "hit" | "miss" | "error" = "hit";
|
||||
let errorCode: string | undefined;
|
||||
let errorStatus: number | undefined;
|
||||
try {
|
||||
const init = await this.#buildInit(locale, namespace, signal);
|
||||
const fire = async () => await this.#fetchOnce(url, init);
|
||||
const response = this.#retry
|
||||
? await this.#retry.run(
|
||||
fire,
|
||||
signal !== undefined ? { signal } : undefined
|
||||
)
|
||||
: await fire();
|
||||
|
||||
if (response.status === 404) {
|
||||
outcome = "miss";
|
||||
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
|
||||
errorStatus = 404;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`HttpBackend: ${url} returned 404`,
|
||||
{
|
||||
context: {
|
||||
locale,
|
||||
namespace,
|
||||
url,
|
||||
status: 404,
|
||||
backend: this.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
outcome = "error";
|
||||
errorCode = I18N_CODES.BACKEND_READ_FAILED;
|
||||
errorStatus = response.status;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`HttpBackend: ${url} returned ${response.status}`,
|
||||
{
|
||||
context: {
|
||||
locale,
|
||||
namespace,
|
||||
url,
|
||||
status: response.status,
|
||||
backend: this.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.#parsePayload(response, locale, namespace);
|
||||
} catch (cause) {
|
||||
outcome = "error";
|
||||
errorCode = I18N_CODES.BACKEND_READ_FAILED;
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`HttpBackend: parse failed for ${url}`,
|
||||
{
|
||||
cause,
|
||||
context: {
|
||||
locale,
|
||||
namespace,
|
||||
url,
|
||||
backend: this.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (outcome === "hit") {
|
||||
outcome = "error";
|
||||
if (err instanceof I18nError) {
|
||||
errorCode = err.code;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
recordRead(
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
performance.now() - t0,
|
||||
errorCode,
|
||||
errorStatus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchOnce(url: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await this.#fetchFn(url, init);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof I18nError &&
|
||||
cause.code === I18N_CODES.BACKEND_TIMEOUT
|
||||
) {
|
||||
throw cause;
|
||||
}
|
||||
// AbortError surfaced from fetch when its signal aborts.
|
||||
const causeCode =
|
||||
cause instanceof Error
|
||||
? (cause as Error & { code?: string }).code
|
||||
: undefined;
|
||||
const isAbort =
|
||||
cause instanceof Error &&
|
||||
(cause.name === "AbortError" || causeCode === "ABORT_ERR");
|
||||
if (isAbort) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
`HttpBackend: fetch aborted for ${url}`,
|
||||
{ cause, context: { url, backend: this.name } }
|
||||
);
|
||||
}
|
||||
// TypeError on network failure (DNS, CORS preflight, refused).
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`HttpBackend: fetch failed for ${url}`,
|
||||
{ cause, context: { url, backend: this.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #buildInit(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<RequestInit> {
|
||||
const headers = this.#customHeaders
|
||||
? await this.#customHeaders(locale, namespace)
|
||||
: undefined;
|
||||
const init: RequestInit = {
|
||||
...(this.#requestOptions ?? {}),
|
||||
...(headers ? { headers } : {}),
|
||||
};
|
||||
if (this.#crossDomain) {
|
||||
init.credentials = init.credentials ?? "include";
|
||||
init.mode = init.mode ?? "cors";
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
init.signal = signal;
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"HttpBackend: operation aborted",
|
||||
{
|
||||
context: { backend: this.name, reason: signal.reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Parses the HTTP `Response` body into a `TranslationRecord`. Default
|
||||
* implementation does `await res.json()` and rejects non-object payloads
|
||||
* (arrays, null, primitives) as `BACKEND_READ_FAILED`. Override to parse
|
||||
* YAML, MessagePack, or to apply schema validation.
|
||||
*/
|
||||
export type ParsePayloadFn = (
|
||||
response: Response,
|
||||
locale: string,
|
||||
namespace: string
|
||||
) => Promise<TranslationRecord>;
|
||||
|
||||
/** Local alias for HeadersInit so we don't depend on DOM lib types. */
|
||||
export type HttpHeadersInit = Headers | Record<string, string>;
|
||||
|
||||
/** Per-request header builder. Receives `(locale, namespace)`. */
|
||||
export type CustomHeadersFn = (
|
||||
locale: string,
|
||||
namespace: string
|
||||
) => HttpHeadersInit | Promise<HttpHeadersInit>;
|
||||
|
||||
/**
|
||||
* Optional retry policy. Structural so callers can plug
|
||||
* `@suaveplan/retry`'s `RetryPolicy` directly without us taking a hard
|
||||
* dep.
|
||||
*/
|
||||
export interface HttpBackendRetry {
|
||||
readonly run: <T>(
|
||||
operation: () => Promise<T>,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for {@link HttpBackend}.
|
||||
*
|
||||
* - `loadPath` — required URL template with `{locale}` and `{namespace}`
|
||||
* placeholders. Example: `/locales/{locale}/{namespace}.json`.
|
||||
* - `name` — backend label override; defaults to `"http"`.
|
||||
* - `fetchFn` — injectable fetch; defaults to `globalThis.fetch`.
|
||||
* - `parsePayload` — custom payload parser; defaults to
|
||||
* `defaultParsePayload` (JSON object).
|
||||
* - `customHeaders` — per-request header builder.
|
||||
* - `requestOptions` — static request options merged into every fetch
|
||||
* call.
|
||||
* - `crossDomain` — when `true`, sets `credentials: "include"` and
|
||||
* `mode: "cors"` on every request unless already set.
|
||||
* - `retry` — optional retry policy wrapping every fetch.
|
||||
*/
|
||||
export interface HttpBackendOptions {
|
||||
readonly loadPath: string;
|
||||
readonly name?: string;
|
||||
readonly fetchFn?: typeof fetch;
|
||||
readonly parsePayload?: ParsePayloadFn;
|
||||
readonly customHeaders?: CustomHeadersFn;
|
||||
readonly requestOptions?: RequestInit;
|
||||
readonly crossDomain?: boolean;
|
||||
readonly retry?: HttpBackendRetry;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Feature barrel for `@suaveplan/i18n-backend-http/backend`. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
buildUrl,
|
||||
defaultParsePayload,
|
||||
HttpBackend,
|
||||
type HttpBackendOptionsInput,
|
||||
HttpBackendOptionsSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
CustomHeadersFn,
|
||||
HttpBackendOptions,
|
||||
HttpBackendRetry,
|
||||
HttpHeadersInit,
|
||||
ParsePayloadFn,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# `httpBackend(options)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`httpBackend(options)` is the builder-style factory for
|
||||
[`HttpBackend`](../backend/backend.md). It constructs a fresh
|
||||
`HttpBackend` instance per call — no singletons, no shared state, no
|
||||
caching of options or fetch bindings — and surfaces the same Zod
|
||||
boundary validation and capability matrix as the constructor.
|
||||
Consumers who prefer composition over `new` reach for this factory; the
|
||||
two forms are interchangeable. Both exist because i18n-family packages
|
||||
historically preferred named factories for ergonomic test fixtures and
|
||||
dependency-injection setups.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-argument-overhead alias for `new HttpBackend(options)`.
|
||||
- Returns a fresh `HttpBackend` per call (no implicit caching).
|
||||
- Throws `I18nError(I18N.INVALID_OPTIONS)` on malformed input (Zod
|
||||
boundary). Throws nothing else at construction time.
|
||||
- Threads every option (`loadPath`, `fetchFn`, `parsePayload`,
|
||||
`customHeaders`, `requestOptions`, `crossDomain`, `retry`, `name`)
|
||||
unchanged into the constructor.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const backend = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
const backend = httpBackend({
|
||||
name: "cdn",
|
||||
loadPath: "https://cdn.example/i18n/{locale}/{namespace}.json",
|
||||
crossDomain: true,
|
||||
customHeaders: () => ({ "x-tenant": "acme" }),
|
||||
retry: {
|
||||
run: async (op, _opts) => op(),
|
||||
},
|
||||
});
|
||||
|
||||
const ctrl = new AbortController();
|
||||
queueMicrotask(() => ctrl.abort());
|
||||
try {
|
||||
await backend.read("en", "auth", ctrl.signal);
|
||||
} catch (err) {
|
||||
// I18nError(I18N.BACKEND_TIMEOUT)
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `httpBackend(options: HttpBackendOptions): HttpBackend`
|
||||
|
||||
Returns a new `HttpBackend`. Every option is passed verbatim. Throws
|
||||
`I18nError(I18N.INVALID_OPTIONS)` when `options` fails Zod validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory holds no module-level state. Two calls produce two
|
||||
distinct `HttpBackend` instances even when the option objects are
|
||||
referentially identical — this is deliberate so test fixtures can
|
||||
build many cheaply-isolated backends without aliasing issues. The
|
||||
factory is defined in a separate file so it can be tree-shaken away by
|
||||
consumers that import the class directly via the `./backend` subpath.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HttpBackend } from "../backend/backend.js";
|
||||
import { httpBackend } from "./factory.js";
|
||||
|
||||
const NOOP_FETCH: typeof fetch = async () => new Response("{}");
|
||||
|
||||
describe("httpBackend factory", () => {
|
||||
it("returns an HttpBackend instance", { timeout: 5000 }, () => {
|
||||
const result = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: NOOP_FETCH,
|
||||
});
|
||||
expect(result).toBeInstanceOf(HttpBackend);
|
||||
});
|
||||
|
||||
it("default name is http", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: NOOP_FETCH,
|
||||
}).name
|
||||
).toBe("http");
|
||||
});
|
||||
|
||||
it("passes name through to the constructor", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: NOOP_FETCH,
|
||||
name: "cdn",
|
||||
}).name
|
||||
).toBe("cdn");
|
||||
});
|
||||
|
||||
it("returns a fresh instance per call (no singleton)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: NOOP_FETCH,
|
||||
});
|
||||
const b = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn: NOOP_FETCH,
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("passes loadPath through and the resulting backend reads it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured = "";
|
||||
const fetchFn: typeof fetch = async (input) => {
|
||||
captured = typeof input === "string" ? input : input.toString();
|
||||
return new Response("{}");
|
||||
};
|
||||
const backend = httpBackend({
|
||||
loadPath: "/i18n/{locale}/{namespace}.json",
|
||||
fetchFn,
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
expect(captured).toBe("/i18n/en/auth.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { HttpBackend } from "../backend/backend.js";
|
||||
import type { HttpBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `HttpBackend` per call — no internal singleton, no shared
|
||||
* state. Equivalent to `new HttpBackend(options)`.
|
||||
*/
|
||||
export function httpBackend(options: HttpBackendOptions): HttpBackend {
|
||||
return new HttpBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { httpBackend } from "./factory.js";
|
||||
101
packages/localization/i18n/i18n-backend-http/src/index.test.ts
Normal file
101
packages/localization/i18n/i18n-backend-http/src/index.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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-http — root barrel", () => {
|
||||
it("re-exports HttpBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.HttpBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports defaultParsePayload", { timeout: 5000 }, () => {
|
||||
expect(typeof root.defaultParsePayload).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports buildUrl", { timeout: 5000 }, () => {
|
||||
expect(typeof root.buildUrl).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports httpBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.httpBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports HttpBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.HttpBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.getHttpBackendMetrics).toBe("function");
|
||||
expect(typeof root.getHttpBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetHttpBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetHttpBackendMetricsCache).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-http — 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.HttpBackend).toBe("function");
|
||||
expect(typeof backend.defaultParsePayload).toBe("function");
|
||||
expect(typeof backend.buildUrl).toBe("function");
|
||||
expect(backend.HttpBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.httpBackend).toBe("function");
|
||||
expect(typeof observability.getHttpBackendMetrics).toBe("function");
|
||||
expect(typeof observability.recordRead).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-http — 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 or @suaveplan/retry", {
|
||||
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");
|
||||
expect(deps).not.toHaveProperty("@suaveplan/retry");
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
27
packages/localization/i18n/i18n-backend-http/src/index.ts
Normal file
27
packages/localization/i18n/i18n-backend-http/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-http. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
buildUrl,
|
||||
defaultParsePayload,
|
||||
HttpBackend,
|
||||
type HttpBackendOptionsInput,
|
||||
HttpBackendOptionsSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
CustomHeadersFn,
|
||||
HttpBackendOptions,
|
||||
HttpBackendRetry,
|
||||
HttpHeadersInit,
|
||||
ParsePayloadFn,
|
||||
} from "./backend/backend.types.js";
|
||||
export { httpBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getHttpBackendMetrics,
|
||||
getHttpBackendMetricsState,
|
||||
type HttpBackendMetricSample,
|
||||
type HttpBackendMetricsState,
|
||||
recordRead,
|
||||
resetHttpBackendMetricsCache,
|
||||
resetHttpBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
HttpBackendMetricSample,
|
||||
HttpBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getHttpBackendMetrics,
|
||||
getHttpBackendMetricsState,
|
||||
recordRead,
|
||||
resetHttpBackendMetricsCache,
|
||||
resetHttpBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `@suaveplan/i18n-backend-http/observability`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Lazy OpenTelemetry instrument factory and matched in-memory state ring
|
||||
for `@suaveplan/i18n-backend-http`. Production paths in
|
||||
[`backend.ts`](../backend/backend.md) call `recordRead(...)` once per
|
||||
`HttpBackend.read` call; the instruments are no-ops until a
|
||||
`MeterProvider` is registered against `@opentelemetry/api`, so consumers
|
||||
pay zero overhead unless they wire up the SDK. The matched state ring
|
||||
exists so spec-conformance tests can assert exact attribute payloads
|
||||
without standing up a full SDK + `InMemoryMetricExporter` pipeline.
|
||||
|
||||
## Features
|
||||
|
||||
- `i18n.backend.http.read_duration_ms` — Histogram, milliseconds.
|
||||
Attributes: `locale`, `namespace`, `outcome ∈ hit | miss | error`.
|
||||
- `i18n.backend.http.read_total` — Counter. Attribute: `outcome`.
|
||||
- `i18n.backend.http.read_errors_total` — Counter. Attributes: `code`
|
||||
(the `I18N.*` error code), optional `status` (HTTP status code as
|
||||
string when the error originated from a known HTTP response).
|
||||
- Zero-cost when no MeterProvider is wired — every instrument is a
|
||||
cached singleton.
|
||||
- Test-only state ring + reset helpers
|
||||
(`getHttpBackendMetricsState`, `resetHttpBackendMetricsState`,
|
||||
`resetHttpBackendMetricsCache`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordRead } from "@suaveplan/i18n-backend-http/observability";
|
||||
|
||||
recordRead("en", "auth", "hit", 0.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getHttpBackendMetricsState,
|
||||
recordRead,
|
||||
resetHttpBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-http/observability";
|
||||
|
||||
resetHttpBackendMetricsState();
|
||||
recordRead("xx", "auth", "miss", 1.2, "I18N.BACKEND_LOCALE_NOT_AVAILABLE", 404);
|
||||
const state = getHttpBackendMetricsState();
|
||||
console.log(state.readErrorsTotal);
|
||||
// → [{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE", status: "404" } }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getHttpBackendMetrics(): { readDurationMs, readTotal, readErrorsTotal }`
|
||||
— idempotent factory.
|
||||
- `recordRead(locale, namespace, outcome, durationMs, errorCode?, status?)`
|
||||
— emit + ring-record.
|
||||
- `getHttpBackendMetricsState(): HttpBackendMetricsState` — frozen
|
||||
snapshot.
|
||||
- `resetHttpBackendMetricsState()` / `resetHttpBackendMetricsCache()` —
|
||||
test helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`status` is encoded as a string attribute so OTel SDK aggregators can
|
||||
union it with the `code` label without numeric coercion ambiguity. The
|
||||
counter cardinality is bounded by the small `I18N.*` error-code set
|
||||
times a typical handful of HTTP statuses (404 / 500 / 502 / 503), which
|
||||
stays well below downstream backend high-cardinality limits. The cached
|
||||
instruments survive across `recordRead` calls inside one process — the
|
||||
`resetHttpBackendMetricsCache` helper exists purely so tests can verify
|
||||
the cache-hit path is exercised.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getHttpBackendMetrics,
|
||||
getHttpBackendMetricsState,
|
||||
recordRead,
|
||||
resetHttpBackendMetricsCache,
|
||||
resetHttpBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("HttpBackend metrics — instrument lifecycle", () => {
|
||||
it("getHttpBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetHttpBackendMetricsCache();
|
||||
const a = getHttpBackendMetrics();
|
||||
const b = getHttpBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetHttpBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getHttpBackendMetrics();
|
||||
resetHttpBackendMetricsCache();
|
||||
const b = getHttpBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HttpBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetHttpBackendMetricsState();
|
||||
recordRead("en", "auth", "hit", 0.42);
|
||||
const state = getHttpBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "hit",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied without status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetHttpBackendMetricsState();
|
||||
recordRead("en", "auth", "error", 0.42, "I18N.BACKEND_READ_FAILED");
|
||||
const state = getHttpBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "error" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_READ_FAILED" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records error counter with status when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetHttpBackendMetricsState();
|
||||
recordRead(
|
||||
"xx",
|
||||
"auth",
|
||||
"miss",
|
||||
0.5,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
404
|
||||
);
|
||||
const state = getHttpBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
status: "404",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HttpBackend metrics — state ring helpers", () => {
|
||||
it("resetHttpBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("en", "auth", "hit", 0.1);
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
404
|
||||
);
|
||||
resetHttpBackendMetricsState();
|
||||
const state = getHttpBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-http`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-http')`.
|
||||
* 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
|
||||
* `resetHttpBackendMetricsState()` between cases and read
|
||||
* `getHttpBackendMetricsState()` to inspect counters / histogram
|
||||
* counts.
|
||||
*/
|
||||
|
||||
export interface HttpBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HttpBackendMetricsState {
|
||||
readonly readDurationMs: HttpBackendMetricSample[];
|
||||
readonly readTotal: HttpBackendMetricSample[];
|
||||
readonly readErrorsTotal: HttpBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: HttpBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to call
|
||||
* from any production code path.
|
||||
*/
|
||||
export function getHttpBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-http");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.http.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of an HttpBackend.read call; labels: locale, namespace, outcome.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.http.read_total", {
|
||||
description:
|
||||
"HttpBackend.read call count; label: outcome (hit | miss | error).",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.http.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"HttpBackend.read error count; labels: code (the I18N error code), status (HTTP status when applicable).",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{outcome}` and per-error-code `read_errors_total{code,status?}`
|
||||
* counters and pushes one sample into the in-memory state ring per counter
|
||||
* touched. The optional `status` attribute is included on
|
||||
* `read_errors_total` when the error originated from a known HTTP status
|
||||
* code (404 / 5xx); transport / parse failures emit only `code`.
|
||||
*/
|
||||
export function recordRead(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
outcome: "hit" | "miss" | "error",
|
||||
durationMs: number,
|
||||
errorCode?: string,
|
||||
status?: number
|
||||
): void {
|
||||
const ins = getHttpBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
};
|
||||
ins.readDurationMs.record(durationMs, histogramAttrs);
|
||||
state.readDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { outcome };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs: Record<string, string> = { code: errorCode };
|
||||
if (status !== undefined) {
|
||||
errorAttrs["status"] = String(status);
|
||||
}
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getHttpBackendMetricsState(): HttpBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetHttpBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetHttpBackendMetricsCache(): 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"]
|
||||
}
|
||||
41
packages/localization/i18n/i18n-backend-http/vite.config.ts
Normal file
41
packages/localization/i18n/i18n-backend-http/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-http",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/i18n-shared",
|
||||
"@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,
|
||||
},
|
||||
});
|
||||
121
packages/localization/i18n/i18n-backend-memory/README.md
Normal file
121
packages/localization/i18n/i18n-backend-memory/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# @suaveplan/i18n-backend-memory - Universal in-memory translation backend for the @suaveplan/i18n family. Backed by Map<locale, Map<namespace, TranslationRecord>> plus a modification ledger; implements the full Backend contract (read, readMulti, save, lastModified). Designed for tests, seeded defaults, and the cache layer of a ChainedBackend. Zero runtime dependencies on @suaveplan/i18n core.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal in-memory translation backend for the
|
||||
`@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace,
|
||||
TranslationRecord>>` plus a modification ledger; implements the full
|
||||
`Backend` contract (`read`, `readMulti`, `save`, `lastModified`).
|
||||
Designed for tests, seeded defaults, and the cache layer of a
|
||||
`ChainedBackend`. Zero runtime dependencies on `@suaveplan/i18n`
|
||||
core.
|
||||
|
||||
<!-- 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)_
|
||||
## Why this exists
|
||||
|
||||
The Wave 5 modular split lifts each backend out of the
|
||||
`@suaveplan/i18n` monolith into its own sibling so consumers can
|
||||
install only the backends they need (matching the `crypto-*` /
|
||||
`drive-*` / `network-plugin-*` granularity established elsewhere in
|
||||
the project). The in-memory backend is the simplest and most
|
||||
universal of the five — it has zero external runtime deps, runs
|
||||
identically in browser and server, and is the canonical seed source
|
||||
for every other backend's tests.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-backend-memory
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = memoryBackend({
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
fr: { common: { hello: "Bonjour" } },
|
||||
},
|
||||
});
|
||||
|
||||
const enCommon = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const t = await backend.lastModified("en", "auth"); // number (ms)
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-backend-memory` | Root barrel — class + factory + types + schemas + observability |
|
||||
| `@suaveplan/i18n-backend-memory/backend` | `MemoryBackend` class only |
|
||||
| `@suaveplan/i18n-backend-memory/factory` | `memoryBackend` factory function only |
|
||||
| `@suaveplan/i18n-backend-memory/schemas` | Zod boundary schemas |
|
||||
| `@suaveplan/i18n-backend-memory/observability` | OTel instruments + in-memory state ring |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [backend.md](src/backend/backend.md) — `MemoryBackend` class + capability matrix
|
||||
- [factory.md](src/factory/factory.md) — `memoryBackend` factory function
|
||||
- [observability.md](src/observability/observability.md) — OTel instruments + metric names + cardinality
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Flag | Value | Notes |
|
||||
|---|---|---|
|
||||
| `canRead` | `true` | always — every backend reads |
|
||||
| `canReadMulti` | `true` | bulk-load O(N) over the namespace map |
|
||||
| `canSave` | `true` | persists into the in-memory store |
|
||||
| `canTrackModification` | `true` | injectable `now()` clock |
|
||||
| `canWatch` | `false` | no reactive `loaded` events |
|
||||
|
||||
## Testing
|
||||
|
||||
The package's own tests use the shared `runBackendConformance`
|
||||
harness from `@suaveplan/i18n-shared/testing` so the universal
|
||||
`Backend` contract is exercised against a fresh in-memory backend
|
||||
per assertion:
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
runBackendConformance("MemoryBackend", async () => {
|
||||
const backend = new MemoryBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0`
|
||||
release. The metric names under `i18n.backend.memory.*` are part of
|
||||
the public API contract and will not change without a major bump.
|
||||
|
||||
## Wave 5 Scope
|
||||
|
||||
This package is Wave 5a of the 14-package i18n modular split. It
|
||||
ships pure additions only — `packages/i18n/i18n/src/core/backend/memory/`
|
||||
retains its copy of the backend until Wave 6 deletes it. Both copies
|
||||
coexist during Wave 5; downstream consumers continue to import from
|
||||
the monolith and observe no behaviour change.
|
||||
|
||||
82
packages/localization/i18n/i18n-backend-memory/package.json
Normal file
82
packages/localization/i18n/i18n-backend-memory/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-memory",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal in-memory translation backend for the @suaveplan/i18n family. Backed by Map<locale, Map<namespace, TranslationRecord>> plus a modification ledger; implements the full Backend contract (read, readMulti, save, lastModified). Designed for tests, seeded defaults, and the cache layer of a ChainedBackend. Zero runtime dependencies on @suaveplan/i18n core.",
|
||||
"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/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,129 @@
|
||||
# `MemoryBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryBackend` is the universal in-memory translation backend
|
||||
for the `@suaveplan/i18n` family. It implements the full `Backend`
|
||||
contract from `@suaveplan/types` (read, readMulti, save,
|
||||
lastModified) backed by `Map<locale, Map<namespace,
|
||||
TranslationRecord>>` plus a modification ledger keyed by `${locale}
|
||||
${namespace}`. It is the canonical seed source for tests, the
|
||||
default cache layer for `ChainedBackend`, and the reference
|
||||
implementation every other backend's conformance suite is matched
|
||||
against.
|
||||
|
||||
The backend has zero runtime dependencies on `@suaveplan/i18n` core,
|
||||
so it can be used standalone — for example, a test suite needing
|
||||
catalog-shaped data can import `MemoryBackend` without paying the
|
||||
cost of pulling in the full catalog stack.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 5-flag capability matrix: `canRead: true`, `canReadMulti:
|
||||
true`, `canSave: true`, `canTrackModification: true`, `canWatch:
|
||||
false`.
|
||||
- Optional constructor seed (`{ <locale>: { <namespace>:
|
||||
TranslationRecord } }`) for pre-populating before first read.
|
||||
- Injectable `now: () => number` clock for deterministic
|
||||
`lastModified` testing.
|
||||
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
|
||||
and `save`; pre-aborted signals throw `I18nError(I18N.BACKEND_TIMEOUT)`.
|
||||
- OTel histograms + counters emitted on every read/save:
|
||||
`i18n.backend.memory.read_duration_ms`, `read_total{outcome}`,
|
||||
`read_errors_total{code}`, `write_duration_ms`,
|
||||
`write_total{outcome}`.
|
||||
- Zod boundary validation: invalid options throw
|
||||
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
fr: { common: { hello: "Bonjour" } },
|
||||
},
|
||||
});
|
||||
|
||||
const enCommon = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The injectable clock makes `lastModified` deterministic so chained-
|
||||
backend tests can assert exact timestamps without fighting wall-clock
|
||||
non-determinism.
|
||||
|
||||
```ts
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
let tick = 1000;
|
||||
const backend = new MemoryBackend({
|
||||
name: "cache",
|
||||
now: () => tick++,
|
||||
});
|
||||
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
await backend.save("en", "auth", { login: "Sign in" });
|
||||
|
||||
const tCommon = await backend.lastModified("en", "common"); // 1000
|
||||
const tAuth = await backend.lastModified("en", "auth"); // 1001
|
||||
|
||||
// Cancellable reads play nicely with multi-backend orchestration.
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await backend.read("en", "common", controller.signal); // throws BACKEND_TIMEOUT
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryBackend implements Backend`
|
||||
|
||||
- `constructor(options?: MemoryBackendOptions)` — validates `options`
|
||||
via `MemoryBackendOptionsSchema`; throws `I18nError(I18N.INVALID_OPTIONS)`
|
||||
on schema mismatch.
|
||||
- `name: string` — backend label (default `"memory"`).
|
||||
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
|
||||
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
|
||||
resolves with the seeded record; throws
|
||||
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
|
||||
- `readMulti(locale, namespaces, signal?): Promise<Record<string,
|
||||
TranslationRecord>>` — returns only the namespaces that exist;
|
||||
missing namespaces are omitted (not undefined).
|
||||
- `save(locale, namespace, record, signal?): Promise<void>` —
|
||||
persists the record and stamps the modification ledger via the
|
||||
injected `now()` function.
|
||||
- `lastModified(locale, namespace): Promise<number | undefined>` —
|
||||
returns the timestamp of the last `save` for the slot, or
|
||||
`undefined` if never written.
|
||||
- `clear(): void` — test-only helper, drops all in-memory state.
|
||||
- `snapshot(): MemorySeed` — returns a fresh deep-copy snapshot of
|
||||
the current store keyed by locale → namespace.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `Map<locale, Map<namespace, ...>>` two-level data structure
|
||||
gives O(1) lookups and O(1) inserts. The modification ledger is a
|
||||
flat `Map<string, number>` keyed by `"${locale} ${namespace}"`
|
||||
with a single space separator (BCP-47 tags do not contain spaces,
|
||||
so the key is unambiguous).
|
||||
- `signal?.aborted` is checked at the entry of every async method
|
||||
and the throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The
|
||||
`aborted` flag is sampled exactly once at the top — the backend
|
||||
performs no further async work, so post-yield abort is not
|
||||
meaningful.
|
||||
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
|
||||
a `MeterProvider` is registered. Test suites use the in-memory
|
||||
metrics state ring exposed by `./observability` to assert
|
||||
counter/histogram payloads without standing up the full SDK.
|
||||
- Seeded options call the same private `#put` path as `save`, so
|
||||
the modification ledger is populated for every seeded slot using
|
||||
the injected `now()` function. The seed is consumed
|
||||
synchronously at construction; consumers needing async seeding
|
||||
call `save()` post-construction.
|
||||
@@ -0,0 +1,313 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { memoryBackend } from "../factory/factory.js";
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "../observability/observability.js";
|
||||
import { MemoryBackend } from "./backend.js";
|
||||
import type { MemorySeed } from "./backend.types.js";
|
||||
|
||||
// ── Run the universal Backend contract conformance suite ────────────
|
||||
|
||||
runBackendConformance("MemoryBackend", async () => {
|
||||
const backend = new MemoryBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Memory-specific behaviour ───────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend specifics", () => {
|
||||
it("reads seeded records on construction", { timeout: 5000 }, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { auth: { login: { title: "Sign in" } } },
|
||||
fr: { auth: { login: { title: "Se connecter" } } },
|
||||
},
|
||||
});
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in" },
|
||||
});
|
||||
expect(await backend.read("fr", "auth")).toEqual({
|
||||
login: { title: "Se connecter" },
|
||||
});
|
||||
});
|
||||
|
||||
it("save then read round-trips", { timeout: 5000 }, async () => {
|
||||
const backend = memoryBackend();
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("lastModified returns the injected timestamp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({ now: () => 1234 });
|
||||
await backend.save("en", "auth", { x: "y" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(t).toBe(1234);
|
||||
});
|
||||
|
||||
it("lastModified is monotonic across saves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let t = 1000;
|
||||
const backend = new MemoryBackend({ now: () => t++ });
|
||||
await backend.save("en", "auth", { a: "1" });
|
||||
const t1 = await backend.lastModified("en", "auth");
|
||||
await backend.save("en", "auth", { a: "2" });
|
||||
const t2 = await backend.lastModified("en", "auth");
|
||||
expect(t2).toBeGreaterThan(t1 as number);
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
expect(await backend.lastModified("xx", "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("readMulti omits missing namespaces", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" }, errors: { e: "E" } } },
|
||||
});
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"errors",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
errors: { e: "E" },
|
||||
});
|
||||
expect(result).not.toHaveProperty("missing");
|
||||
});
|
||||
|
||||
it("readMulti returns empty for absent locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const result = await backend.readMulti("xx", ["any"]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("read miss throws BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
await expect(backend.read("en", "auth")).rejects.toMatchObject({
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("default name is 'memory'", { timeout: 5000 }, () => {
|
||||
expect(new MemoryBackend().name).toBe("memory");
|
||||
});
|
||||
|
||||
it("custom name override", { timeout: 5000 }, () => {
|
||||
expect(new MemoryBackend({ name: "cache" }).name).toBe("cache");
|
||||
});
|
||||
|
||||
it("capabilities matrix matches the contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new MemoryBackend().capabilities;
|
||||
expect(c).toEqual({
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() drops all state", { timeout: 5000 }, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
backend.clear();
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("snapshot() returns a copy of the seed-shape", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { auth: { ok: "Ok" }, common: { yes: "Yes" } },
|
||||
fr: { auth: { ok: "OK" } },
|
||||
},
|
||||
});
|
||||
const snap = backend.snapshot();
|
||||
expect(snap).toEqual({
|
||||
en: { auth: { ok: "Ok" }, common: { yes: "Yes" } },
|
||||
fr: { auth: { ok: "OK" } },
|
||||
});
|
||||
// Mutating the snapshot does not affect the backend.
|
||||
(snap.en as Record<string, unknown>).extra = { x: "y" };
|
||||
expect(backend.snapshot().en).not.toHaveProperty("extra");
|
||||
});
|
||||
});
|
||||
|
||||
// ── AbortSignal cancellation ────────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend AbortSignal", () => {
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.readMulti("en", ["auth"], ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.save("en", "auth", { x: "y" }, ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
// State unchanged.
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-aborted signal does not affect operation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
await backend.save("en", "auth", { x: "y" }, ac.signal);
|
||||
expect(await backend.read("en", "auth", ac.signal)).toEqual({ x: "y" });
|
||||
expect(await backend.readMulti("en", ["auth"], ac.signal)).toEqual({
|
||||
auth: { x: "y" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boundary validation (Zod) ───────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend boundary validation", () => {
|
||||
it("invalid options throw INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
seed: "not an object" as unknown as MemorySeed,
|
||||
})
|
||||
).toThrow(I18nError);
|
||||
try {
|
||||
new MemoryBackend({
|
||||
seed: "not an object" as unknown as MemorySeed,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("empty-string name throws INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new MemoryBackend({ name: "" })).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("non-function now throws INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
now: 12345 as unknown as () => number,
|
||||
})
|
||||
).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("unknown option key throws (strict schema)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
bogus: true,
|
||||
} as unknown as Parameters<typeof MemoryBackend>[0])
|
||||
).toThrow(I18nError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Observability ───────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend observability", () => {
|
||||
it("emits read_total{outcome=hit} on read hit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
expect(state.readDurationMs.length).toBe(1);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits read_total{outcome=miss} + read_errors_total{code} on miss", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.read("en", "auth").catch(() => undefined);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits write_total{outcome=ok} on save", { timeout: 5000 }, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.save("en", "auth", { x: "y" });
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([{ attributes: { outcome: "ok" } }]);
|
||||
expect(state.writeDurationMs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead, recordWrite } from "../observability/observability.js";
|
||||
import type { MemoryBackendOptions, MemorySeed } from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for the per-namespace translation record. Accepts
|
||||
* any object-shaped value; the i18n message resolver enforces the
|
||||
* deeper string/nested-record contract.
|
||||
*/
|
||||
export const TranslationRecordSchema: z.ZodType<Record<string, unknown>> =
|
||||
z.record(z.string(), z.unknown());
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link MemorySeed}. Validates that the seed
|
||||
* is `Record<locale, Record<namespace, TranslationRecord>>`-shaped.
|
||||
*/
|
||||
export const MemorySeedSchema: z.ZodType<
|
||||
Record<string, Record<string, Record<string, unknown>>>
|
||||
> = z.record(z.string(), z.record(z.string(), TranslationRecordSchema));
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link MemoryBackendOptions}. Validates that
|
||||
* `seed` (if present) is a seed-shaped object, that `name` (if
|
||||
* present) is a non-empty string, and that `now` (if present) is a
|
||||
* function. Caller-supplied option values failing this schema cause
|
||||
* the constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export const MemoryBackendOptionsSchema = z
|
||||
.object({
|
||||
seed: MemorySeedSchema.optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
now: z.function().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MemoryBackendOptionsInput = z.input<
|
||||
typeof MemoryBackendOptionsSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* In-memory translation backend.
|
||||
*
|
||||
* Backed by `Map<locale, Map<namespace, TranslationRecord>>` plus a
|
||||
* `Map<string, number>` modification ledger keyed by `${locale}
|
||||
* ${namespace}`. Implements the full `Backend` contract from
|
||||
* `@suaveplan/types` (`read`, `readMulti`, `save`, `lastModified`).
|
||||
* Designed for tests, seeded defaults, and the cache layer of a
|
||||
* `ChainedBackend`.
|
||||
*
|
||||
* Universal: runs identically in browser and server. No
|
||||
* platform-specific imports.
|
||||
*/
|
||||
export class MemoryBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
readonly #data = new Map<string, Map<string, TranslationRecord>>();
|
||||
readonly #modified = new Map<string, number>();
|
||||
readonly #now: () => number;
|
||||
|
||||
public constructor(options: MemoryBackendOptions = {}) {
|
||||
const parsed = MemoryBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"MemoryBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
this.name = options.name ?? "memory";
|
||||
this.#now = options.now ?? Date.now;
|
||||
if (options.seed) {
|
||||
for (const [locale, byNamespace] of Object.entries(options.seed)) {
|
||||
for (const [ns, record] of Object.entries(byNamespace)) {
|
||||
this.#put(locale, ns, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
const ns = this.#data.get(locale)?.get(namespace);
|
||||
if (ns === undefined) {
|
||||
recordRead(
|
||||
locale,
|
||||
namespace,
|
||||
"miss",
|
||||
performance.now() - t0,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`MemoryBackend: no record for ${locale}/${namespace}`,
|
||||
{ context: { locale, namespace, backend: this.name } }
|
||||
);
|
||||
}
|
||||
recordRead(locale, namespace, "hit", performance.now() - t0);
|
||||
return ns;
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
this.#checkAborted(signal);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
const byLocale = this.#data.get(locale);
|
||||
if (!byLocale) return out;
|
||||
for (const ns of namespaces) {
|
||||
const record = byLocale.get(ns);
|
||||
if (record !== undefined) {
|
||||
out[ns] = record;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
this.#put(locale, namespace, record);
|
||||
recordWrite(locale, namespace, "ok", performance.now() - t0);
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
return this.#modified.get(`${locale} ${namespace}`);
|
||||
}
|
||||
|
||||
/** Test-only: drop all state. Not part of the Backend interface. */
|
||||
public clear(): void {
|
||||
this.#data.clear();
|
||||
this.#modified.clear();
|
||||
}
|
||||
|
||||
/** Convenience for tests / consumers. Snapshot of the seed shape. */
|
||||
public snapshot(): MemorySeed {
|
||||
const out: Record<string, Record<string, TranslationRecord>> = {};
|
||||
for (const [locale, byNs] of this.#data) {
|
||||
const inner: Record<string, TranslationRecord> = {};
|
||||
for (const [ns, rec] of byNs) {
|
||||
inner[ns] = rec;
|
||||
}
|
||||
out[locale] = inner;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#put(locale: string, namespace: string, record: TranslationRecord): void {
|
||||
let byNs = this.#data.get(locale);
|
||||
if (!byNs) {
|
||||
byNs = new Map();
|
||||
this.#data.set(locale, byNs);
|
||||
}
|
||||
byNs.set(namespace, record);
|
||||
this.#modified.set(`${locale} ${namespace}`, this.#now());
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"MemoryBackend: operation aborted",
|
||||
{
|
||||
context: { backend: this.name, reason: signal.reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Seed shape: `{ <locale>: { <namespace>: TranslationRecord } }`. Pre-
|
||||
* populates the in-memory store at construction time.
|
||||
*/
|
||||
export type MemorySeed = Record<string, Record<string, TranslationRecord>>;
|
||||
|
||||
/**
|
||||
* Constructor options for {@link MemoryBackend}.
|
||||
*
|
||||
* - `seed` — optional pre-population for tests and seeded defaults.
|
||||
* - `name` — optional name override (default `"memory"`). Useful when
|
||||
* multiple in-memory backends are registered in a chain.
|
||||
* - `now` — injectable clock for deterministic `lastModified` testing
|
||||
* (default `Date.now`).
|
||||
*/
|
||||
export interface MemoryBackendOptions {
|
||||
readonly seed?: MemorySeed;
|
||||
readonly name?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Feature barrel for `@suaveplan/i18n-backend-memory/backend`. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MemoryBackend,
|
||||
type MemoryBackendOptionsInput,
|
||||
MemoryBackendOptionsSchema,
|
||||
MemorySeedSchema,
|
||||
TranslationRecordSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
MemoryBackendOptions,
|
||||
MemorySeed,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `memoryBackend(options?)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Factory wrapper around `new MemoryBackend(options)` for callers that
|
||||
prefer fluent builder-style composition over `new`. Returns a fresh
|
||||
backend per call — no internal singleton, no shared state, no
|
||||
caching. The factory exists primarily so chained-backend
|
||||
configurations can be expressed declaratively without the visual
|
||||
weight of repeated `new` keywords.
|
||||
|
||||
The factory is the recommended public entry point for routine use
|
||||
because it composes naturally with the rest of the i18n family
|
||||
(every backend ships a `<name>Backend` factory mirroring its class
|
||||
constructor). Direct `new MemoryBackend(...)` remains supported and
|
||||
is exactly equivalent.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `MemoryBackend` per call.
|
||||
- Forwards every constructor option (`seed`, `name`, `now`)
|
||||
unchanged.
|
||||
- Same Zod boundary validation as the class constructor — invalid
|
||||
options throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = memoryBackend();
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
const record = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The factory shines in chained-backend configurations where multiple
|
||||
in-memory backends (e.g. a cache layer + a write-through layer)
|
||||
need to be expressed inline.
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const cache = memoryBackend({ name: "cache" });
|
||||
const seed = memoryBackend({
|
||||
name: "seed",
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Hand both to a ChainedBackend (Wave 5e).
|
||||
// const chain = chainedBackend({ backends: [cache, seed] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `memoryBackend(options?: MemoryBackendOptions): MemoryBackend`
|
||||
|
||||
- `options.seed?: MemorySeed` — optional pre-population (`{ <locale>:
|
||||
{ <namespace>: TranslationRecord } }`).
|
||||
- `options.name?: string` — optional name override (default
|
||||
`"memory"`).
|
||||
- `options.now?: () => number` — injectable clock for deterministic
|
||||
`lastModified` testing.
|
||||
- Returns: a fresh `MemoryBackend` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a one-liner: `return new MemoryBackend(options)`.
|
||||
It exists for ergonomic parity with the rest of the
|
||||
`<name>Backend` factory pattern across the i18n backend family
|
||||
and adds no behaviour beyond instantiation.
|
||||
- The factory does NOT memoise — every call returns a distinct
|
||||
backend instance. Callers needing shared state pass the same
|
||||
backend reference to multiple call sites; do not call the
|
||||
factory multiple times expecting the same store.
|
||||
- For consumers who need polymorphism over `BackendFactory<TOptions>`
|
||||
from `@suaveplan/types`, this factory satisfies that signature.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryBackend } from "../backend/backend.js";
|
||||
import { memoryBackend } from "./factory.js";
|
||||
|
||||
describe("memoryBackend factory", () => {
|
||||
it("returns a MemoryBackend instance", { timeout: 5000 }, () => {
|
||||
const result = memoryBackend();
|
||||
expect(result).toBeInstanceOf(MemoryBackend);
|
||||
});
|
||||
|
||||
it("default name is 'memory'", { timeout: 5000 }, () => {
|
||||
expect(memoryBackend().name).toBe("memory");
|
||||
});
|
||||
|
||||
it("passes seed through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = memoryBackend({
|
||||
seed: { en: { common: { x: "y" } } },
|
||||
});
|
||||
expect(await backend.read("en", "common")).toEqual({ x: "y" });
|
||||
});
|
||||
|
||||
it("passes name through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(memoryBackend({ name: "cache" }).name).toBe("cache");
|
||||
});
|
||||
|
||||
it("passes now through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = memoryBackend({ now: () => 7777 });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await backend.lastModified("en", "auth")).toBe(7777);
|
||||
});
|
||||
|
||||
it("returns a fresh instance per call (no singleton)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = memoryBackend();
|
||||
const b = memoryBackend();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MemoryBackend } from "../backend/backend.js";
|
||||
import type { MemoryBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `MemoryBackend` per call — no internal singleton, no shared
|
||||
* state. Equivalent to `new MemoryBackend(options)`.
|
||||
*/
|
||||
export function memoryBackend(options?: MemoryBackendOptions): MemoryBackend {
|
||||
return new MemoryBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { memoryBackend } from "./factory.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
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-memory — root barrel", () => {
|
||||
it("re-exports MemoryBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.MemoryBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports memoryBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.memoryBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports MemoryBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.MemoryBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.recordWrite).toBe("function");
|
||||
expect(typeof root.getMemoryBackendMetrics).toBe("function");
|
||||
expect(typeof root.getMemoryBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetMemoryBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetMemoryBackendMetricsCache).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-memory — 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.MemoryBackend).toBe("function");
|
||||
expect(backend.MemoryBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.memoryBackend).toBe("function");
|
||||
expect(typeof observability.getMemoryBackendMetrics).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-memory — 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("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();
|
||||
}
|
||||
});
|
||||
});
|
||||
25
packages/localization/i18n/i18n-backend-memory/src/index.ts
Normal file
25
packages/localization/i18n/i18n-backend-memory/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-memory. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MemoryBackend,
|
||||
type MemoryBackendOptionsInput,
|
||||
MemoryBackendOptionsSchema,
|
||||
MemorySeedSchema,
|
||||
TranslationRecordSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
MemoryBackendOptions,
|
||||
MemorySeed,
|
||||
} from "./backend/backend.types.js";
|
||||
export { memoryBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
type MemoryBackendMetricSample,
|
||||
type MemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
MemoryBackendMetricSample,
|
||||
MemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,113 @@
|
||||
# `MemoryBackend` Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrumentation for
|
||||
`@suaveplan/i18n-backend-memory`. It ships per-call metric emitters
|
||||
(`recordRead`, `recordWrite`) that the backend invokes around every
|
||||
`read` / `save` operation, plus a small in-memory state ring so spec-
|
||||
conformance tests can assert exact attribute payloads without
|
||||
standing up a full SDK + InMemoryMetricExporter pipeline. Every
|
||||
instrument is built lazily and is a no-op when no MeterProvider is
|
||||
registered — consumers pay zero overhead unless they wire up the
|
||||
SDK.
|
||||
|
||||
The metric naming follows the per-backend prefix convention agreed
|
||||
in the Wave 5 design: `i18n.backend.memory.*`. Sibling backend
|
||||
packages (`i18n-backend-http`, `i18n-backend-storage`, etc.) emit
|
||||
under their own prefixes so consumers can group dashboard panels
|
||||
per backend without shared-namespace collisions.
|
||||
|
||||
## Features
|
||||
|
||||
- Five instruments — two histograms, three counters — covering both
|
||||
read and write paths.
|
||||
- In-memory state ring for test assertions (no exporter required).
|
||||
- Lazy meter construction; idempotent `getMemoryBackendMetrics()`.
|
||||
- Test-only `resetMemoryBackendMetricsCache()` so suites can
|
||||
rebuild the meter under a fresh provider.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-memory/observability";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend({ seed: { en: { common: { hi: "Hi" } } } });
|
||||
await backend.read("en", "common");
|
||||
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Mismatched reads emit BOTH an outcome counter AND an
|
||||
`read_errors_total` counter labelled by error code. Tests can assert
|
||||
the full payload to detect regressions in error classification.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-memory/observability";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.read("en", "missing").catch(() => undefined);
|
||||
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Emitted instruments
|
||||
|
||||
| Instrument | Kind | Labels | Unit |
|
||||
|---|---|---|---|
|
||||
| `i18n.backend.memory.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
|
||||
| `i18n.backend.memory.read_total` | Counter | `outcome` (hit \| miss) | count |
|
||||
| `i18n.backend.memory.read_errors_total` | Counter | `code` (I18N error code) | count |
|
||||
| `i18n.backend.memory.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
|
||||
| `i18n.backend.memory.write_total` | Counter | `outcome` (ok \| error) | count |
|
||||
|
||||
### Functions
|
||||
|
||||
- `getMemoryBackendMetrics()` — returns cached instruments; builds
|
||||
them on first call.
|
||||
- `recordRead(locale, namespace, outcome, durationMs, errorCode?)`
|
||||
— emit a read sample.
|
||||
- `recordWrite(locale, namespace, outcome, durationMs)` — emit a
|
||||
write sample.
|
||||
- `getMemoryBackendMetricsState()` — frozen-shape snapshot of every
|
||||
emitted sample since last reset.
|
||||
- `resetMemoryBackendMetricsState()` — clear the in-memory state
|
||||
ring (test fixture).
|
||||
- `resetMemoryBackendMetricsCache()` — drop the cached instruments
|
||||
so a fresh `MeterProvider` can be wired (test fixture).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state ring is intentionally cardinality-bounded by the
|
||||
caller's test cases. It exists for assertion ergonomics, not for
|
||||
production telemetry — production telemetry flows through the
|
||||
registered OTel `MeterProvider`.
|
||||
- Cardinality safety: aggregate counters use only `outcome` (hit /
|
||||
miss / ok / error); per-error-code attribution lives on a
|
||||
separate `read_errors_total` counter so consumers can drop the
|
||||
high-cardinality counter without losing aggregate visibility.
|
||||
- Metric names are stable: every `i18n.backend.memory.*` name is
|
||||
part of the package's public API contract and will not change
|
||||
without a major bump.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("MemoryBackend metrics — instrument lifecycle", () => {
|
||||
it("getMemoryBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
const a = getMemoryBackendMetrics();
|
||||
const b = getMemoryBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetMemoryBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getMemoryBackendMetrics();
|
||||
resetMemoryBackendMetricsCache();
|
||||
const b = getMemoryBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordRead("en", "auth", "hit", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{ attributes: { locale: "en", namespace: "auth", outcome: "hit" } },
|
||||
]);
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"miss",
|
||||
0.42,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — recordWrite", () => {
|
||||
it("records duration + total on ok", { timeout: 5000 }, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordWrite("en", "auth", "ok", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeDurationMs).toEqual([
|
||||
{ attributes: { locale: "en", namespace: "auth", outcome: "ok" } },
|
||||
]);
|
||||
expect(state.writeTotal).toEqual([{ attributes: { outcome: "ok" } }]);
|
||||
});
|
||||
|
||||
it("records duration + total on error", { timeout: 5000 }, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordWrite("en", "auth", "error", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([
|
||||
{ attributes: { outcome: "error" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — state ring helpers", () => {
|
||||
it("resetMemoryBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("en", "auth", "hit", 0.1);
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
recordWrite("en", "auth", "ok", 0.1);
|
||||
resetMemoryBackendMetricsState();
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
expect(state.writeDurationMs).toEqual([]);
|
||||
expect(state.writeTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-memory`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-memory')`.
|
||||
* 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
|
||||
* `resetMemoryBackendMetricsState()` between cases and read
|
||||
* `getMemoryBackendMetricsState()` to inspect counters / histogram
|
||||
* counts.
|
||||
*/
|
||||
|
||||
export interface MemoryBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MemoryBackendMetricsState {
|
||||
readonly readDurationMs: MemoryBackendMetricSample[];
|
||||
readonly readTotal: MemoryBackendMetricSample[];
|
||||
readonly readErrorsTotal: MemoryBackendMetricSample[];
|
||||
readonly writeDurationMs: MemoryBackendMetricSample[];
|
||||
readonly writeTotal: MemoryBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
readonly writeDurationMs: Histogram;
|
||||
readonly writeTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: MemoryBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
writeDurationMs: [],
|
||||
writeTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to
|
||||
* call from any production code path.
|
||||
*/
|
||||
export function getMemoryBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-memory");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.memory.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MemoryBackend.read call; labels: locale, namespace, outcome.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.memory.read_total", {
|
||||
description:
|
||||
"MemoryBackend.read call count; labels: outcome (hit | miss).",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.memory.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"MemoryBackend.read error count; labels: code (the I18N error code).",
|
||||
}
|
||||
),
|
||||
writeDurationMs: meter.createHistogram(
|
||||
"i18n.backend.memory.write_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MemoryBackend.save call; labels: locale, namespace, outcome.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
writeTotal: meter.createCounter("i18n.backend.memory.write_total", {
|
||||
description:
|
||||
"MemoryBackend.save call count; labels: outcome (ok | error).",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{outcome}` and per-error-code `read_errors_total{code}`
|
||||
* counters and pushes one sample into the in-memory state ring per
|
||||
* counter touched.
|
||||
*/
|
||||
export function recordRead(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
outcome: "hit" | "miss",
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const ins = getMemoryBackendMetrics();
|
||||
const attributes: Record<string, string> = { locale, namespace, outcome };
|
||||
ins.readDurationMs.record(durationMs, attributes);
|
||||
state.readDurationMs.push({ attributes });
|
||||
const aggregate = { outcome };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs = { code: errorCode };
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `save` call's duration + outcome. Mirrors the
|
||||
* `write_total{outcome}` counter into the in-memory state ring.
|
||||
*/
|
||||
export function recordWrite(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
outcome: "ok" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = getMemoryBackendMetrics();
|
||||
const attributes: Record<string, string> = { locale, namespace, outcome };
|
||||
ins.writeDurationMs.record(durationMs, attributes);
|
||||
state.writeDurationMs.push({ attributes });
|
||||
const aggregate = { outcome };
|
||||
ins.writeTotal.add(1, aggregate);
|
||||
state.writeTotal.push({ attributes: aggregate });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getMemoryBackendMetricsState(): MemoryBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetMemoryBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
state.writeDurationMs.length = 0;
|
||||
state.writeTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetMemoryBackendMetricsCache(): 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,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-memory",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/i18n-shared",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
130
packages/localization/i18n/i18n-backend-storage/README.md
Normal file
130
packages/localization/i18n/i18n-backend-storage/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# @suaveplan/i18n-backend-storage - Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal translation backend for the `@suaveplan/i18n` family,
|
||||
backed by any `AbstractStorage` implementation from
|
||||
`@suaveplan/storage`. Implements the full `Backend` contract
|
||||
(`read`, `readMulti`, `save`, `lastModified`) over three wire
|
||||
layouts: `single-key-per-namespace` (default),
|
||||
`single-key-per-locale`, and `flat-keys`. Pluggable codec, optional
|
||||
`AbortSignal` cancellation, OpenTelemetry instrumentation under the
|
||||
`i18n.backend.storage.*` prefix. Zero runtime dependencies on
|
||||
`@suaveplan/i18n` core.
|
||||
|
||||
<!-- 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)_
|
||||
## Why this exists
|
||||
|
||||
The Wave 5 modular split lifts each backend out of the
|
||||
`@suaveplan/i18n` monolith into its own sibling so consumers can
|
||||
install only the backends they need. The storage backend is the
|
||||
canonical persistence layer: pair it with `MemoryStorage` for tests,
|
||||
`OpfsStorage` / `IndexedDBStorage` for offline-first browser apps,
|
||||
or `S3Storage` / `GarageStorage` / `NodeFsStorage` /
|
||||
`SqliteStorage` for server-tier deployments. Splitting it out also
|
||||
isolates `@suaveplan/storage` as a transitive dep — consumers using
|
||||
only the HTTP or memory backends no longer pull it in.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-backend-storage @suaveplan/storage
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await backend.read("en", "auth");
|
||||
// → { login: { title: "Sign in" } }
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-backend-storage` | Root barrel — class + factory + types + schemas + observability |
|
||||
| `@suaveplan/i18n-backend-storage/backend` | `StorageBackend` class + `defaultCodec` |
|
||||
| `@suaveplan/i18n-backend-storage/factory` | `storageBackend` factory function only |
|
||||
| `@suaveplan/i18n-backend-storage/schemas` | Zod boundary schemas |
|
||||
| `@suaveplan/i18n-backend-storage/observability` | OTel instruments + in-memory state ring |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [backend.md](src/backend/backend.md) — `StorageBackend` class, layouts, codec
|
||||
- [factory.md](src/factory/factory.md) — `storageBackend` factory function
|
||||
- [observability.md](src/observability/observability.md) — OTel instruments + cardinality
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Flag | Value | Notes |
|
||||
|---|---|---|
|
||||
| `canRead` | `true` | always |
|
||||
| `canReadMulti` | `true` | per-layout dispatch |
|
||||
| `canSave` | `true` | persists into the wrapped storage |
|
||||
| `canTrackModification` | `true` | consults `getMetadata().modifiedAt` |
|
||||
| `canWatch` | `false` | the storage contract has no change feed |
|
||||
|
||||
## Layouts
|
||||
|
||||
- `single-key-per-namespace` (default) — one key per `(locale, ns)`
|
||||
at `<prefix>/<locale>/<namespace>.json`. Best for HTTP-CDN-style
|
||||
storages.
|
||||
- `single-key-per-locale` — one key per locale holding the whole
|
||||
namespace map. Best when namespaces are small and read together.
|
||||
- `flat-keys` — one key per leaf string at
|
||||
`<prefix>:<locale>:<namespace>:<dotPath>`. Best for content-
|
||||
addressed storages with cheap per-leaf delta shipping.
|
||||
|
||||
## Testing
|
||||
|
||||
The package's own tests use the shared `runBackendConformance`
|
||||
harness from `@suaveplan/i18n-shared/testing` so the universal
|
||||
`Backend` contract is exercised against a fresh storage backend
|
||||
per assertion (against all three layouts):
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
runBackendConformance("StorageBackend", async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0`
|
||||
release. The metric names under `i18n.backend.storage.*` are part of
|
||||
the public API contract and will not change without a major bump.
|
||||
|
||||
## Wave 5 Scope
|
||||
|
||||
This package is Wave 5b of the 14-package i18n modular split. It
|
||||
ships pure additions only — `packages/i18n/i18n/src/core/backend/storage/`
|
||||
retains its copy of the backend until Wave 6 deletes it. Both
|
||||
copies coexist during Wave 5; downstream consumers continue to
|
||||
import from the monolith and observe no behaviour change.
|
||||
|
||||
83
packages/localization/i18n/i18n-backend-storage/package.json
Normal file
83
packages/localization/i18n/i18n-backend-storage/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-storage",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.",
|
||||
"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/storage": "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,167 @@
|
||||
# `StorageBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StorageBackend` adapts any `AbstractStorage` instance from
|
||||
`@suaveplan/storage` into a translation `Backend` for the
|
||||
`@suaveplan/i18n` family. This is the headline persistence
|
||||
integration that lets translations live in S3, OPFS, IndexedDB,
|
||||
SQLite, Garage, or any other storage `@suaveplan/storage` supports
|
||||
— without each consumer integrating once per source. The user-
|
||||
facing pitch is "translations in OPFS for offline" or "translations
|
||||
on a content-addressed CDN" become one config line.
|
||||
|
||||
The backend has zero runtime dependencies on `@suaveplan/i18n` core.
|
||||
It implements the full `Backend` contract from `@suaveplan/types`
|
||||
(`read`, `readMulti`, `save`, `lastModified`) and emits
|
||||
OpenTelemetry instruments under the `i18n.backend.storage.*` prefix
|
||||
so production teams can dashboard latency and miss-rate per layout.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 5-flag capability matrix: `canRead`, `canReadMulti`,
|
||||
`canSave`, `canTrackModification` all `true`; `canWatch: false`.
|
||||
- Three wire layouts: `single-key-per-namespace` (default),
|
||||
`single-key-per-locale`, `flat-keys`.
|
||||
- Pluggable codec — JSON by default; swap for MessagePack, CBOR, or
|
||||
schema-validated JSON.
|
||||
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
|
||||
and `save`; pre-aborted signals throw
|
||||
`I18nError(I18N.BACKEND_TIMEOUT)` without touching the underlying
|
||||
storage.
|
||||
- OTel histograms + counters emitted on every read/save with a
|
||||
`layout` attribute so per-layout latency can be charted.
|
||||
- Zod boundary validation: invalid options throw
|
||||
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
|
||||
- `BACKEND_LOCALE_NOT_AVAILABLE` on miss (chained backends fall
|
||||
through); `BACKEND_READ_FAILED` / `BACKEND_WRITE_FAILED` on
|
||||
transport / codec errors (chained backends propagate without
|
||||
fallthrough).
|
||||
|
||||
## Layouts
|
||||
|
||||
### `single-key-per-namespace` (default)
|
||||
|
||||
```text
|
||||
i18n/en/auth.json → {"login":{"title":"Sign in"}}
|
||||
i18n/en/common.json → {"cancel":"Cancel"}
|
||||
```
|
||||
|
||||
One storage key per `(locale, namespace)`. Reads are exactly one
|
||||
storage `get` per `read(locale, ns)` call. Best for HTTP-CDN-style
|
||||
storages where each key is independently cacheable.
|
||||
|
||||
### `single-key-per-locale`
|
||||
|
||||
```text
|
||||
i18n/en.json → {"auth":{...},"common":{...}}
|
||||
```
|
||||
|
||||
One storage key per locale, holding the entire
|
||||
`Record<namespace, TranslationRecord>` map. Best when namespaces are
|
||||
small and read together. Saving a single namespace requires a
|
||||
read-modify-write of the whole locale map.
|
||||
|
||||
### `flat-keys`
|
||||
|
||||
```text
|
||||
i18n:en:auth:login.title → Sign in
|
||||
i18n:en:common:cancel → Cancel
|
||||
```
|
||||
|
||||
One storage key per leaf string. Best for content-addressed storages
|
||||
where deltas are expensive at namespace granularity but cheap at
|
||||
leaf granularity.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await backend.read("en", "auth");
|
||||
// → { login: { title: "Sign in" } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — OPFS + custom binary codec
|
||||
|
||||
```ts
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
|
||||
import { encode, decode } from "@msgpack/msgpack";
|
||||
|
||||
const backend = new StorageBackend({
|
||||
storage: new OpfsStorage({ rootDir: "i18n" }),
|
||||
layout: "single-key-per-locale",
|
||||
encode: (record) => encode(record),
|
||||
decode: (bytes) => decode(bytes) as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Cancellable reads play nicely with multi-backend orchestration.
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await backend.read("en", "auth", controller.signal); // throws BACKEND_TIMEOUT
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StorageBackend implements Backend`
|
||||
|
||||
- `constructor(options: StorageBackendOptions)` — validates options
|
||||
via `StorageBackendOptionsSchema`; throws
|
||||
`I18nError(I18N.INVALID_OPTIONS)` on schema mismatch or missing
|
||||
`storage`.
|
||||
- `name: string` — backend label (default `"storage"`).
|
||||
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
|
||||
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
|
||||
resolves with the stored record; throws
|
||||
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
|
||||
- `readMulti(locale, namespaces, signal?):
|
||||
Promise<Record<string, TranslationRecord>>` — returns only the
|
||||
namespaces that exist; missing namespaces are omitted.
|
||||
- `save(locale, namespace, record, signal?): Promise<void>` —
|
||||
persists the record; failure surfaces as
|
||||
`BACKEND_WRITE_FAILED`.
|
||||
- `lastModified(locale, namespace): Promise<number | undefined>` —
|
||||
returns the underlying storage's `modifiedAt` field, or
|
||||
`undefined` if metadata is unavailable.
|
||||
|
||||
### `defaultCodec: StorageBackendCodec`
|
||||
|
||||
JSON-based codec (`TextEncoder` / `JSON.stringify`). The decode path
|
||||
rejects non-object payloads (strings, arrays, null) as
|
||||
`BACKEND_READ_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default JSON codec uses `TextEncoder` / `TextDecoder` and
|
||||
`JSON.stringify` / `JSON.parse`. The decode path rejects non-
|
||||
object payloads because translation records are always objects in
|
||||
the contract; custom codecs MUST also reject non-objects or
|
||||
document the relaxation explicitly.
|
||||
- `flat-keys` reads via `AbstractStorage.list({ prefix })`. List is
|
||||
required by every concrete storage, so the layout works against
|
||||
all of them. Saves write each leaf with its own `set` call — for
|
||||
backends that batch writes, prefer `single-key-per-namespace` to
|
||||
amortise the batch.
|
||||
- `lastModified` for `flat-keys` takes the maximum `modifiedAt`
|
||||
across leaves. For other layouts it consults the namespace or
|
||||
locale key directly. All paths swallow storage exceptions and
|
||||
return `undefined` rather than throw, mirroring the contract that
|
||||
`lastModified` is a hint, not a guarantee.
|
||||
- `signal?.aborted` is checked at the entry of every async method.
|
||||
The throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The flag
|
||||
is sampled exactly once at the top — once the underlying
|
||||
`AbstractStorage` operation begins, abort propagation is the
|
||||
storage's responsibility.
|
||||
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
|
||||
a `MeterProvider` is registered. Tests use the in-memory metrics
|
||||
state ring exposed by `./observability` to assert payloads
|
||||
without standing up the full SDK. The `layout` attribute is a
|
||||
three-value low-cardinality enum.
|
||||
@@ -0,0 +1,798 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { storageBackend } from "../factory/factory.js";
|
||||
import { defaultCodec, StorageBackend } from "./backend.js";
|
||||
import type { StorageBackendOptions } from "./backend.types.js";
|
||||
|
||||
// ── Run the universal Backend contract conformance against every layout
|
||||
|
||||
runBackendConformance("StorageBackend (single-key-per-namespace)", async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runBackendConformance("StorageBackend (single-key-per-locale)", async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runBackendConformance("StorageBackend (flat-keys)", async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── StorageBackend specifics ───────────────────────────────────────
|
||||
|
||||
describe("StorageBackend specifics", () => {
|
||||
it("single-key-per-namespace round-trip", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({ storage, prefix: "i18n" });
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw).toBeDefined();
|
||||
const text = new TextDecoder().decode(raw as Uint8Array);
|
||||
expect(JSON.parse(text)).toEqual({ login: { title: "Sign in" } });
|
||||
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in" },
|
||||
});
|
||||
});
|
||||
|
||||
it("single-key-per-locale round-trip", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
prefix: "i18n",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
|
||||
const raw = await storage.get("i18n/en.json");
|
||||
const decoded = JSON.parse(
|
||||
new TextDecoder().decode(raw as Uint8Array)
|
||||
) as Record<string, unknown>;
|
||||
expect(decoded).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
|
||||
expect(await backend.read("en", "common")).toEqual({
|
||||
cancel: "Cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("flat-keys round-trip with nested record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
prefix: "i18n",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const t = await storage.get("i18n:en:auth:login.title");
|
||||
const s = await storage.get("i18n:en:auth:login.subtitle");
|
||||
expect(decoder.decode(t as Uint8Array)).toBe("Sign in");
|
||||
expect(decoder.decode(s as Uint8Array)).toBe("Welcome");
|
||||
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
});
|
||||
|
||||
it("missing key throws BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
for (const layout of [
|
||||
"single-key-per-namespace",
|
||||
"single-key-per-locale",
|
||||
"flat-keys",
|
||||
] as const) {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail(`expected throw for layout ${layout}`);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("custom encode/decode is used", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
const SENTINEL = 0xfa;
|
||||
const codec = {
|
||||
encode: (record: unknown) => {
|
||||
const json = enc.encode(JSON.stringify(record));
|
||||
const out = new Uint8Array(json.length + 1);
|
||||
out[0] = SENTINEL;
|
||||
out.set(json, 1);
|
||||
return out;
|
||||
},
|
||||
decode: (bytes: Uint8Array) => {
|
||||
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
|
||||
const json = dec.decode(bytes.slice(1));
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
},
|
||||
};
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
encode: codec.encode,
|
||||
decode: codec.decode,
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw?.[0]).toBe(SENTINEL);
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("readMulti omits missing namespaces", { timeout: 5000 }, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"common",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
expect(result).not.toHaveProperty("missing");
|
||||
});
|
||||
|
||||
it("readMulti single-key-per-locale", { timeout: 5000 }, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"common",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
});
|
||||
|
||||
it("readMulti single-key-per-locale returns empty for missing locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
const result = await backend.readMulti("xx", ["auth"]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("lastModified returns metadata timestamp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("lastModified single-key-per-locale consults locale key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent flat-keys record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("name defaults to 'storage' and accepts override", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(new StorageBackend({ storage: new MemoryStorage() }).name).toBe(
|
||||
"storage"
|
||||
);
|
||||
expect(
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "opfs-cache",
|
||||
}).name
|
||||
).toBe("opfs-cache");
|
||||
});
|
||||
|
||||
it("capabilities matrix matches the contract", { timeout: 5000 }, () => {
|
||||
const c = new StorageBackend({ storage: new MemoryStorage() })
|
||||
.capabilities;
|
||||
expect(c).toEqual({
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults are applied", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({ storage });
|
||||
// default prefix "i18n" + default layout single-key-per-namespace
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw).toBeDefined();
|
||||
expect(backend.name).toBe("storage");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Read failure modes ─────────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend read failures", () => {
|
||||
it("rejects garbage JSON payloads as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("not-json")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects array JSON payloads (non-record) as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("[1,2,3]")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale wraps non-I18nError decode failures", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
decode: () => {
|
||||
throw new Error("decoder-boom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale passes I18nError decode failures unwrapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
decode: () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_READ_FAILED" as never,
|
||||
"inner-i18n-decode"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-i18n-decode");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-namespace passes I18nError decode failures unwrapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("garbage")
|
||||
);
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
decode: () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_READ_FAILED" as never,
|
||||
"inner-decoder"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-decoder");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale read of absent namespace within present locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
try {
|
||||
await backend.read("en", "common");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-I18nError from storage.get propagates with outcome=error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.get = async () => {
|
||||
throw new Error("storage-boom-not-i18n");
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(I18nError);
|
||||
expect((err as Error).message).toBe("storage-boom-not-i18n");
|
||||
}
|
||||
});
|
||||
|
||||
it("readMulti propagates non-LNA errors from read()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("not-json")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.readMulti("en", ["auth"]);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("flat-keys skips storage keys with empty bytes during read", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n:en:auth:login.title",
|
||||
new TextEncoder().encode("Sign in")
|
||||
);
|
||||
const original = storage.get.bind(storage);
|
||||
storage.get = async (key: string) => {
|
||||
if (key === "i18n:en:auth:phantom") return undefined;
|
||||
return original(key);
|
||||
};
|
||||
const originalList = storage.list.bind(storage);
|
||||
storage.list = async (opts) => {
|
||||
const real = await originalList(opts);
|
||||
return {
|
||||
...real,
|
||||
keys: [...real.keys, "i18n:en:auth:phantom"],
|
||||
};
|
||||
};
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
const record = await backend.read("en", "auth");
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
|
||||
it("flat-keys overlays string onto pre-existing nested record at sibling path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Exercises the assignAtDotPath else-branch where cursor[part] is
|
||||
// a string (not object) and the loop must replace it with an
|
||||
// empty object.
|
||||
const storage = new MemoryStorage();
|
||||
// Put a raw value at "login" first (string), then a sibling path
|
||||
// "login.title" — the assemble path will see the string at
|
||||
// "login" and replace it with a fresh nested record.
|
||||
await storage.set(
|
||||
"i18n:en:auth:login",
|
||||
new TextEncoder().encode("legacy-flat-string")
|
||||
);
|
||||
await storage.set(
|
||||
"i18n:en:auth:login.title",
|
||||
new TextEncoder().encode("Sign in")
|
||||
);
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
const record = await backend.read("en", "auth");
|
||||
// Because keys can list in unspecified order, we just assert
|
||||
// login.title eventually wins as a nested record.
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save failure modes ─────────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend save failures", () => {
|
||||
it("storage set failure throws BACKEND_WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
encode: () => {
|
||||
throw new Error("encode-boom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_WRITE_FAILED");
|
||||
expect((err as I18nError).cause).toBeInstanceOf(Error);
|
||||
expect(((err as I18nError).cause as Error).message).toBe(
|
||||
"encode-boom"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("save propagates I18nError from inner storage without re-wrapping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const original = storage.set.bind(storage);
|
||||
storage.set = async () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_WRITE_FAILED" as never,
|
||||
"inner-i18n-err"
|
||||
);
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-i18n-err");
|
||||
}
|
||||
storage.set = original;
|
||||
});
|
||||
});
|
||||
|
||||
// ── lastModified edge cases ─────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend lastModified edge cases", () => {
|
||||
it("swallows storage.getMetadata exceptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.getMetadata = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flat-keys takes the max across leaf metadata", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("flat-keys returns undefined when all metadata is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
storage.getMetadata = async () => null;
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flat-keys handles partial-null getMetadata responses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
const original = storage.getMetadata.bind(storage);
|
||||
let returnedNull = false;
|
||||
storage.getMetadata = async (key: string) => {
|
||||
if (!returnedNull) {
|
||||
returnedNull = true;
|
||||
return null;
|
||||
}
|
||||
return original(key);
|
||||
};
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t === "number" || t === undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AbortSignal cancellation ────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend AbortSignal", () => {
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.readMulti("en", ["auth"], ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.save("en", "auth", { x: "y" }, ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("non-aborted signal does not affect operation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
await backend.save("en", "auth", { x: "y" }, ac.signal);
|
||||
expect(await backend.read("en", "auth", ac.signal)).toEqual({
|
||||
x: "y",
|
||||
});
|
||||
expect(await backend.readMulti("en", ["auth"], ac.signal)).toEqual({
|
||||
auth: { x: "y" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boundary validation (Zod) ───────────────────────────────────────
|
||||
|
||||
describe("StorageBackend boundary validation", () => {
|
||||
it("missing storage throws INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("falsy storage (null) throws INVALID_OPTIONS post-schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// `storage: null` passes `z.unknown()` boundary but trips the
|
||||
// explicit `!options.storage` guard that protects downstream
|
||||
// method calls. Pins coverage of that distinct error path.
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: null,
|
||||
} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
expect((err as I18nError).message).toContain("storage is required");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed options shape via Zod (e.g. name: number)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: 123 as unknown as string,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown option keys (strict schema)", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
bogus: true,
|
||||
} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty-string name (Zod min(1))", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "",
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default codec ───────────────────────────────────────────────────
|
||||
|
||||
describe("defaultCodec", () => {
|
||||
it("round-trips object record", { timeout: 5000 }, () => {
|
||||
const bytes = defaultCodec.encode({ a: { b: "c" } });
|
||||
expect(defaultCodec.decode(bytes)).toEqual({ a: { b: "c" } });
|
||||
});
|
||||
|
||||
it("default codec rejects array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("[]");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("default codec rejects null", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("null");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("default codec rejects primitive number", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("42");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Factory smoke ───────────────────────────────────────────────────
|
||||
|
||||
describe("storageBackend factory smoke (full coverage in factory.test.ts)", () => {
|
||||
it("factory form storageBackend()", { timeout: 5000 }, () => {
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(backend.name).toBe("storage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type { AbstractStorage } from "@suaveplan/storage";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead, recordWrite } from "../observability/observability.js";
|
||||
import type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link StorageBackendOptions}. Validates the
|
||||
* statically-checkable shape only — `storage`, `encode`, and `decode`
|
||||
* are accepted as opaque `z.unknown()` because Zod cannot type-check
|
||||
* function arity meaningfully and `AbstractStorage` is a structural
|
||||
* contract. Caller-supplied option values failing this schema cause
|
||||
* the constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export const StorageBackendOptionsSchema = z
|
||||
.object({
|
||||
storage: z.unknown(),
|
||||
layout: z
|
||||
.enum([
|
||||
"single-key-per-namespace",
|
||||
"single-key-per-locale",
|
||||
"flat-keys",
|
||||
])
|
||||
.optional(),
|
||||
prefix: z.string().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
encode: z.unknown().optional(),
|
||||
decode: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type StorageBackendOptionsInput = z.input<
|
||||
typeof StorageBackendOptionsSchema
|
||||
>;
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Default JSON codec — UTF-8 `JSON.stringify` / `JSON.parse`. The
|
||||
* decode path rejects non-object payloads (strings, arrays, null) as
|
||||
* `BACKEND_READ_FAILED` because translation records are always
|
||||
* objects in the contract.
|
||||
*/
|
||||
export const defaultCodec: StorageBackendCodec = {
|
||||
encode: (record) => TEXT_ENCODER.encode(JSON.stringify(record)),
|
||||
decode: (bytes) => {
|
||||
const text = TEXT_DECODER.decode(bytes);
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
"StorageBackend: decoded payload is not an object record"
|
||||
);
|
||||
}
|
||||
return parsed as TranslationRecord;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Translation backend backed by any `AbstractStorage` implementation
|
||||
* (MemoryStorage, S3Storage, OPFSStorage, IndexedDBStorage,
|
||||
* NodeFsStorage, SqliteStorage, GarageStorage, …).
|
||||
*
|
||||
* Three wire layouts are supported:
|
||||
*
|
||||
* - `single-key-per-namespace` (default): one storage key per
|
||||
* namespace (`<prefix>/<locale>/<namespace>.json`). Reads exactly
|
||||
* one storage key per `read(locale, ns)` call.
|
||||
* - `single-key-per-locale`: one storage key per locale, holding the
|
||||
* whole `Record<namespace, TranslationRecord>` map. Best when
|
||||
* namespaces are small and read together.
|
||||
* - `flat-keys`: one storage key per leaf string
|
||||
* (`<prefix>:<locale>:<namespace>:<dotPath>` → leaf string). Works
|
||||
* well with content-addressed storages and supports per-leaf delta
|
||||
* shipping.
|
||||
*
|
||||
* Universal — the backend itself is tier-neutral; the concrete
|
||||
* `AbstractStorage` decides the deployment tier.
|
||||
*/
|
||||
export class StorageBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
readonly #storage: AbstractStorage;
|
||||
readonly #layout: StorageBackendLayout;
|
||||
readonly #prefix: string;
|
||||
readonly #encode: (record: TranslationRecord) => Uint8Array;
|
||||
readonly #decode: (bytes: Uint8Array) => TranslationRecord;
|
||||
|
||||
public constructor(options: StorageBackendOptions) {
|
||||
const parsed = StorageBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"StorageBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!options.storage) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"StorageBackend: storage is required"
|
||||
);
|
||||
}
|
||||
|
||||
this.name = options.name ?? "storage";
|
||||
this.#storage = options.storage;
|
||||
this.#layout = options.layout ?? "single-key-per-namespace";
|
||||
this.#prefix = options.prefix ?? "i18n";
|
||||
this.#encode = options.encode ?? defaultCodec.encode;
|
||||
this.#decode = options.decode ?? defaultCodec.decode;
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
let outcome: "hit" | "miss" | "error" = "hit";
|
||||
let errorCode: string | undefined;
|
||||
try {
|
||||
return await this.#readInternal(locale, namespace);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof I18nError &&
|
||||
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
) {
|
||||
outcome = "miss";
|
||||
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
|
||||
} else {
|
||||
outcome = "error";
|
||||
if (err instanceof I18nError) {
|
||||
errorCode = err.code;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
recordRead(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
outcome,
|
||||
performance.now() - t0,
|
||||
errorCode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #readInternal(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord> {
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale);
|
||||
const record = map[namespace];
|
||||
if (!record) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
if (this.#layout === "flat-keys") {
|
||||
const record = await this.#readFlatKeys(locale, namespace);
|
||||
if (record === undefined) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
// single-key-per-namespace
|
||||
const key = this.#nsKey(locale, namespace);
|
||||
const bytes = await this.#storage.get(key);
|
||||
if (!bytes) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
try {
|
||||
return this.#decode(bytes);
|
||||
} catch (cause) {
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`StorageBackend: decode failed for ${key}`,
|
||||
{ cause, context: { key, layout: this.#layout } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
this.#checkAborted(signal);
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale).catch(
|
||||
() => ({}) as Record<string, TranslationRecord>
|
||||
);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const ns of namespaces) {
|
||||
if (map[ns]) out[ns] = map[ns];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const results = await Promise.all(
|
||||
namespaces.map((ns) =>
|
||||
this.read(locale, ns).then(
|
||||
(record) => [ns, record] as const,
|
||||
(err) => {
|
||||
if (
|
||||
err instanceof I18nError &&
|
||||
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const r of results) {
|
||||
if (r) out[r[0]] = r[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale).catch(
|
||||
() => ({}) as Record<string, TranslationRecord>
|
||||
);
|
||||
map[namespace] = record;
|
||||
const bytes = this.#encode(map as TranslationRecord);
|
||||
await this.#storage.set(this.#localeKey(locale), bytes);
|
||||
} else if (this.#layout === "flat-keys") {
|
||||
await this.#saveFlatKeys(locale, namespace, record);
|
||||
} else {
|
||||
const bytes = this.#encode(record);
|
||||
await this.#storage.set(this.#nsKey(locale, namespace), bytes);
|
||||
}
|
||||
recordWrite(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
"ok",
|
||||
performance.now() - t0
|
||||
);
|
||||
} catch (cause) {
|
||||
recordWrite(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
"error",
|
||||
performance.now() - t0
|
||||
);
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED,
|
||||
`StorageBackend: save failed for ${locale}/${namespace}`,
|
||||
{
|
||||
cause,
|
||||
context: { locale, namespace, layout: this.#layout },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
if (this.#layout === "flat-keys") {
|
||||
const list = await this.#storage.list({
|
||||
prefix: `${this.#prefix}:${locale}:${namespace}:`,
|
||||
});
|
||||
if (list.keys.length === 0) return undefined;
|
||||
const stamps: number[] = [];
|
||||
for (const key of list.keys) {
|
||||
const meta = await this.#storage.getMetadata(key);
|
||||
if (meta) stamps.push(meta.modifiedAt);
|
||||
}
|
||||
return stamps.length > 0 ? Math.max(...stamps) : undefined;
|
||||
}
|
||||
const key =
|
||||
this.#layout === "single-key-per-locale"
|
||||
? this.#localeKey(locale)
|
||||
: this.#nsKey(locale, namespace);
|
||||
const meta = await this.#storage.getMetadata(key);
|
||||
return meta?.modifiedAt;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layout helpers ────────────────────────────────────────────
|
||||
|
||||
#nsKey(locale: string, namespace: string): string {
|
||||
return `${this.#prefix}/${locale}/${namespace}.json`;
|
||||
}
|
||||
|
||||
#localeKey(locale: string): string {
|
||||
return `${this.#prefix}/${locale}.json`;
|
||||
}
|
||||
|
||||
async #readLocaleMap(
|
||||
locale: string
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
const bytes = await this.#storage.get(this.#localeKey(locale));
|
||||
if (!bytes) {
|
||||
throw this.#missing(locale, "*");
|
||||
}
|
||||
try {
|
||||
const decoded = this.#decode(bytes) as Record<
|
||||
string,
|
||||
TranslationRecord
|
||||
>;
|
||||
return decoded;
|
||||
} catch (cause) {
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`StorageBackend: decode failed for ${this.#localeKey(locale)}`,
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #readFlatKeys(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord | undefined> {
|
||||
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
|
||||
const list = await this.#storage.list({ prefix });
|
||||
if (list.keys.length === 0) return undefined;
|
||||
const out: TranslationRecord = {};
|
||||
const decoder = new TextDecoder();
|
||||
for (const key of list.keys) {
|
||||
const dotPath = key.slice(prefix.length);
|
||||
const bytes = await this.#storage.get(key);
|
||||
if (!bytes) continue;
|
||||
const value = decoder.decode(bytes);
|
||||
assignAtDotPath(out, dotPath, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async #saveFlatKeys(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord
|
||||
): Promise<void> {
|
||||
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
|
||||
const encoder = new TextEncoder();
|
||||
const flat = flattenLeaves(record);
|
||||
for (const [dotPath, value] of Object.entries(flat)) {
|
||||
await this.#storage.set(
|
||||
`${prefix}${dotPath}`,
|
||||
encoder.encode(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#missing(locale: string, namespace: string): I18nError {
|
||||
return new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`StorageBackend: no record for ${locale}/${namespace}`,
|
||||
{
|
||||
context: {
|
||||
locale,
|
||||
namespace,
|
||||
layout: this.#layout,
|
||||
backend: this.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"StorageBackend: operation aborted",
|
||||
{
|
||||
context: { backend: this.name, reason: signal.reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenLeaves(
|
||||
record: TranslationRecord,
|
||||
prefix = ""
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") {
|
||||
out[path] = v;
|
||||
} else {
|
||||
Object.assign(out, flattenLeaves(v as TranslationRecord, path));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type MutableTranslationRecord = {
|
||||
[key: string]: string | MutableTranslationRecord;
|
||||
};
|
||||
|
||||
function assignAtDotPath(
|
||||
target: TranslationRecord,
|
||||
dotPath: string,
|
||||
value: string
|
||||
): void {
|
||||
const parts = dotPath.split(".");
|
||||
let cursor = target as MutableTranslationRecord;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i] as string;
|
||||
const next = cursor[part];
|
||||
if (typeof next === "object" && next !== null) {
|
||||
cursor = next as MutableTranslationRecord;
|
||||
} else {
|
||||
const fresh: MutableTranslationRecord = {};
|
||||
cursor[part] = fresh;
|
||||
cursor = fresh;
|
||||
}
|
||||
}
|
||||
cursor[parts[parts.length - 1] as string] = value;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AbstractStorage } from "@suaveplan/storage";
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Wire layout for {@link StorageBackend}.
|
||||
*
|
||||
* - `single-key-per-namespace` (default): one storage key per
|
||||
* `(locale, namespace)` pair at `<prefix>/<locale>/<namespace>.json`.
|
||||
* - `single-key-per-locale`: one storage key per locale, holding the
|
||||
* whole `Record<namespace, TranslationRecord>` map.
|
||||
* - `flat-keys`: one storage key per leaf string, addressed by
|
||||
* `<prefix>:<locale>:<namespace>:<dotPath>`.
|
||||
*/
|
||||
export type StorageBackendLayout =
|
||||
| "single-key-per-namespace"
|
||||
| "single-key-per-locale"
|
||||
| "flat-keys";
|
||||
|
||||
/**
|
||||
* Pluggable codec contract. The default codec encodes records as
|
||||
* UTF-8 JSON; consumers can swap in MessagePack, CBOR, or any other
|
||||
* binary format.
|
||||
*/
|
||||
export interface StorageBackendCodec {
|
||||
readonly encode: (record: TranslationRecord) => Uint8Array;
|
||||
readonly decode: (bytes: Uint8Array) => TranslationRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for {@link StorageBackend}.
|
||||
*
|
||||
* - `storage` — required; any `AbstractStorage` instance.
|
||||
* - `layout` — wire layout; defaults to `"single-key-per-namespace"`.
|
||||
* - `prefix` — key prefix; defaults to `"i18n"`.
|
||||
* - `name` — backend label override; defaults to `"storage"`.
|
||||
* - `encode` / `decode` — pluggable codec; defaults to JSON via
|
||||
* `TextEncoder` / `TextDecoder`.
|
||||
*/
|
||||
export interface StorageBackendOptions {
|
||||
readonly storage: AbstractStorage;
|
||||
readonly layout?: StorageBackendLayout;
|
||||
readonly prefix?: string;
|
||||
readonly name?: string;
|
||||
readonly encode?: (record: TranslationRecord) => Uint8Array;
|
||||
readonly decode?: (bytes: Uint8Array) => TranslationRecord;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Feature barrel for `@suaveplan/i18n-backend-storage/backend`. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
defaultCodec,
|
||||
StorageBackend,
|
||||
type StorageBackendOptionsInput,
|
||||
StorageBackendOptionsSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
# `storageBackend(options)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Factory wrapper around `new StorageBackend(options)` for callers
|
||||
that prefer fluent builder-style composition over `new`. Returns a
|
||||
fresh backend per call — no internal singleton, no shared state, no
|
||||
caching. The factory exists primarily so chained-backend
|
||||
configurations can be expressed declaratively without the visual
|
||||
weight of repeated `new` keywords.
|
||||
|
||||
The factory is the recommended public entry point for routine use
|
||||
because it composes naturally with the rest of the i18n family
|
||||
(every backend ships a `<name>Backend` factory mirroring its class
|
||||
constructor). Direct `new StorageBackend(...)` remains supported and
|
||||
is exactly equivalent.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `StorageBackend` per call.
|
||||
- Forwards every constructor option (`storage`, `layout`, `prefix`,
|
||||
`name`, `encode`, `decode`) unchanged.
|
||||
- Same Zod boundary validation as the class constructor — invalid
|
||||
options throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
const record = await backend.read("en", "common");
|
||||
// → { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The factory composes naturally with chained-backend configurations
|
||||
where multiple storages (e.g. an OPFS write-through cache plus an
|
||||
S3 origin) are wired declaratively.
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const cache = storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
name: "cache",
|
||||
});
|
||||
|
||||
const origin = storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
name: "origin",
|
||||
prefix: "translations",
|
||||
});
|
||||
|
||||
// Hand both to a ChainedBackend (Wave 5e).
|
||||
// const chain = chainedBackend({ backends: [cache, origin] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `storageBackend(options: StorageBackendOptions): StorageBackend`
|
||||
|
||||
- `options.storage: AbstractStorage` — required.
|
||||
- `options.layout?: StorageBackendLayout` — wire layout (default
|
||||
`"single-key-per-namespace"`).
|
||||
- `options.prefix?: string` — key prefix (default `"i18n"`).
|
||||
- `options.name?: string` — backend label (default `"storage"`).
|
||||
- `options.encode?: (record) => Uint8Array` — custom encoder.
|
||||
- `options.decode?: (bytes) => TranslationRecord` — custom decoder.
|
||||
- Returns: a fresh `StorageBackend` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a one-liner: `return new StorageBackend(options)`.
|
||||
It exists for ergonomic parity with the rest of the
|
||||
`<name>Backend` factory pattern across the i18n backend family
|
||||
and adds no behaviour beyond instantiation.
|
||||
- The factory does NOT memoise — every call returns a distinct
|
||||
backend instance. Callers needing shared state pass the same
|
||||
backend reference to multiple call sites; do not call the
|
||||
factory multiple times expecting the same store.
|
||||
- For consumers who need polymorphism over
|
||||
`BackendFactory<TOptions>` from `@suaveplan/types`, this factory
|
||||
satisfies that signature.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { StorageBackend } from "../backend/backend.js";
|
||||
import { storageBackend } from "./factory.js";
|
||||
|
||||
describe("storageBackend factory", () => {
|
||||
it("returns a StorageBackend instance", { timeout: 5000 }, () => {
|
||||
const result = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(result).toBeInstanceOf(StorageBackend);
|
||||
});
|
||||
|
||||
it("default name is 'storage'", { timeout: 5000 }, () => {
|
||||
expect(storageBackend({ storage: new MemoryStorage() }).name).toBe(
|
||||
"storage"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes layout through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = storageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
// single-key-per-locale stores under "i18n/en.json"
|
||||
expect(await storage.get("i18n/en.json")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes prefix through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = storageBackend({ storage, prefix: "translations" });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await storage.get("translations/en/auth.json")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes name through to the constructor", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "cache",
|
||||
}).name
|
||||
).toBe("cache");
|
||||
});
|
||||
|
||||
it("passes encode/decode through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const SENTINEL = 0xab;
|
||||
const backend = storageBackend({
|
||||
storage,
|
||||
encode: (record) => {
|
||||
const json = new TextEncoder().encode(JSON.stringify(record));
|
||||
const out = new Uint8Array(json.length + 1);
|
||||
out[0] = SENTINEL;
|
||||
out.set(json, 1);
|
||||
return out;
|
||||
},
|
||||
decode: (bytes) => {
|
||||
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(bytes.slice(1))
|
||||
) as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw?.[0]).toBe(SENTINEL);
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("returns a fresh instance per call (no singleton)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = storageBackend({ storage: new MemoryStorage() });
|
||||
const b = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StorageBackend } from "../backend/backend.js";
|
||||
import type { StorageBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `StorageBackend` per call — no internal singleton, no shared
|
||||
* state. Equivalent to `new StorageBackend(options)`.
|
||||
*/
|
||||
export function storageBackend(options: StorageBackendOptions): StorageBackend {
|
||||
return new StorageBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { storageBackend } from "./factory.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
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-storage — root barrel", () => {
|
||||
it("re-exports StorageBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.StorageBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports defaultCodec", { timeout: 5000 }, () => {
|
||||
expect(typeof root.defaultCodec.encode).toBe("function");
|
||||
expect(typeof root.defaultCodec.decode).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports storageBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.storageBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports StorageBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.StorageBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.recordWrite).toBe("function");
|
||||
expect(typeof root.getStorageBackendMetrics).toBe("function");
|
||||
expect(typeof root.getStorageBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetStorageBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetStorageBackendMetricsCache).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-storage — 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.StorageBackend).toBe("function");
|
||||
expect(typeof backend.defaultCodec.encode).toBe("function");
|
||||
expect(backend.StorageBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.storageBackend).toBe("function");
|
||||
expect(typeof observability.getStorageBackendMetrics).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-storage — 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("package.json depends on storage but not 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).toHaveProperty("@suaveplan/storage");
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
25
packages/localization/i18n/i18n-backend-storage/src/index.ts
Normal file
25
packages/localization/i18n/i18n-backend-storage/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-storage. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
defaultCodec,
|
||||
StorageBackend,
|
||||
type StorageBackendOptionsInput,
|
||||
StorageBackendOptionsSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend/backend.types.js";
|
||||
export { storageBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
type StorageBackendMetricSample,
|
||||
type StorageBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
StorageBackendMetricSample,
|
||||
StorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
# `StorageBackend` Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrumentation for
|
||||
`@suaveplan/i18n-backend-storage`. It ships per-call metric emitters
|
||||
(`recordRead`, `recordWrite`) that the backend invokes around every
|
||||
`read` / `save` operation, plus a small in-memory state ring so
|
||||
spec-conformance tests can assert exact attribute payloads without
|
||||
standing up a full SDK + InMemoryMetricExporter pipeline. Every
|
||||
instrument is built lazily and is a no-op when no MeterProvider is
|
||||
registered — consumers pay zero overhead unless they wire up the
|
||||
SDK.
|
||||
|
||||
The metric naming follows the per-backend prefix convention agreed
|
||||
in the Wave 5 design: `i18n.backend.storage.*`. Sibling backend
|
||||
packages emit under their own prefixes so consumers can group
|
||||
dashboard panels per backend without shared-namespace collisions.
|
||||
Every counter and histogram carries a low-cardinality `layout`
|
||||
attribute (`single-key-per-namespace` | `single-key-per-locale` |
|
||||
`flat-keys`) so production teams can drill into per-layout latency
|
||||
or miss-rate.
|
||||
|
||||
## Features
|
||||
|
||||
- Five instruments — two histograms, three counters — covering both
|
||||
read and write paths.
|
||||
- `layout` attribute on every aggregate so per-layout dashboards
|
||||
work without parsing namespaces.
|
||||
- In-memory state ring for test assertions (no exporter required).
|
||||
- Lazy meter construction; idempotent
|
||||
`getStorageBackendMetrics()`.
|
||||
- Test-only `resetStorageBackendMetricsCache()` so suites can
|
||||
rebuild the meter under a fresh provider.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getStorageBackendMetricsState,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-storage/observability";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
resetStorageBackendMetricsState();
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.read("en", "auth");
|
||||
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{ attributes: { outcome: "hit", layout: "single-key-per-namespace" } },
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A read miss emits both an outcome counter AND a
|
||||
`read_errors_total` counter labelled by error code. Both carry the
|
||||
`layout` attribute so per-layout error rates are charted directly.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getStorageBackendMetricsState,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-storage/observability";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
resetStorageBackendMetricsState();
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.read("en", "missing").catch(() => undefined);
|
||||
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
layout: "flat-keys",
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Emitted instruments
|
||||
|
||||
| Instrument | Kind | Labels | Unit |
|
||||
|---|---|---|---|
|
||||
| `i18n.backend.storage.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
|
||||
| `i18n.backend.storage.read_total` | Counter | `outcome` (hit \| miss \| error), `layout` | count |
|
||||
| `i18n.backend.storage.read_errors_total` | Counter | `code` (I18N error code), `layout` | count |
|
||||
| `i18n.backend.storage.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
|
||||
| `i18n.backend.storage.write_total` | Counter | `outcome` (ok \| error), `layout` | count |
|
||||
|
||||
### Functions
|
||||
|
||||
- `getStorageBackendMetrics()` — returns cached instruments; builds
|
||||
them on first call.
|
||||
- `recordRead(locale, namespace, layout, outcome, durationMs, errorCode?)`
|
||||
— emit a read sample.
|
||||
- `recordWrite(locale, namespace, layout, outcome, durationMs)` —
|
||||
emit a write sample.
|
||||
- `getStorageBackendMetricsState()` — frozen-shape snapshot of every
|
||||
emitted sample since last reset.
|
||||
- `resetStorageBackendMetricsState()` — clear the in-memory state
|
||||
ring (test fixture).
|
||||
- `resetStorageBackendMetricsCache()` — drop the cached instruments
|
||||
so a fresh `MeterProvider` can be wired (test fixture).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state ring is intentionally cardinality-bounded by the
|
||||
caller's test cases. It exists for assertion ergonomics, not for
|
||||
production telemetry — production telemetry flows through the
|
||||
registered OTel `MeterProvider`.
|
||||
- Cardinality safety: aggregate counters use only `outcome` +
|
||||
`layout` (each three or fewer values); per-error-code attribution
|
||||
lives on a separate `read_errors_total` counter so consumers can
|
||||
drop the high-cardinality counter without losing aggregate
|
||||
visibility.
|
||||
- Metric names are stable: every `i18n.backend.storage.*` name is
|
||||
part of the package's public API contract and will not change
|
||||
without a major bump.
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("StorageBackend metrics — instrument lifecycle", () => {
|
||||
it("getStorageBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsCache();
|
||||
const a = getStorageBackendMetrics();
|
||||
const b = getStorageBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetStorageBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getStorageBackendMetrics();
|
||||
resetStorageBackendMetricsCache();
|
||||
const b = getStorageBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "hit",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "hit",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"flat-keys",
|
||||
"miss",
|
||||
0.42,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{ attributes: { outcome: "miss", layout: "flat-keys" } },
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
layout: "flat-keys",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records error outcome", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"single-key-per-locale",
|
||||
"error",
|
||||
0.42,
|
||||
"I18N.BACKEND_READ_FAILED"
|
||||
);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "error",
|
||||
layout: "single-key-per-locale",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_READ_FAILED",
|
||||
layout: "single-key-per-locale",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — recordWrite", () => {
|
||||
it("records duration + total on ok", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.writeDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "ok",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.writeTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "ok",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records duration + total on error", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordWrite("en", "auth", "flat-keys", "error", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([
|
||||
{ attributes: { outcome: "error", layout: "flat-keys" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — state ring helpers", () => {
|
||||
it("resetStorageBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.1);
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"single-key-per-namespace",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.1);
|
||||
resetStorageBackendMetricsState();
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
expect(state.writeDurationMs).toEqual([]);
|
||||
expect(state.writeTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
import type { StorageBackendLayout } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-storage`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-storage')`.
|
||||
* 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
|
||||
* `resetStorageBackendMetricsState()` between cases and read
|
||||
* `getStorageBackendMetricsState()` to inspect counters / histogram
|
||||
* counts.
|
||||
*/
|
||||
|
||||
export interface StorageBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StorageBackendMetricsState {
|
||||
readonly readDurationMs: StorageBackendMetricSample[];
|
||||
readonly readTotal: StorageBackendMetricSample[];
|
||||
readonly readErrorsTotal: StorageBackendMetricSample[];
|
||||
readonly writeDurationMs: StorageBackendMetricSample[];
|
||||
readonly writeTotal: StorageBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
readonly writeDurationMs: Histogram;
|
||||
readonly writeTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: StorageBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
writeDurationMs: [],
|
||||
writeTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to
|
||||
* call from any production code path.
|
||||
*/
|
||||
export function getStorageBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-storage");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.storage.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a StorageBackend.read call; labels: locale, namespace, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.storage.read_total", {
|
||||
description:
|
||||
"StorageBackend.read call count; labels: outcome (hit | miss | error), layout.",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.storage.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"StorageBackend.read error count; labels: code (the I18N error code), layout.",
|
||||
}
|
||||
),
|
||||
writeDurationMs: meter.createHistogram(
|
||||
"i18n.backend.storage.write_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a StorageBackend.save call; labels: locale, namespace, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
writeTotal: meter.createCounter("i18n.backend.storage.write_total", {
|
||||
description:
|
||||
"StorageBackend.save call count; labels: outcome (ok | error), layout.",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{outcome,layout}` and per-error-code
|
||||
* `read_errors_total{code,layout}` counters and pushes one sample
|
||||
* into the in-memory state ring per counter touched.
|
||||
*/
|
||||
export function recordRead(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
layout: StorageBackendLayout,
|
||||
outcome: "hit" | "miss" | "error",
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const ins = getStorageBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
layout,
|
||||
};
|
||||
ins.readDurationMs.record(durationMs, histogramAttrs);
|
||||
state.readDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { outcome, layout };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs: Record<string, string> = { code: errorCode, layout };
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `save` call's duration + outcome. Mirrors the
|
||||
* `write_total{outcome,layout}` counter into the in-memory state
|
||||
* ring.
|
||||
*/
|
||||
export function recordWrite(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
layout: StorageBackendLayout,
|
||||
outcome: "ok" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = getStorageBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
layout,
|
||||
};
|
||||
ins.writeDurationMs.record(durationMs, histogramAttrs);
|
||||
state.writeDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { outcome, layout };
|
||||
ins.writeTotal.add(1, aggregate);
|
||||
state.writeTotal.push({ attributes: aggregate });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getStorageBackendMetricsState(): StorageBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetStorageBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
state.writeDurationMs.length = 0;
|
||||
state.writeTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetStorageBackendMetricsCache(): 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"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user