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,130 @@
# @suaveplan/i18n-backend-storage - Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.
## Summary
Universal translation backend for the `@suaveplan/i18n` family,
backed by any `AbstractStorage` implementation from
`@suaveplan/storage`. Implements the full `Backend` contract
(`read`, `readMulti`, `save`, `lastModified`) over three wire
layouts: `single-key-per-namespace` (default),
`single-key-per-locale`, and `flat-keys`. Pluggable codec, optional
`AbortSignal` cancellation, OpenTelemetry instrumentation under the
`i18n.backend.storage.*` prefix. Zero runtime dependencies on
`@suaveplan/i18n` core.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The Wave 5 modular split lifts each backend out of the
`@suaveplan/i18n` monolith into its own sibling so consumers can
install only the backends they need. The storage backend is the
canonical persistence layer: pair it with `MemoryStorage` for tests,
`OpfsStorage` / `IndexedDBStorage` for offline-first browser apps,
or `S3Storage` / `GarageStorage` / `NodeFsStorage` /
`SqliteStorage` for server-tier deployments. Splitting it out also
isolates `@suaveplan/storage` as a transitive dep — consumers using
only the HTTP or memory backends no longer pull it in.
## Install
```bash
bun add @suaveplan/i18n-backend-storage @suaveplan/storage
```
## Quick Start
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = storageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { login: { title: "Sign in" } });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-backend-storage` | Root barrel — class + factory + types + schemas + observability |
| `@suaveplan/i18n-backend-storage/backend` | `StorageBackend` class + `defaultCodec` |
| `@suaveplan/i18n-backend-storage/factory` | `storageBackend` factory function only |
| `@suaveplan/i18n-backend-storage/schemas` | Zod boundary schemas |
| `@suaveplan/i18n-backend-storage/observability` | OTel instruments + in-memory state ring |
## Modules Documentation
- [backend.md](src/backend/backend.md) — `StorageBackend` class, layouts, codec
- [factory.md](src/factory/factory.md) — `storageBackend` factory function
- [observability.md](src/observability/observability.md) — OTel instruments + cardinality
## Capabilities
| Flag | Value | Notes |
|---|---|---|
| `canRead` | `true` | always |
| `canReadMulti` | `true` | per-layout dispatch |
| `canSave` | `true` | persists into the wrapped storage |
| `canTrackModification` | `true` | consults `getMetadata().modifiedAt` |
| `canWatch` | `false` | the storage contract has no change feed |
## Layouts
- `single-key-per-namespace` (default) — one key per `(locale, ns)`
at `<prefix>/<locale>/<namespace>.json`. Best for HTTP-CDN-style
storages.
- `single-key-per-locale` — one key per locale holding the whole
namespace map. Best when namespaces are small and read together.
- `flat-keys` — one key per leaf string at
`<prefix>:<locale>:<namespace>:<dotPath>`. Best for content-
addressed storages with cheap per-leaf delta shipping.
## Testing
The package's own tests use the shared `runBackendConformance`
harness from `@suaveplan/i18n-shared/testing` so the universal
`Backend` contract is exercised against a fresh storage backend
per assertion (against all three layouts):
```ts
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
runBackendConformance("StorageBackend", async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
```
## Versioning
This package is part of the i18n modular-split rollout. Stays at
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0`
release. The metric names under `i18n.backend.storage.*` are part of
the public API contract and will not change without a major bump.
## Wave 5 Scope
This package is Wave 5b of the 14-package i18n modular split. It
ships pure additions only — `packages/i18n/i18n/src/core/backend/storage/`
retains its copy of the backend until Wave 6 deletes it. Both
copies coexist during Wave 5; downstream consumers continue to
import from the monolith and observe no behaviour change.

View File

@@ -0,0 +1,83 @@
{
"name": "@suaveplan/i18n-backend-storage",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./backend": {
"import": {
"types": "./dist/backend/index.d.ts",
"default": "./dist/backend/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,167 @@
# `StorageBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StorageBackend` adapts any `AbstractStorage` instance from
`@suaveplan/storage` into a translation `Backend` for the
`@suaveplan/i18n` family. This is the headline persistence
integration that lets translations live in S3, OPFS, IndexedDB,
SQLite, Garage, or any other storage `@suaveplan/storage` supports
— without each consumer integrating once per source. The user-
facing pitch is "translations in OPFS for offline" or "translations
on a content-addressed CDN" become one config line.
The backend has zero runtime dependencies on `@suaveplan/i18n` core.
It implements the full `Backend` contract from `@suaveplan/types`
(`read`, `readMulti`, `save`, `lastModified`) and emits
OpenTelemetry instruments under the `i18n.backend.storage.*` prefix
so production teams can dashboard latency and miss-rate per layout.
## Features
- Full 5-flag capability matrix: `canRead`, `canReadMulti`,
`canSave`, `canTrackModification` all `true`; `canWatch: false`.
- Three wire layouts: `single-key-per-namespace` (default),
`single-key-per-locale`, `flat-keys`.
- Pluggable codec — JSON by default; swap for MessagePack, CBOR, or
schema-validated JSON.
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
and `save`; pre-aborted signals throw
`I18nError(I18N.BACKEND_TIMEOUT)` without touching the underlying
storage.
- OTel histograms + counters emitted on every read/save with a
`layout` attribute so per-layout latency can be charted.
- Zod boundary validation: invalid options throw
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
- `BACKEND_LOCALE_NOT_AVAILABLE` on miss (chained backends fall
through); `BACKEND_READ_FAILED` / `BACKEND_WRITE_FAILED` on
transport / codec errors (chained backends propagate without
fallthrough).
## Layouts
### `single-key-per-namespace` (default)
```text
i18n/en/auth.json → {"login":{"title":"Sign in"}}
i18n/en/common.json → {"cancel":"Cancel"}
```
One storage key per `(locale, namespace)`. Reads are exactly one
storage `get` per `read(locale, ns)` call. Best for HTTP-CDN-style
storages where each key is independently cacheable.
### `single-key-per-locale`
```text
i18n/en.json → {"auth":{...},"common":{...}}
```
One storage key per locale, holding the entire
`Record<namespace, TranslationRecord>` map. Best when namespaces are
small and read together. Saving a single namespace requires a
read-modify-write of the whole locale map.
### `flat-keys`
```text
i18n:en:auth:login.title → Sign in
i18n:en:common:cancel → Cancel
```
One storage key per leaf string. Best for content-addressed storages
where deltas are expensive at namespace granularity but cheap at
leaf granularity.
## Usage: Basic Example
```ts
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { login: { title: "Sign in" } });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Usage: Advanced Example — OPFS + custom binary codec
```ts
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
import { encode, decode } from "@msgpack/msgpack";
const backend = new StorageBackend({
storage: new OpfsStorage({ rootDir: "i18n" }),
layout: "single-key-per-locale",
encode: (record) => encode(record),
decode: (bytes) => decode(bytes) as Record<string, unknown>,
});
// Cancellable reads play nicely with multi-backend orchestration.
const controller = new AbortController();
controller.abort();
await backend.read("en", "auth", controller.signal); // throws BACKEND_TIMEOUT
```
## API Reference
### `class StorageBackend implements Backend`
- `constructor(options: StorageBackendOptions)` — validates options
via `StorageBackendOptionsSchema`; throws
`I18nError(I18N.INVALID_OPTIONS)` on schema mismatch or missing
`storage`.
- `name: string` — backend label (default `"storage"`).
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
resolves with the stored record; throws
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
- `readMulti(locale, namespaces, signal?):
Promise<Record<string, TranslationRecord>>` — returns only the
namespaces that exist; missing namespaces are omitted.
- `save(locale, namespace, record, signal?): Promise<void>` —
persists the record; failure surfaces as
`BACKEND_WRITE_FAILED`.
- `lastModified(locale, namespace): Promise<number | undefined>` —
returns the underlying storage's `modifiedAt` field, or
`undefined` if metadata is unavailable.
### `defaultCodec: StorageBackendCodec`
JSON-based codec (`TextEncoder` / `JSON.stringify`). The decode path
rejects non-object payloads (strings, arrays, null) as
`BACKEND_READ_FAILED`.
## Implementation Notes
- The default JSON codec uses `TextEncoder` / `TextDecoder` and
`JSON.stringify` / `JSON.parse`. The decode path rejects non-
object payloads because translation records are always objects in
the contract; custom codecs MUST also reject non-objects or
document the relaxation explicitly.
- `flat-keys` reads via `AbstractStorage.list({ prefix })`. List is
required by every concrete storage, so the layout works against
all of them. Saves write each leaf with its own `set` call — for
backends that batch writes, prefer `single-key-per-namespace` to
amortise the batch.
- `lastModified` for `flat-keys` takes the maximum `modifiedAt`
across leaves. For other layouts it consults the namespace or
locale key directly. All paths swallow storage exceptions and
return `undefined` rather than throw, mirroring the contract that
`lastModified` is a hint, not a guarantee.
- `signal?.aborted` is checked at the entry of every async method.
The throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The flag
is sampled exactly once at the top — once the underlying
`AbstractStorage` operation begins, abort propagation is the
storage's responsibility.
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
a `MeterProvider` is registered. Tests use the in-memory metrics
state ring exposed by `./observability` to assert payloads
without standing up the full SDK. The `layout` attribute is a
three-value low-cardinality enum.

View File

@@ -0,0 +1,798 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { storageBackend } from "../factory/factory.js";
import { defaultCodec, StorageBackend } from "./backend.js";
import type { StorageBackendOptions } from "./backend.types.js";
// ── Run the universal Backend contract conformance against every layout
runBackendConformance("StorageBackend (single-key-per-namespace)", async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
runBackendConformance("StorageBackend (single-key-per-locale)", async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
});
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
runBackendConformance("StorageBackend (flat-keys)", async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
});
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
// ── StorageBackend specifics ───────────────────────────────────────
describe("StorageBackend specifics", () => {
it("single-key-per-namespace round-trip", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({ storage, prefix: "i18n" });
await backend.save("en", "auth", { login: { title: "Sign in" } });
const raw = await storage.get("i18n/en/auth.json");
expect(raw).toBeDefined();
const text = new TextDecoder().decode(raw as Uint8Array);
expect(JSON.parse(text)).toEqual({ login: { title: "Sign in" } });
expect(await backend.read("en", "auth")).toEqual({
login: { title: "Sign in" },
});
});
it("single-key-per-locale round-trip", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
layout: "single-key-per-locale",
prefix: "i18n",
});
await backend.save("en", "auth", { ok: "Ok" });
await backend.save("en", "common", { cancel: "Cancel" });
const raw = await storage.get("i18n/en.json");
const decoded = JSON.parse(
new TextDecoder().decode(raw as Uint8Array)
) as Record<string, unknown>;
expect(decoded).toEqual({
auth: { ok: "Ok" },
common: { cancel: "Cancel" },
});
expect(await backend.read("en", "common")).toEqual({
cancel: "Cancel",
});
});
it("flat-keys round-trip with nested record", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
layout: "flat-keys",
prefix: "i18n",
});
await backend.save("en", "auth", {
login: { title: "Sign in", subtitle: "Welcome" },
});
const decoder = new TextDecoder();
const t = await storage.get("i18n:en:auth:login.title");
const s = await storage.get("i18n:en:auth:login.subtitle");
expect(decoder.decode(t as Uint8Array)).toBe("Sign in");
expect(decoder.decode(s as Uint8Array)).toBe("Welcome");
expect(await backend.read("en", "auth")).toEqual({
login: { title: "Sign in", subtitle: "Welcome" },
});
});
it("missing key throws BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
for (const layout of [
"single-key-per-namespace",
"single-key-per-locale",
"flat-keys",
] as const) {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout,
});
try {
await backend.read("en", "auth");
expect.fail(`expected throw for layout ${layout}`);
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
}
}
});
it("custom encode/decode is used", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const enc = new TextEncoder();
const dec = new TextDecoder();
const SENTINEL = 0xfa;
const codec = {
encode: (record: unknown) => {
const json = enc.encode(JSON.stringify(record));
const out = new Uint8Array(json.length + 1);
out[0] = SENTINEL;
out.set(json, 1);
return out;
},
decode: (bytes: Uint8Array) => {
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
const json = dec.decode(bytes.slice(1));
return JSON.parse(json) as Record<string, unknown>;
},
};
const backend = new StorageBackend({
storage,
encode: codec.encode,
decode: codec.decode,
});
await backend.save("en", "auth", { ok: "Ok" });
const raw = await storage.get("i18n/en/auth.json");
expect(raw?.[0]).toBe(SENTINEL);
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
});
it("readMulti omits missing namespaces", { timeout: 5000 }, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { ok: "Ok" });
await backend.save("en", "common", { cancel: "Cancel" });
const result = await backend.readMulti("en", [
"auth",
"common",
"missing",
]);
expect(result).toEqual({
auth: { ok: "Ok" },
common: { cancel: "Cancel" },
});
expect(result).not.toHaveProperty("missing");
});
it("readMulti single-key-per-locale", { timeout: 5000 }, async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
});
await backend.save("en", "auth", { ok: "Ok" });
await backend.save("en", "common", { cancel: "Cancel" });
const result = await backend.readMulti("en", [
"auth",
"common",
"missing",
]);
expect(result).toEqual({
auth: { ok: "Ok" },
common: { cancel: "Cancel" },
});
});
it("readMulti single-key-per-locale returns empty for missing locale", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
});
const result = await backend.readMulti("xx", ["auth"]);
expect(result).toEqual({});
});
it("lastModified returns metadata timestamp", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { ok: "Ok" });
const t = await backend.lastModified("en", "auth");
expect(typeof t).toBe("number");
});
it("lastModified single-key-per-locale consults locale key", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
});
await backend.save("en", "auth", { ok: "Ok" });
const t = await backend.lastModified("en", "auth");
expect(typeof t).toBe("number");
});
it("lastModified returns undefined for absent record", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
});
it("lastModified returns undefined for absent flat-keys record", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
});
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
});
it("name defaults to 'storage' and accepts override", {
timeout: 5000,
}, () => {
expect(new StorageBackend({ storage: new MemoryStorage() }).name).toBe(
"storage"
);
expect(
new StorageBackend({
storage: new MemoryStorage(),
name: "opfs-cache",
}).name
).toBe("opfs-cache");
});
it("capabilities matrix matches the contract", { timeout: 5000 }, () => {
const c = new StorageBackend({ storage: new MemoryStorage() })
.capabilities;
expect(c).toEqual({
canRead: true,
canReadMulti: true,
canSave: true,
canTrackModification: true,
canWatch: false,
});
});
it("defaults are applied", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({ storage });
// default prefix "i18n" + default layout single-key-per-namespace
await backend.save("en", "auth", { ok: "Ok" });
const raw = await storage.get("i18n/en/auth.json");
expect(raw).toBeDefined();
expect(backend.name).toBe("storage");
});
});
// ── Read failure modes ─────────────────────────────────────────────
describe("StorageBackend read failures", () => {
it("rejects garbage JSON payloads as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set(
"i18n/en/auth.json",
new TextEncoder().encode("not-json")
);
const backend = new StorageBackend({ storage });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("rejects array JSON payloads (non-record) as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set(
"i18n/en/auth.json",
new TextEncoder().encode("[1,2,3]")
);
const backend = new StorageBackend({ storage });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("single-key-per-locale wraps non-I18nError decode failures", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
const backend = new StorageBackend({
storage,
layout: "single-key-per-locale",
decode: () => {
throw new Error("decoder-boom");
},
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("single-key-per-locale passes I18nError decode failures unwrapped", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
const backend = new StorageBackend({
storage,
layout: "single-key-per-locale",
decode: () => {
throw new I18nError(
"I18N.BACKEND_READ_FAILED" as never,
"inner-i18n-decode"
);
},
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect((err as I18nError).message).toBe("inner-i18n-decode");
}
});
it("single-key-per-namespace passes I18nError decode failures unwrapped", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set(
"i18n/en/auth.json",
new TextEncoder().encode("garbage")
);
const backend = new StorageBackend({
storage,
decode: () => {
throw new I18nError(
"I18N.BACKEND_READ_FAILED" as never,
"inner-decoder"
);
},
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect((err as I18nError).message).toBe("inner-decoder");
}
});
it("single-key-per-locale read of absent namespace within present locale", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
});
await backend.save("en", "auth", { ok: "Ok" });
try {
await backend.read("en", "common");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
}
});
it("non-I18nError from storage.get propagates with outcome=error", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
storage.get = async () => {
throw new Error("storage-boom-not-i18n");
};
const backend = new StorageBackend({ storage });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(I18nError);
expect((err as Error).message).toBe("storage-boom-not-i18n");
}
});
it("readMulti propagates non-LNA errors from read()", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set(
"i18n/en/auth.json",
new TextEncoder().encode("not-json")
);
const backend = new StorageBackend({ storage });
try {
await backend.readMulti("en", ["auth"]);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("flat-keys skips storage keys with empty bytes during read", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
await storage.set(
"i18n:en:auth:login.title",
new TextEncoder().encode("Sign in")
);
const original = storage.get.bind(storage);
storage.get = async (key: string) => {
if (key === "i18n:en:auth:phantom") return undefined;
return original(key);
};
const originalList = storage.list.bind(storage);
storage.list = async (opts) => {
const real = await originalList(opts);
return {
...real,
keys: [...real.keys, "i18n:en:auth:phantom"],
};
};
const backend = new StorageBackend({
storage,
layout: "flat-keys",
});
const record = await backend.read("en", "auth");
expect(record).toEqual({ login: { title: "Sign in" } });
});
it("flat-keys overlays string onto pre-existing nested record at sibling path", {
timeout: 5000,
}, async () => {
// Exercises the assignAtDotPath else-branch where cursor[part] is
// a string (not object) and the loop must replace it with an
// empty object.
const storage = new MemoryStorage();
// Put a raw value at "login" first (string), then a sibling path
// "login.title" — the assemble path will see the string at
// "login" and replace it with a fresh nested record.
await storage.set(
"i18n:en:auth:login",
new TextEncoder().encode("legacy-flat-string")
);
await storage.set(
"i18n:en:auth:login.title",
new TextEncoder().encode("Sign in")
);
const backend = new StorageBackend({
storage,
layout: "flat-keys",
});
const record = await backend.read("en", "auth");
// Because keys can list in unspecified order, we just assert
// login.title eventually wins as a nested record.
expect(record).toEqual({ login: { title: "Sign in" } });
});
});
// ── Save failure modes ─────────────────────────────────────────────
describe("StorageBackend save failures", () => {
it("storage set failure throws BACKEND_WRITE_FAILED", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
encode: () => {
throw new Error("encode-boom");
},
});
try {
await backend.save("en", "auth", { ok: "Ok" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_WRITE_FAILED");
expect((err as I18nError).cause).toBeInstanceOf(Error);
expect(((err as I18nError).cause as Error).message).toBe(
"encode-boom"
);
}
});
it("save propagates I18nError from inner storage without re-wrapping", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const original = storage.set.bind(storage);
storage.set = async () => {
throw new I18nError(
"I18N.BACKEND_WRITE_FAILED" as never,
"inner-i18n-err"
);
};
const backend = new StorageBackend({ storage });
try {
await backend.save("en", "auth", { ok: "Ok" });
expect.fail("expected throw");
} catch (err) {
expect((err as I18nError).message).toBe("inner-i18n-err");
}
storage.set = original;
});
});
// ── lastModified edge cases ─────────────────────────────────────────
describe("StorageBackend lastModified edge cases", () => {
it("swallows storage.getMetadata exceptions", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
storage.getMetadata = async () => {
throw new Error("boom");
};
const backend = new StorageBackend({ storage });
expect(await backend.lastModified("en", "auth")).toBeUndefined();
});
it("flat-keys takes the max across leaf metadata", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
layout: "flat-keys",
});
await backend.save("en", "auth", {
login: { title: "Sign in", subtitle: "Welcome" },
});
const t = await backend.lastModified("en", "auth");
expect(typeof t).toBe("number");
});
it("flat-keys returns undefined when all metadata is null", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
layout: "flat-keys",
});
await backend.save("en", "auth", { ok: "Ok" });
storage.getMetadata = async () => null;
expect(await backend.lastModified("en", "auth")).toBeUndefined();
});
it("flat-keys handles partial-null getMetadata responses", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = new StorageBackend({
storage,
layout: "flat-keys",
});
await backend.save("en", "auth", {
login: { title: "Sign in", subtitle: "Welcome" },
});
const original = storage.getMetadata.bind(storage);
let returnedNull = false;
storage.getMetadata = async (key: string) => {
if (!returnedNull) {
returnedNull = true;
return null;
}
return original(key);
};
const t = await backend.lastModified("en", "auth");
expect(typeof t === "number" || t === undefined).toBe(true);
});
});
// ── AbortSignal cancellation ────────────────────────────────────────
describe("StorageBackend AbortSignal", () => {
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
const ac = new AbortController();
ac.abort();
await expect(
backend.read("en", "auth", ac.signal)
).rejects.toBeInstanceOf(I18nError);
await expect(
backend.read("en", "auth", ac.signal)
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
});
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
const ac = new AbortController();
ac.abort();
await expect(
backend.readMulti("en", ["auth"], ac.signal)
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
});
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
const ac = new AbortController();
ac.abort();
await expect(
backend.save("en", "auth", { x: "y" }, ac.signal)
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
});
it("non-aborted signal does not affect operation", {
timeout: 5000,
}, async () => {
const backend = new StorageBackend({ storage: new MemoryStorage() });
const ac = new AbortController();
await backend.save("en", "auth", { x: "y" }, ac.signal);
expect(await backend.read("en", "auth", ac.signal)).toEqual({
x: "y",
});
expect(await backend.readMulti("en", ["auth"], ac.signal)).toEqual({
auth: { x: "y" },
});
});
});
// ── Boundary validation (Zod) ───────────────────────────────────────
describe("StorageBackend boundary validation", () => {
it("missing storage throws INVALID_OPTIONS", { timeout: 5000 }, () => {
try {
new StorageBackend({} as unknown as StorageBackendOptions);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("falsy storage (null) throws INVALID_OPTIONS post-schema", {
timeout: 5000,
}, () => {
// `storage: null` passes `z.unknown()` boundary but trips the
// explicit `!options.storage` guard that protects downstream
// method calls. Pins coverage of that distinct error path.
try {
new StorageBackend({
storage: null,
} as unknown as StorageBackendOptions);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
expect((err as I18nError).message).toContain("storage is required");
}
});
it("rejects malformed options shape via Zod (e.g. name: number)", {
timeout: 5000,
}, () => {
try {
new StorageBackend({
storage: new MemoryStorage(),
name: 123 as unknown as string,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("rejects unknown option keys (strict schema)", { timeout: 5000 }, () => {
try {
new StorageBackend({
storage: new MemoryStorage(),
bogus: true,
} as unknown as StorageBackendOptions);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
it("rejects empty-string name (Zod min(1))", { timeout: 5000 }, () => {
try {
new StorageBackend({
storage: new MemoryStorage(),
name: "",
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
}
});
});
// ── Default codec ───────────────────────────────────────────────────
describe("defaultCodec", () => {
it("round-trips object record", { timeout: 5000 }, () => {
const bytes = defaultCodec.encode({ a: { b: "c" } });
expect(defaultCodec.decode(bytes)).toEqual({ a: { b: "c" } });
});
it("default codec rejects array", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("[]");
try {
defaultCodec.decode(bytes);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("default codec rejects null", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("null");
try {
defaultCodec.decode(bytes);
expect.fail("expected throw");
} catch (err) {
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
it("default codec rejects primitive number", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode("42");
try {
defaultCodec.decode(bytes);
expect.fail("expected throw");
} catch (err) {
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
}
});
});
// ── Factory smoke ───────────────────────────────────────────────────
describe("storageBackend factory smoke (full coverage in factory.test.ts)", () => {
it("factory form storageBackend()", { timeout: 5000 }, () => {
const backend = storageBackend({ storage: new MemoryStorage() });
expect(backend.name).toBe("storage");
});
});

View File

@@ -0,0 +1,462 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type { AbstractStorage } from "@suaveplan/storage";
import type {
Backend,
BackendCapabilities,
TranslationRecord,
} from "@suaveplan/types";
import z from "zod";
import { recordRead, recordWrite } from "../observability/observability.js";
import type {
StorageBackendCodec,
StorageBackendLayout,
StorageBackendOptions,
} from "./backend.types.js";
/**
* Zod boundary schema for {@link StorageBackendOptions}. Validates the
* statically-checkable shape only — `storage`, `encode`, and `decode`
* are accepted as opaque `z.unknown()` because Zod cannot type-check
* function arity meaningfully and `AbstractStorage` is a structural
* contract. Caller-supplied option values failing this schema cause
* the constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
*/
export const StorageBackendOptionsSchema = z
.object({
storage: z.unknown(),
layout: z
.enum([
"single-key-per-namespace",
"single-key-per-locale",
"flat-keys",
])
.optional(),
prefix: z.string().optional(),
name: z.string().min(1).optional(),
encode: z.unknown().optional(),
decode: z.unknown().optional(),
})
.strict();
export type StorageBackendOptionsInput = z.input<
typeof StorageBackendOptionsSchema
>;
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
/**
* Default JSON codec — UTF-8 `JSON.stringify` / `JSON.parse`. The
* decode path rejects non-object payloads (strings, arrays, null) as
* `BACKEND_READ_FAILED` because translation records are always
* objects in the contract.
*/
export const defaultCodec: StorageBackendCodec = {
encode: (record) => TEXT_ENCODER.encode(JSON.stringify(record)),
decode: (bytes) => {
const text = TEXT_DECODER.decode(bytes);
const parsed = JSON.parse(text) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
"StorageBackend: decoded payload is not an object record"
);
}
return parsed as TranslationRecord;
},
};
/**
* Translation backend backed by any `AbstractStorage` implementation
* (MemoryStorage, S3Storage, OPFSStorage, IndexedDBStorage,
* NodeFsStorage, SqliteStorage, GarageStorage, …).
*
* Three wire layouts are supported:
*
* - `single-key-per-namespace` (default): one storage key per
* namespace (`<prefix>/<locale>/<namespace>.json`). Reads exactly
* one storage key per `read(locale, ns)` call.
* - `single-key-per-locale`: one storage key per locale, holding the
* whole `Record<namespace, TranslationRecord>` map. Best when
* namespaces are small and read together.
* - `flat-keys`: one storage key per leaf string
* (`<prefix>:<locale>:<namespace>:<dotPath>` → leaf string). Works
* well with content-addressed storages and supports per-leaf delta
* shipping.
*
* Universal — the backend itself is tier-neutral; the concrete
* `AbstractStorage` decides the deployment tier.
*/
export class StorageBackend implements Backend {
public readonly name: string;
public readonly capabilities: BackendCapabilities = {
canRead: true,
canReadMulti: true,
canSave: true,
canTrackModification: true,
canWatch: false,
};
readonly #storage: AbstractStorage;
readonly #layout: StorageBackendLayout;
readonly #prefix: string;
readonly #encode: (record: TranslationRecord) => Uint8Array;
readonly #decode: (bytes: Uint8Array) => TranslationRecord;
public constructor(options: StorageBackendOptions) {
const parsed = StorageBackendOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"StorageBackend: invalid options",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
if (!options.storage) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"StorageBackend: storage is required"
);
}
this.name = options.name ?? "storage";
this.#storage = options.storage;
this.#layout = options.layout ?? "single-key-per-namespace";
this.#prefix = options.prefix ?? "i18n";
this.#encode = options.encode ?? defaultCodec.encode;
this.#decode = options.decode ?? defaultCodec.decode;
}
public async read(
locale: string,
namespace: string,
signal?: AbortSignal
): Promise<TranslationRecord> {
this.#checkAborted(signal);
const t0 = performance.now();
let outcome: "hit" | "miss" | "error" = "hit";
let errorCode: string | undefined;
try {
return await this.#readInternal(locale, namespace);
} catch (err) {
if (
err instanceof I18nError &&
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
) {
outcome = "miss";
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
} else {
outcome = "error";
if (err instanceof I18nError) {
errorCode = err.code;
}
}
throw err;
} finally {
recordRead(
locale,
namespace,
this.#layout,
outcome,
performance.now() - t0,
errorCode
);
}
}
async #readInternal(
locale: string,
namespace: string
): Promise<TranslationRecord> {
if (this.#layout === "single-key-per-locale") {
const map = await this.#readLocaleMap(locale);
const record = map[namespace];
if (!record) {
throw this.#missing(locale, namespace);
}
return record;
}
if (this.#layout === "flat-keys") {
const record = await this.#readFlatKeys(locale, namespace);
if (record === undefined) {
throw this.#missing(locale, namespace);
}
return record;
}
// single-key-per-namespace
const key = this.#nsKey(locale, namespace);
const bytes = await this.#storage.get(key);
if (!bytes) {
throw this.#missing(locale, namespace);
}
try {
return this.#decode(bytes);
} catch (cause) {
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`StorageBackend: decode failed for ${key}`,
{ cause, context: { key, layout: this.#layout } }
);
}
}
public async readMulti(
locale: string,
namespaces: readonly string[],
signal?: AbortSignal
): Promise<Record<string, TranslationRecord>> {
this.#checkAborted(signal);
if (this.#layout === "single-key-per-locale") {
const map = await this.#readLocaleMap(locale).catch(
() => ({}) as Record<string, TranslationRecord>
);
const out: Record<string, TranslationRecord> = {};
for (const ns of namespaces) {
if (map[ns]) out[ns] = map[ns];
}
return out;
}
const results = await Promise.all(
namespaces.map((ns) =>
this.read(locale, ns).then(
(record) => [ns, record] as const,
(err) => {
if (
err instanceof I18nError &&
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
) {
return null;
}
throw err;
}
)
)
);
const out: Record<string, TranslationRecord> = {};
for (const r of results) {
if (r) out[r[0]] = r[1];
}
return out;
}
public async save(
locale: string,
namespace: string,
record: TranslationRecord,
signal?: AbortSignal
): Promise<void> {
this.#checkAborted(signal);
const t0 = performance.now();
try {
if (this.#layout === "single-key-per-locale") {
const map = await this.#readLocaleMap(locale).catch(
() => ({}) as Record<string, TranslationRecord>
);
map[namespace] = record;
const bytes = this.#encode(map as TranslationRecord);
await this.#storage.set(this.#localeKey(locale), bytes);
} else if (this.#layout === "flat-keys") {
await this.#saveFlatKeys(locale, namespace, record);
} else {
const bytes = this.#encode(record);
await this.#storage.set(this.#nsKey(locale, namespace), bytes);
}
recordWrite(
locale,
namespace,
this.#layout,
"ok",
performance.now() - t0
);
} catch (cause) {
recordWrite(
locale,
namespace,
this.#layout,
"error",
performance.now() - t0
);
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_WRITE_FAILED,
`StorageBackend: save failed for ${locale}/${namespace}`,
{
cause,
context: { locale, namespace, layout: this.#layout },
}
);
}
}
public async lastModified(
locale: string,
namespace: string
): Promise<number | undefined> {
try {
if (this.#layout === "flat-keys") {
const list = await this.#storage.list({
prefix: `${this.#prefix}:${locale}:${namespace}:`,
});
if (list.keys.length === 0) return undefined;
const stamps: number[] = [];
for (const key of list.keys) {
const meta = await this.#storage.getMetadata(key);
if (meta) stamps.push(meta.modifiedAt);
}
return stamps.length > 0 ? Math.max(...stamps) : undefined;
}
const key =
this.#layout === "single-key-per-locale"
? this.#localeKey(locale)
: this.#nsKey(locale, namespace);
const meta = await this.#storage.getMetadata(key);
return meta?.modifiedAt;
} catch {
return undefined;
}
}
// ── Layout helpers ────────────────────────────────────────────
#nsKey(locale: string, namespace: string): string {
return `${this.#prefix}/${locale}/${namespace}.json`;
}
#localeKey(locale: string): string {
return `${this.#prefix}/${locale}.json`;
}
async #readLocaleMap(
locale: string
): Promise<Record<string, TranslationRecord>> {
const bytes = await this.#storage.get(this.#localeKey(locale));
if (!bytes) {
throw this.#missing(locale, "*");
}
try {
const decoded = this.#decode(bytes) as Record<
string,
TranslationRecord
>;
return decoded;
} catch (cause) {
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`StorageBackend: decode failed for ${this.#localeKey(locale)}`,
{ cause }
);
}
}
async #readFlatKeys(
locale: string,
namespace: string
): Promise<TranslationRecord | undefined> {
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
const list = await this.#storage.list({ prefix });
if (list.keys.length === 0) return undefined;
const out: TranslationRecord = {};
const decoder = new TextDecoder();
for (const key of list.keys) {
const dotPath = key.slice(prefix.length);
const bytes = await this.#storage.get(key);
if (!bytes) continue;
const value = decoder.decode(bytes);
assignAtDotPath(out, dotPath, value);
}
return out;
}
async #saveFlatKeys(
locale: string,
namespace: string,
record: TranslationRecord
): Promise<void> {
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
const encoder = new TextEncoder();
const flat = flattenLeaves(record);
for (const [dotPath, value] of Object.entries(flat)) {
await this.#storage.set(
`${prefix}${dotPath}`,
encoder.encode(value)
);
}
}
#missing(locale: string, namespace: string): I18nError {
return new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`StorageBackend: no record for ${locale}/${namespace}`,
{
context: {
locale,
namespace,
layout: this.#layout,
backend: this.name,
},
}
);
}
#checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new I18nError(
I18N_CODES.BACKEND_TIMEOUT,
"StorageBackend: operation aborted",
{
context: { backend: this.name, reason: signal.reason },
}
);
}
}
}
function flattenLeaves(
record: TranslationRecord,
prefix = ""
): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
const path = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") {
out[path] = v;
} else {
Object.assign(out, flattenLeaves(v as TranslationRecord, path));
}
}
return out;
}
type MutableTranslationRecord = {
[key: string]: string | MutableTranslationRecord;
};
function assignAtDotPath(
target: TranslationRecord,
dotPath: string,
value: string
): void {
const parts = dotPath.split(".");
let cursor = target as MutableTranslationRecord;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i] as string;
const next = cursor[part];
if (typeof next === "object" && next !== null) {
cursor = next as MutableTranslationRecord;
} else {
const fresh: MutableTranslationRecord = {};
cursor[part] = fresh;
cursor = fresh;
}
}
cursor[parts[parts.length - 1] as string] = value;
}

