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,247 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: i18n (i18n-storage-backends)
**Package**: `@suaveplan/i18n`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Backend interface contract
The package SHALL expose a `Backend` interface that any external translation source can implement. The interface SHALL declare a stable `name: string`, a required `read(locale, namespace) → Promise<TranslationRecord>` method, and four optional methods: `readMulti?(locale, namespaces) → Promise<Record<string, TranslationRecord>>`, `save?(locale, namespace, record) → Promise<void>`, `lastModified?(locale, namespace) → Promise<number | undefined>`, and lifecycle hooks `init?(catalog)` / `dispose?()`. Implementations SHALL surface transport / parse failures as `I18nError(I18N.BACKEND_READ_FAILED)` (read path) or `I18nError(I18N.BACKEND_WRITE_FAILED)` (write path) with the underlying error attached as `cause`.
#### Scenario: read returns a TranslationRecord
- **WHEN** a registered `Backend.read('en', 'auth')` resolves with `{ login: { title: 'Sign in' } }`
- **THEN** the catalog stores it under `(en, auth)` and a subsequent `t('auth:login.title')` returns `'Sign in'`
#### Scenario: read failure maps to I18N.BACKEND_READ_FAILED
- **WHEN** a backend's `read` rejects with a `TypeError`
- **THEN** the catalog throws `I18nError` with code `I18N.BACKEND_READ_FAILED` and `error.cause === TypeError`
#### Scenario: missing readMulti falls back to N reads
- **WHEN** `MessageCatalog.preload(['en'], ['auth', 'common'])` is called against a backend that omits `readMulti`
- **THEN** `read` is invoked once for each `(locale, namespace)` pair concurrently and the catalog merges the results
**Verified by:** `packages/i18n/i18n/src/core/backend/backend.test.ts::"read populates catalog"` AND `packages/i18n/i18n/src/core/backend/backend.test.ts::"read failure surfaces BACKEND_READ_FAILED"` AND `packages/i18n/i18n/src/core/backend/backend.test.ts::"falls back to per-namespace read when readMulti is absent"`
---
### Requirement: MemoryBackend reference implementation
The package SHALL ship a `MemoryBackend` implementation backed by `Map<locale, Map<namespace, TranslationRecord>>`. It SHALL implement `read`, `readMulti`, `save`, and `lastModified`. Construction SHALL accept an optional `seed: Record<locale, Record<namespace, TranslationRecord>>` for tests and seeded defaults.
#### Scenario: seed is read on first request
- **WHEN** `new MemoryBackend({ seed: { en: { auth: { login: { title: 'Sign in' } } } } })` then `read('en', 'auth')`
- **THEN** the resolved record deep-equals the seeded value
#### Scenario: save then read round-trips
- **WHEN** `backend.save('en', 'auth', { login: { title: 'Sign in' } })` is called, then `backend.read('en', 'auth')`
- **THEN** the resolved record deep-equals the saved value AND `lastModified('en', 'auth')` returns a number ≥ the timestamp before `save`
**Verified by:** `packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"reads seeded values"` AND `packages/i18n/i18n/src/core/backend/memory/memory-backend.test.ts::"save then read round-trips with lastModified"`
---
### Requirement: HttpBackend with templated load path
The package SHALL ship an `HttpBackend` that fetches translations from a URL constructed from a `loadPath` template containing `{locale}` and `{namespace}` placeholders. Defaults: `fetchFn = globalThis.fetch`, `parsePayload = (res) => res.json()`. A non-2xx HTTP response SHALL surface as `I18nError(I18N.BACKEND_READ_FAILED)` with `context.status` set to the HTTP status code. A 404 SHALL surface as `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` so chained backends can fall through.
#### Scenario: GET /locales/en/auth.json returns translations
- **WHEN** `new HttpBackend({ loadPath: '/locales/{locale}/{namespace}.json', fetchFn })` then `read('en', 'auth')` with `fetchFn` resolving to `Response.json({ login: { title: 'Sign in' } })`
- **THEN** the resolved record equals `{ login: { title: 'Sign in' } }` AND `fetchFn` was called with `'/locales/en/auth.json'`
#### Scenario: HTTP 404 surfaces LOCALE_NOT_AVAILABLE
- **WHEN** `fetchFn` resolves with status 404
- **THEN** the call rejects with `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` and `context.status === 404`
#### Scenario: HTTP 500 surfaces BACKEND_READ_FAILED
- **WHEN** `fetchFn` resolves with status 500
- **THEN** the call rejects with `I18nError(I18N.BACKEND_READ_FAILED)` and `context.status === 500`
**Verified by:** `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"fetches templated URL"` AND `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"404 surfaces LOCALE_NOT_AVAILABLE"` AND `packages/i18n/i18n/src/core/backend/http/http-backend.test.ts::"5xx surfaces BACKEND_READ_FAILED"`
---
### Requirement: StorageBackend wraps any AbstractStorage
The package SHALL ship a `StorageBackend` that adapts any `AbstractStorage` instance from `@suaveplan/storage` into a `Backend`. It SHALL support three storage layouts via a `layout` option:
- `single-key-per-namespace` (default): key = `${prefix}/${locale}/${namespace}.json`, value = JSON-encoded `TranslationRecord`
- `single-key-per-locale`: key = `${prefix}/${locale}.json`, value = `Record<namespace, TranslationRecord>`
- `flat-keys`: key = `${prefix}:${locale}:${namespace}:${dotPath}`, value = the leaf string
Codec SHALL be pluggable via `encode(record): Uint8Array` / `decode(bytes): TranslationRecord` (default: `JSON.stringify` + `TextEncoder` / `TextDecoder` + `JSON.parse`).
#### Scenario: single-key-per-namespace round-trips through MemoryStorage
- **WHEN** `new StorageBackend({ storage: new MemoryStorage(), layout: 'single-key-per-namespace', prefix: 'i18n' })` and `backend.save('en', 'auth', { login: { title: 'Sign in' } })`
- **THEN** `MemoryStorage.get('i18n/en/auth.json')` decodes to `{ login: { title: 'Sign in' } }` AND `backend.read('en', 'auth')` returns the same record
#### Scenario: single-key-per-locale stores all namespaces under one key
- **WHEN** `layout: 'single-key-per-locale'` and `backend.save('en', 'auth', X)` then `backend.save('en', 'common', Y)`
- **THEN** `MemoryStorage.get('i18n/en.json')` decodes to `{ auth: X, common: Y }`
#### Scenario: flat-keys writes one storage key per leaf
- **WHEN** `layout: 'flat-keys'` and `backend.save('en', 'auth', { login: { title: 'Sign in' } })`
- **THEN** `MemoryStorage.get('i18n:en:auth:login.title')` decodes to `'Sign in'`
#### Scenario: codec swap to MessagePack
- **WHEN** `new StorageBackend({ storage, encode: msgpackEncode, decode: msgpackDecode })` is constructed
- **THEN** `save` calls `msgpackEncode` for serialisation and `read` calls `msgpackDecode`
**Verified by:** `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-namespace round-trip"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"single-key-per-locale shape"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.test.ts::"flat-keys shape"` AND `packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"codec swap to MessagePack"`
---
### Requirement: MultiFileFsBackend reads JSON files in a per-namespace tree
The package SHALL ship a `MultiFileFsBackend` (Node/Bun-only; pure ESM dynamic-import of `node:fs/promises`) that loads `<root>/<locale>/<namespace>.json` from disk. It SHALL support three layouts:
- `nested` (default): `<root>/<locale>/<namespace>.json` (e.g. `locales/en/auth.json`)
- `flat`: `<root>/<locale>.<namespace>.json` (e.g. `locales/en.auth.json`)
- `glob`: caller-supplied `globPattern` with `{locale}` and `{namespace}` placeholders
When `watch: true`, it SHALL use `node:fs/promises.watch` to detect changes and emit a `loaded` event via the catalog event bus on each successful reload. Optional peer-dependency parsers SHALL be supported when present: `yaml` (for `.yaml`/`.yml`), `gettext-parser` (for `.po`), `json5` (for `.json5`).
#### Scenario: nested layout loads namespace files
- **WHEN** `<root>/en/auth.json` contains `{ "login": { "title": "Sign in" } }` and `backend.read('en', 'auth')` is called
- **THEN** the resolved record equals the file's parsed content
#### Scenario: missing file maps to LOCALE_NOT_AVAILABLE
- **WHEN** `<root>/de/auth.json` does not exist and `backend.read('de', 'auth')` is called
- **THEN** the call rejects with `I18nError(I18N.BACKEND_LOCALE_NOT_AVAILABLE)` AND `context.path` is the resolved file path
#### Scenario: watch emits loaded event on file change
- **WHEN** `watch: true` and an external process writes a new value to `<root>/en/auth.json`
- **THEN** the catalog emits `loaded` with `{ locale: 'en', namespace: 'auth', backend: 'multi-file-fs' }` within 500 ms
**Verified by:** `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"nested layout loads namespace files"` AND `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"missing file maps to LOCALE_NOT_AVAILABLE"` AND `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts::"watch emits loaded event on file change"`
---
### Requirement: ChainedBackend composes N backends with write-through cache
The package SHALL ship a `ChainedBackend` that composes a non-empty ordered list of backends. `read(locale, ns)` SHALL walk the list in order; the first backend whose `read` resolves wins. After a successful read, the resolved record SHALL be written through to every preceding backend whose `save` is implemented (the standard "cache from IndexedDB, fall through to HTTPS" pattern). `readMulti` SHALL fan out concurrently and merge per the configured `mergeStrategy: 'first-wins' | 'last-wins'` (default `'first-wins'`).
#### Scenario: cache hit short-circuits the chain
- **WHEN** `Chained([memory, http])` and `memory.read('en', 'auth')` resolves
- **THEN** `http.read` is never called
#### Scenario: cache miss writes through to earlier backends
- **WHEN** `Chained([memory, http])`, `memory.read` rejects with `BACKEND_LOCALE_NOT_AVAILABLE`, and `http.read` resolves with `R`
- **THEN** the chained `read` resolves with `R` AND `memory.save('en', 'auth', R)` is called exactly once
#### Scenario: BACKEND_READ_FAILED does not fall through
- **WHEN** the first backend rejects with `BACKEND_READ_FAILED` (a transport / parse error, not a 404-equivalent)
- **THEN** the chained `read` propagates the error without calling subsequent backends
**Verified by:** `packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"cache hit short-circuits"` AND `packages/i18n/i18n/src/core/backend/chained/chained-backend.spec-conformance.test.ts::"cache miss writes through"` AND `packages/i18n/i18n/src/core/backend/chained/chained-backend.test.ts::"BACKEND_READ_FAILED does not fall through"`
---
### Requirement: MessageCatalog accepts and routes through registered backends
`MessageCatalog` SHALL accept `backend?: Backend` (single) or `backends?: Backend[]` (auto-wrapped in `ChainedBackend`) at construction. When a backend is registered, `MessageCatalog.preload(locales, namespaces?) → Promise<void>` SHALL bulk-load via `readMulti` when supported and via concurrent `read` calls otherwise. Repeated `preload` calls for the same `(locale, namespace)` pair SHALL be deduplicated via the existing in-flight Promise map. `MessageCatalog.invalidate(locale?, namespace?) → void` SHALL drop the in-memory cache for the affected pairs so the next read re-fetches.
#### Scenario: preload populates catalog via readMulti
- **WHEN** a `MemoryBackend` carries seeds for `en/auth` and `en/common` and `catalog.preload(['en'], ['auth', 'common'])` is called
- **THEN** `t('auth:login.title')` and `t('common:cancel')` resolve from the catalog without further backend calls
#### Scenario: invalidate forces re-read
- **WHEN** `catalog.preload(['en'], ['auth'])` succeeds, then `catalog.invalidate('en', 'auth')`, then `catalog.preload(['en'], ['auth'])` is called again
- **THEN** the backend's `read` was called twice for the `(en, auth)` pair
#### Scenario: concurrent preload calls dedup
- **WHEN** `catalog.preload(['en'], ['auth'])` is called twice in immediate succession (no `await` between them)
- **THEN** the backend's `read` is called exactly once
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"preload populates via readMulti"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"invalidate forces re-read"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"concurrent preload calls dedup"`
---
### Requirement: getAsync triggers in-flight backend load for missing namespace
When `lazyLoad: true` is set (default when a backend is registered) and `MessageCatalog.getAsync(key, locale?)` is called for a key in a namespace not yet present, the catalog SHALL trigger a backend `read` for that `(locale, namespace)` and resolve the returned Promise once the read completes (success or fallback). The synchronous `get(key)` and `t(key)` SHALL continue to behave as today: return `undefined` / the key string when the namespace is absent, even with `lazyLoad: true`. This separation SHALL be enforced so existing synchronous call sites are unchanged.
#### Scenario: getAsync resolves to backend value
- **WHEN** the catalog has no `auth` namespace loaded and `catalog.getAsync('auth:login.title')` is called
- **THEN** the backend's `read('en', 'auth')` is invoked AND the returned Promise resolves to `{ value: 'Sign in', source: 'catalog', ... }`
#### Scenario: getAsync surfaces backend errors
- **WHEN** the backend's `read` rejects with `BACKEND_READ_FAILED`
- **THEN** `getAsync` rejects with the same `I18nError`
#### Scenario: synchronous t remains unchanged under lazyLoad
- **WHEN** `lazyLoad: true` and `t('auth:login.title')` is called for a not-yet-loaded namespace
- **THEN** the synchronous return value is `'auth:login.title'` (the key) AND the backend `read` was NOT triggered (only `getAsync` triggers backend calls)
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync resolves to backend value"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"getAsync surfaces backend errors"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"synchronous t never triggers backend reads"`
---
### Requirement: saveMissing writes back through registered backend
When `saveMissing: true` is passed in catalog options and a backend with a `save` method is registered, `MessageCatalog` SHALL invoke `backend.save(locale, namespace, { ...existing, [key]: defaultValue })` whenever a key miss occurs. If the backend lacks `save` or the call rejects, `MessageCatalog` SHALL emit an OTel counter sample (`i18n.backend.write.total{outcome=error}`) but SHALL NOT throw — saveMissing is best-effort by design.
#### Scenario: missing key triggers save
- **WHEN** `saveMissing: true` and `t('auth:new.unknown.key', { defaultValue: 'Default' })` is called for a key absent from the catalog
- **THEN** the backend's `save('en', 'auth', record)` is called once with the merged record containing the new key
#### Scenario: save rejection does not throw
- **WHEN** `backend.save` rejects
- **THEN** `t()` returns the default value AND no exception is thrown AND the OTel counter `i18n.backend.write.total{outcome=error}` increments by 1
**Verified by:** `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"saveMissing triggers backend.save"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"save rejection does not throw"`
---
### Requirement: Backend OTel instrumentation
All backend operations SHALL emit OTel telemetry built via `getMeter('suaveplan.i18n')`:
- Histogram `i18n.backend.read.duration_ms` — labels: `backend.name`, `locale`, `namespace`, `outcome` (`hit | miss | error`)
- Counter `i18n.backend.read.total` — labels: `backend.name`, `outcome`
- Counter `i18n.backend.write.total` — labels: `backend.name`, `outcome`
- Gauge `i18n.backend.cache.size` — observable callback returning the count of `(locale, namespace)` pairs currently held in `MessageCatalog`'s in-memory store
#### Scenario: read records duration sample
- **WHEN** `StorageBackend.read('en', 'auth')` is called once with an in-memory `MeterProvider`
- **THEN** exactly one sample is recorded under `i18n.backend.read.duration_ms` with attributes `{ backend.name: 'storage', locale: 'en', namespace: 'auth', outcome: 'hit' }`
#### Scenario: cache size gauge reflects catalog state
- **WHEN** `catalog.preload(['en', 'fr'], ['auth'])` succeeds, then the OTel gauge is observed
- **THEN** the observed value is `2` (one pair per locale × namespace)
**Verified by:** `packages/i18n/i18n/src/core/backend/storage/storage-backend.spec-conformance.test.ts::"records read.duration_ms sample"` AND `packages/i18n/i18n/src/core/catalog/catalog-backend.test.ts::"cache.size gauge reflects state"`
<!-- markdownlint-enable MD024 -->