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

9.9 KiB

Capability Spec: i18n-backend-memory (i18n-backend-memory)

Package: @suaveplan/i18n-backend-memory Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

Requirements

Requirement: MemoryBackend implements the Backend interface

The package SHALL export a MemoryBackend class that implements the Backend interface from @suaveplan/types/i18n (universal-tier, browser + server). The class SHALL declare a static capabilities matrix of { canRead: true, canReadMulti: true, canSave: true, canTrackModification: true, canWatch: false } and SHALL accept an optional MemoryBackendOptions constructor argument carrying { seed?, name?, now? }. Default name SHALL equal "memory". Default now SHALL equal Date.now.

Scenario: capabilities matrix matches the contract

  • WHEN new MemoryBackend().capabilities is read
  • THEN the result equals { canRead: true, canReadMulti: true, canSave: true, canTrackModification: true, canWatch: false }

Scenario: default name is "memory"

  • WHEN new MemoryBackend().name is read
  • THEN the value equals "memory"

Scenario: custom name override

  • WHEN new MemoryBackend({ name: "cache" }).name is read
  • THEN the value equals "cache"

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"capabilities matrix matches the contract" AND packages/i18n/i18n-backend-memory/src/backend.test.ts::"default name is memory" AND packages/i18n/i18n-backend-memory/src/backend.test.ts::"custom name override"


Requirement: read returns the seeded record on hit

The read(locale, namespace, signal?) method SHALL return the TranslationRecord previously seeded for the (locale, namespace) pair. Lookup SHALL be O(1) via the underlying Map<locale, Map<namespace, TranslationRecord>> data structure.

Scenario: read hit returns seeded record

  • GIVEN a MemoryBackend constructed with { seed: { en: { common: { hello: "Hello" } } } }
  • WHEN backend.read("en", "common") is awaited
  • THEN the result equals { hello: "Hello" }

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"read hit returns seeded record"


Requirement: read miss throws BACKEND_LOCALE_NOT_AVAILABLE

The read method SHALL throw I18nError with code I18N.BACKEND_LOCALE_NOT_AVAILABLE when the (locale, namespace) pair has no record. The error context SHALL include { locale, namespace, backend } so chained-backend orchestration can log the missed lookup.

Scenario: read miss throws BACKEND_LOCALE_NOT_AVAILABLE

  • GIVEN an empty MemoryBackend
  • WHEN backend.read("en", "common") is awaited
  • THEN the call throws I18nError with code "I18N.BACKEND_LOCALE_NOT_AVAILABLE"

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"read miss throws BACKEND_LOCALE_NOT_AVAILABLE"


Requirement: readMulti returns partial records without throwing

The readMulti(locale, namespaces, signal?) method SHALL return a Record<namespace, TranslationRecord> map containing only the namespaces that exist for the locale. Missing namespaces SHALL be omitted from the map (NOT undefined values, NOT thrown errors). When the locale is entirely absent, the method SHALL return {}.

Scenario: readMulti omits missing namespaces

  • GIVEN a MemoryBackend seeded with { en: { common: {...}, errors: {...} } }
  • WHEN backend.readMulti("en", ["common", "missing"]) is awaited
  • THEN the result is { common: {...} } and does NOT contain a missing key

Scenario: readMulti returns empty for absent locale

  • WHEN backend.readMulti("xx", ["any"]) is awaited
  • THEN the result is {}

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"readMulti omits missing namespaces" AND packages/i18n/i18n-backend-memory/src/backend.test.ts::"readMulti returns empty for absent locale"


Requirement: save round-trip with deterministic lastModified

The save(locale, namespace, record, signal?) method SHALL persist the record into the in-memory store and SHALL update the modification ledger to the value of the injected now() function. A subsequent lastModified(locale, namespace) call SHALL return that exact timestamp.

Scenario: save then read round-trips

  • WHEN backend.save("en", "common", { hello: "Hi" }) is awaited and then backend.read("en", "common") is awaited
  • THEN the read result equals { hello: "Hi" }

