Files
2026-05-27 23:47:53 +01:00

6.4 KiB

StorageBackend

(no submodules)

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)

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

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

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

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

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.