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

201 lines
9.9 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`