View File

@@ -0,0 +1,46 @@
import type { AbstractStorage } from "@suaveplan/storage";
import type { TranslationRecord } from "@suaveplan/types";
/**
* Wire layout for {@link StorageBackend}.
*
* - `single-key-per-namespace` (default): one storage key per
* `(locale, namespace)` pair at `<prefix>/<locale>/<namespace>.json`.
* - `single-key-per-locale`: one storage key per locale, holding the
* whole `Record<namespace, TranslationRecord>` map.
* - `flat-keys`: one storage key per leaf string, addressed by
* `<prefix>:<locale>:<namespace>:<dotPath>`.
*/
export type StorageBackendLayout =
| "single-key-per-namespace"
| "single-key-per-locale"
| "flat-keys";
/**
* Pluggable codec contract. The default codec encodes records as
* UTF-8 JSON; consumers can swap in MessagePack, CBOR, or any other
* binary format.
*/
export interface StorageBackendCodec {
readonly encode: (record: TranslationRecord) => Uint8Array;
readonly decode: (bytes: Uint8Array) => TranslationRecord;
}
/**
* Constructor options for {@link StorageBackend}.
*
* - `storage` — required; any `AbstractStorage` instance.
* - `layout` — wire layout; defaults to `"single-key-per-namespace"`.
* - `prefix` — key prefix; defaults to `"i18n"`.
* - `name` — backend label override; defaults to `"storage"`.
* - `encode` / `decode` — pluggable codec; defaults to JSON via
* `TextEncoder` / `TextDecoder`.
*/
export interface StorageBackendOptions {
readonly storage: AbstractStorage;
readonly layout?: StorageBackendLayout;
readonly prefix?: string;
readonly name?: string;
readonly encode?: (record: TranslationRecord) => Uint8Array;
readonly decode?: (bytes: Uint8Array) => TranslationRecord;
}

