12 KiB
Capability Spec: i18n-backend-storage (i18n-backend-storage)
Package: @suaveplan/i18n-backend-storage
Status: ACTIVE
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
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 }).capabilitiesis 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
nameequals"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
StorageBackendwithMemoryStorageandlayout: "single-key-per-namespace" - WHEN
backend.save("en", "common", { hello: "Hi" })is awaited thenbackend.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
StorageBackendwithMemoryStorageandlayout: "single-key-per-locale" - WHEN
backend.save("en", "common", { x: "y" })andbackend.save("en", "errors", { e: "f" })are awaited thenbackend.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
StorageBackendwithMemoryStorageandlayout: "flat-keys" - WHEN
backend.save("en", "common", { home: { title: "Hello" } })is awaited thenbackend.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
I18nErrorwith 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
StorageBackendwhose injected storage'ssetrejects withError("disk full") - WHEN
backend.save("en", "common", { x: "y" })is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_WRITE_FAILED"AND the error's.cause.messageequals"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 amissingkey
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
StorageBackendwhose 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
AbortControllerwhose signal is already aborted - WHEN
backend.read("en", "common", { signal })is awaited - THEN the call throws
I18nErrorwith 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
StorageBackendwithencode: (r) => msgpack.encode(r)anddecode: (b) => msgpack.decode(b) - WHEN
backend.save(...)thenbackend.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
I18nErrorwith 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
I18nErrorwith 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 asingle-key-per-localebackend - THEN
i18n.backend.storage.read_totalincrements withoutcome="hit"ANDlayout="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—StorageBackendclass only@suaveplan/i18n-backend-storage/factory—storageBackendfactory only@suaveplan/i18n-backend-storage/codec—defaultCodecandStorageBackendCodectype only@suaveplan/i18n-backend-storage/layout-helpers—flattenLeaves,assignAtDotPath,defaultPrefixonly
Scenario: every subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Scenario: package.json depends on @suaveplan/storage but not @suaveplan/i18n
- WHEN
package.jsondependenciesis parsed - THEN the result contains
@suaveplan/storageAND 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"