Files
2026-05-27 23:29:53 +01:00

125 lines
12 KiB
Markdown

# `@suaveplan/i18n-backend-fs-multifile` — Node fs/promises multi-file translation backend
**Package**: `@suaveplan/i18n-backend-fs-multifile` (NEW, **server tier**)
**Status**: PROPOSED
**Type**: feature (new server-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-fs-multifile/`
## Why
The Wave 5 backend split lifts each `Backend` implementation from the `@suaveplan/i18n` monolith. The `multi-file-fs` backend is the canonical server-tier source — it reads `<root>/<locale>/<namespace>.<ext>` (`nested`), `<root>/<locale>.<namespace>.<ext>` (`flat`), or a caller-supplied glob pattern from the local filesystem via `node:fs/promises`. It optionally watches the root directory via `fs.watch` and emits `loaded` events through the catalog event bus for HMR / Suspense refresh. It auto-detects `yaml`, `json5`, and (planned) `gettext-parser` peer parsers via dynamic `import()` so consumers can pick a serialisation format without forcing a hard install on JSON-only deployments.
### Tier split rationale (server-only, NOT universal)
This is the **only** Wave 5 backend that is not universal-tier. Three reasons compel a server-only tier:
1. **Hard `node:fs/promises` import.** The default `#getFs()` path performs `await import("node:fs/promises")` which fails at module evaluation in browsers and rejects with `ERR_MODULE_NOT_FOUND` in non-Node runtimes that do not expose Node built-ins (Cloudflare Workers, Deno without `--allow-read`).
2. **Universal-tier consumers must not pull `node:fs`.** Even with tree-shaking, leaving this backend in the universal tier would break browser bundlers that resolve `node:` schemes at build time. Splitting it into a server-only package eliminates the risk.
3. **Path-traversal surface is server-shaped.** The backend resolves `<root>/<locale>/<namespace>.<ext>` against a host filesystem; the security model is "trust the catalog config", which only makes sense in server / build-time contexts. Browser-tier consumers should use `@suaveplan/i18n-backend-http` or `@suaveplan/i18n-backend-storage` against `OPFSStorage` instead.
The `package.json` SHALL declare `"engines": { "node": ">=20" }` and SHALL NOT declare a `"browser"` field. A leading source comment in `src/backend.ts` SHALL state explicitly: `// Server-tier package — uses node:fs/promises. Browser consumers: use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.`
The `Backend` interface itself moves to `@suaveplan/types/i18n` in Wave 1. `@suaveplan/i18n-backend-fs-multifile` 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-fs-multifile`
- ADDED: `packages/i18n/i18n-backend-fs-multifile/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md). `package.json` declares `"engines": { "node": ">=20" }` and omits `"browser"` field.
- ADDED: `src/backend.ts` — `MultiFileFsBackend` class implementing `Backend` from `@suaveplan/types/i18n`. Source: `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.ts` L67-L284. Capabilities: `canRead: true`, `canReadMulti: false`, `canSave: true`, `canTrackModification: true`, `canWatch` follows the `watch` option.
- ADDED: `src/backend.types.ts` — `MultiFileFsBackendOptions`, `MultiFileFsLayout`, `MultiFileFsLike`. Source: monolith L15-L51.
- ADDED: `src/backend.schemas.ts` — `MultiFileFsBackendOptionsSchema` Zod schema. Source: monolith `multi-file-fs-backend.schemas.ts` (15 lines).
- ADDED: `src/factory.ts` — `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend` factory. Source: monolith L403-L407.
- ADDED: `src/path-resolver.ts` — `resolvePath(root, layout, extension, globPattern, locale, namespace): string` extracted from monolith `#resolvePath` for tree-shaking + isolated testing. Source: monolith L190-L205.
- ADDED: `src/payload-codec.ts` — `parsePayload`, `parseJson`, `parseWithPeer`, `encodePayload` lifted as standalone helpers. Source: monolith L313-L400.
- ADDED: `src/fs-error-classifier.ts` — `classifyFsError(err, path)` lifted; maps `ENOENT` / `ENOTDIR` to `BACKEND_LOCALE_NOT_AVAILABLE` and other errno codes to `BACKEND_READ_FAILED`. Source: monolith L297-L311.
- ADDED: `src/observability.ts` — emits `i18n.backend.fs_multifile.read_duration_ms`, `i18n.backend.fs_multifile.read_total{outcome,layout}`, `i18n.backend.fs_multifile.read_errors_total{code,layout}`, `i18n.backend.fs_multifile.write_duration_ms`, `i18n.backend.fs_multifile.write_total{outcome,layout}`.
- ADDED: `src/backend.test.ts`, `src/backend.spec-conformance.test.ts`, `src/factory.test.ts`, `src/path-resolver.test.ts`, `src/payload-codec.test.ts`, `src/fs-error-classifier.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/path-resolver.md`, `src/payload-codec.md`, `src/fs-error-classifier.md` — co-located docs.
- ADDED: `src/index.ts` — explicit named re-exports.
### Subpath exports
- `.` — root barrel
- `./backend` — `MultiFileFsBackend` class only
- `./factory` — `multiFileFsBackend` factory only
- `./path-resolver` — `resolvePath` helper only
- `./payload-codec` — `parsePayload`, `parseJson`, `parseWithPeer`, `encodePayload` only
- `./fs-error-classifier` — `classifyFsError` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — for `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^` — for `Backend`, `BackendCapabilities`, `BackendInitContext`, `TranslationRecord` from `@suaveplan/types/i18n`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms` — for `MultiFileFsBackendOptionsSchema`
- **No** dep on `@suaveplan/i18n` core. **No** dep on any other backend package. **No** dep on `yaml` / `json5` (auto-detected via dynamic `import()`; consumers install the peer they need; the backend surfaces a clear `BACKEND_READ_FAILED` with the missing-peer name when the peer is unavailable).
### Tier
**Server-only.** Declares `"engines": { "node": ">=20" }`. Omits `"browser"` field. Source files carry a leading tier-marker comment.
## Out of scope
- **Removing the multi-file-fs backend from `packages/i18n/i18n/src/core/backend/multi-file-fs/`** — happens in Wave 6.
- **Bundling `yaml` / `json5` as direct deps** — explicit non-goal; peer-detection keeps the package lean and avoids forcing a parser choice on JSON-only consumers.
- **Browser-tier compatibility** — explicit non-goal (see tier split rationale); browser consumers use `@suaveplan/i18n-backend-http` or `@suaveplan/i18n-backend-storage`.
- **Atomic file writes / lock files** — out of scope; consumers who need atomic semantics use `StorageBackend` against an atomic `AbstractStorage`.
- **Recursive directory watch** — `fs.watch` semantics are platform-dependent; the watcher emits events for the root directory only.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `MultiFileFsBackend` class + capabilities matrix | `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.ts` | L67-L109 | Verbatim port; rewire imports |
| `init` (sets context, optionally starts watch) | same file | L111-L116 | Verbatim port |
| `dispose` (aborts watcher) | same file | L118-L121 | Verbatim port |
| `read` (resolve → readFile → parsePayload → outcome telemetry) | same file | L123-L152 | Verbatim port |
| `save` | same file | L154-L174 | Verbatim port |
| `lastModified` | same file | L176-L188 | Verbatim port |
| `#resolvePath` (lifted to `src/path-resolver.ts`) | same file | L190-L205 | Extracted |
| `#ensureDir` | same file | L207-L212 | Inline private method |
| `#getFs` (dynamic `node:fs/promises` import) | same file | L214-L240 | Verbatim port |
| `#startWatch` / `#runWatchLoop` / `#parseEventPath` | same file | L242-L283 | Verbatim port |
| `joinPath` | same file | L286-L295 | Inline helper |
| `classifyFsError` (lifted to `src/fs-error-classifier.ts`) | same file | L297-L311 | Extracted |
| `parsePayload`, `parseJson`, `parseWithPeer` (lifted to `src/payload-codec.ts`) | same file | L313-L386 | Extracted |
| `encodePayload` (lifted to `src/payload-codec.ts`) | same file | L388-L400 | Extracted |
| `MultiFileFsBackendOptions`, `MultiFileFsLayout`, `MultiFileFsLike` types | same file | L15-L51 | Verbatim port to `backend.types.ts` |
| `multiFileFsBackend` factory | same file | L403-L407 | Lifted to `factory.ts` |
| `MultiFileFsBackendOptionsSchema` | `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.schemas.ts` | full file (15 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.fs_multifile.`; `layout` attribute added |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath exports `./backend`, `./factory`, `./path-resolver`, `./payload-codec`, `./fs-error-classifier` | Tree-shaking and reuse — codecs and resolvers are reusable in custom backends |
| `AbortSignal` parameter on `read` / `save` | Cancellation parity across backends |
| Server-tier marker (engines.node, no browser field, leading source comment) | Explicit tier declaration so universal-tier consumers cannot accidentally pull this package |
| Peer-detection contract (`BACKEND_READ_FAILED` with peer name) | Documented behaviour from monolith — split formalises it |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts` — verbatim port of monolith `multi-file-fs-backend.test.ts` (556 lines); covers `nested`, `flat`, `glob` layouts; `read` hit / miss / parse-failure; `save` round-trip; `lastModified`; `init` / `dispose` lifecycle; injected `MultiFileFsLike` so tests do not touch real disk; YAML / JSON5 peer-detection happy path + missing-peer path
- `packages/i18n/i18n-backend-fs-multifile/src/backend.spec-conformance.test.ts` — every spec requirement mapped 1:1 to an `it(...)` title
- `packages/i18n/i18n-backend-fs-multifile/src/factory.test.ts` — `multiFileFsBackend()` returns `MultiFileFsBackend` instance; default `name === "multi-file-fs"`
- `packages/i18n/i18n-backend-fs-multifile/src/path-resolver.test.ts` — every layout produces the documented path shape; `glob` substitutes `{locale}` / `{namespace}`
- `packages/i18n/i18n-backend-fs-multifile/src/payload-codec.test.ts` — JSON parse + reject array/null; YAML peer-detect happy path with stub; missing-peer path → `BACKEND_READ_FAILED` with peer name in context; unknown extension → `BACKEND_READ_FAILED`
- `packages/i18n/i18n-backend-fs-multifile/src/fs-error-classifier.test.ts` — `ENOENT`/`ENOTDIR` → `BACKEND_LOCALE_NOT_AVAILABLE`; `EACCES` → `BACKEND_READ_FAILED`
- `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts` — every documented subpath resolves; `package.json` declares `engines.node`; no `browser` field
## Impact
- New **server-tier** package — joins the i18n family under `packages/i18n/`.
- Eliminates the universal-tier-leakage risk that the monolith carries today.
- Universal-tier root barrel of `@suaveplan/i18n` (Wave 6) does NOT re-export this package — server-tier consumers import directly via `@suaveplan/i18n-backend-fs-multifile`.
- `MANIFEST.md` regenerated; `CLAUDE.md` package listing updated with a server-tier marker.