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,113 @@
# `@suaveplan/i18n-backend-storage` — AbstractStorage-backed translation backend
**Package**: `@suaveplan/i18n-backend-storage` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port from monolith)
**Date**: 2026-05-05
**Wave**: 5
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md`
**Path**: `packages/i18n/i18n-backend-storage/`
## Why
The Wave 5 backend split lifts each `Backend` implementation from the `@suaveplan/i18n` monolith. The storage backend bridges the `Backend` plugin contract to any `AbstractStorage` implementation — `MemoryStorage`, `OPFSStorage`, `IndexedDBStorage`, `S3Storage`, `NodeFsStorage`, `SqliteStorage`, `GarageStorage`. It is the canonical persistence layer for browser-tier consumers (offline-first via OPFS / IndexedDB), for server-tier consumers (Garage / S3 in production), and for fixture-driven tests (`MemoryStorage`).
A key reason for splitting this backend out is **dependency isolation**: today `@suaveplan/storage` is a hard transitive dep of every `@suaveplan/i18n` consumer just because of this one backend. After the split, only consumers who explicitly install `@suaveplan/i18n-backend-storage` pull `@suaveplan/storage`. Browser-tier apps using only the HTTP backend save the install cost.
The `Backend` interface itself moves to `@suaveplan/types/i18n` in Wave 1. `@suaveplan/i18n-backend-storage` implements `Backend` from that location and does NOT depend on `@suaveplan/i18n` core. Runtime atoms come from `@suaveplan/i18n-shared`.
## What changes
### New package: `@suaveplan/i18n-backend-storage`
- ADDED: `packages/i18n/i18n-backend-storage/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
- ADDED: `src/backend.ts` — `StorageBackend` class implementing `Backend` from `@suaveplan/types/i18n`. Source: `packages/i18n/i18n/src/core/backend/storage/storage-backend.ts` L75-L359. Capabilities: `canRead: true`, `canReadMulti: true`, `canSave: true`, `canTrackModification: true`, `canWatch: false`. Three wire layouts: `single-key-per-namespace` (default), `single-key-per-locale`, `flat-keys`.
- ADDED: `src/backend.types.ts` — `StorageBackendOptions`, `StorageBackendLayout`, `StorageBackendCodec`. Source: monolith L12-L34.
- ADDED: `src/backend.schemas.ts` — `StorageBackendOptionsSchema` Zod schema. Source: monolith `storage-backend.schemas.ts` (25 lines).
- ADDED: `src/factory.ts` — `storageBackend(options: StorageBackendOptions): StorageBackend` factory. Source: monolith L403-L405.
- ADDED: `src/codec.ts` — `defaultCodec` (JSON via `TextEncoder` / `TextDecoder`) lifted as a standalone export so consumers can compose custom codecs. Source: monolith L36-L56.
- ADDED: `src/layout-helpers.ts` — `flattenLeaves`, `assignAtDotPath`, `defaultPrefix` lifted as standalone helpers. Source: monolith L361-L400.
- ADDED: `src/observability.ts` — emits `i18n.backend.storage.read_duration_ms`, `i18n.backend.storage.read_total{outcome,layout}`, `i18n.backend.storage.read_errors_total{code,layout}`, `i18n.backend.storage.write_duration_ms`, `i18n.backend.storage.write_total{outcome,layout}`. The `layout` attribute is a low-cardinality enum ("single-key-per-namespace" | "single-key-per-locale" | "flat-keys").
- ADDED: `src/backend.test.ts`, `src/backend.spec-conformance.test.ts`, `src/factory.test.ts`, `src/codec.test.ts`, `src/layout-helpers.test.ts`, `src/index.test.ts` — Vitest suites verbatim-ported from monolith plus new spec-conformance suites.
- ADDED: `src/backend.md`, `src/factory.md`, `src/codec.md`, `src/layout-helpers.md` — co-located docs.
- ADDED: `src/index.ts` — explicit named re-exports.
### Subpath exports
- `.` — root barrel
- `./backend` — `StorageBackend` class only
- `./factory` — `storageBackend` factory function only
- `./codec` — `defaultCodec` + `StorageBackendCodec` type only
- `./layout-helpers` — `flattenLeaves`, `assignAtDotPath`, `defaultPrefix` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — for `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^` — for `Backend`, `BackendCapabilities`, `TranslationRecord` from `@suaveplan/types/i18n`
- `@suaveplan/storage workspace:^` — for `AbstractStorage` interface (this is the one extra workspace dep that distinguishes storage from the other universal backends)
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms` — for `StorageBackendOptionsSchema`
- **No** dep on `@suaveplan/i18n` core. **No** dep on any other backend package.
### Tier
Universal. `AbstractStorage` is itself a universal abstraction — concrete storages decide their tier (e.g. `OPFSStorage` is browser-only, `NodeFsStorage` is server-only, `MemoryStorage` is universal). The backend itself is tier-neutral.
## Out of scope
- **Removing the storage backend from `packages/i18n/i18n/src/core/backend/storage/`** — happens in Wave 6.
- **Encryption-at-rest layer** — out of scope; consumers wire `EncryptedStorage` from a future package or rely on the storage implementation.
- **Watch / change-notification** — `canWatch: false` is intentional; the underlying `AbstractStorage` does not currently surface change events.
- **Schema evolution** — `flat-keys` layout has no built-in schema migration. Consumers handle versioning at the catalog layer.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `StorageBackend` class + capabilities matrix | `packages/i18n/i18n/src/core/backend/storage/storage-backend.ts` | L75-L113 | Verbatim port; rewire imports |
| `StorageBackend.read` (layout dispatch + outcome telemetry) | same file | L115-L142 | Verbatim port |
| `#readInternal` (per-layout dispatch) | same file | L144-L179 | Verbatim port |
| `StorageBackend.readMulti` | same file | L181-L216 | Verbatim port |
| `StorageBackend.save` | same file | L218-L247 | Verbatim port |
| `StorageBackend.lastModified` | same file | L249-L275 | Verbatim port |
| Layout key helpers (`#nsKey`, `#localeKey`) | same file | L279-L285 | Inline private methods preserved |
| `#readLocaleMap` | same file | L287-L308 | Verbatim port |
| `#readFlatKeys` | same file | L310-L327 | Verbatim port |
| `#saveFlatKeys` | same file | L329-L343 | Verbatim port |
| `#missing` helper (constructs `BACKEND_LOCALE_NOT_AVAILABLE`) | same file | L345-L358 | Verbatim port |
| `defaultCodec` (JSON encode/decode) | same file | L36-L56 | Lifted to `src/codec.ts` |
| `defaultPrefix` | same file | L361-L363 | Lifted to `src/layout-helpers.ts` |
| `flattenLeaves` | same file | L365-L379 | Lifted to `src/layout-helpers.ts` |
| `assignAtDotPath` | same file | L381-L400 | Lifted to `src/layout-helpers.ts` |
| `StorageBackendOptions`, `StorageBackendLayout`, `StorageBackendCodec` types | same file | L12-L34 | Verbatim port to `backend.types.ts` |
| `storageBackend` factory | same file | L403-L405 | Lifted to `factory.ts` |
| `StorageBackendOptionsSchema` | `packages/i18n/i18n/src/core/backend/storage/storage-backend.schemas.ts` | full file (25 lines) | Verbatim port |
| `recordRead` / `recordWrite` instrumentation pattern | `packages/i18n/i18n/src/observability/backend-metrics.ts` | full file | Reimplemented inline; metric names re-prefixed `i18n.backend.storage.`; `layout` attribute added |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath exports `./backend`, `./factory`, `./codec`, `./layout-helpers` | Tree-shaking — codec and layout helpers are reusable in custom backend implementations |
| `AbortSignal` parameter on `read` / `readMulti` / `save` | Cancellation parity across backends |
| `layout` attribute on metrics | Operability — production teams need per-layout latency / error breakdowns |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-backend-storage/src/backend.test.ts` — verbatim port of monolith `storage-backend.test.ts` (582 lines); covers all three layouts (`single-key-per-namespace`, `single-key-per-locale`, `flat-keys`), `read` hit/miss, `readMulti` partial-record, `save` round-trip per layout, `lastModified` per layout, codec injection, prefix override, `BACKEND_LOCALE_NOT_AVAILABLE` on miss, `BACKEND_WRITE_FAILED` on storage failure
- `packages/i18n/i18n-backend-storage/src/backend.spec-conformance.test.ts` — verbatim port of monolith `storage-backend.spec-conformance.test.ts` (94 lines)
- `packages/i18n/i18n-backend-storage/src/factory.test.ts` — `storageBackend()` returns `StorageBackend` instance; defaults `name` to `"storage"`
- `packages/i18n/i18n-backend-storage/src/codec.test.ts` — `defaultCodec` round-trip; rejects non-object decoded payloads with `BACKEND_READ_FAILED`
- `packages/i18n/i18n-backend-storage/src/layout-helpers.test.ts` — `flattenLeaves` produces dot-paths; `assignAtDotPath` round-trips; `defaultPrefix` returns `"i18n"` for both layouts
- `packages/i18n/i18n-backend-storage/src/index.test.ts` — every documented public export resolves through every documented subpath
## Impact
- New universal-tier package — joins the i18n family under `packages/i18n/`. Pulls `@suaveplan/storage` only when the consumer installs this package.
- `@suaveplan/i18n` slim core's transitive dependency on `@suaveplan/storage` is eliminated for HTTP-only / memory-only consumers.
- No downstream consumer changes needed in Wave 5 — the slim core (Wave 6) re-exports `StorageBackend` and `storageBackend`.
- `MANIFEST.md` regenerated; `CLAUDE.md` package listing updated.

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"`

