# Capability Spec: i18n-backend-storage (i18n-backend-storage) **Package**: `@suaveplan/i18n-backend-storage` **Status**: PROPOSED — first delta; becomes canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: StorageBackend implements the Backend interface The package SHALL export a `StorageBackend` class that implements the `Backend` interface from `@suaveplan/types/i18n` (universal-tier; tier follows the injected `AbstractStorage`). The class SHALL declare a `capabilities` matrix of `{ canRead: true, canReadMulti: true, canSave: true, canTrackModification: true, canWatch: false }`. The class SHALL accept a required `StorageBackendOptions` constructor argument carrying `{ storage, layout?, prefix?, name?, encode?, decode? }`. Default `name` SHALL equal `"storage"`. Default `layout` SHALL equal `"single-key-per-namespace"`. Default `prefix` SHALL equal `"i18n"`. #### Scenario: capabilities matrix matches the contract - **WHEN** `new StorageBackend({ storage }).capabilities` is read - **THEN** the result equals `{ canRead: true, canReadMulti: true, canSave: true, canTrackModification: true, canWatch: false }` #### Scenario: defaults are applied - **WHEN** `new StorageBackend({ storage })` is constructed - **THEN** `name` equals `"storage"`, layout effectively equals `"single-key-per-namespace"`, and prefix equals `"i18n"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"capabilities matrix matches the contract"` AND `packages/i18n/i18n-backend-storage/src/backend.test.ts::"defaults are applied"` --- ### Requirement: single-key-per-namespace layout When `layout === "single-key-per-namespace"`, the backend SHALL key one storage entry per `(locale, namespace)` pair at `//.json`. `read` SHALL fetch exactly one storage key and decode it via the configured codec. #### Scenario: single-key-per-namespace round-trip - **GIVEN** a `StorageBackend` with `MemoryStorage` and `layout: "single-key-per-namespace"` - **WHEN** `backend.save("en", "common", { hello: "Hi" })` is awaited then `backend.read("en", "common")` is awaited - **THEN** the read result equals `{ hello: "Hi" }` and the underlying storage key is `"i18n/en/common.json"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"single-key-per-namespace round-trip"` --- ### Requirement: single-key-per-locale layout When `layout === "single-key-per-locale"`, the backend SHALL key one storage entry per locale at `/.json` holding a `Record` map. `read` SHALL fetch the locale map and project the requested namespace; missing namespaces SHALL throw `BACKEND_LOCALE_NOT_AVAILABLE`. `save` SHALL read-modify-write the locale map. #### Scenario: single-key-per-locale round-trip - **GIVEN** a `StorageBackend` with `MemoryStorage` and `layout: "single-key-per-locale"` - **WHEN** `backend.save("en", "common", { x: "y" })` and `backend.save("en", "errors", { e: "f" })` are awaited then `backend.read("en", "errors")` is awaited - **THEN** the read result equals `{ e: "f" }` and the underlying storage key is `"i18n/en.json"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"single-key-per-locale round-trip"` --- ### Requirement: flat-keys layout When `layout === "flat-keys"`, the backend SHALL key one storage entry per leaf string at `:::`. `read` SHALL list all keys under the prefix and assemble the nested record via `assignAtDotPath`. `save` SHALL flatten the record via `flattenLeaves` and write each leaf as a separate key. `lastModified` SHALL return the maximum `modifiedAt` across all leaves. #### Scenario: flat-keys round-trip with nested record - **GIVEN** a `StorageBackend` with `MemoryStorage` and `layout: "flat-keys"` - **WHEN** `backend.save("en", "common", { home: { title: "Hello" } })` is awaited then `backend.read("en", "common")` is awaited - **THEN** the read result equals `{ home: { title: "Hello" } }` and the underlying storage key `"i18n:en:common:home.title"` exists **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"flat-keys round-trip with nested record"` --- ### Requirement: read miss throws BACKEND_LOCALE_NOT_AVAILABLE For every layout, `read` SHALL throw `I18nError` with code `I18N.BACKEND_LOCALE_NOT_AVAILABLE` when the requested `(locale, namespace)` pair has no stored value. The error context SHALL include `{ locale, namespace, layout, backend }`. #### Scenario: missing key throws BACKEND_LOCALE_NOT_AVAILABLE - **GIVEN** an empty `StorageBackend` (any layout) - **WHEN** `backend.read("xx", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_LOCALE_NOT_AVAILABLE"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"missing key throws BACKEND_LOCALE_NOT_AVAILABLE"` --- ### Requirement: storage failure throws BACKEND_WRITE_FAILED When the underlying `AbstractStorage.set` throws, `save` SHALL surface the failure as `I18nError` with code `I18N.BACKEND_WRITE_FAILED` and SHALL preserve the original error as `cause`. #### Scenario: storage set failure throws BACKEND_WRITE_FAILED - **GIVEN** a `StorageBackend` whose injected storage's `set` rejects with `Error("disk full")` - **WHEN** `backend.save("en", "common", { x: "y" })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_WRITE_FAILED"` AND the error's `.cause.message` equals `"disk full"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"storage set failure throws BACKEND_WRITE_FAILED"` --- ### Requirement: readMulti per-layout dispatch The `readMulti(locale, namespaces, signal?)` method SHALL return a `Record` map containing only the namespaces present. For `single-key-per-locale`, it SHALL fetch the locale map once and project requested namespaces. For other layouts, it SHALL fan out concurrent `read` calls and omit namespaces that throw `BACKEND_LOCALE_NOT_AVAILABLE`. #### Scenario: readMulti omits missing namespaces - **GIVEN** a `StorageBackend` (any layout) with only `(en, common)` saved - **WHEN** `backend.readMulti("en", ["common", "missing"])` is awaited - **THEN** the result is `{ common: {...} }` and does NOT contain a `missing` key **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"readMulti omits missing namespaces"` --- ### Requirement: lastModified returns the underlying storage timestamp `lastModified(locale, namespace)` SHALL return the `modifiedAt` field from `AbstractStorage.getMetadata` for the resolved key (or the maximum across leaves for `flat-keys`). It SHALL return `undefined` rather than throw when metadata is missing. #### Scenario: lastModified returns metadata timestamp - **GIVEN** a `StorageBackend` whose storage records `{ modifiedAt: 1234 }` for the saved key - **WHEN** `backend.lastModified("en", "common")` is awaited - **THEN** the result equals `1234` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"lastModified returns metadata timestamp"` --- ### Requirement: AbortSignal cancellation throws BACKEND_TIMEOUT Every async method (`read`, `readMulti`, `save`) SHALL accept an optional `signal?: AbortSignal` parameter. When the signal is already aborted at call time, the method SHALL throw `I18nError` with code `I18N.BACKEND_TIMEOUT` and SHALL NOT touch the underlying storage. #### Scenario: pre-aborted signal aborts read - **GIVEN** an `AbortController` whose signal is already aborted - **WHEN** `backend.read("en", "common", { signal })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_TIMEOUT"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"` --- ### Requirement: Custom codec injection The constructor options `encode?` and `decode?` SHALL be invoked instead of the default JSON codec when supplied. The default codec SHALL accept JSON object payloads and SHALL reject arrays / null / primitives with `BACKEND_READ_FAILED`. #### Scenario: custom encode/decode is used - **GIVEN** a `StorageBackend` with `encode: (r) => msgpack.encode(r)` and `decode: (b) => msgpack.decode(b)` - **WHEN** `backend.save(...)` then `backend.read(...)` round-trips - **THEN** the round-trip preserves the record AND the underlying storage bytes are MessagePack-encoded #### Scenario: default codec rejects array - **WHEN** `defaultCodec.decode(new TextEncoder().encode("[]"))` is called - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"custom encode/decode is used"` AND `packages/i18n/i18n-backend-storage/src/codec.test.ts::"default codec rejects array"` --- ### Requirement: storageBackend factory function The package SHALL export `storageBackend(options: StorageBackendOptions): StorageBackend` returning `new StorageBackend(options)`. #### Scenario: factory returns StorageBackend instance - **WHEN** `storageBackend({ storage })` is called - **THEN** the result satisfies `result instanceof StorageBackend` **Verified by:** `packages/i18n/i18n-backend-storage/src/factory.test.ts::"factory returns StorageBackend instance"` --- ### Requirement: Boundary validation via Zod The constructor SHALL validate `StorageBackendOptions` via `StorageBackendOptionsSchema` (Zod) and SHALL throw `I18nError(I18N.INVALID_OPTIONS)` on invalid input. Missing `storage` SHALL throw `INVALID_OPTIONS`. #### Scenario: missing storage throws INVALID_OPTIONS - **WHEN** `new StorageBackend({} as unknown as StorageBackendOptions)` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.test.ts::"missing storage throws INVALID_OPTIONS"` --- ### Requirement: OpenTelemetry instrumentation with layout attribute The backend SHALL emit `i18n.backend.storage.read_duration_ms` (histogram), `i18n.backend.storage.read_total{outcome,layout}` (counter), `i18n.backend.storage.read_errors_total{code,layout}` (counter), `i18n.backend.storage.write_duration_ms` (histogram), and `i18n.backend.storage.write_total{outcome,layout}` (counter). The `layout` attribute SHALL be one of the three documented enum values. #### Scenario: read emits layout attribute - **WHEN** `backend.read("en", "common")` succeeds on a `single-key-per-locale` backend - **THEN** `i18n.backend.storage.read_total` increments with `outcome="hit"` AND `layout="single-key-per-locale"` **Verified by:** `packages/i18n/i18n-backend-storage/src/backend.spec-conformance.test.ts::"read emits layout attribute"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can import only the surfaces they need: - `@suaveplan/i18n-backend-storage` — root barrel - `@suaveplan/i18n-backend-storage/backend` — `StorageBackend` class only - `@suaveplan/i18n-backend-storage/factory` — `storageBackend` factory only - `@suaveplan/i18n-backend-storage/codec` — `defaultCodec` and `StorageBackendCodec` type only - `@suaveplan/i18n-backend-storage/layout-helpers` — `flattenLeaves`, `assignAtDotPath`, `defaultPrefix` only #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-backend-storage/src/index.test.ts::"every documented subpath resolves"` --- ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT depend on `@suaveplan/i18n` core nor on any other backend package. The package MAY depend on `@suaveplan/storage` (the only such dep among the universal backends). #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` #### Scenario: package.json depends on @suaveplan/storage but not @suaveplan/i18n - **WHEN** `package.json` `dependencies` is parsed - **THEN** the result contains `@suaveplan/storage` AND does NOT contain `@suaveplan/i18n` **Verified by:** `packages/i18n/i18n-backend-storage/src/index.test.ts::"package.json declares sideEffects false"` AND `packages/i18n/i18n-backend-storage/src/index.test.ts::"package.json depends on storage but not i18n core"`