Initial Commit

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

View File

@@ -0,0 +1,35 @@
# `localization`
Internationalization, localization, locale detection, and translation infrastructure.
<!-- begin:auto-docs -->
## Subcategories
1 subcategory, 19 packages.
### `i18n`
Internationalization core, backends, detectors, and locale resolution.
| 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 -->

View File

@@ -0,0 +1,3 @@
# `localization`
Internationalization, localization, locale detection, and translation infrastructure.

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

View File

@@ -0,0 +1 @@
Internationalization core, backends, detectors, and locale resolution.

View File

@@ -0,0 +1,45 @@
# @suaveplan/i18n-backend-chained - Universal chained backend orchestrator for the @suaveplan/i18n family. Composes N backends into a single Backend with read-fallthrough on BACKEND_LOCALE_NOT_AVAILABLE, write-through cache semantics after a successful read, and concurrent fan-out for readMulti with first-wins / last-wins merge. Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain. Emits OpenTelemetry instrumentation under i18n.backend.chained.* including a cache_hit/cache_miss/primary outcome attribute on the read counter.
## Summary
Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`, write-through cache semantics after a successful read, and concurrent fan-out for `readMulti` with `first-wins` (default) or `last-wins` merge strategy.
The chained backend is the canonical wiring for offline-first applications: register an in-memory or storage cache at index 0 and a network source of truth (`HttpBackend`) at the last index. The chain checks the cache first, falls through on miss, and writes the resolved record back to the cache so subsequent reads avoid the network round-trip. Transport failures (`BACKEND_READ_FAILED`) propagate immediately to stop the chain — silent degradation across an HTTP outage is not a feature.
OpenTelemetry instrumentation is emitted under `i18n.backend.chained.*`. The read counter carries an extended outcome attribute (`cache_hit | cache_miss | primary`) on top of the base `hit | miss | error` set so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Modules
| Module | Path | Doc |
|---|---|---|
| `ChainedBackend` class + Zod schema | `src/backend/backend.ts` | [`backend.md`](./src/backend/backend.md) |
| `chainedBackend` factory | `src/factory/factory.ts` | [`factory.md`](./src/factory/factory.md) |
| OTel instruments + state ring | `src/observability/observability.ts` | [`observability.md`](./src/observability/observability.md) |
## Subpath exports
- `@suaveplan/i18n-backend-chained` — root barrel
- `@suaveplan/i18n-backend-chained/backend` — `ChainedBackend` class + types + schema
- `@suaveplan/i18n-backend-chained/factory` — `chainedBackend` factory
- `@suaveplan/i18n-backend-chained/observability` — OTel instruments + state ring
## Install
```sh
bun add @suaveplan/i18n-backend-chained
```
## License
MIT — see repository root.

View File

@@ -0,0 +1,83 @@
{
"name": "@suaveplan/i18n-backend-chained",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal chained backend orchestrator for the @suaveplan/i18n family. Composes N backends into a single Backend with read-fallthrough on BACKEND_LOCALE_NOT_AVAILABLE, write-through cache semantics after a successful read, and concurrent fan-out for readMulti with first-wins / last-wins merge. Transport failures (BACKEND_READ_FAILED) propagate immediately to stop the chain. Emits OpenTelemetry instrumentation under i18n.backend.chained.* including a cache_hit/cache_miss/primary outcome attribute on the read counter.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./backend": {
"import": {
"types": "./dist/backend/index.d.ts",
"default": "./dist/backend/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/i18n-backend-memory": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,110 @@
# `ChainedBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ChainedBackend` composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE` and write-through cache semantics after a successful read. It is the canonical wiring for offline-first applications: register a fast cache (in-memory `MemoryBackend` or `StorageBackend(OPFSStorage)`) at index 0 and a source-of-truth backend (`HttpBackend`) at the last index. The chain checks the cache first, falls through to the next backend on a cache miss, then writes the resolved record back to the cache so subsequent reads avoid the network round-trip.
Transport failures (`BACKEND_READ_FAILED`) propagate immediately to stop the chain — silent degradation across an HTTP outage is a bug, not a feature. Only `BACKEND_LOCALE_NOT_AVAILABLE` (the resource is genuinely missing from a backend) triggers fallthrough.
## Features
- Read-fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; immediate propagation on `BACKEND_READ_FAILED`.
- Write-through cache after a successful read from index N to indices 0..N-1 (best-effort; per-backend save failures are swallowed and never block the read return).
- `readMulti` concurrent fan-out across every backend with `first-wins` (default) or `last-wins` merge strategy; gaps are filled from the other side regardless of strategy.
- `save` fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED` with `cause` preserved.
- `lastModified` returns the first defined timestamp across children.
- `init` forwards in registration order; `dispose` runs in reverse order — both safely callable multiple times.
- Capabilities matrix derived from the union of child capabilities (`canRead: true` always; `canSave`, `canTrackModification`, `canWatch` follow the union).
- AbortSignal threading on `read`, `readMulti`, and `save`. A pre-aborted signal throws `BACKEND_TIMEOUT` without calling any child.
- OpenTelemetry instrumentation under `i18n.backend.chained.*`. The read counter carries an outcome attribute of `cache_hit | cache_miss | primary` so production teams can size cache layers correctly. A `fallthrough_total{from_backend, to_backend}` counter tracks per-link fallthrough rates.
- New `CHAINED_BACKEND_ALL_FAILED` error code thrown defensively when the chain is empty at runtime (the constructor's Zod schema already rejects empty arrays at construction time).
## Usage: Basic Example
```ts
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = new ChainedBackend({
backends: [
memoryBackend(),
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
],
});
const record = await chain.read("en", "auth");
// On miss the http backend served the request; the memory backend now
// caches the record so subsequent reads hit memory.
```
## Usage: Advanced Example
```ts
import { ChainedBackend } from "@suaveplan/i18n-backend-chained/backend";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const controller = new AbortController();
const chain = new ChainedBackend({
backends: [
memoryBackend({ name: "memory-cache" }),
httpBackend({
name: "https-source",
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
}),
],
name: "offline-first",
mergeStrategy: "first-wins",
});
// AbortSignal threading: the chain throws BACKEND_TIMEOUT immediately
// when the signal is already aborted, and threads the signal into
// every child call when the child accepts it.
const merged = await chain.readMulti(
"en",
["auth", "common", "billing"],
controller.signal
);
// Capabilities come from the union of children — memory has canSave,
// http does not, so the chain reports canSave: true.
chain.capabilities.canSave; // true
```
## API Reference
### `class ChainedBackend implements Backend`
#### Constructor
`new ChainedBackend(options: ChainedBackendOptions)`
- `options.backends: readonly Backend[]` — required, non-empty.
- `options.name?: string` — defaults to `"chained"`.
- `options.mergeStrategy?: "first-wins" | "last-wins"` — defaults to `"first-wins"`.
Throws `I18nError(INVALID_OPTIONS)` when `backends` is empty or any field fails validation.
#### Instance methods
- `read(locale, namespace, signal?): Promise<TranslationRecord>` — sequential fallthrough on `BACKEND_LOCALE_NOT_AVAILABLE`; propagates `BACKEND_READ_FAILED` immediately; throws `BACKEND_TIMEOUT` on a pre-aborted signal; throws `CHAINED_BACKEND_ALL_FAILED` when the chain is empty at runtime.
- `readMulti(locale, namespaces, signal?): Promise<Record<string, TranslationRecord>>` — concurrent fan-out + merge per strategy; per-backend errors are swallowed.
- `save(locale, namespace, record, signal?): Promise<void>` — fan-out to every save-capable child; failures surface as `BACKEND_WRITE_FAILED`.
- `lastModified(locale, namespace): Promise<number | undefined>` — first defined timestamp across children.
- `init(context): Promise<void>` — forwards in registration order.
- `dispose(): Promise<void>` — reverse-order forward.
### `ChainedBackendOptionsSchema`
Zod schema validating the constructor options shape. Rejects empty `backends` arrays.
## Implementation Notes
- The `read` outcome attribute is computed by `#classifyReadOutcome(successIndex)`. Single-backend chains report `primary`; multi-backend chains report `cache_hit` (index 0 served the request) or `cache_miss` (a later backend served the request after fallthrough). On total miss, the outcome is `miss`; on `BACKEND_READ_FAILED` propagation, the outcome is `error`.
- `init` and `dispose` are safe to call multiple times — both methods iterate the children and forward the lifecycle event without keeping internal state.
- `writeThrough` is intentionally best-effort: a failing cache layer should never block the read return. Failures are swallowed silently. Production observability comes from the `i18n.backend.chained.write_total{outcome=error}` counter incremented by the `save` path (not the write-through path).
- The `Backend` interface in `@suaveplan/types` does not declare a `signal` parameter on `read` / `readMulti` / `save`, but concrete backends (HTTP, sodium-backed remote) accept one. The chained backend threads `signal` to children via a structural cast — children that ignore the third argument receive normal positional arguments only.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
# `chainedBackend` factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`chainedBackend` is a builder-style alternative to `new ChainedBackend(options)`. It returns a fresh `ChainedBackend` per call with no internal singleton or shared state. The factory accepts either an array shorthand for the common "just give me this list of backends" case or a full options object when callers want to override `name` or `mergeStrategy`.
This separation between class constructor and factory function lets consumers write composable code (`pipe(loadConfig, mapBackends, chainedBackend)`) without sprinkling `new` keywords through their initialisation pipeline. It also pairs naturally with the `httpBackend` / `memoryBackend` / `storageBackend` factories from sibling packages so an entire i18n backend chain can be expressed as a single nested function call.
## Features
- Array shorthand: `chainedBackend([memoryBackend(), httpBackend({...})])` — uses default `name: "chained"` and `mergeStrategy: "first-wins"`.
- Options form: `chainedBackend({ backends, name, mergeStrategy })` — passes options straight through to the constructor.
- No mutation of the input array — the factory wraps the array in a new options object before calling the constructor.
- Returns a fresh `ChainedBackend` per call (no caching, no singleton).
- Throws `I18nError(INVALID_OPTIONS)` if the resulting options fail Zod validation (empty `backends` array, etc.).
## Usage: Basic Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = chainedBackend([
memoryBackend(),
httpBackend({ loadPath: "/locales/{locale}/{namespace}.json" }),
]);
await chain.read("en", "auth");
```
## Usage: Advanced Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained/factory";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
import { httpBackend } from "@suaveplan/i18n-backend-http";
const chain = chainedBackend({
backends: [
memoryBackend({ name: "memory-cache" }),
httpBackend({
name: "https-source",
loadPath: "https://i18n.example.com/{locale}/{namespace}.json",
}),
],
name: "production-chain",
mergeStrategy: "last-wins",
});
chain.name; // "production-chain"
```
## API Reference
`chainedBackend(backendsOrOptions: readonly Backend[] | ChainedBackendOptions): ChainedBackend`
- `backendsOrOptions` — either an array of backends (array shorthand) or a full `ChainedBackendOptions` object. The function uses `Array.isArray` to discriminate at runtime.
Returns a fresh `ChainedBackend` instance. Throws `I18nError(INVALID_OPTIONS)` on validation failure.
## Implementation Notes
- The factory is a thin wrapper around `new ChainedBackend(options)`. All validation happens in the constructor's Zod schema; the factory itself adds no logic beyond array-vs-options discrimination.
- The factory is the recommended ergonomic surface for application code; the class is exported for libraries that need to subclass or extend the chain. Both surfaces produce identical `ChainedBackend` instances.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
# Chained-backend observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every OpenTelemetry instrument and the in-memory state ring used by `@suaveplan/i18n-backend-chained`. Production code paths in `ChainedBackend` call `recordRead`, `recordWrite`, and `recordFallthrough`; tests reset and inspect the in-memory state ring via `resetChainedBackendMetricsState` and `getChainedBackendMetricsState`. The instruments are built lazily on first call so consumers without an OTel `MeterProvider` registered pay zero overhead.
The emitted metric prefix is `i18n.backend.chained.*`, and every counter and histogram carries a `backend` attribute so multi-chain deployments can disambiguate per-instance metrics. The chained backend's `read_total` counter additionally carries an `outcome` attribute drawn from the extended set `hit | miss | error | cache_hit | cache_miss | primary` — the latter three values are the chained-backend's contribution beyond the base outcome set used by leaf backends.
## Features
- Lazy meter construction; idempotent `getChainedBackendMetrics()`.
- Six instruments: `read_duration_ms` (histogram), `read_total{outcome}` (counter), `read_errors_total{code}` (counter), `write_duration_ms` (histogram), `write_total{outcome}` (counter), `fallthrough_total{from_backend, to_backend}` (counter).
- In-memory state ring for spec-conformance tests — avoids standing up a full SDK pipeline.
- `recordRead` / `recordWrite` / `recordFallthrough` are the only API surface production code calls.
- `resetChainedBackendMetricsState` clears the ring; `resetChainedBackendMetricsCache` drops cached instruments so a fresh meter can be built (test-only).
## Usage: Basic Example
```ts
import {
getChainedBackendMetrics,
recordRead,
} from "@suaveplan/i18n-backend-chained/observability";
// Lazy-build instruments on first use.
const ins = getChainedBackendMetrics();
// Production hook — emit a hit on the cache layer at index 0.
recordRead("chained", "en", "auth", "cache_hit", 0.42);
```
## Usage: Advanced Example
```ts
import {
getChainedBackendMetricsState,
recordFallthrough,
recordWrite,
resetChainedBackendMetricsCache,
resetChainedBackendMetricsState,
} from "@suaveplan/i18n-backend-chained/observability";
resetChainedBackendMetricsCache();
resetChainedBackendMetricsState();
recordWrite("chained", "ok", 1.23);
recordFallthrough("memory-cache", "https-source");
const state = getChainedBackendMetricsState();
state.writeTotal.length; // 1
state.fallthroughTotal[0]?.attributes; // { from_backend: "memory-cache", to_backend: "https-source" }
```
## API Reference
- `getChainedBackendMetrics(): CachedInstruments` — lazy build / cached fetch.
- `recordRead(backend, locale, namespace, outcome, durationMs, errorCode?)` — emit one read sample.
- `recordWrite(backend, outcome, durationMs)` — emit one write sample.
- `recordFallthrough(fromBackend, toBackend)` — emit one fallthrough sample.
- `getChainedBackendMetricsState(): ChainedBackendMetricsState` — read the in-memory state ring.
- `resetChainedBackendMetricsState(): void` — clear the ring.
- `resetChainedBackendMetricsCache(): void` — drop cached instruments (test-only).
- `ChainedReadOutcome` — `"hit" | "miss" | "error" | "cache_hit" | "cache_miss" | "primary"`.
- `ChainedWriteOutcome` — `"ok" | "error"`.
## Implementation Notes
- The `fallthrough_total` counter is bounded in cardinality by the number of unique chain links observed in production — typically O(N) for a chain of N backends, never proliferating beyond the registered set. Consumers configuring per-tenant chains should keep `from_backend` / `to_backend` low-cardinality (avoid embedding tenant ids in backend names).
- The in-memory state ring grows unbounded in long-running tests. Always call `resetChainedBackendMetricsState()` between cases.
- The OTel `Counter` and `Histogram` instruments are no-ops when no `MeterProvider` is registered — the default. Production wiring registers a SDK + exporter at process start.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
]
}