View File

@@ -0,0 +1,14 @@
// Feature barrel for `@suaveplan/i18n-backend-storage/backend`. Explicit
// named re-exports only — no `export *` per CLAUDE.md §10.
export {
defaultCodec,
StorageBackend,
type StorageBackendOptionsInput,
StorageBackendOptionsSchema,
} from "./backend.js";
export type {
StorageBackendCodec,
StorageBackendLayout,
StorageBackendOptions,
} from "./backend.types.js";

View File

@@ -0,0 +1,94 @@
# `storageBackend(options)`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory wrapper around `new StorageBackend(options)` for callers
that prefer fluent builder-style composition over `new`. Returns a
fresh backend per call — no internal singleton, no shared state, no
caching. The factory exists primarily so chained-backend
configurations can be expressed declaratively without the visual
weight of repeated `new` keywords.
The factory is the recommended public entry point for routine use
because it composes naturally with the rest of the i18n family
(every backend ships a `<name>Backend` factory mirroring its class
constructor). Direct `new StorageBackend(...)` remains supported and
is exactly equivalent.
## Features
- Returns a fresh `StorageBackend` per call.
- Forwards every constructor option (`storage`, `layout`, `prefix`,
`name`, `encode`, `decode`) unchanged.
- Same Zod boundary validation as the class constructor — invalid
options throw `I18nError(I18N.INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const backend = storageBackend({ storage: new MemoryStorage() });
await backend.save("en", "common", { hello: "Hello" });
const record = await backend.read("en", "common");
// → { hello: "Hello" }
```
## Usage: Advanced Example
The factory composes naturally with chained-backend configurations
where multiple storages (e.g. an OPFS write-through cache plus an
S3 origin) are wired declaratively.
```ts
import { storageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
const cache = storageBackend({
storage: new MemoryStorage(),
layout: "single-key-per-locale",
name: "cache",
});
const origin = storageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
name: "origin",
prefix: "translations",
});
// Hand both to a ChainedBackend (Wave 5e).
// const chain = chainedBackend({ backends: [cache, origin] });
```
## API Reference
### `storageBackend(options: StorageBackendOptions): StorageBackend`
- `options.storage: AbstractStorage` — required.
- `options.layout?: StorageBackendLayout` — wire layout (default
`"single-key-per-namespace"`).
- `options.prefix?: string` — key prefix (default `"i18n"`).
- `options.name?: string` — backend label (default `"storage"`).
- `options.encode?: (record) => Uint8Array` — custom encoder.
- `options.decode?: (bytes) => TranslationRecord` — custom decoder.
- Returns: a fresh `StorageBackend` instance.
## Implementation Notes
- The factory is a one-liner: `return new StorageBackend(options)`.
It exists for ergonomic parity with the rest of the
`<name>Backend` factory pattern across the i18n backend family
and adds no behaviour beyond instantiation.
- The factory does NOT memoise — every call returns a distinct
backend instance. Callers needing shared state pass the same
backend reference to multiple call sites; do not call the
factory multiple times expecting the same store.
- For consumers who need polymorphism over
`BackendFactory<TOptions>` from `@suaveplan/types`, this factory
satisfies that signature.

