Initial Commit
This commit is contained in:
130
packages/localization/i18n/i18n-backend-storage/README.md
Normal file
130
packages/localization/i18n/i18n-backend-storage/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# @suaveplan/i18n-backend-storage - Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal translation backend for the `@suaveplan/i18n` family,
|
||||
backed by any `AbstractStorage` implementation from
|
||||
`@suaveplan/storage`. Implements the full `Backend` contract
|
||||
(`read`, `readMulti`, `save`, `lastModified`) over three wire
|
||||
layouts: `single-key-per-namespace` (default),
|
||||
`single-key-per-locale`, and `flat-keys`. Pluggable codec, optional
|
||||
`AbortSignal` cancellation, OpenTelemetry instrumentation under the
|
||||
`i18n.backend.storage.*` prefix. Zero runtime dependencies on
|
||||
`@suaveplan/i18n` core.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Why this exists
|
||||
|
||||
The Wave 5 modular split lifts each backend out of the
|
||||
`@suaveplan/i18n` monolith into its own sibling so consumers can
|
||||
install only the backends they need. The storage backend is the
|
||||
canonical persistence layer: pair it with `MemoryStorage` for tests,
|
||||
`OpfsStorage` / `IndexedDBStorage` for offline-first browser apps,
|
||||
or `S3Storage` / `GarageStorage` / `NodeFsStorage` /
|
||||
`SqliteStorage` for server-tier deployments. Splitting it out also
|
||||
isolates `@suaveplan/storage` as a transitive dep — consumers using
|
||||
only the HTTP or memory backends no longer pull it in.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-backend-storage @suaveplan/storage
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await backend.read("en", "auth");
|
||||
// → { login: { title: "Sign in" } }
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-backend-storage` | Root barrel — class + factory + types + schemas + observability |
|
||||
| `@suaveplan/i18n-backend-storage/backend` | `StorageBackend` class + `defaultCodec` |
|
||||
| `@suaveplan/i18n-backend-storage/factory` | `storageBackend` factory function only |
|
||||
| `@suaveplan/i18n-backend-storage/schemas` | Zod boundary schemas |
|
||||
| `@suaveplan/i18n-backend-storage/observability` | OTel instruments + in-memory state ring |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [backend.md](src/backend/backend.md) — `StorageBackend` class, layouts, codec
|
||||
- [factory.md](src/factory/factory.md) — `storageBackend` factory function
|
||||
- [observability.md](src/observability/observability.md) — OTel instruments + cardinality
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Flag | Value | Notes |
|
||||
|---|---|---|
|
||||
| `canRead` | `true` | always |
|
||||
| `canReadMulti` | `true` | per-layout dispatch |
|
||||
| `canSave` | `true` | persists into the wrapped storage |
|
||||
| `canTrackModification` | `true` | consults `getMetadata().modifiedAt` |
|
||||
| `canWatch` | `false` | the storage contract has no change feed |
|
||||
|
||||
## Layouts
|
||||
|
||||
- `single-key-per-namespace` (default) — one key per `(locale, ns)`
|
||||
at `<prefix>/<locale>/<namespace>.json`. Best for HTTP-CDN-style
|
||||
storages.
|
||||
- `single-key-per-locale` — one key per locale holding the whole
|
||||
namespace map. Best when namespaces are small and read together.
|
||||
- `flat-keys` — one key per leaf string at
|
||||
`<prefix>:<locale>:<namespace>:<dotPath>`. Best for content-
|
||||
addressed storages with cheap per-leaf delta shipping.
|
||||
|
||||
## Testing
|
||||
|
||||
The package's own tests use the shared `runBackendConformance`
|
||||
harness from `@suaveplan/i18n-shared/testing` so the universal
|
||||
`Backend` contract is exercised against a fresh storage backend
|
||||
per assertion (against all three layouts):
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
runBackendConformance("StorageBackend", async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0`
|
||||
release. The metric names under `i18n.backend.storage.*` are part of
|
||||
the public API contract and will not change without a major bump.
|
||||
|
||||
## Wave 5 Scope
|
||||
|
||||
This package is Wave 5b of the 14-package i18n modular split. It
|
||||
ships pure additions only — `packages/i18n/i18n/src/core/backend/storage/`
|
||||
retains its copy of the backend until Wave 6 deletes it. Both
|
||||
copies coexist during Wave 5; downstream consumers continue to
|
||||
import from the monolith and observe no behaviour change.
|
||||
|
||||
83
packages/localization/i18n/i18n-backend-storage/package.json
Normal file
83
packages/localization/i18n/i18n-backend-storage/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-backend-storage",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal translation backend backed by any AbstractStorage from @suaveplan/storage. Three wire layouts: single-key-per-namespace (default), single-key-per-locale, flat-keys. Pluggable codec, AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.storage.*. Zero runtime dependencies on @suaveplan/i18n core.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./backend": {
|
||||
"import": {
|
||||
"types": "./dist/backend/index.d.ts",
|
||||
"default": "./dist/backend/index.js"
|
||||
}
|
||||
},
|
||||
"./factory": {
|
||||
"import": {
|
||||
"types": "./dist/factory/index.d.ts",
|
||||
"default": "./dist/factory/index.js"
|
||||
}
|
||||
},
|
||||
"./observability": {
|
||||
"import": {
|
||||
"types": "./dist/observability/index.d.ts",
|
||||
"default": "./dist/observability/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/i18n-shared": "workspace:^",
|
||||
"@suaveplan/storage": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/typescript-config": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# `StorageBackend`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StorageBackend` adapts any `AbstractStorage` instance from
|
||||
`@suaveplan/storage` into a translation `Backend` for the
|
||||
`@suaveplan/i18n` family. This is the headline persistence
|
||||
integration that lets translations live in S3, OPFS, IndexedDB,
|
||||
SQLite, Garage, or any other storage `@suaveplan/storage` supports
|
||||
— without each consumer integrating once per source. The user-
|
||||
facing pitch is "translations in OPFS for offline" or "translations
|
||||
on a content-addressed CDN" become one config line.
|
||||
|
||||
The backend has zero runtime dependencies on `@suaveplan/i18n` core.
|
||||
It implements the full `Backend` contract from `@suaveplan/types`
|
||||
(`read`, `readMulti`, `save`, `lastModified`) and emits
|
||||
OpenTelemetry instruments under the `i18n.backend.storage.*` prefix
|
||||
so production teams can dashboard latency and miss-rate per layout.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 5-flag capability matrix: `canRead`, `canReadMulti`,
|
||||
`canSave`, `canTrackModification` all `true`; `canWatch: false`.
|
||||
- Three wire layouts: `single-key-per-namespace` (default),
|
||||
`single-key-per-locale`, `flat-keys`.
|
||||
- Pluggable codec — JSON by default; swap for MessagePack, CBOR, or
|
||||
schema-validated JSON.
|
||||
- Optional `signal?: AbortSignal` parameter on `read`, `readMulti`,
|
||||
and `save`; pre-aborted signals throw
|
||||
`I18nError(I18N.BACKEND_TIMEOUT)` without touching the underlying
|
||||
storage.
|
||||
- OTel histograms + counters emitted on every read/save with a
|
||||
`layout` attribute so per-layout latency can be charted.
|
||||
- Zod boundary validation: invalid options throw
|
||||
`I18nError(I18N.INVALID_OPTIONS)` at construction time.
|
||||
- `BACKEND_LOCALE_NOT_AVAILABLE` on miss (chained backends fall
|
||||
through); `BACKEND_READ_FAILED` / `BACKEND_WRITE_FAILED` on
|
||||
transport / codec errors (chained backends propagate without
|
||||
fallthrough).
|
||||
|
||||
## Layouts
|
||||
|
||||
### `single-key-per-namespace` (default)
|
||||
|
||||
```text
|
||||
i18n/en/auth.json → {"login":{"title":"Sign in"}}
|
||||
i18n/en/common.json → {"cancel":"Cancel"}
|
||||
```
|
||||
|
||||
One storage key per `(locale, namespace)`. Reads are exactly one
|
||||
storage `get` per `read(locale, ns)` call. Best for HTTP-CDN-style
|
||||
storages where each key is independently cacheable.
|
||||
|
||||
### `single-key-per-locale`
|
||||
|
||||
```text
|
||||
i18n/en.json → {"auth":{...},"common":{...}}
|
||||
```
|
||||
|
||||
One storage key per locale, holding the entire
|
||||
`Record<namespace, TranslationRecord>` map. Best when namespaces are
|
||||
small and read together. Saving a single namespace requires a
|
||||
read-modify-write of the whole locale map.
|
||||
|
||||
### `flat-keys`
|
||||
|
||||
```text
|
||||
i18n:en:auth:login.title → Sign in
|
||||
i18n:en:common:cancel → Cancel
|
||||
```
|
||||
|
||||
One storage key per leaf string. Best for content-addressed storages
|
||||
where deltas are expensive at namespace granularity but cheap at
|
||||
leaf granularity.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await backend.read("en", "auth");
|
||||
// → { login: { title: "Sign in" } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — OPFS + custom binary codec
|
||||
|
||||
```ts
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
|
||||
import { encode, decode } from "@msgpack/msgpack";
|
||||
|
||||
const backend = new StorageBackend({
|
||||
storage: new OpfsStorage({ rootDir: "i18n" }),
|
||||
layout: "single-key-per-locale",
|
||||
encode: (record) => encode(record),
|
||||
decode: (bytes) => decode(bytes) as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// Cancellable reads play nicely with multi-backend orchestration.
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await backend.read("en", "auth", controller.signal); // throws BACKEND_TIMEOUT
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StorageBackend implements Backend`
|
||||
|
||||
- `constructor(options: StorageBackendOptions)` — validates options
|
||||
via `StorageBackendOptionsSchema`; throws
|
||||
`I18nError(I18N.INVALID_OPTIONS)` on schema mismatch or missing
|
||||
`storage`.
|
||||
- `name: string` — backend label (default `"storage"`).
|
||||
- `capabilities: BackendCapabilities` — frozen 5-flag matrix.
|
||||
- `read(locale, namespace, signal?): Promise<TranslationRecord>` —
|
||||
resolves with the stored record; throws
|
||||
`I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` on miss.
|
||||
- `readMulti(locale, namespaces, signal?):
|
||||
Promise<Record<string, TranslationRecord>>` — returns only the
|
||||
namespaces that exist; missing namespaces are omitted.
|
||||
- `save(locale, namespace, record, signal?): Promise<void>` —
|
||||
persists the record; failure surfaces as
|
||||
`BACKEND_WRITE_FAILED`.
|
||||
- `lastModified(locale, namespace): Promise<number | undefined>` —
|
||||
returns the underlying storage's `modifiedAt` field, or
|
||||
`undefined` if metadata is unavailable.
|
||||
|
||||
### `defaultCodec: StorageBackendCodec`
|
||||
|
||||
JSON-based codec (`TextEncoder` / `JSON.stringify`). The decode path
|
||||
rejects non-object payloads (strings, arrays, null) as
|
||||
`BACKEND_READ_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default JSON codec uses `TextEncoder` / `TextDecoder` and
|
||||
`JSON.stringify` / `JSON.parse`. The decode path rejects non-
|
||||
object payloads because translation records are always objects in
|
||||
the contract; custom codecs MUST also reject non-objects or
|
||||
document the relaxation explicitly.
|
||||
- `flat-keys` reads via `AbstractStorage.list({ prefix })`. List is
|
||||
required by every concrete storage, so the layout works against
|
||||
all of them. Saves write each leaf with its own `set` call — for
|
||||
backends that batch writes, prefer `single-key-per-namespace` to
|
||||
amortise the batch.
|
||||
- `lastModified` for `flat-keys` takes the maximum `modifiedAt`
|
||||
across leaves. For other layouts it consults the namespace or
|
||||
locale key directly. All paths swallow storage exceptions and
|
||||
return `undefined` rather than throw, mirroring the contract that
|
||||
`lastModified` is a hint, not a guarantee.
|
||||
- `signal?.aborted` is checked at the entry of every async method.
|
||||
The throw surfaces as `I18nError(I18N.BACKEND_TIMEOUT)`. The flag
|
||||
is sampled exactly once at the top — once the underlying
|
||||
`AbstractStorage` operation begins, abort propagation is the
|
||||
storage's responsibility.
|
||||
- OTel `recordRead` / `recordWrite` calls emit zero overhead unless
|
||||
a `MeterProvider` is registered. Tests use the in-memory metrics
|
||||
state ring exposed by `./observability` to assert payloads
|
||||
without standing up the full SDK. The `layout` attribute is a
|
||||
three-value low-cardinality enum.
|
||||
@@ -0,0 +1,798 @@
|
||||
import { I18nError } from "@suaveplan/i18n-shared";
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { storageBackend } from "../factory/factory.js";
|
||||
import { defaultCodec, StorageBackend } from "./backend.js";
|
||||
import type { StorageBackendOptions } from "./backend.types.js";
|
||||
|
||||
// ── Run the universal Backend contract conformance against every layout
|
||||
|
||||
runBackendConformance("StorageBackend (single-key-per-namespace)", async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runBackendConformance("StorageBackend (single-key-per-locale)", async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runBackendConformance("StorageBackend (flat-keys)", async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── StorageBackend specifics ───────────────────────────────────────
|
||||
|
||||
describe("StorageBackend specifics", () => {
|
||||
it("single-key-per-namespace round-trip", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({ storage, prefix: "i18n" });
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw).toBeDefined();
|
||||
const text = new TextDecoder().decode(raw as Uint8Array);
|
||||
expect(JSON.parse(text)).toEqual({ login: { title: "Sign in" } });
|
||||
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in" },
|
||||
});
|
||||
});
|
||||
|
||||
it("single-key-per-locale round-trip", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
prefix: "i18n",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
|
||||
const raw = await storage.get("i18n/en.json");
|
||||
const decoded = JSON.parse(
|
||||
new TextDecoder().decode(raw as Uint8Array)
|
||||
) as Record<string, unknown>;
|
||||
expect(decoded).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
|
||||
expect(await backend.read("en", "common")).toEqual({
|
||||
cancel: "Cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("flat-keys round-trip with nested record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
prefix: "i18n",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const t = await storage.get("i18n:en:auth:login.title");
|
||||
const s = await storage.get("i18n:en:auth:login.subtitle");
|
||||
expect(decoder.decode(t as Uint8Array)).toBe("Sign in");
|
||||
expect(decoder.decode(s as Uint8Array)).toBe("Welcome");
|
||||
|
||||
expect(await backend.read("en", "auth")).toEqual({
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
});
|
||||
|
||||
it("missing key throws BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
for (const layout of [
|
||||
"single-key-per-namespace",
|
||||
"single-key-per-locale",
|
||||
"flat-keys",
|
||||
] as const) {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout,
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail(`expected throw for layout ${layout}`);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("custom encode/decode is used", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
const SENTINEL = 0xfa;
|
||||
const codec = {
|
||||
encode: (record: unknown) => {
|
||||
const json = enc.encode(JSON.stringify(record));
|
||||
const out = new Uint8Array(json.length + 1);
|
||||
out[0] = SENTINEL;
|
||||
out.set(json, 1);
|
||||
return out;
|
||||
},
|
||||
decode: (bytes: Uint8Array) => {
|
||||
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
|
||||
const json = dec.decode(bytes.slice(1));
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
},
|
||||
};
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
encode: codec.encode,
|
||||
decode: codec.decode,
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw?.[0]).toBe(SENTINEL);
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("readMulti omits missing namespaces", { timeout: 5000 }, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"common",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
expect(result).not.toHaveProperty("missing");
|
||||
});
|
||||
|
||||
it("readMulti single-key-per-locale", { timeout: 5000 }, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.save("en", "common", { cancel: "Cancel" });
|
||||
const result = await backend.readMulti("en", [
|
||||
"auth",
|
||||
"common",
|
||||
"missing",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
});
|
||||
|
||||
it("readMulti single-key-per-locale returns empty for missing locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
const result = await backend.readMulti("xx", ["auth"]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("lastModified returns metadata timestamp", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("lastModified single-key-per-locale consults locale key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lastModified returns undefined for absent flat-keys record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("name defaults to 'storage' and accepts override", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(new StorageBackend({ storage: new MemoryStorage() }).name).toBe(
|
||||
"storage"
|
||||
);
|
||||
expect(
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "opfs-cache",
|
||||
}).name
|
||||
).toBe("opfs-cache");
|
||||
});
|
||||
|
||||
it("capabilities matrix matches the contract", { timeout: 5000 }, () => {
|
||||
const c = new StorageBackend({ storage: new MemoryStorage() })
|
||||
.capabilities;
|
||||
expect(c).toEqual({
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults are applied", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({ storage });
|
||||
// default prefix "i18n" + default layout single-key-per-namespace
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw).toBeDefined();
|
||||
expect(backend.name).toBe("storage");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Read failure modes ─────────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend read failures", () => {
|
||||
it("rejects garbage JSON payloads as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("not-json")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects array JSON payloads (non-record) as BACKEND_READ_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("[1,2,3]")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale wraps non-I18nError decode failures", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
decode: () => {
|
||||
throw new Error("decoder-boom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale passes I18nError decode failures unwrapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set("i18n/en.json", new TextEncoder().encode("garbage"));
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
decode: () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_READ_FAILED" as never,
|
||||
"inner-i18n-decode"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-i18n-decode");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-namespace passes I18nError decode failures unwrapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("garbage")
|
||||
);
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
decode: () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_READ_FAILED" as never,
|
||||
"inner-decoder"
|
||||
);
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-decoder");
|
||||
}
|
||||
});
|
||||
|
||||
it("single-key-per-locale read of absent namespace within present locale", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
try {
|
||||
await backend.read("en", "common");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-I18nError from storage.get propagates with outcome=error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.get = async () => {
|
||||
throw new Error("storage-boom-not-i18n");
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.read("en", "auth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(I18nError);
|
||||
expect((err as Error).message).toBe("storage-boom-not-i18n");
|
||||
}
|
||||
});
|
||||
|
||||
it("readMulti propagates non-LNA errors from read()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n/en/auth.json",
|
||||
new TextEncoder().encode("not-json")
|
||||
);
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.readMulti("en", ["auth"]);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("flat-keys skips storage keys with empty bytes during read", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
await storage.set(
|
||||
"i18n:en:auth:login.title",
|
||||
new TextEncoder().encode("Sign in")
|
||||
);
|
||||
const original = storage.get.bind(storage);
|
||||
storage.get = async (key: string) => {
|
||||
if (key === "i18n:en:auth:phantom") return undefined;
|
||||
return original(key);
|
||||
};
|
||||
const originalList = storage.list.bind(storage);
|
||||
storage.list = async (opts) => {
|
||||
const real = await originalList(opts);
|
||||
return {
|
||||
...real,
|
||||
keys: [...real.keys, "i18n:en:auth:phantom"],
|
||||
};
|
||||
};
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
const record = await backend.read("en", "auth");
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
|
||||
it("flat-keys overlays string onto pre-existing nested record at sibling path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Exercises the assignAtDotPath else-branch where cursor[part] is
|
||||
// a string (not object) and the loop must replace it with an
|
||||
// empty object.
|
||||
const storage = new MemoryStorage();
|
||||
// Put a raw value at "login" first (string), then a sibling path
|
||||
// "login.title" — the assemble path will see the string at
|
||||
// "login" and replace it with a fresh nested record.
|
||||
await storage.set(
|
||||
"i18n:en:auth:login",
|
||||
new TextEncoder().encode("legacy-flat-string")
|
||||
);
|
||||
await storage.set(
|
||||
"i18n:en:auth:login.title",
|
||||
new TextEncoder().encode("Sign in")
|
||||
);
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
const record = await backend.read("en", "auth");
|
||||
// Because keys can list in unspecified order, we just assert
|
||||
// login.title eventually wins as a nested record.
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save failure modes ─────────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend save failures", () => {
|
||||
it("storage set failure throws BACKEND_WRITE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
encode: () => {
|
||||
throw new Error("encode-boom");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_WRITE_FAILED");
|
||||
expect((err as I18nError).cause).toBeInstanceOf(Error);
|
||||
expect(((err as I18nError).cause as Error).message).toBe(
|
||||
"encode-boom"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("save propagates I18nError from inner storage without re-wrapping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const original = storage.set.bind(storage);
|
||||
storage.set = async () => {
|
||||
throw new I18nError(
|
||||
"I18N.BACKEND_WRITE_FAILED" as never,
|
||||
"inner-i18n-err"
|
||||
);
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
try {
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).message).toBe("inner-i18n-err");
|
||||
}
|
||||
storage.set = original;
|
||||
});
|
||||
});
|
||||
|
||||
// ── lastModified edge cases ─────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend lastModified edge cases", () => {
|
||||
it("swallows storage.getMetadata exceptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.getMetadata = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const backend = new StorageBackend({ storage });
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flat-keys takes the max across leaf metadata", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t).toBe("number");
|
||||
});
|
||||
|
||||
it("flat-keys returns undefined when all metadata is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
storage.getMetadata = async () => null;
|
||||
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flat-keys handles partial-null getMetadata responses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = new StorageBackend({
|
||||
storage,
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.save("en", "auth", {
|
||||
login: { title: "Sign in", subtitle: "Welcome" },
|
||||
});
|
||||
const original = storage.getMetadata.bind(storage);
|
||||
let returnedNull = false;
|
||||
storage.getMetadata = async (key: string) => {
|
||||
if (!returnedNull) {
|
||||
returnedNull = true;
|
||||
return null;
|
||||
}
|
||||
return original(key);
|
||||
};
|
||||
const t = await backend.lastModified("en", "auth");
|
||||
expect(typeof t === "number" || t === undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AbortSignal cancellation ────────────────────────────────────────
|
||||
|
||||
describe("StorageBackend AbortSignal", () => {
|
||||
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toBeInstanceOf(I18nError);
|
||||
await expect(
|
||||
backend.read("en", "auth", ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.readMulti("en", ["auth"], ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
backend.save("en", "auth", { x: "y" }, ac.signal)
|
||||
).rejects.toMatchObject({ code: "I18N.BACKEND_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("non-aborted signal does not affect operation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
const ac = new AbortController();
|
||||
await backend.save("en", "auth", { x: "y" }, ac.signal);
|
||||
expect(await backend.read("en", "auth", ac.signal)).toEqual({
|
||||
x: "y",
|
||||
});
|
||||
expect(await backend.readMulti("en", ["auth"], ac.signal)).toEqual({
|
||||
auth: { x: "y" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boundary validation (Zod) ───────────────────────────────────────
|
||||
|
||||
describe("StorageBackend boundary validation", () => {
|
||||
it("missing storage throws INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("falsy storage (null) throws INVALID_OPTIONS post-schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// `storage: null` passes `z.unknown()` boundary but trips the
|
||||
// explicit `!options.storage` guard that protects downstream
|
||||
// method calls. Pins coverage of that distinct error path.
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: null,
|
||||
} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
expect((err as I18nError).message).toContain("storage is required");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed options shape via Zod (e.g. name: number)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: 123 as unknown as string,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown option keys (strict schema)", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
bogus: true,
|
||||
} as unknown as StorageBackendOptions);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty-string name (Zod min(1))", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "",
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.INVALID_OPTIONS");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default codec ───────────────────────────────────────────────────
|
||||
|
||||
describe("defaultCodec", () => {
|
||||
it("round-trips object record", { timeout: 5000 }, () => {
|
||||
const bytes = defaultCodec.encode({ a: { b: "c" } });
|
||||
expect(defaultCodec.decode(bytes)).toEqual({ a: { b: "c" } });
|
||||
});
|
||||
|
||||
it("default codec rejects array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("[]");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("default codec rejects null", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("null");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("default codec rejects primitive number", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("42");
|
||||
try {
|
||||
defaultCodec.decode(bytes);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as I18nError).code).toBe("I18N.BACKEND_READ_FAILED");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Factory smoke ───────────────────────────────────────────────────
|
||||
|
||||
describe("storageBackend factory smoke (full coverage in factory.test.ts)", () => {
|
||||
it("factory form storageBackend()", { timeout: 5000 }, () => {
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(backend.name).toBe("storage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import type { AbstractStorage } from "@suaveplan/storage";
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { recordRead, recordWrite } from "../observability/observability.js";
|
||||
import type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend.types.js";
|
||||
|
||||
/**
|
||||
* Zod boundary schema for {@link StorageBackendOptions}. Validates the
|
||||
* statically-checkable shape only — `storage`, `encode`, and `decode`
|
||||
* are accepted as opaque `z.unknown()` because Zod cannot type-check
|
||||
* function arity meaningfully and `AbstractStorage` is a structural
|
||||
* contract. Caller-supplied option values failing this schema cause
|
||||
* the constructor to throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export const StorageBackendOptionsSchema = z
|
||||
.object({
|
||||
storage: z.unknown(),
|
||||
layout: z
|
||||
.enum([
|
||||
"single-key-per-namespace",
|
||||
"single-key-per-locale",
|
||||
"flat-keys",
|
||||
])
|
||||
.optional(),
|
||||
prefix: z.string().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
encode: z.unknown().optional(),
|
||||
decode: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type StorageBackendOptionsInput = z.input<
|
||||
typeof StorageBackendOptionsSchema
|
||||
>;
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Default JSON codec — UTF-8 `JSON.stringify` / `JSON.parse`. The
|
||||
* decode path rejects non-object payloads (strings, arrays, null) as
|
||||
* `BACKEND_READ_FAILED` because translation records are always
|
||||
* objects in the contract.
|
||||
*/
|
||||
export const defaultCodec: StorageBackendCodec = {
|
||||
encode: (record) => TEXT_ENCODER.encode(JSON.stringify(record)),
|
||||
decode: (bytes) => {
|
||||
const text = TEXT_DECODER.decode(bytes);
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
"StorageBackend: decoded payload is not an object record"
|
||||
);
|
||||
}
|
||||
return parsed as TranslationRecord;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Translation backend backed by any `AbstractStorage` implementation
|
||||
* (MemoryStorage, S3Storage, OPFSStorage, IndexedDBStorage,
|
||||
* NodeFsStorage, SqliteStorage, GarageStorage, …).
|
||||
*
|
||||
* Three wire layouts are supported:
|
||||
*
|
||||
* - `single-key-per-namespace` (default): one storage key per
|
||||
* namespace (`<prefix>/<locale>/<namespace>.json`). Reads exactly
|
||||
* one storage key per `read(locale, ns)` call.
|
||||
* - `single-key-per-locale`: one storage key per locale, holding the
|
||||
* whole `Record<namespace, TranslationRecord>` map. Best when
|
||||
* namespaces are small and read together.
|
||||
* - `flat-keys`: one storage key per leaf string
|
||||
* (`<prefix>:<locale>:<namespace>:<dotPath>` → leaf string). Works
|
||||
* well with content-addressed storages and supports per-leaf delta
|
||||
* shipping.
|
||||
*
|
||||
* Universal — the backend itself is tier-neutral; the concrete
|
||||
* `AbstractStorage` decides the deployment tier.
|
||||
*/
|
||||
export class StorageBackend implements Backend {
|
||||
public readonly name: string;
|
||||
public readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
readonly #storage: AbstractStorage;
|
||||
readonly #layout: StorageBackendLayout;
|
||||
readonly #prefix: string;
|
||||
readonly #encode: (record: TranslationRecord) => Uint8Array;
|
||||
readonly #decode: (bytes: Uint8Array) => TranslationRecord;
|
||||
|
||||
public constructor(options: StorageBackendOptions) {
|
||||
const parsed = StorageBackendOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"StorageBackend: invalid options",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { issues: parsed.error.issues },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!options.storage) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_OPTIONS,
|
||||
"StorageBackend: storage is required"
|
||||
);
|
||||
}
|
||||
|
||||
this.name = options.name ?? "storage";
|
||||
this.#storage = options.storage;
|
||||
this.#layout = options.layout ?? "single-key-per-namespace";
|
||||
this.#prefix = options.prefix ?? "i18n";
|
||||
this.#encode = options.encode ?? defaultCodec.encode;
|
||||
this.#decode = options.decode ?? defaultCodec.decode;
|
||||
}
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<TranslationRecord> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
let outcome: "hit" | "miss" | "error" = "hit";
|
||||
let errorCode: string | undefined;
|
||||
try {
|
||||
return await this.#readInternal(locale, namespace);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof I18nError &&
|
||||
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
) {
|
||||
outcome = "miss";
|
||||
errorCode = I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE;
|
||||
} else {
|
||||
outcome = "error";
|
||||
if (err instanceof I18nError) {
|
||||
errorCode = err.code;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
recordRead(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
outcome,
|
||||
performance.now() - t0,
|
||||
errorCode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #readInternal(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord> {
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale);
|
||||
const record = map[namespace];
|
||||
if (!record) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
if (this.#layout === "flat-keys") {
|
||||
const record = await this.#readFlatKeys(locale, namespace);
|
||||
if (record === undefined) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
// single-key-per-namespace
|
||||
const key = this.#nsKey(locale, namespace);
|
||||
const bytes = await this.#storage.get(key);
|
||||
if (!bytes) {
|
||||
throw this.#missing(locale, namespace);
|
||||
}
|
||||
try {
|
||||
return this.#decode(bytes);
|
||||
} catch (cause) {
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`StorageBackend: decode failed for ${key}`,
|
||||
{ cause, context: { key, layout: this.#layout } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
this.#checkAborted(signal);
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale).catch(
|
||||
() => ({}) as Record<string, TranslationRecord>
|
||||
);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const ns of namespaces) {
|
||||
if (map[ns]) out[ns] = map[ns];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const results = await Promise.all(
|
||||
namespaces.map((ns) =>
|
||||
this.read(locale, ns).then(
|
||||
(record) => [ns, record] as const,
|
||||
(err) => {
|
||||
if (
|
||||
err instanceof I18nError &&
|
||||
err.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const r of results) {
|
||||
if (r) out[r[0]] = r[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
this.#checkAborted(signal);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (this.#layout === "single-key-per-locale") {
|
||||
const map = await this.#readLocaleMap(locale).catch(
|
||||
() => ({}) as Record<string, TranslationRecord>
|
||||
);
|
||||
map[namespace] = record;
|
||||
const bytes = this.#encode(map as TranslationRecord);
|
||||
await this.#storage.set(this.#localeKey(locale), bytes);
|
||||
} else if (this.#layout === "flat-keys") {
|
||||
await this.#saveFlatKeys(locale, namespace, record);
|
||||
} else {
|
||||
const bytes = this.#encode(record);
|
||||
await this.#storage.set(this.#nsKey(locale, namespace), bytes);
|
||||
}
|
||||
recordWrite(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
"ok",
|
||||
performance.now() - t0
|
||||
);
|
||||
} catch (cause) {
|
||||
recordWrite(
|
||||
locale,
|
||||
namespace,
|
||||
this.#layout,
|
||||
"error",
|
||||
performance.now() - t0
|
||||
);
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_WRITE_FAILED,
|
||||
`StorageBackend: save failed for ${locale}/${namespace}`,
|
||||
{
|
||||
cause,
|
||||
context: { locale, namespace, layout: this.#layout },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
if (this.#layout === "flat-keys") {
|
||||
const list = await this.#storage.list({
|
||||
prefix: `${this.#prefix}:${locale}:${namespace}:`,
|
||||
});
|
||||
if (list.keys.length === 0) return undefined;
|
||||
const stamps: number[] = [];
|
||||
for (const key of list.keys) {
|
||||
const meta = await this.#storage.getMetadata(key);
|
||||
if (meta) stamps.push(meta.modifiedAt);
|
||||
}
|
||||
return stamps.length > 0 ? Math.max(...stamps) : undefined;
|
||||
}
|
||||
const key =
|
||||
this.#layout === "single-key-per-locale"
|
||||
? this.#localeKey(locale)
|
||||
: this.#nsKey(locale, namespace);
|
||||
const meta = await this.#storage.getMetadata(key);
|
||||
return meta?.modifiedAt;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layout helpers ────────────────────────────────────────────
|
||||
|
||||
#nsKey(locale: string, namespace: string): string {
|
||||
return `${this.#prefix}/${locale}/${namespace}.json`;
|
||||
}
|
||||
|
||||
#localeKey(locale: string): string {
|
||||
return `${this.#prefix}/${locale}.json`;
|
||||
}
|
||||
|
||||
async #readLocaleMap(
|
||||
locale: string
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
const bytes = await this.#storage.get(this.#localeKey(locale));
|
||||
if (!bytes) {
|
||||
throw this.#missing(locale, "*");
|
||||
}
|
||||
try {
|
||||
const decoded = this.#decode(bytes) as Record<
|
||||
string,
|
||||
TranslationRecord
|
||||
>;
|
||||
return decoded;
|
||||
} catch (cause) {
|
||||
if (cause instanceof I18nError) throw cause;
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_READ_FAILED,
|
||||
`StorageBackend: decode failed for ${this.#localeKey(locale)}`,
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #readFlatKeys(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord | undefined> {
|
||||
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
|
||||
const list = await this.#storage.list({ prefix });
|
||||
if (list.keys.length === 0) return undefined;
|
||||
const out: TranslationRecord = {};
|
||||
const decoder = new TextDecoder();
|
||||
for (const key of list.keys) {
|
||||
const dotPath = key.slice(prefix.length);
|
||||
const bytes = await this.#storage.get(key);
|
||||
if (!bytes) continue;
|
||||
const value = decoder.decode(bytes);
|
||||
assignAtDotPath(out, dotPath, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async #saveFlatKeys(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord
|
||||
): Promise<void> {
|
||||
const prefix = `${this.#prefix}:${locale}:${namespace}:`;
|
||||
const encoder = new TextEncoder();
|
||||
const flat = flattenLeaves(record);
|
||||
for (const [dotPath, value] of Object.entries(flat)) {
|
||||
await this.#storage.set(
|
||||
`${prefix}${dotPath}`,
|
||||
encoder.encode(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#missing(locale: string, namespace: string): I18nError {
|
||||
return new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`StorageBackend: no record for ${locale}/${namespace}`,
|
||||
{
|
||||
context: {
|
||||
locale,
|
||||
namespace,
|
||||
layout: this.#layout,
|
||||
backend: this.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_TIMEOUT,
|
||||
"StorageBackend: operation aborted",
|
||||
{
|
||||
context: { backend: this.name, reason: signal.reason },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenLeaves(
|
||||
record: TranslationRecord,
|
||||
prefix = ""
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") {
|
||||
out[path] = v;
|
||||
} else {
|
||||
Object.assign(out, flattenLeaves(v as TranslationRecord, path));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type MutableTranslationRecord = {
|
||||
[key: string]: string | MutableTranslationRecord;
|
||||
};
|
||||
|
||||
function assignAtDotPath(
|
||||
target: TranslationRecord,
|
||||
dotPath: string,
|
||||
value: string
|
||||
): void {
|
||||
const parts = dotPath.split(".");
|
||||
let cursor = target as MutableTranslationRecord;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i] as string;
|
||||
const next = cursor[part];
|
||||
if (typeof next === "object" && next !== null) {
|
||||
cursor = next as MutableTranslationRecord;
|
||||
} else {
|
||||
const fresh: MutableTranslationRecord = {};
|
||||
cursor[part] = fresh;
|
||||
cursor = fresh;
|
||||
}
|
||||
}
|
||||
cursor[parts[parts.length - 1] as string] = value;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AbstractStorage } from "@suaveplan/storage";
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Wire layout for {@link StorageBackend}.
|
||||
*
|
||||
* - `single-key-per-namespace` (default): one storage key per
|
||||
* `(locale, namespace)` pair at `<prefix>/<locale>/<namespace>.json`.
|
||||
* - `single-key-per-locale`: one storage key per locale, holding the
|
||||
* whole `Record<namespace, TranslationRecord>` map.
|
||||
* - `flat-keys`: one storage key per leaf string, addressed by
|
||||
* `<prefix>:<locale>:<namespace>:<dotPath>`.
|
||||
*/
|
||||
export type StorageBackendLayout =
|
||||
| "single-key-per-namespace"
|
||||
| "single-key-per-locale"
|
||||
| "flat-keys";
|
||||
|
||||
/**
|
||||
* Pluggable codec contract. The default codec encodes records as
|
||||
* UTF-8 JSON; consumers can swap in MessagePack, CBOR, or any other
|
||||
* binary format.
|
||||
*/
|
||||
export interface StorageBackendCodec {
|
||||
readonly encode: (record: TranslationRecord) => Uint8Array;
|
||||
readonly decode: (bytes: Uint8Array) => TranslationRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for {@link StorageBackend}.
|
||||
*
|
||||
* - `storage` — required; any `AbstractStorage` instance.
|
||||
* - `layout` — wire layout; defaults to `"single-key-per-namespace"`.
|
||||
* - `prefix` — key prefix; defaults to `"i18n"`.
|
||||
* - `name` — backend label override; defaults to `"storage"`.
|
||||
* - `encode` / `decode` — pluggable codec; defaults to JSON via
|
||||
* `TextEncoder` / `TextDecoder`.
|
||||
*/
|
||||
export interface StorageBackendOptions {
|
||||
readonly storage: AbstractStorage;
|
||||
readonly layout?: StorageBackendLayout;
|
||||
readonly prefix?: string;
|
||||
readonly name?: string;
|
||||
readonly encode?: (record: TranslationRecord) => Uint8Array;
|
||||
readonly decode?: (bytes: Uint8Array) => TranslationRecord;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Feature barrel for `@suaveplan/i18n-backend-storage/backend`. Explicit
|
||||
// named re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
defaultCodec,
|
||||
StorageBackend,
|
||||
type StorageBackendOptionsInput,
|
||||
StorageBackendOptionsSchema,
|
||||
} from "./backend.js";
|
||||
export type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend.types.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
# `storageBackend(options)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Factory wrapper around `new StorageBackend(options)` for callers
|
||||
that prefer fluent builder-style composition over `new`. Returns a
|
||||
fresh backend per call — no internal singleton, no shared state, no
|
||||
caching. The factory exists primarily so chained-backend
|
||||
configurations can be expressed declaratively without the visual
|
||||
weight of repeated `new` keywords.
|
||||
|
||||
The factory is the recommended public entry point for routine use
|
||||
because it composes naturally with the rest of the i18n family
|
||||
(every backend ships a `<name>Backend` factory mirroring its class
|
||||
constructor). Direct `new StorageBackend(...)` remains supported and
|
||||
is exactly equivalent.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns a fresh `StorageBackend` per call.
|
||||
- Forwards every constructor option (`storage`, `layout`, `prefix`,
|
||||
`name`, `encode`, `decode`) unchanged.
|
||||
- Same Zod boundary validation as the class constructor — invalid
|
||||
options throw `I18nError(I18N.INVALID_OPTIONS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const backend = storageBackend({ storage: new MemoryStorage() });
|
||||
|
||||
await backend.save("en", "common", { hello: "Hello" });
|
||||
const record = await backend.read("en", "common");
|
||||
// → { hello: "Hello" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
The factory composes naturally with chained-backend configurations
|
||||
where multiple storages (e.g. an OPFS write-through cache plus an
|
||||
S3 origin) are wired declaratively.
|
||||
|
||||
```ts
|
||||
import { storageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
const cache = storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "single-key-per-locale",
|
||||
name: "cache",
|
||||
});
|
||||
|
||||
const origin = storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
name: "origin",
|
||||
prefix: "translations",
|
||||
});
|
||||
|
||||
// Hand both to a ChainedBackend (Wave 5e).
|
||||
// const chain = chainedBackend({ backends: [cache, origin] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `storageBackend(options: StorageBackendOptions): StorageBackend`
|
||||
|
||||
- `options.storage: AbstractStorage` — required.
|
||||
- `options.layout?: StorageBackendLayout` — wire layout (default
|
||||
`"single-key-per-namespace"`).
|
||||
- `options.prefix?: string` — key prefix (default `"i18n"`).
|
||||
- `options.name?: string` — backend label (default `"storage"`).
|
||||
- `options.encode?: (record) => Uint8Array` — custom encoder.
|
||||
- `options.decode?: (bytes) => TranslationRecord` — custom decoder.
|
||||
- Returns: a fresh `StorageBackend` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The factory is a one-liner: `return new StorageBackend(options)`.
|
||||
It exists for ergonomic parity with the rest of the
|
||||
`<name>Backend` factory pattern across the i18n backend family
|
||||
and adds no behaviour beyond instantiation.
|
||||
- The factory does NOT memoise — every call returns a distinct
|
||||
backend instance. Callers needing shared state pass the same
|
||||
backend reference to multiple call sites; do not call the
|
||||
factory multiple times expecting the same store.
|
||||
- For consumers who need polymorphism over
|
||||
`BackendFactory<TOptions>` from `@suaveplan/types`, this factory
|
||||
satisfies that signature.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { StorageBackend } from "../backend/backend.js";
|
||||
import { storageBackend } from "./factory.js";
|
||||
|
||||
describe("storageBackend factory", () => {
|
||||
it("returns a StorageBackend instance", { timeout: 5000 }, () => {
|
||||
const result = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(result).toBeInstanceOf(StorageBackend);
|
||||
});
|
||||
|
||||
it("default name is 'storage'", { timeout: 5000 }, () => {
|
||||
expect(storageBackend({ storage: new MemoryStorage() }).name).toBe(
|
||||
"storage"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes layout through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = storageBackend({
|
||||
storage,
|
||||
layout: "single-key-per-locale",
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
// single-key-per-locale stores under "i18n/en.json"
|
||||
expect(await storage.get("i18n/en.json")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes prefix through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const backend = storageBackend({ storage, prefix: "translations" });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
expect(await storage.get("translations/en/auth.json")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes name through to the constructor", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
storageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
name: "cache",
|
||||
}).name
|
||||
).toBe("cache");
|
||||
});
|
||||
|
||||
it("passes encode/decode through to the constructor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const SENTINEL = 0xab;
|
||||
const backend = storageBackend({
|
||||
storage,
|
||||
encode: (record) => {
|
||||
const json = new TextEncoder().encode(JSON.stringify(record));
|
||||
const out = new Uint8Array(json.length + 1);
|
||||
out[0] = SENTINEL;
|
||||
out.set(json, 1);
|
||||
return out;
|
||||
},
|
||||
decode: (bytes) => {
|
||||
if (bytes[0] !== SENTINEL) throw new Error("missing sentinel");
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(bytes.slice(1))
|
||||
) as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
const raw = await storage.get("i18n/en/auth.json");
|
||||
expect(raw?.[0]).toBe(SENTINEL);
|
||||
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
||||
});
|
||||
|
||||
it("returns a fresh instance per call (no singleton)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = storageBackend({ storage: new MemoryStorage() });
|
||||
const b = storageBackend({ storage: new MemoryStorage() });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StorageBackend } from "../backend/backend.js";
|
||||
import type { StorageBackendOptions } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* Factory form for callers that prefer builders over `new`. Returns a
|
||||
* fresh `StorageBackend` per call — no internal singleton, no shared
|
||||
* state. Equivalent to `new StorageBackend(options)`.
|
||||
*/
|
||||
export function storageBackend(options: StorageBackendOptions): StorageBackend {
|
||||
return new StorageBackend(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { storageBackend } from "./factory.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
const PACKAGE_JSON_PATH = fileURLToPath(
|
||||
new URL("../package.json", import.meta.url)
|
||||
);
|
||||
|
||||
describe("@suaveplan/i18n-backend-storage — root barrel", () => {
|
||||
it("re-exports StorageBackend", { timeout: 5000 }, () => {
|
||||
expect(typeof root.StorageBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports defaultCodec", { timeout: 5000 }, () => {
|
||||
expect(typeof root.defaultCodec.encode).toBe("function");
|
||||
expect(typeof root.defaultCodec.decode).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports storageBackend factory", { timeout: 5000 }, () => {
|
||||
expect(typeof root.storageBackend).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports StorageBackendOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.StorageBackendOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports observability helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof root.recordRead).toBe("function");
|
||||
expect(typeof root.recordWrite).toBe("function");
|
||||
expect(typeof root.getStorageBackendMetrics).toBe("function");
|
||||
expect(typeof root.getStorageBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetStorageBackendMetricsState).toBe("function");
|
||||
expect(typeof root.resetStorageBackendMetricsCache).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-storage — every subpath resolves", () => {
|
||||
it("subpath barrels resolve through the package", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const backend = await import("./backend/index.js");
|
||||
const factory = await import("./factory/index.js");
|
||||
const observability = await import("./observability/index.js");
|
||||
|
||||
expect(typeof backend.StorageBackend).toBe("function");
|
||||
expect(typeof backend.defaultCodec.encode).toBe("function");
|
||||
expect(backend.StorageBackendOptionsSchema).toBeDefined();
|
||||
expect(typeof factory.storageBackend).toBe("function");
|
||||
expect(typeof observability.getStorageBackendMetrics).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-backend-storage — package.json", () => {
|
||||
it("declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
sideEffects?: boolean;
|
||||
};
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("does not depend on @suaveplan/i18n core", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies ?? {};
|
||||
expect(deps).not.toHaveProperty("@suaveplan/i18n");
|
||||
});
|
||||
|
||||
it("package.json depends on storage but not i18n core", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies ?? {};
|
||||
expect(deps).toHaveProperty("@suaveplan/storage");
|
||||
expect(deps).not.toHaveProperty("@suaveplan/i18n");
|
||||
});
|
||||
|
||||
it("@suaveplan/testing is in devDependencies only (Rule 8)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
expect(pkg.dependencies ?? {}).not.toHaveProperty("@suaveplan/testing");
|
||||
expect(pkg.peerDependencies ?? {}).not.toHaveProperty(
|
||||
"@suaveplan/testing"
|
||||
);
|
||||
expect(pkg.devDependencies ?? {}).toHaveProperty("@suaveplan/testing");
|
||||
});
|
||||
|
||||
it("declares every documented subpath export", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
const documented = [".", "./backend", "./factory", "./observability"];
|
||||
for (const subpath of documented) {
|
||||
expect(pkg.exports?.[subpath]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
25
packages/localization/i18n/i18n-backend-storage/src/index.ts
Normal file
25
packages/localization/i18n/i18n-backend-storage/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Root barrel for @suaveplan/i18n-backend-storage. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
defaultCodec,
|
||||
StorageBackend,
|
||||
type StorageBackendOptionsInput,
|
||||
StorageBackendOptionsSchema,
|
||||
} from "./backend/backend.js";
|
||||
export type {
|
||||
StorageBackendCodec,
|
||||
StorageBackendLayout,
|
||||
StorageBackendOptions,
|
||||
} from "./backend/backend.types.js";
|
||||
export { storageBackend } from "./factory/factory.js";
|
||||
export {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
type StorageBackendMetricSample,
|
||||
type StorageBackendMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
StorageBackendMetricSample,
|
||||
StorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
# `StorageBackend` Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrumentation for
|
||||
`@suaveplan/i18n-backend-storage`. It ships per-call metric emitters
|
||||
(`recordRead`, `recordWrite`) that the backend invokes around every
|
||||
`read` / `save` operation, plus a small in-memory state ring so
|
||||
spec-conformance tests can assert exact attribute payloads without
|
||||
standing up a full SDK + InMemoryMetricExporter pipeline. Every
|
||||
instrument is built lazily and is a no-op when no MeterProvider is
|
||||
registered — consumers pay zero overhead unless they wire up the
|
||||
SDK.
|
||||
|
||||
The metric naming follows the per-backend prefix convention agreed
|
||||
in the Wave 5 design: `i18n.backend.storage.*`. Sibling backend
|
||||
packages emit under their own prefixes so consumers can group
|
||||
dashboard panels per backend without shared-namespace collisions.
|
||||
Every counter and histogram carries a low-cardinality `layout`
|
||||
attribute (`single-key-per-namespace` | `single-key-per-locale` |
|
||||
`flat-keys`) so production teams can drill into per-layout latency
|
||||
or miss-rate.
|
||||
|
||||
## Features
|
||||
|
||||
- Five instruments — two histograms, three counters — covering both
|
||||
read and write paths.
|
||||
- `layout` attribute on every aggregate so per-layout dashboards
|
||||
work without parsing namespaces.
|
||||
- In-memory state ring for test assertions (no exporter required).
|
||||
- Lazy meter construction; idempotent
|
||||
`getStorageBackendMetrics()`.
|
||||
- Test-only `resetStorageBackendMetricsCache()` so suites can
|
||||
rebuild the meter under a fresh provider.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getStorageBackendMetricsState,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-storage/observability";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
resetStorageBackendMetricsState();
|
||||
const backend = new StorageBackend({ storage: new MemoryStorage() });
|
||||
await backend.save("en", "auth", { ok: "Ok" });
|
||||
await backend.read("en", "auth");
|
||||
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{ attributes: { outcome: "hit", layout: "single-key-per-namespace" } },
|
||||
]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A read miss emits both an outcome counter AND a
|
||||
`read_errors_total` counter labelled by error code. Both carry the
|
||||
`layout` attribute so per-layout error rates are charted directly.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getStorageBackendMetricsState,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "@suaveplan/i18n-backend-storage/observability";
|
||||
import { StorageBackend } from "@suaveplan/i18n-backend-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage";
|
||||
|
||||
resetStorageBackendMetricsState();
|
||||
const backend = new StorageBackend({
|
||||
storage: new MemoryStorage(),
|
||||
layout: "flat-keys",
|
||||
});
|
||||
await backend.read("en", "missing").catch(() => undefined);
|
||||
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
layout: "flat-keys",
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Emitted instruments
|
||||
|
||||
| Instrument | Kind | Labels | Unit |
|
||||
|---|---|---|---|
|
||||
| `i18n.backend.storage.read_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
|
||||
| `i18n.backend.storage.read_total` | Counter | `outcome` (hit \| miss \| error), `layout` | count |
|
||||
| `i18n.backend.storage.read_errors_total` | Counter | `code` (I18N error code), `layout` | count |
|
||||
| `i18n.backend.storage.write_duration_ms` | Histogram | `locale`, `namespace`, `outcome`, `layout` | ms |
|
||||
| `i18n.backend.storage.write_total` | Counter | `outcome` (ok \| error), `layout` | count |
|
||||
|
||||
### Functions
|
||||
|
||||
- `getStorageBackendMetrics()` — returns cached instruments; builds
|
||||
them on first call.
|
||||
- `recordRead(locale, namespace, layout, outcome, durationMs, errorCode?)`
|
||||
— emit a read sample.
|
||||
- `recordWrite(locale, namespace, layout, outcome, durationMs)` —
|
||||
emit a write sample.
|
||||
- `getStorageBackendMetricsState()` — frozen-shape snapshot of every
|
||||
emitted sample since last reset.
|
||||
- `resetStorageBackendMetricsState()` — clear the in-memory state
|
||||
ring (test fixture).
|
||||
- `resetStorageBackendMetricsCache()` — drop the cached instruments
|
||||
so a fresh `MeterProvider` can be wired (test fixture).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The state ring is intentionally cardinality-bounded by the
|
||||
caller's test cases. It exists for assertion ergonomics, not for
|
||||
production telemetry — production telemetry flows through the
|
||||
registered OTel `MeterProvider`.
|
||||
- Cardinality safety: aggregate counters use only `outcome` +
|
||||
`layout` (each three or fewer values); per-error-code attribution
|
||||
lives on a separate `read_errors_total` counter so consumers can
|
||||
drop the high-cardinality counter without losing aggregate
|
||||
visibility.
|
||||
- Metric names are stable: every `i18n.backend.storage.*` name is
|
||||
part of the package's public API contract and will not change
|
||||
without a major bump.
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getStorageBackendMetrics,
|
||||
getStorageBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetStorageBackendMetricsCache,
|
||||
resetStorageBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("StorageBackend metrics — instrument lifecycle", () => {
|
||||
it("getStorageBackendMetrics is idempotent (cache hit path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsCache();
|
||||
const a = getStorageBackendMetrics();
|
||||
const b = getStorageBackendMetrics();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetStorageBackendMetricsCache rebuilds instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getStorageBackendMetrics();
|
||||
resetStorageBackendMetricsCache();
|
||||
const b = getStorageBackendMetrics();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — recordRead", () => {
|
||||
it("records duration + total on hit (no error code)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "hit",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "hit",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
});
|
||||
|
||||
it("records error counter when errorCode supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"flat-keys",
|
||||
"miss",
|
||||
0.42,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{ attributes: { outcome: "miss", layout: "flat-keys" } },
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
layout: "flat-keys",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records error outcome", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"single-key-per-locale",
|
||||
"error",
|
||||
0.42,
|
||||
"I18N.BACKEND_READ_FAILED"
|
||||
);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "error",
|
||||
layout: "single-key-per-locale",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.readErrorsTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
code: "I18N.BACKEND_READ_FAILED",
|
||||
layout: "single-key-per-locale",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — recordWrite", () => {
|
||||
it("records duration + total on ok", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.writeDurationMs).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
locale: "en",
|
||||
namespace: "auth",
|
||||
outcome: "ok",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(state.writeTotal).toEqual([
|
||||
{
|
||||
attributes: {
|
||||
outcome: "ok",
|
||||
layout: "single-key-per-namespace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records duration + total on error", { timeout: 5000 }, () => {
|
||||
resetStorageBackendMetricsState();
|
||||
recordWrite("en", "auth", "flat-keys", "error", 0.42);
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.writeTotal).toEqual([
|
||||
{ attributes: { outcome: "error", layout: "flat-keys" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageBackend metrics — state ring helpers", () => {
|
||||
it("resetStorageBackendMetricsState clears every ring", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordRead("en", "auth", "single-key-per-namespace", "hit", 0.1);
|
||||
recordRead(
|
||||
"en",
|
||||
"auth",
|
||||
"single-key-per-namespace",
|
||||
"miss",
|
||||
0.1,
|
||||
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
|
||||
);
|
||||
recordWrite("en", "auth", "single-key-per-namespace", "ok", 0.1);
|
||||
resetStorageBackendMetricsState();
|
||||
const state = getStorageBackendMetricsState();
|
||||
expect(state.readDurationMs).toEqual([]);
|
||||
expect(state.readTotal).toEqual([]);
|
||||
expect(state.readErrorsTotal).toEqual([]);
|
||||
expect(state.writeDurationMs).toEqual([]);
|
||||
expect(state.writeTotal).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
import type { StorageBackendLayout } from "../backend/backend.types.js";
|
||||
|
||||
/**
|
||||
* OTel instruments for `@suaveplan/i18n-backend-storage`.
|
||||
*
|
||||
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-storage')`.
|
||||
* All instruments are no-ops when no MeterProvider is registered (the
|
||||
* default), so consumers pay zero overhead unless they wire up the
|
||||
* SDK.
|
||||
*
|
||||
* In addition to emitting via OTel, this module retains a small
|
||||
* in-memory state ring so spec-conformance tests can assert exact
|
||||
* attribute payloads without standing up a full SDK +
|
||||
* InMemoryMetricExporter pipeline. Tests call
|
||||
* `resetStorageBackendMetricsState()` between cases and read
|
||||
* `getStorageBackendMetricsState()` to inspect counters / histogram
|
||||
* counts.
|
||||
*/
|
||||
|
||||
export interface StorageBackendMetricSample {
|
||||
readonly attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StorageBackendMetricsState {
|
||||
readonly readDurationMs: StorageBackendMetricSample[];
|
||||
readonly readTotal: StorageBackendMetricSample[];
|
||||
readonly readErrorsTotal: StorageBackendMetricSample[];
|
||||
readonly writeDurationMs: StorageBackendMetricSample[];
|
||||
readonly writeTotal: StorageBackendMetricSample[];
|
||||
}
|
||||
|
||||
interface CachedInstruments {
|
||||
readonly readDurationMs: Histogram;
|
||||
readonly readTotal: Counter;
|
||||
readonly readErrorsTotal: Counter;
|
||||
readonly writeDurationMs: Histogram;
|
||||
readonly writeTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | undefined;
|
||||
|
||||
const state: StorageBackendMetricsState = {
|
||||
readDurationMs: [],
|
||||
readTotal: [],
|
||||
readErrorsTotal: [],
|
||||
writeDurationMs: [],
|
||||
writeTotal: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build (or return cached) OTel instruments. Idempotent — safe to
|
||||
* call from any production code path.
|
||||
*/
|
||||
export function getStorageBackendMetrics(): CachedInstruments {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-backend-storage");
|
||||
cached = {
|
||||
readDurationMs: meter.createHistogram(
|
||||
"i18n.backend.storage.read_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a StorageBackend.read call; labels: locale, namespace, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
readTotal: meter.createCounter("i18n.backend.storage.read_total", {
|
||||
description:
|
||||
"StorageBackend.read call count; labels: outcome (hit | miss | error), layout.",
|
||||
}),
|
||||
readErrorsTotal: meter.createCounter(
|
||||
"i18n.backend.storage.read_errors_total",
|
||||
{
|
||||
description:
|
||||
"StorageBackend.read error count; labels: code (the I18N error code), layout.",
|
||||
}
|
||||
),
|
||||
writeDurationMs: meter.createHistogram(
|
||||
"i18n.backend.storage.write_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of a StorageBackend.save call; labels: locale, namespace, outcome, layout.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
writeTotal: meter.createCounter("i18n.backend.storage.write_total", {
|
||||
description:
|
||||
"StorageBackend.save call count; labels: outcome (ok | error), layout.",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `read` call's duration + outcome. Mirrors the
|
||||
* `read_total{outcome,layout}` and per-error-code
|
||||
* `read_errors_total{code,layout}` counters and pushes one sample
|
||||
* into the in-memory state ring per counter touched.
|
||||
*/
|
||||
export function recordRead(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
layout: StorageBackendLayout,
|
||||
outcome: "hit" | "miss" | "error",
|
||||
durationMs: number,
|
||||
errorCode?: string
|
||||
): void {
|
||||
const ins = getStorageBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
layout,
|
||||
};
|
||||
ins.readDurationMs.record(durationMs, histogramAttrs);
|
||||
state.readDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { outcome, layout };
|
||||
ins.readTotal.add(1, aggregate);
|
||||
state.readTotal.push({ attributes: aggregate });
|
||||
if (errorCode !== undefined) {
|
||||
const errorAttrs: Record<string, string> = { code: errorCode, layout };
|
||||
ins.readErrorsTotal.add(1, errorAttrs);
|
||||
state.readErrorsTotal.push({ attributes: errorAttrs });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a `save` call's duration + outcome. Mirrors the
|
||||
* `write_total{outcome,layout}` counter into the in-memory state
|
||||
* ring.
|
||||
*/
|
||||
export function recordWrite(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
layout: StorageBackendLayout,
|
||||
outcome: "ok" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = getStorageBackendMetrics();
|
||||
const histogramAttrs: Record<string, string> = {
|
||||
locale,
|
||||
namespace,
|
||||
outcome,
|
||||
layout,
|
||||
};
|
||||
ins.writeDurationMs.record(durationMs, histogramAttrs);
|
||||
state.writeDurationMs.push({ attributes: histogramAttrs });
|
||||
const aggregate: Record<string, string> = { outcome, layout };
|
||||
ins.writeTotal.add(1, aggregate);
|
||||
state.writeTotal.push({ attributes: aggregate });
|
||||
}
|
||||
|
||||
/** Return a frozen snapshot of the in-memory state. */
|
||||
export function getStorageBackendMetricsState(): StorageBackendMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reset the in-memory state ring. Tests call this between cases. */
|
||||
export function resetStorageBackendMetricsState(): void {
|
||||
state.readDurationMs.length = 0;
|
||||
state.readTotal.length = 0;
|
||||
state.readErrorsTotal.length = 0;
|
||||
state.writeDurationMs.length = 0;
|
||||
state.writeTotal.length = 0;
|
||||
}
|
||||
|
||||
/** Test-only: drop the cached instruments so a fresh meter can be built. */
|
||||
export function resetStorageBackendMetricsCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user