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,canTrackModificationalltrue;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?: AbortSignalparameter onread,readMulti, andsave; pre-aborted signals throwI18nError(I18N.BACKEND_TIMEOUT)without touching the underlying storage. - OTel histograms + counters emitted on every read/save with a
layoutattribute so per-layout latency can be charted. - Zod boundary validation: invalid options throw
I18nError(I18N.INVALID_OPTIONS)at construction time. BACKEND_LOCALE_NOT_AVAILABLEon miss (chained backends fall through);BACKEND_READ_FAILED/BACKEND_WRITE_FAILEDon 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 viaStorageBackendOptionsSchema; throwsI18nError(I18N.INVALID_OPTIONS)on schema mismatch or missingstorage.name: string— backend label (default"storage").capabilities: BackendCapabilities— frozen 5-flag matrix.read(locale, namespace, signal?): Promise<TranslationRecord>— resolves with the stored record; throwsI18nError(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 asBACKEND_WRITE_FAILED.lastModified(locale, namespace): Promise<number | undefined>— returns the underlying storage'smodifiedAtfield, orundefinedif 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/TextDecoderandJSON.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-keysreads viaAbstractStorage.list({ prefix }). List is required by every concrete storage, so the layout works against all of them. Saves write each leaf with its ownsetcall — for backends that batch writes, prefersingle-key-per-namespaceto amortise the batch.lastModifiedforflat-keystakes the maximummodifiedAtacross leaves. For other layouts it consults the namespace or locale key directly. All paths swallow storage exceptions and returnundefinedrather than throw, mirroring the contract thatlastModifiedis a hint, not a guarantee.signal?.abortedis checked at the entry of every async method. The throw surfaces asI18nError(I18N.BACKEND_TIMEOUT). The flag is sampled exactly once at the top — once the underlyingAbstractStorageoperation begins, abort propagation is the storage's responsibility.- OTel
recordRead/recordWritecalls emit zero overhead unless aMeterProvideris registered. Tests use the in-memory metrics state ring exposed by./observabilityto assert payloads without standing up the full SDK. Thelayoutattribute is a three-value low-cardinality enum.