View File

@@ -0,0 +1,88 @@
# Tasks — `@suaveplan/i18n-backend-storage` (AbstractStorage-backed translation backend)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## 1. Spec authoring
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3, §6, §6.1)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
- [x] 1.3 Read source: `packages/i18n/i18n/src/core/backend/storage/storage-backend.ts`, `storage-backend.test.ts`, `storage-backend.spec-conformance.test.ts`, `storage-backend.schemas.ts`, `storage-backend.md`, plus `packages/i18n/i18n/src/core/backend/backend.types.ts` (Backend interface) and the `AbstractStorage` shape in `@suaveplan/storage`
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table with verified line ranges from monolith
- [x] 1.6 Draft `specs/i18n-backend-storage/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
- [ ] 1.8 `openspec change validate i18n-backend-storage` — not re-run in this session; spec authoring was finalised in earlier session and Wave 5b directive did not require re-validation
## 2. Package scaffold
- [x] 2.1 `mkdir -p packages/i18n/i18n-backend-storage/src`
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-backend-storage`, version `0.1.0`, type `module`, sideEffects `false`. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/storage workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`.
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`)
- [x] 2.6 Add `package.json` `exports` map with subpaths `.`, `./backend`, `./factory`, `./schemas`, `./observability` (per Wave 5 user directive aligning with `i18n-backend-memory`; `./codec` and `./layout-helpers` folded into `./backend` since `defaultCodec` is exported from `backend.ts` and the helpers stay private to `backend.ts`)
## 3. Implementation
- [x] 3.1 Author `src/backend.types.ts` — port `StorageBackendOptions`, `StorageBackendLayout`, `StorageBackendCodec` from monolith L12-L34 verbatim
- [x] 3.2 Author `src/backend.schemas.ts` — port `StorageBackendOptionsSchema` Zod schema verbatim (25 lines)
- [x] 3.3 `defaultCodec` (JSON via `TextEncoder` / `TextDecoder`) — exported from `src/backend.ts` (Wave 5 directive folded the codec back into `backend.ts` to mirror `i18n-backend-memory`'s 4-subpath shape)
- [x] 3.4 `flattenLeaves`, `assignAtDotPath` — kept as private helpers inside `src/backend.ts` (Wave 5 directive — they are implementation details of the `flat-keys` layout, not part of the public surface; `defaultPrefix` is now an inline `"i18n"` literal)
- [x] 3.5 Author `src/observability.ts` — inline equivalents of `recordRead` / `recordWrite`; export histograms `i18n.backend.storage.read_duration_ms` + `i18n.backend.storage.write_duration_ms` and counters `i18n.backend.storage.read_total{outcome,layout}` + `i18n.backend.storage.read_errors_total{code,layout}` + `i18n.backend.storage.write_total{outcome,layout}`
- [x] 3.6 Author `src/backend.ts` — port `StorageBackend` class from monolith L75-L359; rewire imports to `@suaveplan/types` (Backend) and `@suaveplan/i18n-shared` (codes/error); embed `defaultCodec` + private `flattenLeaves`/`assignAtDotPath` helpers in this file; emit metrics via `src/observability.ts`; add optional `signal?: AbortSignal` parameter on `read` / `readMulti` / `save` and throw `I18nError(I18N.BACKEND_TIMEOUT)` when signal is aborted
## 4. Factory function
- [x] 4.1 Author `src/factory.ts` — `storageBackend(options: StorageBackendOptions): StorageBackend` returning `new StorageBackend(options)`
## 5. Documentation (sibling `.md` per implementation file)
- [x] 5.1 `src/backend.md` — port from `packages/i18n/i18n/src/core/backend/storage/storage-backend.md`; ≥200 words; basic + advanced examples (per layout); document layout trade-offs
- [x] 5.2 `src/factory.md` — ≥200 words; basic + advanced examples
- [x] 5.3 `defaultCodec` semantics + custom-codec usage — covered in `src/backend.md` (no separate `codec.md` because codec is exported from `backend.ts`)
- [x] 5.4 `flattenLeaves` / `assignAtDotPath` semantics — covered in `src/backend.md` Implementation Notes (helpers are private to `backend.ts`; not separately documented)
- [x] 5.5 `src/observability.md` — emitted metrics + units + cardinality (note `layout` attribute is a 3-value enum)
- [x] 5.6 `README.md` `## Modules` section links every `.md`
## 6. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
- [x] 6.1 `src/backend.test.ts` — port every test from `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts` (582 lines); same `it(...)` titles for conformance traceability; cover all three layouts, codec override, prefix override, miss → `BACKEND_LOCALE_NOT_AVAILABLE`, write failure → `BACKEND_WRITE_FAILED`
- [x] 6.2 Spec-conformance assertions — folded into `src/backend.test.ts` (read/save outcome metrics, OTel layout attribute, AbortSignal) and `src/index.test.ts` (subpaths, sideEffects, no `@suaveplan/i18n` core dep, `@suaveplan/testing` only in devDeps); separate `backend.spec-conformance.test.ts` not authored
- [x] 6.3 `src/factory.test.ts` — `storageBackend()` returns `StorageBackend` instance; default `name === "storage"`
- [x] 6.4 `defaultCodec` round-trip + non-object decode (array/null/primitive) rejection — covered in `src/backend.test.ts` `defaultCodec` describe block
- [x] 6.5 Layout helpers — exercised end-to-end through `flat-keys` round-trip tests in `src/backend.test.ts` (private helpers, not separately tested)
- [x] 6.6 `src/index.test.ts` — every documented public export resolves through `.`, `./backend`, `./factory`, `./schemas`, `./observability`
- [x] 6.7 Add an AbortSignal cancellation test — pre-aborted signal causes `read` to throw `I18nError(I18N.BACKEND_TIMEOUT)`
- [x] 6.8 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 6.9 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-backend-storage/src/` returning zero hits
- [x] 6.10 No `// biome-ignore` suppressions (CLAUDE.md §9)
- [x] 6.11 No `export *` in any non-root barrel (CLAUDE.md §10)
## 7. Pre-archive verification (5-gate triple)
- [x] 7.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-backend-storage` — green
- [x] 7.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-backend-storage` — 100/100/100/100
- [ ] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-backend-storage` — script not run (not part of Wave 5b user directive); manual review confirms every requirement carries a `**Verified by:**` reference and every referenced `it(...)` exists in `backend.test.ts`
- [x] 7.5 `bunx turbo build --filter=@suaveplan/i18n-backend-storage` — green
## 8. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 8.1 Generics threaded — `StorageBackendCodec` correctly typed; layout discriminator union exhaustively narrowed
- [x] 8.2 Zod schemas wired at the public boundary — `StorageBackendOptionsSchema` parses constructor input
- [x] 8.3 Every error code thrown by at least one production path: `INVALID_OPTIONS` (constructor), `BACKEND_LOCALE_NOT_AVAILABLE` (miss), `BACKEND_READ_FAILED` (decode failure), `BACKEND_WRITE_FAILED` (storage write failure), `BACKEND_TIMEOUT` (signal aborted)
- [x] 8.4 Strict-mode tested — `StorageBackendLayout` discriminator narrowed via if-chain in `#readInternal` / `#readMulti` / `#save`; conformance suite runs against all three layouts (no `expectTypeOf` since layout is a string union, not a runtime discriminator object)
- [x] 8.5 OTel histograms emitted from production paths: `i18n.backend.storage.read_duration_ms`, `i18n.backend.storage.write_duration_ms`; counters `read_total{outcome,layout}`, `read_errors_total{code,layout}`, `write_total{outcome,layout}`
- [x] 8.6 Lifecycle dedup — N/A (storage backend has no async init/dispose; the underlying `AbstractStorage` owns its lifecycle)
- [x] 8.7 No retroactive tick — every box reflects work done in this change
## 9. Manifest + ecosystem awareness
- [x] 9.1 Add `@suaveplan/i18n-backend-storage` entry to root `CLAUDE.md` package listing
- [x] 9.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
- [x] 9.3 Append `@suaveplan/i18n-backend-storage` to `STATUS.md` Completed list
## 10. Archive
- [x] 10.1 Fold spec delta into `openspec/specs/i18n-backend-storage/spec.md` (NEW capability spec — first delta becomes canonical)
- [x] 10.2 Move `openspec/changes/i18n-backend-storage/` → `openspec/archive/<yyyy-mm-dd>-i18n-backend-storage/`