View File

@@ -0,0 +1,83 @@
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StorageBackend } from "../backend/backend.js";
import { storageBackend } from "./factory.js";
describe("storageBackend factory", () => {
it("returns a StorageBackend instance", { timeout: 5000 }, () => {
const result = storageBackend({ storage: new MemoryStorage() });
expect(result).toBeInstanceOf(StorageBackend);
});
it("default name is 'storage'", { timeout: 5000 }, () => {
expect(storageBackend({ storage: new MemoryStorage() }).name).toBe(
"storage"
);
});
it("passes layout through to the constructor", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = storageBackend({
storage,
layout: "single-key-per-locale",
});
await backend.save("en", "auth", { ok: "Ok" });
// single-key-per-locale stores under "i18n/en.json"
expect(await storage.get("i18n/en.json")).toBeDefined();
});
it("passes prefix through to the constructor", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const backend = storageBackend({ storage, prefix: "translations" });
await backend.save("en", "auth", { ok: "Ok" });
expect(await storage.get("translations/en/auth.json")).toBeDefined();
});
it("passes name through to the constructor", { timeout: 5000 }, () => {
expect(
storageBackend({
storage: new MemoryStorage(),
name: "cache",
}).name
).toBe("cache");
});
it("passes encode/decode through to the constructor", {
timeout: 5000,
}, async () => {
const storage = new MemoryStorage();
const SENTINEL = 0xab;
const backend = storageBackend({
storage,
encode: (record) => {
const json = new TextEncoder().encode(JSON.stringify(record));
const out = new Uint8Array(json.length + 1);
out[0] = SENTINEL;
out.set(json, 1);
return out;
},
decode: (bytes) => {
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
return JSON.parse(
new TextDecoder().decode(bytes.slice(1))
) as Record<string, unknown>;
},
});
await backend.save("en", "auth", { ok: "Ok" });
const raw = await storage.get("i18n/en/auth.json");
expect(raw?.[0]).toBe(SENTINEL);
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
});
it("returns a fresh instance per call (no singleton)", {
timeout: 5000,
}, () => {
const a = storageBackend({ storage: new MemoryStorage() });
const b = storageBackend({ storage: new MemoryStorage() });
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,11 @@
import { StorageBackend } from "../backend/backend.js";
import type { StorageBackendOptions } from "../backend/backend.types.js";
/**
* Factory form for callers that prefer builders over `new`. Returns a
* fresh `StorageBackend` per call — no internal singleton, no shared
* state. Equivalent to `new StorageBackend(options)`.
*/
export function storageBackend(options: StorageBackendOptions): StorageBackend {
return new StorageBackend(options);
}

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
// Root barrel for @suaveplan/i18n-backend-storage. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export {
defaultCodec,
StorageBackend,
type StorageBackendOptionsInput,
StorageBackendOptionsSchema,
} from "./backend/backend.js";
export type {
StorageBackendCodec,
StorageBackendLayout,
StorageBackendOptions,
} from "./backend/backend.types.js";
export { storageBackend } from "./factory/factory.js";
export {
getStorageBackendMetrics,
getStorageBackendMetricsState,
recordRead,
recordWrite,
resetStorageBackendMetricsCache,
resetStorageBackendMetricsState,
type StorageBackendMetricSample,
type StorageBackendMetricsState,
} from "./observability/observability.js";

View File

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

View File

@@ -0,0 +1,132 @@
# `StorageBackend` Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrumentation for
`@suaveplan/i18n-backend-storage`. It ships per-call metric emitters
(`recordRead`, `recordWrite`) that the backend invokes around every
`read` / `save` operation, plus a small in-memory state ring so
spec-conformance tests can assert exact attribute payloads without
standing up a full SDK + InMemoryMetricExporter pipeline. Every
instrument is built lazily and is a no-op when no MeterProvider is
registered — consumers pay zero overhead unless they wire up the
SDK.
The metric naming follows the per-backend prefix convention agreed
in the Wave 5 design: `i18n.backend.storage.*`. Sibling backend
packages emit under their own prefixes so consumers can group
dashboard panels per backend without shared-namespace collisions.
Every counter and histogram carries a low-cardinality `layout`
attribute (`single-key-per-namespace` | `single-key-per-locale` |
`flat-keys`) so production teams can drill into per-layout latency
or miss-rate.
## Features
- Five instruments — two histograms, three counters — covering both
read and write paths.
- `layout` attribute on every aggregate so per-layout dashboards
work without parsing namespaces.
- In-memory state ring for test assertions (no exporter required).
- Lazy meter construction; idempotent
`getStorageBackendMetrics()`.
- Test-only `resetStorageBackendMetricsCache()` so suites can
rebuild the meter under a fresh provider.
## Usage: Basic Example
```ts
import {
getStorageBackendMetricsState,
resetStorageBackendMetricsState,
} from "@suaveplan/i18n-backend-storage/observability";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
resetStorageBackendMetricsState();
const backend = new StorageBackend({ storage: new MemoryStorage() });
await backend.save("en", "auth", { ok: "Ok" });
await backend.read("en", "auth");
const state = getStorageBackendMetricsState();
expect(state.readTotal).toEqual([
{ attributes: { outcome: "hit", layout: "single-key-per-namespace" } },
]);
```
## Usage: Advanced Example
A read miss emits both an outcome counter AND a
`read_errors_total` counter labelled by error code. Both carry the
`layout` attribute so per-layout error rates are charted directly.
```ts
import {
getStorageBackendMetricsState,
resetStorageBackendMetricsState,
} from "@suaveplan/i18n-backend-storage/observability";
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
import { MemoryStorage } from "@suaveplan/storage";
resetStorageBackendMetricsState();
const backend = new StorageBackend({
storage: new MemoryStorage(),
layout: "flat-keys",
});
await backend.read("en", "missing").catch(() => undefined);
const state = getStorageBackendMetricsState();
expect(state.readErrorsTotal).toEqual([
{
attributes: {
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
layout: "flat-keys",
},
},
]);
```
## API Reference
### Emitted instruments
| Instrument | Kind | Labels | Unit |
|---|---|---|---|
| `i18n.backend.storage.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
| `i18n.backend.storage.read_total` | Counter | `outcome` (hit \| miss \| error), `layout` | count |
| `i18n.backend.storage.read_errors_total` | Counter | `code` (I18N error code), `layout` | count |
| `i18n.backend.storage.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
| `i18n.backend.storage.write_total` | Counter | `outcome` (ok \| error), `layout` | count |
### Functions
- `getStorageBackendMetrics()` — returns cached instruments; builds
them on first call.
- `recordRead(locale, namespace, layout, outcome, durationMs, errorCode?)`
— emit a read sample.
- `recordWrite(locale, namespace, layout, outcome, durationMs)` —
emit a write sample.
- `getStorageBackendMetricsState()` — frozen-shape snapshot of every
emitted sample since last reset.
- `resetStorageBackendMetricsState()` — clear the in-memory state
ring (test fixture).
- `resetStorageBackendMetricsCache()` — drop the cached instruments
so a fresh `MeterProvider` can be wired (test fixture).
## Implementation Notes
- The state ring is intentionally cardinality-bounded by the
caller's test cases. It exists for assertion ergonomics, not for
production telemetry — production telemetry flows through the
registered OTel `MeterProvider`.
- Cardinality safety: aggregate counters use only `outcome` +
`layout` (each three or fewer values); per-error-code attribution
lives on a separate `read_errors_total` counter so consumers can
drop the high-cardinality counter without losing aggregate
visibility.
- Metric names are stable: every `i18n.backend.storage.*` name is
part of the package's public API contract and will not change
without a major bump.

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getStorageBackendMetrics,
getStorageBackendMetricsState,
recordRead,
recordWrite,
resetStorageBackendMetricsCache,
resetStorageBackendMetricsState,
} from "./observability.js";
describe("StorageBackend metrics — instrument lifecycle", () => {
it("getStorageBackendMetrics is idempotent (cache hit path)", {
timeout: 5000,
}, () => {
resetStorageBackendMetricsCache();
const a = getStorageBackendMetrics();
const b = getStorageBackendMetrics();
expect(a).toBe(b);
});
it("resetStorageBackendMetricsCache rebuilds instruments", {
timeout: 5000,
}, () => {
const a = getStorageBackendMetrics();
resetStorageBackendMetricsCache();
const b = getStorageBackendMetrics();
expect(a).not.toBe(b);
});
});
describe("StorageBackend metrics — recordRead", () => {
it("records duration + total on hit (no error code)", {
timeout: 5000,
}, () => {
resetStorageBackendMetricsState();
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.42);
const state = getStorageBackendMetricsState();
expect(state.readDurationMs).toEqual([
{
attributes: {
locale: "en",
namespace: "auth",
outcome: "hit",
layout: "single-key-per-namespace",
},
},
]);
expect(state.readTotal).toEqual([
{
attributes: {
outcome: "hit",
layout: "single-key-per-namespace",
},
},
]);
expect(state.readErrorsTotal).toEqual([]);
});
it("records error counter when errorCode supplied", {
timeout: 5000,
}, () => {
resetStorageBackendMetricsState();
recordRead(
"en",
"auth",
"flat-keys",
"miss",
0.42,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
const state = getStorageBackendMetricsState();
expect(state.readTotal).toEqual([
{ attributes: { outcome: "miss", layout: "flat-keys" } },
]);
expect(state.readErrorsTotal).toEqual([
{
attributes: {
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
layout: "flat-keys",
},
},
]);
});
it("records error outcome", { timeout: 5000 }, () => {
resetStorageBackendMetricsState();
recordRead(
"en",
"auth",
"single-key-per-locale",
"error",
0.42,
"I18N.BACKEND_READ_FAILED"
);
const state = getStorageBackendMetricsState();
expect(state.readTotal).toEqual([
{
attributes: {
outcome: "error",
layout: "single-key-per-locale",
},
},
]);
expect(state.readErrorsTotal).toEqual([
{
attributes: {
code: "I18N.BACKEND_READ_FAILED",
layout: "single-key-per-locale",
},
},
]);
});
});
describe("StorageBackend metrics — recordWrite", () => {
it("records duration + total on ok", { timeout: 5000 }, () => {
resetStorageBackendMetricsState();
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.42);
const state = getStorageBackendMetricsState();
expect(state.writeDurationMs).toEqual([
{
attributes: {
locale: "en",
namespace: "auth",
outcome: "ok",
layout: "single-key-per-namespace",
},
},
]);
expect(state.writeTotal).toEqual([
{
attributes: {
outcome: "ok",
layout: "single-key-per-namespace",
},
},
]);
});
it("records duration + total on error", { timeout: 5000 }, () => {
resetStorageBackendMetricsState();
recordWrite("en", "auth", "flat-keys", "error", 0.42);
const state = getStorageBackendMetricsState();
expect(state.writeTotal).toEqual([
{ attributes: { outcome: "error", layout: "flat-keys" } },
]);
});
});
describe("StorageBackend metrics — state ring helpers", () => {
it("resetStorageBackendMetricsState clears every ring", {
timeout: 5000,
}, () => {
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.1);
recordRead(
"en",
"auth",
"single-key-per-namespace",
"miss",
0.1,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.1);
resetStorageBackendMetricsState();
const state = getStorageBackendMetricsState();
expect(state.readDurationMs).toEqual([]);
expect(state.readTotal).toEqual([]);
expect(state.readErrorsTotal).toEqual([]);
expect(state.writeDurationMs).toEqual([]);
expect(state.writeTotal).toEqual([]);
});
});

