# `@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 `//.` (`nested`), `/..` (`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 `//.` 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.