Files
suaveplan/openspec/archive/2026-05-09-i18n-backend-storage/specs/i18n-backend-storage/spec.md
2026-05-27 23:29:53 +01:00

12 KiB

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 <prefix>/<locale>/<namespace>.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 <prefix>/<locale>.json holding a Record<namespace, TranslationRecord> 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 <prefix>:<locale>:<namespace>:<dotPath>. 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<namespace, TranslationRecord> 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"