Scenario: lastModified returns the injected timestamp

  • GIVEN a MemoryBackend constructed with { now: () => 1234 }
  • WHEN backend.save("en", "common", { x: "y" }) is awaited and then backend.lastModified("en", "common") is awaited
  • THEN the result equals 1234

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"save then read round-trips" AND packages/i18n/i18n-backend-memory/src/backend.test.ts::"lastModified returns the injected 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 mutate state.

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-memory/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"


Requirement: memoryBackend factory function

The package SHALL export a memoryBackend(options?: MemoryBackendOptions): MemoryBackend factory function that returns new MemoryBackend(options). The factory exists so consumers can compose backends fluently without new.

Scenario: factory returns MemoryBackend instance

  • WHEN memoryBackend() is called
  • THEN the result satisfies result instanceof MemoryBackend

Scenario: factory passes seed through

  • WHEN memoryBackend({ seed: { en: { common: { x: "y" } } } }).read("en", "common") is awaited
  • THEN the result equals { x: "y" }

Verified by: packages/i18n/i18n-backend-memory/src/factory.test.ts::"factory returns MemoryBackend instance" AND packages/i18n/i18n-backend-memory/src/factory.test.ts::"factory passes seed through"


Requirement: Boundary validation via Zod

The constructor SHALL validate MemoryBackendOptions via MemoryBackendOptionsSchema (Zod) and SHALL throw I18nError(I18N.INVALID_OPTIONS) on invalid input.

Scenario: invalid options throw INVALID_OPTIONS

  • WHEN new MemoryBackend({ seed: "not an object" as unknown as MemorySeed }) is called
  • THEN the call throws I18nError with code "I18N.INVALID_OPTIONS"

Verified by: packages/i18n/i18n-backend-memory/src/backend.test.ts::"invalid options throw INVALID_OPTIONS"


Requirement: OpenTelemetry instrumentation

The backend SHALL emit i18n.backend.memory.read_duration_ms (histogram, milliseconds), i18n.backend.memory.read_total{outcome="hit"|"miss"|"error"} (counter), i18n.backend.memory.read_errors_total{code} (counter), i18n.backend.memory.write_duration_ms (histogram), and i18n.backend.memory.write_total{outcome="ok"|"error"} (counter). The histograms SHALL be observed via performance.now() deltas around each call.

Scenario: read emits read_total counter on hit

  • WHEN backend.read("en", "common") succeeds
  • THEN i18n.backend.memory.read_total increments with outcome="hit"

Scenario: read emits read_total counter on miss

  • WHEN backend.read("en", "missing") throws BACKEND_LOCALE_NOT_AVAILABLE
  • THEN i18n.backend.memory.read_total increments with outcome="miss"

Scenario: save emits write_total counter

  • WHEN backend.save("en", "common", {...}) succeeds
  • THEN i18n.backend.memory.write_total increments with outcome="ok"

Verified by: packages/i18n/i18n-backend-memory/src/backend.spec-conformance.test.ts::"read emits read_total counter on hit" AND packages/i18n/i18n-backend-memory/src/backend.spec-conformance.test.ts::"read emits read_total counter on miss" AND packages/i18n/i18n-backend-memory/src/backend.spec-conformance.test.ts::"save emits write_total counter"


Requirement: Subpath exports

The package SHALL declare subpath exports so consumers can import only the surfaces they need:

  • @suaveplan/i18n-backend-memory — root barrel (class + factory + types)
  • @suaveplan/i18n-backend-memory/backend — MemoryBackend class only
  • @suaveplan/i18n-backend-memory/factory — memoryBackend factory function 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-memory/src/index.test.ts::"every documented subpath resolves"


Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json so bundlers can tree-shake unused exports, and SHALL NOT depend on @suaveplan/i18n core nor on any other backend package.

Scenario: package.json declares sideEffects false

  • WHEN package.json is parsed
  • THEN the sideEffects field equals false

Scenario: package.json does not depend on @suaveplan/i18n core

  • WHEN package.json dependencies is parsed
  • THEN the result does NOT contain @suaveplan/i18n

Verified by: packages/i18n/i18n-backend-memory/src/index.test.ts::"package.json declares sideEffects false" AND packages/i18n/i18n-backend-memory/src/index.test.ts::"package.json does not depend on i18n core"