View File

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

View File

@@ -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);
});
});

View File

@@ -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}'`
);
}

View File

@@ -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;
}

View File

@@ -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";

View File

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

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

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

View File

@@ -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");
});
});

View File

@@ -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";

View File

@@ -0,0 +1,14 @@
export type {
MultiFileFsBackendMetricSample,
MultiFileFsBackendMetricsState,
MultiFileFsReadOutcome,
MultiFileFsWriteOutcome,
} from "./observability.js";
export {
getMultiFileFsBackendMetrics,
getMultiFileFsBackendMetricsState,
recordRead,
recordWrite,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "./observability.js";

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,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:/,
],
},
},
});

View File

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

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

View 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"
]
}

View File

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

View File

@@ -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);
});
});

View File

@@ -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 },
}
);
}
}
}

View File

@@ -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;
}

View File

@@ -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";

View File

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

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

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

View 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();
}
});
});

View 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";

View File

@@ -0,0 +1,11 @@
export type {
HttpBackendMetricSample,
HttpBackendMetricsState,
} from "./observability.js";
export {
getHttpBackendMetrics,
getHttpBackendMetricsState,
recordRead,
resetHttpBackendMetricsCache,
resetHttpBackendMetricsState,
} from "./observability.js";

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,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:/,
],
},
},
});

