Initial Commit
This commit is contained in:
121
packages/localization/i18n/i18n-backend-memory/README.md
Normal file
121
packages/localization/i18n/i18n-backend-memory/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# @suaveplan/i18n-backend-memory - Universal in-memory translation backend for the @suaveplan/i18n family. Backed by Map<locale, Map<namespace, TranslationRecord>> plus a modification ledger; implements the full Backend contract (read, readMulti, save, lastModified). Designed for tests, seeded defaults, and the cache layer of a ChainedBackend. Zero runtime dependencies on @suaveplan/i18n core.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal in-memory translation backend for the
|
||||
`@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace,
|
||||
TranslationRecord>>` plus a modification ledger; implements the full
|
||||
`Backend` contract (`read`, `readMulti`, `save`, `lastModified`).
|
||||
Designed for tests, seeded defaults, and the cache layer of a
|
||||
`ChainedBackend`. Zero runtime dependencies on `@suaveplan/i18n`
|
||||
core.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Why this exists
|
||||
|
||||
The Wave 5 modular split lifts each backend out of the
|
||||
`@suaveplan/i18n` monolith into its own sibling so consumers can
|
||||
install only the backends they need (matching the `crypto-*` /
|
||||
`drive-*` / `network-plugin-*` granularity established elsewhere in
|
||||
the project). The in-memory backend is the simplest and most
|
||||
universal of the five — it has zero external runtime deps, runs
|
||||
identically in browser and server, and is the canonical seed source
|
||||
for every other backend's tests.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-backend-memory
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = memoryBackend({
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
fr: { common: { hello: "Bonjour" } },
|
||||
},
|
||||
});
|
||||
|
||||
const enCommon = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const t = await backend.lastModified("en", "auth"); // number (ms)
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-backend-memory` | Root barrel — class + factory + types + schemas + observability |
|
||||
| `@suaveplan/i18n-backend-memory/backend` | `MemoryBackend` class only |
|
||||
| `@suaveplan/i18n-backend-memory/factory` | `memoryBackend` factory function only |
|
||||
| `@suaveplan/i18n-backend-memory/schemas` | Zod boundary schemas |
|
||||
| `@suaveplan/i18n-backend-memory/observability` | OTel instruments + in-memory state ring |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [backend.md](src/backend/backend.md) — `MemoryBackend` class + capability matrix
|
||||
- [factory.md](src/factory/factory.md) — `memoryBackend` factory function
|
||||
- [observability.md](src/observability/observability.md) — OTel instruments + metric names + cardinality
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Flag | Value | Notes |
|
||||
|---|---|---|
|
||||
| `canRead` | `true` | always — every backend reads |
|
||||
| `canReadMulti` | `true` | bulk-load O(N) over the namespace map |
|
||||
| `canSave` | `true` | persists into the in-memory store |
|
||||
| `canTrackModification` | `true` | injectable `now()` clock |
|
||||
| `canWatch` | `false` | no reactive `loaded` events |
|
||||
|
||||
## Testing
|
||||
|
||||
The package's own tests use the shared `runBackendConformance`
|
||||
harness from `@suaveplan/i18n-shared/testing` so the universal
|
||||
`Backend` contract is exercised against a fresh in-memory backend
|
||||
per assertion:
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
runBackendConformance("MemoryBackend", async () => {
|
||||
const backend = new MemoryBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0`
|
||||
release. The metric names under `i18n.backend.memory.*` are part of
|
||||
the public API contract and will not change without a major bump.
|
||||
|
||||
## Wave 5 Scope
|
||||
|
||||
This package is Wave 5a of the 14-package i18n modular split. It
|
||||
ships pure additions only — `packages/i18n/i18n/src/core/backend/memory/`
|
||||
retains its copy of the backend until Wave 6 deletes it. Both copies
|
||||
coexist during Wave 5; downstream consumers continue to import from
|
||||
the monolith and observe no behaviour change.
|
||||
|
||||
82
packages/localization/i18n/i18n-backend-memory/package.json
Normal file
82
packages/localization/i18n/i18n-backend-memory/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-memory",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal in-memory translation backend for the @suaveplan/i18n family. Backed by Map<locale, Map<namespace, TranslationRecord>> plus a modification ledger; implements the full Backend contract (read, readMulti, save, lastModified). Designed for tests, seeded defaults, and the cache layer of a ChainedBackend. Zero runtime dependencies on @suaveplan/i18n core.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./backend": {
|
||||
"import": {
|
||||
"types": "./dist/backend/index.d.ts",
|
||||
"default": "./dist/backend/index.js"
|
||||
}
|
||||
},
|
||||
"./factory": {
|
||||
"import": {
|
||||
"types": "./dist/factory/index.d.ts",
|
||||
"default": "./dist/factory/index.js"
|
||||
}
|
||||
},
|
||||
"./observability": {
|
||||
"import": {
|
||||
"types": "./dist/observability/index.d.ts",
|
||||
"default": "./dist/observability/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/i18n-shared": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
# `MemoryBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryBackend` is the universal in-memory translation backend
|
||||
for the `@suaveplan/i18n` family. It implements the full `Backend`
|
||||
contract from `@suaveplan/types` (read, readMulti, save,
|
||||
lastModified) backed by `Map<locale, Map<namespace,
|
||||
TranslationRecord>>` plus a modification ledger keyed by `${locale}
|
||||
${namespace}`. It is the canonical seed source for tests, the
|
||||
default cache layer for `ChainedBackend`, and the reference
|
||||
implementation every other backend's conformance suite is matched
|
||||
against.
|
||||
|
||||
The backend has zero runtime dependencies on `@suaveplan/i18n` core,
|
||||
so it can be used standalone — for example, a test suite needing
|
||||
catalog-shaped data can import `MemoryBackend` without paying the
|
||||
cost of pulling in the full catalog stack.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 5-flag capability matrix: `canRead: true`, `canReadMulti:
|
||||
true`, `canSave: true`, `canTrackModification: true`, `canWatch:
|
||||
false`.
|
||||
- Optional constructor seed (`{ <locale>: { <namespace>:
|
||||
TranslationRecord } }`) for pre-populating before first read.
|
||||
- Injectable `now: () => number` clock for deterministic
|
||||
`lastModified` testing.
|
||||
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
|
||||
and `save`; pre-aborted signals throw `I18nError(I18N.BACKEND_TIMEOUT)`.
|
||||
- OTel histograms + counters emitted on every read/save:
|
||||
`i18n.backend.memory.read_duration_ms`, `read_total{outcome}`,
|
||||
`read_errors_total{code}`, `write_duration_ms`,
|
||||
`write_total{outcome}`.
|
||||
- Zod boundary validation: invalid options throw
|
||||
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
fr: { common: { hello: "Bonjour" } },
|
||||
},
|
||||
});
|
||||
|
||||
const enCommon = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The injectable clock makes `lastModified` deterministic so chained-
|
||||
backend tests can assert exact timestamps without fighting wall-clock
|
||||
non-determinism.
|
||||
|
||||
```ts
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
let tick = 1000;
|
||||
const backend = new MemoryBackend({
|
||||
name: "cache",
|
||||
now: () => tick++,
|
||||
});
|
||||
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
await backend.save("en", "auth", { login: "Sign in" });
|
||||
|
||||
const tCommon = await backend.lastModified("en", "common"); // 1000
|
||||
const tAuth = await backend.lastModified("en", "auth"); // 1001
|
||||
|
||||
// Cancellable reads play nicely with multi-backend orchestration.
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await backend.read("en", "common", controller.signal); // throws BACKEND_TIMEOUT
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryBackend implements Backend`
|
||||
|
||||
- `constructor(options?: MemoryBackendOptions)` — validates `options`
|
||||
via `MemoryBackendOptionsSchema`; throws `I18nError(I18N.INVALID_OPTIONS)`
|
||||
on schema mismatch.
|
||||
- `name: string` — backend label (default `"memory"`).
|
||||
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
|
||||
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
|
||||
resolves with the seeded record; throws
|
||||
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
|
||||
- `readMulti(locale, namespaces, signal?): Promise<Record<string,
|
||||
TranslationRecord>>` — returns only the namespaces that exist;
|
||||
missing namespaces are omitted (not undefined).
|
||||
- `save(locale, namespace, record, signal?): Promise<void>` —
|
||||
persists the record and stamps the modification ledger via the
|
||||
injected `now()` function.
|
||||
- `lastModified(locale, namespace): Promise<number | undefined>` —
|
||||
returns the timestamp of the last `save` for the slot, or
|
||||
`undefined` if never written.
|
||||
- `clear(): void` — test-only helper, drops all in-memory state.
|
||||
- `snapshot(): MemorySeed` — returns a fresh deep-copy snapshot of
|
||||
the current store keyed by locale → namespace.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `Map<locale, Map<namespace, ...>>` two-level data structure
|
||||
gives O(1) lookups and O(1) inserts. The modification ledger is a
|
||||
flat `Map<string, number>` keyed by `"${locale} ${namespace}"`
|
||||
with a single space separator (BCP-47 tags do not contain spaces,
|
||||
so the key is unambiguous).
|
||||
- `signal?.aborted` is checked at the entry of every async method
|
||||
and the throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The
|
||||
`aborted` flag is sampled exactly once at the top — the backend
|
||||
performs no further async work, so post-yield abort is not
|
||||
meaningful.
|
||||
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
|
||||
a `MeterProvider` is registered. Test suites use the in-memory
|
||||
metrics state ring exposed by `./observability` to assert
|
||||
counter/histogram payloads without standing up the full SDK.
|
||||
- Seeded options call the same private `#put` path as `save`, so
|
||||
the modification ledger is populated for every seeded slot using
|
||||
the injected `now()` function. The seed is consumed
|
||||
synchronously at construction; consumers needing async seeding
|
||||
call `save()` post-construction.
|
||||
@@ -0,0 +1,313 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { memoryBackend } from "../factory/factory.js";
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "../observability/observability.js";
|
||||
import { MemoryBackend } from "./backend.js";
|
||||
import type { MemorySeed } from "./backend.types.js";
|
||||
|
||||
// ── Run the universal Backend contract conformance suite ────────────
|
||||
|
||||
runBackendConformance("MemoryBackend", async () => {
|
||||
const backend = new MemoryBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Memory-specific behaviour ───────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend specifics", () => {
|
||||
it("reads seeded records on construction", { timeout: 5000 }, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { auth: { login: { title: "Sign in" } } },
|
||||
fr: { auth: { login: { title: "Se connecter" } } },
|
||||
},
|
||||
});
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in" },
|
||||
});
|
||||
expect(await backend.read("fr", "auth")).toEqual({
|
||||
login: { title: "Se connecter" },
|
||||
});
|
||||
});
|
||||
|
||||
it("save then read round-trips", { timeout: 5000 }, async () => {
|
||||
const backend = memoryBackend();
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("lastModified returns the injected timestamp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({ now: () => 1234 });
|
||||
await backend.save("en", "auth", { x: "y" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(t).toBe(1234);
|
||||
});
|
||||
|
||||
it("lastModified is monotonic across saves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let t = 1000;
|
||||
const backend = new MemoryBackend({ now: () => t++ });
|
||||
await backend.save("en", "auth", { a: "1" });
|
||||
const t1 = await backend.lastModified("en", "auth");
|
||||
await backend.save("en", "auth", { a: "2" });
|
||||
const t2 = await backend.lastModified("en", "auth");
|
||||
expect(t2).toBeGreaterThan(t1 as number);
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent slot", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
expect(await backend.lastModified("xx", "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("readMulti omits missing namespaces", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" }, errors: { e: "E" } } },
|
||||
});
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"errors",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
errors: { e: "E" },
|
||||
});
|
||||
expect(result).not.toHaveProperty("missing");
|
||||
});
|
||||
|
||||
it("readMulti returns empty for absent locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const result = await backend.readMulti("xx", ["any"]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("read miss throws BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
await expect(backend.read("en", "auth")).rejects.toMatchObject({
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("default name is 'memory'", { timeout: 5000 }, () => {
|
||||
expect(new MemoryBackend().name).toBe("memory");
|
||||
});
|
||||
|
||||
it("custom name override", { timeout: 5000 }, () => {
|
||||
expect(new MemoryBackend({ name: "cache" }).name).toBe("cache");
|
||||
});
|
||||
|
||||
it("capabilities matrix matches the contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const c = new MemoryBackend().capabilities;
|
||||
expect(c).toEqual({
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() drops all state", { timeout: 5000 }, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
backend.clear();
|
||||
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("snapshot() returns a copy of the seed-shape", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: {
|
||||
en: { auth: { ok: "Ok" }, common: { yes: "Yes" } },
|
||||
fr: { auth: { ok: "OK" } },
|
||||
},
|
||||
});
|
||||
const snap = backend.snapshot();
|
||||
expect(snap).toEqual({
|
||||
en: { auth: { ok: "Ok" }, common: { yes: "Yes" } },
|
||||
fr: { auth: { ok: "OK" } },
|
||||
});
|
||||
// Mutating the snapshot does not affect the backend.
|
||||
(snap.en as Record<string, unknown>).extra = { x: "y" };
|
||||
expect(backend.snapshot().en).not.toHaveProperty("extra");
|
||||
});
|
||||
});
|
||||
|
||||
// ── AbortSignal cancellation ────────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend AbortSignal", () => {
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.readMulti("en", ["auth"], ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.save("en", "auth", { x: "y" }, ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
// State unchanged.
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-aborted signal does not affect operation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new MemoryBackend();
|
||||
const ac = new AbortController();
|
||||
await backend.save("en", "auth", { x: "y" }, ac.signal);
|
||||
expect(await backend.read("en", "auth", ac.signal)).toEqual({ x: "y" });
|
||||
expect(await backend.readMulti("en", ["auth"], ac.signal)).toEqual({
|
||||
auth: { x: "y" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boundary validation (Zod) ───────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend boundary validation", () => {
|
||||
it("invalid options throw INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
seed: "not an object" as unknown as MemorySeed,
|
||||
})
|
||||
).toThrow(I18nError);
|
||||
try {
|
||||
new MemoryBackend({
|
||||
seed: "not an object" as unknown as MemorySeed,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("empty-string name throws INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new MemoryBackend({ name: "" })).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("non-function now throws INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
now: 12345 as unknown as () => number,
|
||||
})
|
||||
).toThrow(I18nError);
|
||||
});
|
||||
|
||||
it("unknown option key throws (strict schema)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new MemoryBackend({
|
||||
bogus: true,
|
||||
} as unknown as Parameters<typeof MemoryBackend>[0])
|
||||
).toThrow(I18nError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Observability ───────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryBackend observability", () => {
|
||||
it("emits read_total{outcome=hit} on read hit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend({
|
||||
seed: { en: { auth: { ok: "Ok" } } },
|
||||
});
|
||||
await backend.read("en", "auth");
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
expect(state.readDurationMs.length).toBe(1);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits read_total{outcome=miss} + read_errors_total{code} on miss", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.read("en", "auth").catch(() => undefined);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits write_total{outcome=ok} on save", { timeout: 5000 }, async () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.save("en", "auth", { x: "y" });
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([{ attributes: { outcome: "ok" } }]);
|
||||
expect(state.writeDurationMs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead, recordWrite } from "../observability/observability.js";
|
||||
import type { MemoryBackendOptions, MemorySeed } from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for the per-namespace translation record. Accepts
|
||||
* any object-shaped value; the i18n message resolver enforces the
|
||||
* deeper string/nested-record contract.
|
||||
*/
|
||||
export const TranslationRecordSchema: z.ZodType<Record<string, unknown>> =
|
||||
z.record(z.string(), z.unknown());
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link MemorySeed}. Validates that the seed
|
||||
* is `Record<locale, Record<namespace, TranslationRecord>>`-shaped.
|
||||
*/
|
||||
export const MemorySeedSchema: z.ZodType<
|
||||
Record<string, Record<string, Record<string, unknown>>>
|
||||
> = z.record(z.string(), z.record(z.string(), TranslationRecordSchema));
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link MemoryBackendOptions}. Validates that
|
||||
* `seed` (if present) is a seed-shaped object, that `name` (if
|
||||
* present) is a non-empty string, and that `now` (if present) is a
|
||||
* function. Caller-supplied option values failing this schema cause
|
||||
* the constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export const MemoryBackendOptionsSchema = z
|
||||
.object({
|
||||
seed: MemorySeedSchema.optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
now: z.function().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type MemoryBackendOptionsInput = z.input<
|
||||
typeof MemoryBackendOptionsSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* In-memory translation backend.
|
||||
*
|
||||
* Backed by `Map<locale, Map<namespace, TranslationRecord>>` plus a
|
||||
* `Map<string, number>` modification ledger keyed by `${locale}
|
||||
* ${namespace}`. Implements the full `Backend` contract from
|
||||
* `@suaveplan/types` (`read`, `readMulti`, `save`, `lastModified`).
|
||||
* Designed for tests, seeded defaults, and the cache layer of a
|
||||
* `ChainedBackend`.
|
||||
*
|
||||
* Universal: runs identically in browser and server. No
|
||||
* platform-specific imports.
|
||||
*/
|
||||
export class MemoryBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
readonly #data = new Map<string, Map<string, TranslationRecord>>();
|
||||
readonly #modified = new Map<string, number>();
|
||||
readonly #now: () => number;
|
||||
|
||||
public constructor(options: MemoryBackendOptions = {}) {
|
||||
const parsed = MemoryBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"MemoryBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
this.name = options.name ?? "memory";
|
||||
this.#now = options.now ?? Date.now;
|
||||
if (options.seed) {
|
||||
for (const [locale, byNamespace] of Object.entries(options.seed)) {
|
||||
for (const [ns, record] of Object.entries(byNamespace)) {
|
||||
this.#put(locale, ns, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
const ns = this.#data.get(locale)?.get(namespace);
|
||||
if (ns === undefined) {
|
||||
recordRead(
|
||||
locale,
|
||||
namespace,
|
||||
"miss",
|
||||
performance.now() - t0,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`MemoryBackend: no record for ${locale}/${namespace}`,
|
||||
{ context: { locale, namespace, backend: this.name } }
|
||||
);
|
||||
}
|
||||
recordRead(locale, namespace, "hit", performance.now() - t0);
|
||||
return ns;
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
this.#checkAborted(signal);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
const byLocale = this.#data.get(locale);
|
||||
if (!byLocale) return out;
|
||||
for (const ns of namespaces) {
|
||||
const record = byLocale.get(ns);
|
||||
if (record !== undefined) {
|
||||
out[ns] = record;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
this.#put(locale, namespace, record);
|
||||
recordWrite(locale, namespace, "ok", performance.now() - t0);
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
return this.#modified.get(`${locale} ${namespace}`);
|
||||
}
|
||||
|
||||
/** Test-only: drop all state. Not part of the Backend interface. */
|
||||
public clear(): void {
|
||||
this.#data.clear();
|
||||
this.#modified.clear();
|
||||
}
|
||||
|
||||
/** Convenience for tests / consumers. Snapshot of the seed shape. */
|
||||
public snapshot(): MemorySeed {
|
||||
const out: Record<string, Record<string, TranslationRecord>> = {};
|
||||
for (const [locale, byNs] of this.#data) {
|
||||
const inner: Record<string, TranslationRecord> = {};
|
||||
for (const [ns, rec] of byNs) {
|
||||
inner[ns] = rec;
|
||||
}
|
||||
out[locale] = inner;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#put(locale: string, namespace: string, record: TranslationRecord): void {
|
||||
let byNs = this.#data.get(locale);
|
||||
if (!byNs) {
|
||||
byNs = new Map();
|
||||
this.#data.set(locale, byNs);
|
||||
}
|
||||
byNs.set(namespace, record);
|
||||
this.#modified.set(`${locale} ${namespace}`, this.#now());
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"MemoryBackend: operation aborted",
|
||||
{
|
||||
context: { backend: this.name, reason: signal.reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Seed shape: `{ <locale>: { <namespace>: TranslationRecord } }`. Pre-
|
||||
* populates the in-memory store at construction time.
|
||||
*/
|
||||
export type MemorySeed = Record<string, Record<string, TranslationRecord>>;
|
||||
|
||||
/**
|
||||
* Constructor options for {@link MemoryBackend}.
|
||||
*
|
||||
* - `seed` — optional pre-population for tests and seeded defaults.
|
||||
* - `name` — optional name override (default `"memory"`). Useful when
|
||||
* multiple in-memory backends are registered in a chain.
|
||||
* - `now` — injectable clock for deterministic `lastModified` testing
|
||||
* (default `Date.now`).
|
||||
*/
|
||||
export interface MemoryBackendOptions {
|
||||
readonly seed?: MemorySeed;
|
||||
readonly name?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Feature barrel for `@suaveplan/i18n-backend-memory/backend`. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MemoryBackend,
|
||||
type MemoryBackendOptionsInput,
|
||||
MemoryBackendOptionsSchema,
|
||||
MemorySeedSchema,
|
||||
TranslationRecordSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
MemoryBackendOptions,
|
||||
MemorySeed,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `memoryBackend(options?)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Factory wrapper around `new MemoryBackend(options)` for callers that
|
||||
prefer fluent builder-style composition over `new`. Returns a fresh
|
||||
backend per call — no internal singleton, no shared state, no
|
||||
caching. The factory exists primarily so chained-backend
|
||||
configurations can be expressed declaratively without the visual
|
||||
weight of repeated `new` keywords.
|
||||
|
||||
The factory is the recommended public entry point for routine use
|
||||
because it composes naturally with the rest of the i18n family
|
||||
(every backend ships a `<name>Backend` factory mirroring its class
|
||||
constructor). Direct `new MemoryBackend(...)` remains supported and
|
||||
is exactly equivalent.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `MemoryBackend` per call.
|
||||
- Forwards every constructor option (`seed`, `name`, `now`)
|
||||
unchanged.
|
||||
- Same Zod boundary validation as the class constructor — invalid
|
||||
options throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const backend = memoryBackend();
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
const record = await backend.read("en", "common");
|
||||
// { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The factory shines in chained-backend configurations where multiple
|
||||
in-memory backends (e.g. a cache layer + a write-through layer)
|
||||
need to be expressed inline.
|
||||
|
||||
```ts
|
||||
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
const cache = memoryBackend({ name: "cache" });
|
||||
const seed = memoryBackend({
|
||||
name: "seed",
|
||||
seed: {
|
||||
en: { common: { hello: "Hello" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Hand both to a ChainedBackend (Wave 5e).
|
||||
// const chain = chainedBackend({ backends: [cache, seed] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `memoryBackend(options?: MemoryBackendOptions): MemoryBackend`
|
||||
|
||||
- `options.seed?: MemorySeed` — optional pre-population (`{ <locale>:
|
||||
{ <namespace>: TranslationRecord } }`).
|
||||
- `options.name?: string` — optional name override (default
|
||||
`"memory"`).
|
||||
- `options.now?: () => number` — injectable clock for deterministic
|
||||
`lastModified` testing.
|
||||
- Returns: a fresh `MemoryBackend` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a one-liner: `return new MemoryBackend(options)`.
|
||||
It exists for ergonomic parity with the rest of the
|
||||
`<name>Backend` factory pattern across the i18n backend family
|
||||
and adds no behaviour beyond instantiation.
|
||||
- The factory does NOT memoise — every call returns a distinct
|
||||
backend instance. Callers needing shared state pass the same
|
||||
backend reference to multiple call sites; do not call the
|
||||
factory multiple times expecting the same store.
|
||||
- For consumers who need polymorphism over `BackendFactory<TOptions>`
|
||||
from `@suaveplan/types`, this factory satisfies that signature.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryBackend } from "../backend/backend.js";
|
||||
import { memoryBackend } from "./factory.js";
|
||||
|
||||
describe("memoryBackend factory", () => {
|
||||
it("returns a MemoryBackend instance", { timeout: 5000 }, () => {
|
||||
const result = memoryBackend();
|
||||
expect(result).toBeInstanceOf(MemoryBackend);
|
||||
});
|
||||
|
||||
it("default name is 'memory'", { timeout: 5000 }, () => {
|
||||
expect(memoryBackend().name).toBe("memory");
|
||||
});
|
||||
|
||||
it("passes seed through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = memoryBackend({
|
||||
seed: { en: { common: { x: "y" } } },
|
||||
});
|
||||
expect(await backend.read("en", "common")).toEqual({ x: "y" });
|
||||
});
|
||||
|
||||
it("passes name through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(memoryBackend({ name: "cache" }).name).toBe("cache");
|
||||
});
|
||||
|
||||
it("passes now through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = memoryBackend({ now: () => 7777 });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await backend.lastModified("en", "auth")).toBe(7777);
|
||||
});
|
||||
|
||||
it("returns a fresh instance per call (no singleton)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = memoryBackend();
|
||||
const b = memoryBackend();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MemoryBackend } from "../backend/backend.js";
|
||||
import type { MemoryBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `MemoryBackend` per call — no internal singleton, no shared
|
||||
* state. Equivalent to `new MemoryBackend(options)`.
|
||||
*/
|
||||
export function memoryBackend(options?: MemoryBackendOptions): MemoryBackend {
|
||||
return new MemoryBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { memoryBackend } from "./factory.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
const PACKAGE_JSON_PATH = fileURLToPath(
|
||||
new URL("../package.json", import.meta.url)
|
||||
);
|
||||
|
||||
describe("@suaveplan/i18n-backend-memory — root barrel", () => {
|
||||
it("re-exports MemoryBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.MemoryBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports memoryBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.memoryBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports MemoryBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.MemoryBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.recordWrite).toBe("function");
|
||||
expect(typeof root.getMemoryBackendMetrics).toBe("function");
|
||||
expect(typeof root.getMemoryBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetMemoryBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetMemoryBackendMetricsCache).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-memory — every subpath resolves", () => {
|
||||
it("subpath barrels resolve through the package", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = await import("./backend/index.js");
|
||||
const factory = await import("./factory/index.js");
|
||||
const observability = await import("./observability/index.js");
|
||||
|
||||
expect(typeof backend.MemoryBackend).toBe("function");
|
||||
expect(backend.MemoryBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.memoryBackend).toBe("function");
|
||||
expect(typeof observability.getMemoryBackendMetrics).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-memory — package.json", () => {
|
||||
it("declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
sideEffects?: boolean;
|
||||
};
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("does not depend on @suaveplan/i18n core", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies ?? {};
|
||||
expect(deps).not.toHaveProperty("@suaveplan/i18n");
|
||||
});
|
||||
|
||||
it("declares every documented subpath export", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
const documented = [".", "./backend", "./factory", "./observability"];
|
||||
for (const subpath of documented) {
|
||||
expect(pkg.exports?.[subpath]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
25
packages/localization/i18n/i18n-backend-memory/src/index.ts
Normal file
25
packages/localization/i18n/i18n-backend-memory/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-memory. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
MemoryBackend,
|
||||
type MemoryBackendOptionsInput,
|
||||
MemoryBackendOptionsSchema,
|
||||
MemorySeedSchema,
|
||||
TranslationRecordSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
MemoryBackendOptions,
|
||||
MemorySeed,
|
||||
} from "./backend/backend.types.js";
|
||||
export { memoryBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
type MemoryBackendMetricSample,
|
||||
type MemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
MemoryBackendMetricSample,
|
||||
MemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,113 @@
|
||||
# `MemoryBackend` Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrumentation for
|
||||
`@suaveplan/i18n-backend-memory`. It ships per-call metric emitters
|
||||
(`recordRead`, `recordWrite`) that the backend invokes around every
|
||||
`read` / `save` operation, plus a small in-memory state ring so spec-
|
||||
conformance tests can assert exact attribute payloads without
|
||||
standing up a full SDK + InMemoryMetricExporter pipeline. Every
|
||||
instrument is built lazily and is a no-op when no MeterProvider is
|
||||
registered — consumers pay zero overhead unless they wire up the
|
||||
SDK.
|
||||
|
||||
The metric naming follows the per-backend prefix convention agreed
|
||||
in the Wave 5 design: `i18n.backend.memory.*`. Sibling backend
|
||||
packages (`i18n-backend-http`, `i18n-backend-storage`, etc.) emit
|
||||
under their own prefixes so consumers can group dashboard panels
|
||||
per backend without shared-namespace collisions.
|
||||
|
||||
## Features
|
||||
|
||||
- Five instruments — two histograms, three counters — covering both
|
||||
read and write paths.
|
||||
- In-memory state ring for test assertions (no exporter required).
|
||||
- Lazy meter construction; idempotent `getMemoryBackendMetrics()`.
|
||||
- Test-only `resetMemoryBackendMetricsCache()` so suites can
|
||||
rebuild the meter under a fresh provider.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-memory/observability";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend({ seed: { en: { common: { hi: "Hi" } } } });
|
||||
await backend.read("en", "common");
|
||||
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Mismatched reads emit BOTH an outcome counter AND an
|
||||
`read_errors_total` counter labelled by error code. Tests can assert
|
||||
the full payload to detect regressions in error classification.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getMemoryBackendMetricsState,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-memory/observability";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
resetMemoryBackendMetricsState();
|
||||
const backend = new MemoryBackend();
|
||||
await backend.read("en", "missing").catch(() => undefined);
|
||||
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Emitted instruments
|
||||
|
||||
| Instrument | Kind | Labels | Unit |
|
||||
|---|---|---|---|
|
||||
| `i18n.backend.memory.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
|
||||
| `i18n.backend.memory.read_total` | Counter | `outcome` (hit \| miss) | count |
|
||||
| `i18n.backend.memory.read_errors_total` | Counter | `code` (I18N error code) | count |
|
||||
| `i18n.backend.memory.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome` | ms |
|
||||
| `i18n.backend.memory.write_total` | Counter | `outcome` (ok \| error) | count |
|
||||
|
||||
### Functions
|
||||
|
||||
- `getMemoryBackendMetrics()` — returns cached instruments; builds
|
||||
them on first call.
|
||||
- `recordRead(locale, namespace, outcome, durationMs, errorCode?)`
|
||||
— emit a read sample.
|
||||
- `recordWrite(locale, namespace, outcome, durationMs)` — emit a
|
||||
write sample.
|
||||
- `getMemoryBackendMetricsState()` — frozen-shape snapshot of every
|
||||
emitted sample since last reset.
|
||||
- `resetMemoryBackendMetricsState()` — clear the in-memory state
|
||||
ring (test fixture).
|
||||
- `resetMemoryBackendMetricsCache()` — drop the cached instruments
|
||||
so a fresh `MeterProvider` can be wired (test fixture).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state ring is intentionally cardinality-bounded by the
|
||||
caller's test cases. It exists for assertion ergonomics, not for
|
||||
production telemetry — production telemetry flows through the
|
||||
registered OTel `MeterProvider`.
|
||||
- Cardinality safety: aggregate counters use only `outcome` (hit /
|
||||
miss / ok / error); per-error-code attribution lives on a
|
||||
separate `read_errors_total` counter so consumers can drop the
|
||||
high-cardinality counter without losing aggregate visibility.
|
||||
- Metric names are stable: every `i18n.backend.memory.*` name is
|
||||
part of the package's public API contract and will not change
|
||||
without a major bump.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getMemoryBackendMetrics,
|
||||
getMemoryBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMemoryBackendMetricsCache,
|
||||
resetMemoryBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("MemoryBackend metrics — instrument lifecycle", () => {
|
||||
it("getMemoryBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsCache();
|
||||
const a = getMemoryBackendMetrics();
|
||||
const b = getMemoryBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetMemoryBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getMemoryBackendMetrics();
|
||||
resetMemoryBackendMetricsCache();
|
||||
const b = getMemoryBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordRead("en", "auth", "hit", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{ attributes: { locale: "en", namespace: "auth", outcome: "hit" } },
|
||||
]);
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "hit" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"miss",
|
||||
0.42,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([{ attributes: { outcome: "miss" } }]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{ attributes: { code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — recordWrite", () => {
|
||||
it("records duration + total on ok", { timeout: 5000 }, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordWrite("en", "auth", "ok", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeDurationMs).toEqual([
|
||||
{ attributes: { locale: "en", namespace: "auth", outcome: "ok" } },
|
||||
]);
|
||||
expect(state.writeTotal).toEqual([{ attributes: { outcome: "ok" } }]);
|
||||
});
|
||||
|
||||
it("records duration + total on error", { timeout: 5000 }, () => {
|
||||
resetMemoryBackendMetricsState();
|
||||
recordWrite("en", "auth", "error", 0.42);
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([
|
||||
{ attributes: { outcome: "error" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryBackend metrics — state ring helpers", () => {
|
||||
it("resetMemoryBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("en", "auth", "hit", 0.1);
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
recordWrite("en", "auth", "ok", 0.1);
|
||||
resetMemoryBackendMetricsState();
|
||||
const state = getMemoryBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
expect(state.writeDurationMs).toEqual([]);
|
||||
expect(state.writeTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-memory`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-memory')`.
|
||||
* All instruments are no-ops when no MeterProvider is registered (the
|
||||
* default), so consumers pay zero overhead unless they wire up the
|
||||
* SDK.
|
||||
*
|
||||
* In addition to emitting via OTel, this module retains a small
|
||||
* in-memory state ring so spec-conformance tests can assert exact
|
||||
* attribute payloads without standing up a full SDK +
|
||||
* InMemoryMetricExporter pipeline. Tests call
|
||||
* `resetMemoryBackendMetricsState()` between cases and read
|
||||
* `getMemoryBackendMetricsState()` to inspect counters / histogram
|
||||
* counts.
|
||||
*/
|
||||
|
||||
export interface MemoryBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MemoryBackendMetricsState {
|
||||
readonly readDurationMs: MemoryBackendMetricSample[];
|
||||
readonly readTotal: MemoryBackendMetricSample[];
|
||||
readonly readErrorsTotal: MemoryBackendMetricSample[];
|
||||
readonly writeDurationMs: MemoryBackendMetricSample[];
|
||||
readonly writeTotal: MemoryBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
readonly writeDurationMs: Histogram;
|
||||
readonly writeTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: MemoryBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
writeDurationMs: [],
|
||||
writeTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to
|
||||
* call from any production code path.
|
||||
*/
|
||||
export function getMemoryBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-memory");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.memory.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MemoryBackend.read call; labels: locale, namespace, outcome.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.memory.read_total", {
|
||||
description:
|
||||
"MemoryBackend.read call count; labels: outcome (hit | miss).",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.memory.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"MemoryBackend.read error count; labels: code (the I18N error code).",
|
||||
}
|
||||
),
|
||||
writeDurationMs: meter.createHistogram(
|
||||
"i18n.backend.memory.write_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a MemoryBackend.save call; labels: locale, namespace, outcome.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
writeTotal: meter.createCounter("i18n.backend.memory.write_total", {
|
||||
description:
|
||||
"MemoryBackend.save call count; labels: outcome (ok | error).",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{outcome}` and per-error-code `read_errors_total{code}`
|
||||
* counters and pushes one sample into the in-memory state ring per
|
||||
* counter touched.
|
||||
*/
|
||||
export function recordRead(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
outcome: "hit" | "miss",
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const ins = getMemoryBackendMetrics();
|
||||
const attributes: Record<string, string> = { locale, namespace, outcome };
|
||||
ins.readDurationMs.record(durationMs, attributes);
|
||||
state.readDurationMs.push({ attributes });
|
||||
const aggregate = { outcome };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs = { code: errorCode };
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `save` call's duration + outcome. Mirrors the
|
||||
* `write_total{outcome}` counter into the in-memory state ring.
|
||||
*/
|
||||
export function recordWrite(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
outcome: "ok" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = getMemoryBackendMetrics();
|
||||
const attributes: Record<string, string> = { locale, namespace, outcome };
|
||||
ins.writeDurationMs.record(durationMs, attributes);
|
||||
state.writeDurationMs.push({ attributes });
|
||||
const aggregate = { outcome };
|
||||
ins.writeTotal.add(1, aggregate);
|
||||
state.writeTotal.push({ attributes: aggregate });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getMemoryBackendMetricsState(): MemoryBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetMemoryBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
state.writeDurationMs.length = 0;
|
||||
state.writeTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetMemoryBackendMetricsCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-memory",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/i18n-shared",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user