# Capability Spec: i18n-backend-fs-multifile (i18n-backend-fs-multifile) **Package**: `@suaveplan/i18n-backend-fs-multifile` (server-tier) **Status**: PROPOSED — first delta; becomes canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## Requirements ### Requirement: Server-tier package declaration The package SHALL declare `"engines": { "node": ">=20" }` in `package.json` and SHALL NOT declare a `"browser"` field. The implementation file `src/backend.ts` SHALL include a leading comment marking the package as server-tier and pointing browser-tier consumers to `@suaveplan/i18n-backend-http` and `@suaveplan/i18n-backend-storage`. The slim `@suaveplan/i18n` core (Wave 6) SHALL NOT re-export this package from its universal-tier root barrel. #### Scenario: package.json declares engines.node - **WHEN** `package.json` is parsed - **THEN** the `engines.node` field equals `">=20"` AND the `browser` field does NOT exist #### Scenario: src/backend.ts carries the server-tier marker comment - **WHEN** `src/backend.ts` is read - **THEN** the file contains a leading comment that includes the substring `Server-tier package` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts::"package.json declares engines.node and omits browser field"` AND `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts::"src/backend.ts carries the server-tier marker"` --- ### Requirement: MultiFileFsBackend implements the Backend interface The package SHALL export a `MultiFileFsBackend` class that implements the `Backend` interface from `@suaveplan/types/i18n`. The class SHALL declare a `capabilities` matrix of `{ canRead: true, canReadMulti: false, canSave: true, canTrackModification: true, canWatch: }`. The class SHALL accept a required `MultiFileFsBackendOptions` constructor argument carrying `{ root, layout?, globPattern?, extension?, name?, watch?, fs? }`. Default `name` SHALL equal `"multi-file-fs"`. Default `layout` SHALL equal `"nested"`. Default `extension` SHALL equal `"json"`. Default `watch` SHALL equal `false`. #### Scenario: capabilities matrix follows watch option - **WHEN** `new MultiFileFsBackend({ root: "/tmp/i18n", watch: true }).capabilities.canWatch` is read - **THEN** the value equals `true` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"capabilities matrix follows watch option"` --- ### Requirement: nested layout reads from `//.` When `layout === "nested"` (default), `read(locale, namespace, signal?)` SHALL resolve `//.` and read it via `fs.readFile`. The decoded payload SHALL be returned as a `TranslationRecord`. #### Scenario: nested layout reads expected path - **GIVEN** a `MultiFileFsBackend` with `root: "/tmp/i18n"` and `layout: "nested"` and an injected `MultiFileFsLike` whose `readFile("/tmp/i18n/en/common.json")` resolves to `'{"hello":"Hi"}'` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the result equals `{ hello: "Hi" }` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"nested layout reads expected path"` --- ### Requirement: flat layout reads from `/..` When `layout === "flat"`, `read` SHALL resolve `/..`. #### Scenario: flat layout reads expected path - **GIVEN** a `MultiFileFsBackend` with `root: "/tmp/i18n"` and `layout: "flat"` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the injected `fs.readFile` was called with path `"/tmp/i18n/en.common.json"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"flat layout reads expected path"` --- ### Requirement: glob layout requires globPattern and substitutes placeholders When `layout === "glob"`, the `globPattern` option SHALL be required at construction (missing → `INVALID_OPTIONS`). `read` SHALL substitute `{locale}` and `{namespace}` into `globPattern` and resolve against `root`. #### Scenario: glob layout requires globPattern - **WHEN** `new MultiFileFsBackend({ root: "/tmp", layout: "glob" })` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"` #### Scenario: glob layout substitutes placeholders - **GIVEN** a `MultiFileFsBackend` with `root: "/tmp"`, `layout: "glob"`, `globPattern: "i18n/{namespace}.{locale}.json"` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the injected `fs.readFile` was called with path `"/tmp/i18n/common.en.json"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"glob layout requires globPattern"` AND `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"glob layout substitutes placeholders"` --- ### Requirement: ENOENT/ENOTDIR map to BACKEND_LOCALE_NOT_AVAILABLE When `fs.readFile` rejects with errno `ENOENT` or `ENOTDIR`, `read` SHALL throw `I18nError` with code `I18N.BACKEND_LOCALE_NOT_AVAILABLE`. Other errno codes (`EACCES`, `EBUSY`, etc.) SHALL throw `I18nError` with code `I18N.BACKEND_READ_FAILED`. #### Scenario: ENOENT maps to BACKEND_LOCALE_NOT_AVAILABLE - **GIVEN** a `MultiFileFsBackend` whose `fs.readFile` rejects with `{ code: "ENOENT" }` - **WHEN** `backend.read("en", "missing")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_LOCALE_NOT_AVAILABLE"` #### Scenario: EACCES maps to BACKEND_READ_FAILED - **GIVEN** a `MultiFileFsBackend` whose `fs.readFile` rejects with `{ code: "EACCES" }` - **WHEN** `backend.read("en", "common")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/fs-error-classifier.test.ts::"ENOENT maps to BACKEND_LOCALE_NOT_AVAILABLE"` AND `packages/i18n/i18n-backend-fs-multifile/src/fs-error-classifier.test.ts::"EACCES maps to BACKEND_READ_FAILED"` --- ### Requirement: JSON / JSON5 / YAML peer-detection The codec SHALL support `json` (built-in), `json5` (auto-detected via `import("json5")`), and `yaml` / `yml` (auto-detected via `import("yaml")`) extensions. When the requested peer is not installed, the codec SHALL throw `I18nError` with code `I18N.BACKEND_READ_FAILED` AND the error context SHALL include `{ peer: "" }`. Unknown extensions SHALL throw `I18nError(BACKEND_READ_FAILED)` with the extension in the error message. #### Scenario: missing yaml peer surfaces clear error - **GIVEN** a runtime where `import("yaml")` rejects - **WHEN** `parsePayload(yamlBytes, "yaml", "/tmp/x.yaml")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` AND the error context contains `peer: "yaml"` #### Scenario: unknown extension throws BACKEND_READ_FAILED - **WHEN** `parsePayload(bytes, "toml", "/tmp/x.toml")` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_READ_FAILED"` AND the error message includes `"toml"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/payload-codec.test.ts::"missing yaml peer surfaces clear error"` AND `packages/i18n/i18n-backend-fs-multifile/src/payload-codec.test.ts::"unknown extension throws BACKEND_READ_FAILED"` --- ### Requirement: save round-trip with mkdir parents `save(locale, namespace, record, signal?)` SHALL ensure the parent directory exists via `fs.mkdir({ recursive: true })` and SHALL write the encoded payload via `fs.writeFile`. Encoding for unsupported extensions SHALL throw `I18nError(BACKEND_WRITE_FAILED)`. fs.writeFile failures SHALL surface as `I18nError(BACKEND_WRITE_FAILED)` with `cause` preserved. #### Scenario: save creates parent directory - **WHEN** `backend.save("en", "common", { x: "y" })` is awaited on a `nested`-layout backend - **THEN** the injected `fs.mkdir` was called with `{ recursive: true }` for the parent directory #### Scenario: writeFile failure throws BACKEND_WRITE_FAILED - **GIVEN** a `MultiFileFsBackend` whose `fs.writeFile` rejects with `Error("disk full")` - **WHEN** `backend.save("en", "common", { x: "y" })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_WRITE_FAILED"` AND `.cause.message === "disk full"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"save creates parent directory"` AND `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"writeFile failure throws BACKEND_WRITE_FAILED"` --- ### Requirement: lastModified returns mtime or undefined `lastModified(locale, namespace)` SHALL return `fs.stat(path).mtimeMs` for the resolved file. If `fs.stat` rejects, the method SHALL return `undefined` (NOT throw). #### Scenario: lastModified returns mtime - **GIVEN** a `MultiFileFsBackend` whose `fs.stat` resolves to `{ mtimeMs: 4242 }` - **WHEN** `backend.lastModified("en", "common")` is awaited - **THEN** the result equals `4242` #### Scenario: lastModified swallows stat failure - **GIVEN** a `MultiFileFsBackend` whose `fs.stat` rejects - **WHEN** `backend.lastModified("en", "common")` is awaited - **THEN** the result is `undefined` (no throw) **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"lastModified returns mtime"` AND `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"lastModified swallows stat failure"` --- ### Requirement: init / dispose lifecycle with idempotent watcher `init(context)` SHALL store the `BackendInitContext` for use by the file-watch loop. When `watch: true`, `init` SHALL start a `fs.watch` loop on `root` that calls `context.emitLoaded(locale, namespace, { backend })` for every parsed event path. `dispose()` SHALL abort the watcher and SHALL be safely callable multiple times. #### Scenario: init starts watcher when watch=true - **GIVEN** a `MultiFileFsBackend` with `watch: true` and an injected `fs.watch` returning a controllable async iterable - **WHEN** `backend.init(context)` is awaited and a watch event for `"en/common.json"` is emitted - **THEN** `context.emitLoaded` was called with `("en", "common", { backend: "multi-file-fs" })` #### Scenario: dispose is idempotent - **WHEN** `backend.dispose()` is awaited twice in succession - **THEN** neither call throws **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"init starts watcher when watch=true"` AND `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"dispose is idempotent"` --- ### Requirement: AbortSignal cancellation throws BACKEND_TIMEOUT The `read` and `save` methods SHALL accept an optional `signal?: AbortSignal` parameter. When the signal is already aborted at call time, the method SHALL throw `I18nError` with code `I18N.BACKEND_TIMEOUT` and SHALL NOT touch the filesystem. #### Scenario: pre-aborted signal aborts read - **GIVEN** an `AbortController` whose signal is already aborted - **WHEN** `backend.read("en", "common", { signal })` is awaited - **THEN** the call throws `I18nError` with code `"I18N.BACKEND_TIMEOUT"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.test.ts::"pre-aborted signal aborts read with BACKEND_TIMEOUT"` --- ### Requirement: multiFileFsBackend factory function The package SHALL export `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend` returning `new MultiFileFsBackend(options)`. #### Scenario: factory returns MultiFileFsBackend instance - **WHEN** `multiFileFsBackend({ root: "/tmp" })` is called - **THEN** the result satisfies `result instanceof MultiFileFsBackend` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/factory.test.ts::"factory returns MultiFileFsBackend instance"` --- ### Requirement: OpenTelemetry instrumentation The backend SHALL emit `i18n.backend.fs_multifile.read_duration_ms` (histogram), `i18n.backend.fs_multifile.read_total{outcome,layout}` (counter), `i18n.backend.fs_multifile.read_errors_total{code,layout}` (counter), `i18n.backend.fs_multifile.write_duration_ms` (histogram), and `i18n.backend.fs_multifile.write_total{outcome,layout}` (counter). #### Scenario: read emits layout attribute - **WHEN** `backend.read("en", "common")` succeeds on a `flat`-layout backend - **THEN** `i18n.backend.fs_multifile.read_total` increments with `outcome="hit"` AND `layout="flat"` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/backend.spec-conformance.test.ts::"read emits layout attribute"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports so consumers can import only the surfaces they need: - `@suaveplan/i18n-backend-fs-multifile` — root barrel - `@suaveplan/i18n-backend-fs-multifile/backend` — `MultiFileFsBackend` class only - `@suaveplan/i18n-backend-fs-multifile/factory` — `multiFileFsBackend` factory only - `@suaveplan/i18n-backend-fs-multifile/path-resolver` — `resolvePath` helper only - `@suaveplan/i18n-backend-fs-multifile/payload-codec` — codec helpers only - `@suaveplan/i18n-backend-fs-multifile/fs-error-classifier` — `classifyFsError` only #### Scenario: every subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts::"every documented subpath resolves"` --- ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT depend on `@suaveplan/i18n` core nor on any other backend package nor on `yaml` / `json5` (those are optional peer dependencies). #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` #### Scenario: yaml and json5 are optional peer deps, not direct deps - **WHEN** `package.json` `dependencies` is parsed - **THEN** the result does NOT contain `yaml` AND does NOT contain `json5` AND does NOT contain `@suaveplan/i18n` **Verified by:** `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts::"package.json declares sideEffects false"` AND `packages/i18n/i18n-backend-fs-multifile/src/index.test.ts::"yaml and json5 are optional peer deps not direct deps"`