first commit
This commit is contained in:
@@ -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,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,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.
|
||||
Reference in New Issue
Block a user