View File

@@ -0,0 +1,170 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import type { StorageBackendLayout } from "../backend/backend.types.js";
/**
* OTel instruments for `@suaveplan/i18n-backend-storage`.
*
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-storage')`.
* All instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire up the
* SDK.
*
* In addition to emitting via OTel, this module retains a small
* in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK +
* InMemoryMetricExporter pipeline. Tests call
* `resetStorageBackendMetricsState()` between cases and read
* `getStorageBackendMetricsState()` to inspect counters / histogram
* counts.
*/
export interface StorageBackendMetricSample {
readonly attributes: Record<string, string>;
}
export interface StorageBackendMetricsState {
readonly readDurationMs: StorageBackendMetricSample[];
readonly readTotal: StorageBackendMetricSample[];
readonly readErrorsTotal: StorageBackendMetricSample[];
readonly writeDurationMs: StorageBackendMetricSample[];
readonly writeTotal: StorageBackendMetricSample[];
}
interface CachedInstruments {
readonly readDurationMs: Histogram;
readonly readTotal: Counter;
readonly readErrorsTotal: Counter;
readonly writeDurationMs: Histogram;
readonly writeTotal: Counter;
}
let cached: CachedInstruments | undefined;
const state: StorageBackendMetricsState = {
readDurationMs: [],
readTotal: [],
readErrorsTotal: [],
writeDurationMs: [],
writeTotal: [],
};
/**
* Build (or return cached) OTel instruments. Idempotent — safe to
* call from any production code path.
*/
export function getStorageBackendMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-backend-storage");
cached = {
readDurationMs: meter.createHistogram(
"i18n.backend.storage.read_duration_ms",
{
description:
"Duration in ms of a StorageBackend.read call; labels: locale, namespace, outcome, layout.",
unit: "ms",
}
),
readTotal: meter.createCounter("i18n.backend.storage.read_total", {
description:
"StorageBackend.read call count; labels: outcome (hit | miss | error), layout.",
}),
readErrorsTotal: meter.createCounter(
"i18n.backend.storage.read_errors_total",
{
description:
"StorageBackend.read error count; labels: code (the I18N error code), layout.",
}
),
writeDurationMs: meter.createHistogram(
"i18n.backend.storage.write_duration_ms",
{
description:
"Duration in ms of a StorageBackend.save call; labels: locale, namespace, outcome, layout.",
unit: "ms",
}
),
writeTotal: meter.createCounter("i18n.backend.storage.write_total", {
description:
"StorageBackend.save call count; labels: outcome (ok | error), layout.",
}),
};
return cached;
}
/**
* Record a `read` call's duration + outcome. Mirrors the
* `read_total{outcome,layout}` and per-error-code
* `read_errors_total{code,layout}` counters and pushes one sample
* into the in-memory state ring per counter touched.
*/
export function recordRead(
locale: string,
namespace: string,
layout: StorageBackendLayout,
outcome: "hit" | "miss" | "error",
durationMs: number,
errorCode?: string
): void {
const ins = getStorageBackendMetrics();
const histogramAttrs: Record<string, string> = {
locale,
namespace,
outcome,
layout,
};
ins.readDurationMs.record(durationMs, histogramAttrs);
state.readDurationMs.push({ attributes: histogramAttrs });
const aggregate: Record<string, string> = { outcome, layout };
ins.readTotal.add(1, aggregate);
state.readTotal.push({ attributes: aggregate });
if (errorCode !== undefined) {
const errorAttrs: Record<string, string> = { code: errorCode, layout };
ins.readErrorsTotal.add(1, errorAttrs);
state.readErrorsTotal.push({ attributes: errorAttrs });
}
}
/**
* Record a `save` call's duration + outcome. Mirrors the
* `write_total{outcome,layout}` counter into the in-memory state
* ring.
*/
export function recordWrite(
locale: string,
namespace: string,
layout: StorageBackendLayout,
outcome: "ok" | "error",
durationMs: number
): void {
const ins = getStorageBackendMetrics();
const histogramAttrs: Record<string, string> = {
locale,
namespace,
outcome,
layout,
};
ins.writeDurationMs.record(durationMs, histogramAttrs);
state.writeDurationMs.push({ attributes: histogramAttrs });
const aggregate: Record<string, string> = { outcome, layout };
ins.writeTotal.add(1, aggregate);
state.writeTotal.push({ attributes: aggregate });
}
/** Return a frozen snapshot of the in-memory state. */
export function getStorageBackendMetricsState(): StorageBackendMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetStorageBackendMetricsState(): void {
state.readDurationMs.length = 0;
state.readTotal.length = 0;
state.readErrorsTotal.length = 0;
state.writeDurationMs.length = 0;
state.writeTotal.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetStorageBackendMetricsCache(): void {
cached = undefined;
}

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,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-storage",
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/storage",
"@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,
},
});