114 lines
9.4 KiB
Markdown
114 lines
9.4 KiB
Markdown
# `@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.
|