View File

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

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

View 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"
]
}

View File

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

View File

@@ -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);
});
});

View File

@@ -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 },
}
);
}
}
}

View File

@@ -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;
}

View File

@@ -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";

View File

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

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

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

View File

@@ -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();
}
});
});

View 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";

View File

@@ -0,0 +1,12 @@
export type {
MemoryBackendMetricSample,
MemoryBackendMetricsState,
} from "./observability.js";
export {
getMemoryBackendMetrics,
getMemoryBackendMetricsState,
recordRead,
recordWrite,
resetMemoryBackendMetricsCache,
resetMemoryBackendMetricsState,
} from "./observability.js";

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,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:/,
],
},
},
});

View File

@@ -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,
},
});

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

View 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"
]
}

View File

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

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

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

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

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

View File

@@ -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();
}
});
});

View 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";

View File

@@ -0,0 +1,12 @@
export type {
StorageBackendMetricSample,
StorageBackendMetricsState,
} from "./observability.js";
export {
getStorageBackendMetrics,
getStorageBackendMetricsState,
recordRead,
recordWrite,
resetStorageBackendMetricsCache,
resetStorageBackendMetricsState,
} from "./observability.js";

View File

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

View File

@@ -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([]);
});
});

Some files were not shown because too many files have changed in this diff Show More