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. (Seeopenspec/AGENTS.mdSection 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.jsonis parsed - THEN the
engines.nodefield equals">=20"AND thebrowserfield does NOT exist
Scenario: src/backend.ts carries the server-tier marker comment
- WHEN
src/backend.tsis 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.canWatchis 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
MultiFileFsBackendwithroot: "/tmp/i18n"andlayout: "nested"and an injectedMultiFileFsLikewhosereadFile("/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
MultiFileFsBackendwithroot: "/tmp/i18n"andlayout: "flat" - WHEN
backend.read("en", "common")is awaited - THEN the injected
fs.readFilewas 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
I18nErrorwith code"I18N.INVALID_OPTIONS"
Scenario: glob layout substitutes placeholders
- GIVEN a
MultiFileFsBackendwithroot: "/tmp",layout: "glob",globPattern: "i18n/{namespace}.{locale}.json" - WHEN
backend.read("en", "common")is awaited - THEN the injected
fs.readFilewas 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
MultiFileFsBackendwhosefs.readFilerejects with{ code: "ENOENT" } - WHEN
backend.read("en", "missing")is awaited - THEN the call throws
I18nErrorwith code"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
Scenario: EACCES maps to BACKEND_READ_FAILED
- GIVEN a
MultiFileFsBackendwhosefs.readFilerejects with{ code: "EACCES" } - WHEN
backend.read("en", "common")is awaited - THEN the call throws
I18nErrorwith 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
I18nErrorwith code"I18N.BACKEND_READ_FAILED"AND the error context containspeer: "yaml"
Scenario: unknown extension throws BACKEND_READ_FAILED
- WHEN
parsePayload(bytes, "toml", "/tmp/x.toml")is awaited - THEN the call throws
I18nErrorwith 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 anested-layout backend - THEN the injected
fs.mkdirwas called with{ recursive: true }for the parent directory
Scenario: writeFile failure throws BACKEND_WRITE_FAILED
- GIVEN a
MultiFileFsBackendwhosefs.writeFilerejects withError("disk full") - WHEN
backend.save("en", "common", { x: "y" })is awaited - THEN the call throws
I18nErrorwith 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
MultiFileFsBackendwhosefs.statresolves to{ mtimeMs: 4242 } - WHEN
backend.lastModified("en", "common")is awaited - THEN the result equals
4242
Scenario: lastModified swallows stat failure
- GIVEN a
MultiFileFsBackendwhosefs.statrejects - 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
MultiFileFsBackendwithwatch: trueand an injectedfs.watchreturning a controllable async iterable - WHEN
backend.init(context)is awaited and a watch event for"en/common.json"is emitted - THEN
context.emitLoadedwas 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
AbortControllerwhose signal is already aborted - WHEN
backend.read("en", "common", { signal })is awaited - THEN the call throws
I18nErrorwith 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 aflat-layout backend - THEN
i18n.backend.fs_multifile.read_totalincrements withoutcome="hit"ANDlayout="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—MultiFileFsBackendclass only@suaveplan/i18n-backend-fs-multifile/factory—multiFileFsBackendfactory only@suaveplan/i18n-backend-fs-multifile/path-resolver—resolvePathhelper only@suaveplan/i18n-backend-fs-multifile/payload-codec— codec helpers only@suaveplan/i18n-backend-fs-multifile/fs-error-classifier—classifyFsErroronly
Scenario: every subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Scenario: yaml and json5 are optional peer deps, not direct deps
- WHEN
package.jsondependenciesis parsed - THEN the result does NOT contain
yamlAND does NOT containjson5AND 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"