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

14 KiB

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"