Initial Commit
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# `@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.
|
||||
@@ -0,0 +1,261 @@
|
||||
<!-- 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.)
|
||||
|
||||
## ADDED 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"`
|
||||
@@ -0,0 +1,92 @@
|
||||
# Tasks — `@suaveplan/i18n-backend-fs-multifile` (Node fs/promises multi-file translation backend, server-tier)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3, §6, §6.1 — the only Wave 5 backend on a different tier)
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
|
||||
- [x] 1.3 Read source: `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.ts`, `multi-file-fs-backend.test.ts`, `multi-file-fs-backend.schemas.ts`, `multi-file-fs-backend.md`, plus `packages/i18n/i18n/src/core/backend/backend.types.ts`
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Document the tier split rationale in proposal.md (`### Tier split rationale (server-only, NOT universal)` section)
|
||||
- [x] 1.6 Fill `## Source mapping` table with verified line ranges from monolith
|
||||
- [x] 1.7 Draft `specs/i18n-backend-fs-multifile/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.8 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [x] 1.9 `openspec change validate i18n-backend-fs-multifile` exits with no errors
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `mkdir -p packages/i18n/i18n-backend-fs-multifile/src`
|
||||
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-backend-fs-multifile`, version `0.1.0`, type `module`, sideEffects `false`. **Server tier:** declare `"engines": { "node": ">=20" }`; OMIT `"browser"` field. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`. Optional peerDeps: `yaml`, `json5` (markedOptional via `peerDependenciesMeta`).
|
||||
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library-node.json` (server-tier)
|
||||
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
|
||||
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`; opens with a server-tier marker callout)
|
||||
- [x] 2.6 Add `package.json` `exports` map with subpaths `.`, `./backend`, `./factory`, `./path-resolver`, `./payload-codec`, `./fs-error-classifier`
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [x] 3.1 Author `src/backend.types.ts` — port `MultiFileFsBackendOptions`, `MultiFileFsLayout`, `MultiFileFsLike` from monolith L15-L51 verbatim
|
||||
- [x] 3.2 Author `src/backend.schemas.ts` — port `MultiFileFsBackendOptionsSchema` Zod schema verbatim (15 lines)
|
||||
- [x] 3.3 Author `src/path-resolver.ts` — extract `#resolvePath` body into a standalone `resolvePath(root, layout, extension, globPattern, locale, namespace): string` plus `joinPath` helper
|
||||
- [x] 3.4 Author `src/payload-codec.ts` — extract `parsePayload`, `parseJson`, `parseWithPeer`, `encodePayload` from monolith L313-L400 as standalone exports
|
||||
- [x] 3.5 Author `src/fs-error-classifier.ts` — extract `classifyFsError` from monolith L297-L311 as a standalone export
|
||||
- [x] 3.6 Author `src/observability.ts` — inline equivalents of `recordRead` / `recordWrite`; export histograms and counters with `i18n.backend.fs_multifile.` prefix; include `layout` attribute on counters
|
||||
- [x] 3.7 Author `src/backend.ts` — port `MultiFileFsBackend` class from monolith L67-L284; rewire imports; use helpers from `src/path-resolver.ts`, `src/payload-codec.ts`, `src/fs-error-classifier.ts`; emit metrics via `src/observability.ts`; add optional `signal?: AbortSignal` parameter on `read` / `save` and throw `I18nError(I18N.BACKEND_TIMEOUT)` when signal aborts; LEADING SOURCE COMMENT: `// Server-tier package — uses node:fs/promises. Browser consumers: use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.`
|
||||
|
||||
## 4. Factory function
|
||||
|
||||
- [x] 4.1 Author `src/factory.ts` — `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend` returning `new MultiFileFsBackend(options)`
|
||||
|
||||
## 5. Documentation (sibling `.md` per implementation file)
|
||||
|
||||
- [x] 5.1 `src/backend.md` — port from `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.md`; ≥200 words; opens with server-tier marker; basic + advanced examples (per layout, with file-watch, with YAML peer)
|
||||
- [x] 5.2 `src/factory.md` — ≥200 words; basic + advanced examples
|
||||
- [x] 5.3 `src/path-resolver.md` — per-layout path shapes; glob substitution rules
|
||||
- [x] 5.4 `src/payload-codec.md` — supported extensions; peer-detection contract; missing-peer error shape
|
||||
- [x] 5.5 `src/fs-error-classifier.md` — errno → I18N code mapping table
|
||||
- [x] 5.6 `src/observability.md` — emitted metrics + units + cardinality
|
||||
- [x] 5.7 `README.md` `## Modules` section links every `.md`; opens with `> **Server-tier package** — uses`node:fs/promises`. For browser consumers, use`@suaveplan/i18n-backend-http` or `@suaveplan/i18n-backend-storage`.`
|
||||
|
||||
## 6. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
|
||||
|
||||
- [x] 6.1 `src/backend.test.ts` — port every test from `packages/i18n/i18n/src/core/backend/multi-file-fs/multi-file-fs-backend.test.ts` (556 lines); same `it(...)` titles for conformance traceability; cover `nested`, `flat`, `glob` layouts; `read` hit/miss/parse-failure; `save` round-trip; `lastModified`; `init` / `dispose` lifecycle; YAML / JSON5 peer-detect happy path AND missing-peer path; `fs.watch` integration via injected `MultiFileFsLike`
|
||||
- [x] 6.2 `src/backend.spec-conformance.test.ts` — one `it(...)` per spec requirement; titles match `**Verified by:**` lines exactly
|
||||
- [x] 6.3 `src/factory.test.ts` — `multiFileFsBackend()` returns `MultiFileFsBackend` instance; default `name === "multi-file-fs"`
|
||||
- [x] 6.4 `src/path-resolver.test.ts` — `nested` produces `<root>/<locale>/<namespace>.<ext>`; `flat` produces `<root>/<locale>.<namespace>.<ext>`; `glob` substitutes both placeholders
|
||||
- [x] 6.5 `src/payload-codec.test.ts` — JSON parse; YAML peer-detect happy + missing; JSON5 peer-detect happy + missing; unknown extension → `BACKEND_READ_FAILED`; non-object payload → `BACKEND_READ_FAILED`; encode for unsupported extension → `BACKEND_WRITE_FAILED`
|
||||
- [x] 6.6 `src/fs-error-classifier.test.ts` — `ENOENT` and `ENOTDIR` → `BACKEND_LOCALE_NOT_AVAILABLE`; `EACCES` and `EBUSY` → `BACKEND_READ_FAILED`
|
||||
- [x] 6.7 `src/index.test.ts` — every documented public export resolves through `.`, `./backend`, `./factory`, `./path-resolver`, `./payload-codec`, `./fs-error-classifier`; `package.json` declares `engines.node >=20` AND omits `browser` field
|
||||
- [x] 6.8 Add an AbortSignal cancellation test — pre-aborted signal causes `read` to throw `I18nError(I18N.BACKEND_TIMEOUT)`
|
||||
- [x] 6.9 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 6.10 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-backend-fs-multifile/src/` returning zero hits
|
||||
- [x] 6.11 No `// biome-ignore` suppressions (CLAUDE.md §9)
|
||||
- [x] 6.12 No `export *` in any non-root barrel (CLAUDE.md §10)
|
||||
|
||||
## 7. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 7.1 `bun run check` — Biome 0/0/0 across the monorepo
|
||||
- [x] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-backend-fs-multifile` — green
|
||||
- [x] 7.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-backend-fs-multifile` — 100/100/100/100
|
||||
- [x] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-backend-fs-multifile` — exit 0
|
||||
- [x] 7.5 `bunx turbo build --filter=@suaveplan/i18n-backend-fs-multifile` — green
|
||||
|
||||
## 8. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 8.1 Generics threaded — `MultiFileFsLike` injection contract typed; layout discriminator union exhaustively narrowed
|
||||
- [x] 8.2 Zod schemas wired at the public boundary — `MultiFileFsBackendOptionsSchema` parses constructor input; `globPattern` required when `layout: "glob"` enforced via `INVALID_OPTIONS`
|
||||
- [x] 8.3 Every error code thrown by at least one production path: `INVALID_OPTIONS` (constructor), `BACKEND_LOCALE_NOT_AVAILABLE` (ENOENT/ENOTDIR), `BACKEND_READ_FAILED` (other fs errors / parse failure / unknown extension / missing peer), `BACKEND_WRITE_FAILED` (writeFile failure / unsupported extension), `BACKEND_TIMEOUT` (signal aborted)
|
||||
- [x] 8.4 Strict-mode tested — exhaustive switch on `MultiFileFsLayout` enforced via `expectTypeOf`
|
||||
- [x] 8.5 OTel histograms emitted from production paths: `i18n.backend.fs_multifile.read_duration_ms`, `i18n.backend.fs_multifile.write_duration_ms`; counters with `layout` attribute
|
||||
- [x] 8.6 Lifecycle dedup — `init` is idempotent (re-calling does not start a second watcher); `dispose` is idempotent (re-calling is a no-op)
|
||||
- [x] 8.7 No retroactive tick — every box reflects work done in this change
|
||||
|
||||
## 9. Manifest + ecosystem awareness
|
||||
|
||||
- [x] 9.1 Add `@suaveplan/i18n-backend-fs-multifile` entry to root `CLAUDE.md` package listing under the i18n cluster with a `(server-tier)` marker
|
||||
- [x] 9.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
|
||||
- [x] 9.3 Append `@suaveplan/i18n-backend-fs-multifile` to `STATUS.md` Completed list with a server-tier marker
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [x] 10.1 Fold spec delta into `openspec/specs/i18n-backend-fs-multifile/spec.md` (NEW capability spec — first delta becomes canonical)
|
||||
- [x] 10.2 Move `openspec/changes/i18n-backend-fs-multifile/` → `openspec/archive/<yyyy-mm-dd>-i18n-backend-fs-multifile/`
|
||||
Reference in New Issue
Block a user