Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,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.