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

262 lines
14 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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: <follows watch option> }`. 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 `<root>/<locale>/<namespace>.<ext>`
When `layout === "nested"` (default), `read(locale, namespace, signal?)` SHALL resolve `<root>/<locale>/<namespace>.<extension>` 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 `<root>/<locale>.<namespace>.<ext>`
When `layout === "flat"`, `read` SHALL resolve `<root>/<locale>.<namespace>.<extension>`.
#### 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: "<name>" }`. 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"`