Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
<!-- markdownlint-disable MD024 -->
# 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"`