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