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

12 KiB

@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.