Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# @suaveplan/i18n-backend-fs-multifile - Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
## Summary
> **Server-tier package** — uses `node:fs/promises`. For browser consumers, use
> [`@suaveplan/i18n-backend-http`](../i18n-backend-http) or
> [`@suaveplan/i18n-backend-storage`](../i18n-backend-storage).
Server-tier translation backend for the `@suaveplan/i18n` family. Reads
locale + namespace catalogs from the local filesystem in three wire layouts,
optionally watches the root directory for changes, and emits `loaded` events
into the catalog event bus so React Suspense / HMR consumers refresh without
polling. JSON is built in; YAML and JSON5 are auto-detected via dynamic
`import()` so the package stays lean and the consumer picks the parser.
The backend implements the universal `Backend` contract from
`@suaveplan/types` — capabilities matrix `{ canRead, canReadMulti: false,
canSave, canTrackModification, canWatch }` — and conforms to the
`runBackendConformance` harness shipped from
`@suaveplan/i18n-shared/testing`. Errors are surfaced via `I18nError` from
`@suaveplan/i18n-shared`: `ENOENT`/`ENOTDIR` map to
`BACKEND_LOCALE_NOT_AVAILABLE` (so a chained-backend can fall through to
the next layer), other errno codes map to `BACKEND_READ_FAILED`, and a
pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT` without
touching the filesystem. OpenTelemetry instruments are emitted under the
`i18n.backend.fs_multifile.*` prefix with a `layout` attribute.
<!-- begin:auto-docs -->
## Documentation tree
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [backend](./src/backend/backend.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [observability](./src/observability/observability.md) — <!-- begin:auto-docs --> _(no submodules)_
## Modules
| Module | Doc |
|---|---|
| `MultiFileFsBackend` class + Zod options schema | [`src/backend/backend.md`](./src/backend/backend.md) |
| `multiFileFsBackend()` factory | [`src/factory/factory.md`](./src/factory/factory.md) |
| OTel instruments + state ring | [`src/observability/observability.md`](./src/observability/observability.md) |
## Quick example
```ts
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = multiFileFsBackend({ root: "./locales", layout: "nested" });
const record = await backend.read("en", "auth");
```
See the linked module docs for the advanced examples (watch mode, glob
layout, YAML peer detection, AbortSignal cancellation).

View File

@@ -0,0 +1,85 @@
{
"name": "@suaveplan/i18n-backend-fs-multifile",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.",
"license": "MIT",
"author": "Suaveplan Team",
"engines": {
"node": ">=20"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./backend": {
"import": {
"types": "./dist/backend/index.d.ts",
"default": "./dist/backend/index.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,125 @@
# `backend.ts` — `MultiFileFsBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Server-tier translation `Backend` that reads and writes
`<root>/<locale>/<namespace>.<ext>` (or one of the alternate `flat` /
`glob` layouts) on the local filesystem via `node:fs/promises`. The
class implements the universal `Backend` contract from
`@suaveplan/types` and conforms to the
`runBackendConformance` harness shipped from
`@suaveplan/i18n-shared/testing`. It exists as its own package — and
not part of the universal `@suaveplan/i18n` core — because the
default code path performs `await import("node:fs/promises")` which
fails at module evaluation in browsers and non-Node runtimes that do
not expose Node built-ins.
## Features
- Three wire layouts: `nested` (default), `flat`, `glob` (with
`{locale}` / `{namespace}` substitution into a caller-supplied
pattern).
- `read` / `save` / `lastModified` over a `MultiFileFsLike` shim that
defaults to `node:fs/promises` and is fully injectable for tests.
- JSON built in. YAML and JSON5 are auto-detected via dynamic
`import()` so the package stays free of mandatory parser deps.
- Optional `fs.watch` loop emits `loaded` events into the
`BackendInitContext.emitLoaded` channel for HMR / Suspense refresh.
- Pre-aborted `AbortSignal` short-circuits with `BACKEND_TIMEOUT`
without touching the filesystem. ENOENT / ENOTDIR fall through as
`BACKEND_LOCALE_NOT_AVAILABLE`; other errno codes surface as
`BACKEND_READ_FAILED`.
- Zod-validated options at the public boundary — bad `layout` /
missing `globPattern` / non-string `name` rejected as
`INVALID_OPTIONS`.
## Usage: Basic Example
```ts
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = new MultiFileFsBackend({ root: "./locales" });
const record = await backend.read("en", "auth");
// → { login: { title: "Sign in" } }
```
## Usage: Advanced Example
```ts
import { MultiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = new MultiFileFsBackend({
root: "./locales",
layout: "glob",
globPattern: "{locale}/{namespace}.json",
extension: "json",
watch: true,
});
await backend.init({
catalogName: "app",
emitLoaded: (locale, ns) => {
console.log(`reloaded ${locale}/${ns}`);
},
});
// AbortSignal short-circuits with BACKEND_TIMEOUT.
const ctrl = new AbortController();
ctrl.abort();
await backend.read("en", "auth", ctrl.signal); // throws I18N.BACKEND_TIMEOUT
// YAML peer auto-detection — install `yaml` to enable.
const yamlBackend = new MultiFileFsBackend({
root: "./locales",
extension: "yaml",
});
const fr = await yamlBackend.read("fr", "auth");
await backend.dispose();
```
## API Reference
- `new MultiFileFsBackend(options: MultiFileFsBackendOptions)`:
Constructs and validates options. Throws
`I18nError(I18N.INVALID_OPTIONS)` on Zod failure or when
`layout: "glob"` is supplied without `globPattern`.
- `read(locale, namespace, signal?)`: returns the parsed
`TranslationRecord`. Throws
`BACKEND_LOCALE_NOT_AVAILABLE` on ENOENT/ENOTDIR,
`BACKEND_READ_FAILED` on other fs / parse / unknown-extension /
missing-peer failures, `BACKEND_TIMEOUT` on a pre-aborted signal.
- `save(locale, namespace, record, signal?)`: ensures the parent
directory and writes the encoded payload. Throws
`BACKEND_WRITE_FAILED` on writeFile failure or unsupported
extension; `BACKEND_TIMEOUT` on pre-aborted signal.
- `lastModified(locale, namespace)`: returns `mtimeMs` or `undefined`
(never throws).
- `init(context)`: stores the `BackendInitContext` and starts the
`fs.watch` loop when `watch: true`.
- `dispose()`: aborts the watcher; safe to call multiple times.
- `MultiFileFsBackendOptionsSchema`: exported Zod schema for the
options bag.
## Implementation Notes
- A leading source-comment marks the package as server-tier so
consumers do not accidentally land it in a browser bundle. Wave 6's
slim universal `@suaveplan/i18n` core does NOT re-export this
package.
- The `#getFs` method dynamically imports `node:fs/promises` only
when no `fs` shim was injected, keeping injected-fs tests
hermetic and avoiding the cost when consumers supply their own
filesystem.
- `parsePayload` routes JSON through built-in `JSON.parse`. Unknown
extensions throw `BACKEND_READ_FAILED` with the extension name in
the error message and context. Peer parsers (`yaml`, `json5`) are
loaded via dynamic `import()`; missing peers surface as
`BACKEND_READ_FAILED` with `{ peer }` in the context so consumers
can install the right package.
- `joinPath` collapses leading and trailing slashes so
`root: ""` (relative to cwd) and `root: "/abs"` both produce well-formed
paths.

View File

@@ -0,0 +1,939 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getMultiFileFsBackendMetricsState,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "../observability/observability.js";
import { MultiFileFsBackend } from "./backend.js";
let testRoot: string;
beforeEach(async () => {
testRoot = await mkdtemp(join(tmpdir(), "i18n-mfs-"));
resetMultiFileFsBackendMetricsCache();
resetMultiFileFsBackendMetricsState();
});
afterEach(async () => {
if (testRoot) await rm(testRoot, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────
// Universal Backend conformance suite (real tmpdir; nested layout)
// ─────────────────────────────────────────────────────────────────────
runBackendConformance("MultiFileFsBackend", async () => {
const root = await mkdtemp(join(tmpdir(), "i18n-mfs-conf-"));
const backend = new MultiFileFsBackend({ root });
return {
backend,
async seed(locale, namespace, record) {
await backend.save(locale, namespace, record as never);
},
};
});
// ─────────────────────────────────────────────────────────────────────
// MultiFileFsBackend specifics — nested layout
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend nested layout", () => {
it("loads <root>/<locale>/<namespace>.json", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(
join(testRoot, "en", "auth.json"),
JSON.stringify({ login: { title: "Sign in" } })
);
const backend = new MultiFileFsBackend({ root: testRoot });
const record = await backend.read("en", "auth");
expect(record).toEqual({ login: { title: "Sign in" } });
});
it("nested layout reads expected path", { timeout: 5000 }, async () => {
const seen: string[] = [];
const fs = {
readFile: async (p: string) => {
seen.push(p);
return new TextEncoder().encode(
JSON.stringify({ hello: "Hi" })
);
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({
root: "/tmp/i18n",
layout: "nested",
fs,
});
const record = await backend.read("en", "common");
expect(record).toEqual({ hello: "Hi" });
expect(seen[0]).toBe("/tmp/i18n/en/common.json");
});
it("ENOENT maps to BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
try {
await backend.read("de", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
);
}
});
it("invalid JSON maps to BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.json"), "not-json");
const backend = new MultiFileFsBackend({ root: testRoot });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
}
});
it("array JSON rejects as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.json"), "[]");
const backend = new MultiFileFsBackend({ root: testRoot });
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
I18nError
);
});
it("save creates the locale directory and round-trips", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await backend.save("fr", "common", { ok: "Ok" });
const record = await backend.read("fr", "common");
expect(record).toEqual({ ok: "Ok" });
});
it("save creates parent directory", { timeout: 5000 }, async () => {
const mkdirCalls: Array<{ path: string; recursive?: boolean }> = [];
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {},
mkdir: async (p: string, opts?: { recursive?: boolean }) => {
mkdirCalls.push({ path: p, recursive: opts?.recursive });
},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
await backend.save("en", "common", { x: "y" });
expect(mkdirCalls).toEqual([{ path: "/tmp/i18n/en", recursive: true }]);
});
it("writeFile failure throws BACKEND_WRITE_FAILED", {
timeout: 5000,
}, async () => {
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {
throw new Error("disk full");
},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
try {
await backend.save("en", "common", { x: "y" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_WRITE_FAILED
);
expect(((err as I18nError).cause as Error).message).toBe(
"disk full"
);
}
});
it("save preserves I18nError thrown by encoder", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "yaml",
});
try {
await backend.save("en", "auth", { ok: "Ok" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_WRITE_FAILED
);
}
});
it("lastModified returns mtime-ms after save", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await backend.save("en", "auth", { ok: "Ok" });
const t = await backend.lastModified("en", "auth");
expect(typeof t).toBe("number");
expect(t).toBeGreaterThan(0);
});
it("lastModified returns mtime", { timeout: 5000 }, async () => {
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 4242 }),
};
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
expect(await backend.lastModified("en", "common")).toBe(4242);
});
it("lastModified swallows stat failure", { timeout: 5000 }, async () => {
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => {
throw new Error("stat failed");
},
};
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
expect(await backend.lastModified("en", "auth")).toBeUndefined();
});
it("lastModified returns undefined for missing file", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────
// MultiFileFsBackend specifics — flat layout
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend flat layout", () => {
it("loads <root>/<locale>.<namespace>.json", {
timeout: 5000,
}, async () => {
await writeFile(
join(testRoot, "en.auth.json"),
JSON.stringify({ ok: "Ok" })
);
const backend = new MultiFileFsBackend({
root: testRoot,
layout: "flat",
});
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
});
it("flat layout reads expected path", { timeout: 5000 }, async () => {
const seen: string[] = [];
const fs = {
readFile: async (p: string) => {
seen.push(p);
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({
root: "/tmp/i18n",
layout: "flat",
fs,
});
await backend.read("en", "common");
expect(seen[0]).toBe("/tmp/i18n/en.common.json");
});
});
// ─────────────────────────────────────────────────────────────────────
// MultiFileFsBackend specifics — glob layout
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend glob layout", () => {
it("substitutes {locale} and {namespace} in pattern", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "fr"), { recursive: true });
await writeFile(
join(testRoot, "fr", "auth.fr.json"),
JSON.stringify({ ok: "Ok-fr" })
);
const backend = new MultiFileFsBackend({
root: testRoot,
layout: "glob",
globPattern: "{locale}/{namespace}.{locale}.json",
});
expect(await backend.read("fr", "auth")).toEqual({ ok: "Ok-fr" });
});
it("glob layout requires globPattern", { timeout: 5000 }, () => {
try {
new MultiFileFsBackend({ root: testRoot, layout: "glob" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("glob layout substitutes placeholders", { timeout: 5000 }, async () => {
const seen: string[] = [];
const fs = {
readFile: async (p: string) => {
seen.push(p);
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({
root: "/tmp",
layout: "glob",
globPattern: "i18n/{namespace}.{locale}.json",
fs,
});
await backend.read("en", "common");
expect(seen[0]).toBe("/tmp/i18n/common.en.json");
});
});
// ─────────────────────────────────────────────────────────────────────
// Capabilities + extension support
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend capabilities", () => {
it("capabilities matrix follows watch option", { timeout: 5000 }, () => {
const a = new MultiFileFsBackend({ root: testRoot, watch: true });
expect(a.capabilities.canWatch).toBe(true);
const b = new MultiFileFsBackend({ root: testRoot, watch: false });
expect(b.capabilities.canWatch).toBe(false);
});
});
describe("MultiFileFsBackend extension support", () => {
it("rejects unknown extension as BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.toml"), "ok = 'Ok'");
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "toml",
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
}
});
it("yaml extension parses via the yaml peer when present", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.yaml"), "ok: Ok\n");
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "yaml",
});
try {
const record = await backend.read("en", "auth");
expect(record).toEqual({ ok: "Ok" });
} catch (err) {
// If yaml peer is not installed, the surface error must
// identify the missing peer.
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
expect(String((err as I18nError).message)).toContain("yaml");
}
});
it("yml extension routes through yaml peer", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.yml"), "ok: Ok\n");
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "yml",
});
try {
await backend.read("en", "auth");
} catch (err) {
expect((err as I18nError).message).toContain("yaml");
}
});
it("yaml peer returning non-record payload surfaces BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.yaml"), '"just a string"\n');
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "yaml",
});
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
// Either "non-record" (peer present) or "yaml" (peer missing)
// — both are valid spec outcomes.
const msg = String((err as I18nError).message);
expect(/non-record|yaml/.test(msg)).toBe(true);
}
});
it("json5 extension routes through the parseWithPeer dispatch", {
timeout: 5000,
}, async () => {
await mkdir(join(testRoot, "en"), { recursive: true });
await writeFile(join(testRoot, "en", "auth.json5"), "{ ok: 'Ok', }\n");
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "json5",
});
try {
await backend.read("en", "auth");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
expect(String((err as I18nError).message)).toContain("json5");
}
});
it("save rejects non-JSON extension as BACKEND_WRITE_FAILED", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({
root: testRoot,
extension: "yaml",
});
try {
await backend.save("en", "auth", { ok: "Ok" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_WRITE_FAILED
);
}
});
it("save accepts json5 extension (encodes as JSON)", {
timeout: 5000,
}, async () => {
const writes: Array<{ p: string; data: Uint8Array | string }> = [];
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async (p: string, data: Uint8Array | string) => {
writes.push({ p, data });
},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({
root: "/tmp",
extension: "json5",
fs,
});
await backend.save("en", "auth", { ok: "Ok" });
expect(writes.length).toBe(1);
});
});
// ─────────────────────────────────────────────────────────────────────
// Watch lifecycle
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend watch mode", () => {
it("init starts watcher when watch=true", { timeout: 5000 }, async () => {
const events: Array<{
locale: string;
namespace: string;
ctx: unknown;
}> = [];
const iter = (async function* () {
yield { filename: "en/common.json" };
})();
const fs = {
readFile: async () =>
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 1 }),
watch: () => iter,
};
const backend = new MultiFileFsBackend({
root: "/virtual",
watch: true,
fs,
});
await backend.init({
catalogName: "test",
emitLoaded: (locale, namespace, ctx) => {
events.push({ locale, namespace, ctx });
},
});
await new Promise((r) => setTimeout(r, 30));
await backend.dispose();
expect(events).toEqual([
{
locale: "en",
namespace: "common",
ctx: { backend: "multi-file-fs" },
},
]);
});
it("emits loaded event when a watched file changes", {
timeout: 5000,
}, async () => {
const events: Array<{ locale: string; namespace: string }> = [];
const fakeIter = (async function* () {
yield { filename: "en/auth.json" };
})();
const fs = {
readFile: async () =>
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 1 }),
watch: () => fakeIter,
};
const backend = new MultiFileFsBackend({
root: "/virtual",
watch: true,
fs,
});
await backend.init({
catalogName: "test",
emitLoaded: (locale, namespace) => {
events.push({ locale, namespace });
},
});
await new Promise((r) => setTimeout(r, 30));
await backend.dispose();
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
});
it("watch ignores null filenames and unmatchable patterns", {
timeout: 5000,
}, async () => {
const events: unknown[] = [];
const iter = (async function* () {
yield { filename: null };
yield { filename: "noslash.json" };
})();
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
watch: () => iter,
};
const backend = new MultiFileFsBackend({
root: "/virtual",
watch: true,
fs,
});
await backend.init({
catalogName: "x",
emitLoaded: () => events.push({}),
});
await new Promise((r) => setTimeout(r, 30));
await backend.dispose();
expect(events).toEqual([]);
});
it("watch parses flat-layout filenames", {
timeout: 5000,
}, async () => {
const events: Array<{ locale: string; namespace: string }> = [];
const iter = (async function* () {
yield { filename: "en.auth.json" };
yield { filename: "no-extension" };
})();
const fs = {
readFile: async () =>
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
watch: () => iter,
};
const backend = new MultiFileFsBackend({
root: "/virtual",
layout: "flat",
watch: true,
fs,
});
await backend.init({
catalogName: "test",
emitLoaded: (locale, namespace) => {
events.push({ locale, namespace });
},
});
await new Promise((r) => setTimeout(r, 30));
await backend.dispose();
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
});
it("watch parser ignores unrelated filenames in nested layout", {
timeout: 5000,
}, async () => {
const events: unknown[] = [];
const iter = (async function* () {
yield { filename: "unrelated.txt" };
})();
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
watch: () => iter,
};
const backend = new MultiFileFsBackend({
root: "/virtual",
watch: true,
fs,
});
await backend.init({
catalogName: "test",
emitLoaded: (...args) => events.push(args),
});
await new Promise((r) => setTimeout(r, 30));
await backend.dispose();
expect(events).toEqual([]);
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await backend.dispose();
await backend.dispose();
expect(true).toBe(true);
});
it("init with watch=false does not start a watcher", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await backend.init({ catalogName: "x", emitLoaded: () => {} });
await backend.dispose();
expect(true).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────
// FS error classification
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend filesystem error classification", () => {
it("non-ENOENT errno (e.g. EACCES) maps to BACKEND_READ_FAILED", {
timeout: 5000,
}, async () => {
const fs = {
readFile: async () => {
const err = new Error("permission denied") as Error & {
code: string;
};
err.code = "EACCES";
throw err;
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_READ_FAILED
);
}
});
it("ENOTDIR maps to BACKEND_LOCALE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const fs = {
readFile: async () => {
const err = new Error("not a directory") as Error & {
code: string;
};
err.code = "ENOTDIR";
throw err;
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
try {
await backend.read("en", "auth");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
);
}
});
});
// ─────────────────────────────────────────────────────────────────────
// Constructor option validation
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend constructor", () => {
it("rejects malformed options shape via Zod (e.g. layout: bogus)", {
timeout: 5000,
}, () => {
try {
const bad: unknown = "bogus";
new MultiFileFsBackend({
root: testRoot,
layout: bad as "nested",
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("rejects empty name with INVALID_OPTIONS", { timeout: 5000 }, () => {
try {
new MultiFileFsBackend({ root: testRoot, name: "" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("default name is multi-file-fs", { timeout: 5000 }, () => {
const backend = new MultiFileFsBackend({ root: testRoot });
expect(backend.name).toBe("multi-file-fs");
});
it("accepts empty root (paths relative to cwd)", {
timeout: 5000,
}, async () => {
let mkdirCalls = 0;
let writePath: string | undefined;
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async (p: string) => {
writePath = p;
},
mkdir: async () => {
mkdirCalls++;
},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({
root: "",
layout: "glob",
globPattern: "{namespace}.{locale}.json",
fs,
});
await backend.save("en", "auth", { ok: "Ok" });
expect(writePath).toBe("auth.en.json");
expect(mkdirCalls).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────
// AbortSignal cancellation
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend AbortSignal threading", () => {
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
let touched = false;
const fs = {
readFile: async () => {
touched = true;
return new Uint8Array();
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
const ctrl = new AbortController();
ctrl.abort();
await expect(
backend.read("en", "auth", ctrl.signal)
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
expect(touched).toBe(false);
});
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
timeout: 5000,
}, async () => {
let touched = false;
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {
touched = true;
},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
const ctrl = new AbortController();
ctrl.abort();
await expect(
backend.save("en", "auth", { ok: "Ok" }, ctrl.signal)
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
expect(touched).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────
// Observability — layout attribute on metrics
// ─────────────────────────────────────────────────────────────────────
describe("MultiFileFsBackend observability", () => {
it("read emits layout attribute", { timeout: 5000 }, async () => {
await writeFile(
join(testRoot, "en.common.json"),
JSON.stringify({ ok: "Ok" })
);
const backend = new MultiFileFsBackend({
root: testRoot,
layout: "flat",
});
await backend.read("en", "common");
const state = getMultiFileFsBackendMetricsState();
const flat = state.readTotal.find(
(s) => s.attributes["layout"] === "flat"
);
expect(flat).toBeDefined();
expect(flat?.attributes["outcome"]).toBe("hit");
});
it("write emits layout attribute on success", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await backend.save("en", "auth", { ok: "Ok" });
const state = getMultiFileFsBackendMetricsState();
const okWrite = state.writeTotal.find(
(s) =>
s.attributes["layout"] === "nested" &&
s.attributes["outcome"] === "ok"
);
expect(okWrite).toBeDefined();
});
it("write emits layout attribute on error", {
timeout: 5000,
}, async () => {
const fs = {
readFile: async () => new Uint8Array(),
writeFile: async () => {
throw new Error("disk full");
},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/tmp/i", fs });
await expect(
backend.save("en", "auth", { ok: "Ok" })
).rejects.toBeInstanceOf(I18nError);
const state = getMultiFileFsBackendMetricsState();
const errWrite = state.writeTotal.find(
(s) => s.attributes["outcome"] === "error"
);
expect(errWrite).toBeDefined();
expect(errWrite?.attributes["layout"]).toBe("nested");
});
it("read miss records BACKEND_LOCALE_NOT_AVAILABLE error code", {
timeout: 5000,
}, async () => {
const backend = new MultiFileFsBackend({ root: testRoot });
await expect(backend.read("xx", "auth")).rejects.toBeInstanceOf(
I18nError
);
const state = getMultiFileFsBackendMetricsState();
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
expect(codes).toContain(I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE);
});
it("read error records BACKEND_READ_FAILED code", {
timeout: 5000,
}, async () => {
const fs = {
readFile: async () => {
const err = new Error("perm") as Error & { code: string };
err.code = "EACCES";
throw err;
},
writeFile: async () => {},
mkdir: async () => {},
stat: async () => ({ mtimeMs: 0 }),
};
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
I18nError
);
const state = getMultiFileFsBackendMetricsState();
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
expect(codes).toContain(I18N_CODES.BACKEND_READ_FAILED);
});
});

View File

@@ -0,0 +1,417 @@
// Server-tier package — uses node:fs/promises. Browser consumers: use
// @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage.
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type {
Backend,
BackendCapabilities,
BackendInitContext,
TranslationRecord,
} from "@suaveplan/types";
import z from "zod";
import { recordRead, recordWrite } from "../observability/observability.js";
import type {
MultiFileFsBackendOptions,
MultiFileFsLayout,
MultiFileFsLike,
} from "./backend.types.js";
/**
* Zod boundary schema for {@link MultiFileFsBackendOptions}. Validates
* the statically-checkable shape only — `fs` is accepted as opaque
* `z.unknown()` because the `MultiFileFsLike` contract is structural.
*/
export const MultiFileFsBackendOptionsSchema = z.object({
root: z.string(),
layout: z.enum(["nested", "flat", "glob"]).optional(),
globPattern: z.string().optional(),
extension: z.string().optional(),
name: z.string().min(1).optional(),
watch: z.boolean().optional(),
fs: z.unknown().optional(),
});
export type MultiFileFsBackendOptionsInput = z.input<
typeof MultiFileFsBackendOptionsSchema
>;
/**
* Server-tier filesystem-backed translation source.
*
* Reads `<root>/<locale>/<namespace>.<ext>` (`nested`),
* `<root>/<locale>.<namespace>.<ext>` (`flat`), or a caller-supplied
* `globPattern` (`glob`). Optional peer parsers `yaml` and `json5` are
* auto-detected via dynamic import; an unknown extension or a missing
* peer surfaces as `BACKEND_READ_FAILED` with the peer name in the
* error context.
*
* `fs.watch` is consulted only when `watch: true`. The watcher emits
* `loaded` events through `BackendInitContext.emitLoaded` so React
* Suspense / HMR consumers can refresh without polling.
*
* AbortSignal is honoured on `read` and `save`: a pre-aborted signal
* short-circuits with `BACKEND_TIMEOUT` without touching the
* filesystem.
*/
export class MultiFileFsBackend implements Backend {
public readonly name: string;
public readonly capabilities: BackendCapabilities;
readonly #root: string;
readonly #layout: MultiFileFsLayout;
readonly #globPattern: string | undefined;
readonly #extension: string;
readonly #watch: boolean;
readonly #fs: MultiFileFsLike | "default";
#initContext: BackendInitContext | undefined;
#watchAbort: AbortController | undefined;
public constructor(options: MultiFileFsBackendOptions) {
const parsed = MultiFileFsBackendOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"MultiFileFsBackend: invalid options",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
if (options.layout === "glob" && !options.globPattern) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"MultiFileFsBackend: globPattern is required when layout='glob'"
);
}
this.name = options.name ?? "multi-file-fs";
this.#root = options.root;
this.#layout = options.layout ?? "nested";
this.#globPattern = options.globPattern;
this.#extension = options.extension ?? "json";
this.#watch = options.watch ?? false;
this.#fs = options.fs ?? "default";
this.capabilities = {
canRead: true,
canReadMulti: false,
canSave: true,
canTrackModification: true,
canWatch: this.#watch,
};
}
public async init(context: BackendInitContext): Promise<void> {
this.#initContext = context;
if (this.#watch) {
await this.#startWatch();
}
}
public async dispose(): Promise<void> {
this.#watchAbort?.abort();
this.#watchAbort = undefined;
}
public async read(
locale: string,
namespace: string,
signal?: AbortSignal
): Promise<TranslationRecord> {
this.#checkAborted(signal);
const t0 = performance.now();
let outcome: "hit" | "miss" | "error" = "hit";
let errorCode: string | undefined;
const path = this.#resolvePath(locale, namespace);
try {
const fs = await this.#getFs();
const bytes = await fs.readFile(path).catch((err: unknown) => {
throw classifyFsError(err, path);
});
return await parsePayload(bytes, this.#extension, path);
} catch (err) {
// Every throw from readFile / parsePayload is wrapped in
// `I18nError` upstream — `classifyFsError` covers fs errors
// and `parsePayload` covers parse / encoding errors.
const i18n = err as I18nError;
if (i18n.code === I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE) {
outcome = "miss";
} else {
outcome = "error";
}
errorCode = i18n.code;
throw err;
} finally {
recordRead(
this.name,
locale,
namespace,
this.#layout,
outcome,
performance.now() - t0,
errorCode
);
}
}
public async save(
locale: string,
namespace: string,
record: TranslationRecord,
signal?: AbortSignal
): Promise<void> {
this.#checkAborted(signal);
const path = this.#resolvePath(locale, namespace);
const t0 = performance.now();
try {
const fs = await this.#getFs();
await this.#ensureDir(fs, path);
await fs.writeFile(path, encodePayload(record, this.#extension));
recordWrite(this.name, this.#layout, "ok", performance.now() - t0);
} catch (cause) {
recordWrite(
this.name,
this.#layout,
"error",
performance.now() - t0
);
if (cause instanceof I18nError) throw cause;
throw new I18nError(
I18N_CODES.BACKEND_WRITE_FAILED,
`MultiFileFsBackend: write failed for ${path}`,
{ cause, context: { path, backend: this.name } }
);
}
}
public async lastModified(
locale: string,
namespace: string
): Promise<number | undefined> {
const path = this.#resolvePath(locale, namespace);
try {
const fs = await this.#getFs();
const stat = await fs.stat(path);
return stat.mtimeMs;
} catch {
return undefined;
}
}
#resolvePath(locale: string, namespace: string): string {
if (this.#layout === "glob" && this.#globPattern) {
const pattern = this.#globPattern
.replaceAll("{locale}", locale)
.replaceAll("{namespace}", namespace);
return joinPath(this.#root, pattern);
}
if (this.#layout === "flat") {
return joinPath(
this.#root,
`${locale}.${namespace}.${this.#extension}`
);
}
return joinPath(this.#root, locale, `${namespace}.${this.#extension}`);
}
async #ensureDir(fs: MultiFileFsLike, path: string): Promise<void> {
const slashIdx = path.lastIndexOf("/");
if (slashIdx > 0) {
await fs.mkdir(path.slice(0, slashIdx), { recursive: true });
}
}
async #getFs(): Promise<MultiFileFsLike> {
if (this.#fs !== "default") return this.#fs;
const mod = (await import("node:fs/promises")) as unknown as {
readFile: (path: string) => Promise<Buffer>;
writeFile: (
path: string,
data: Uint8Array | string
) => Promise<void>;
mkdir: (
path: string,
options?: { recursive?: boolean }
) => Promise<string | undefined>;
stat: (path: string) => Promise<{ mtimeMs: number }>;
watch: (path: string) => AsyncIterable<{ filename: string | null }>;
};
return {
readFile: async (p) => new Uint8Array(await mod.readFile(p)),
writeFile: async (p, data) => {
await mod.writeFile(p, data);
},
mkdir: async (p, opts) => {
await mod.mkdir(p, opts);
},
stat: (p) => mod.stat(p),
watch: mod.watch,
};
}
async #startWatch(): Promise<void> {
const fs = await this.#getFs();
const watch = fs.watch as (
p: string
) => AsyncIterable<{ filename: string | null }>;
this.#watchAbort = new AbortController();
// Spawn the watcher; don't await — it runs for the lifetime of
// the backend. Errors from the iterator are intentionally
// swallowed.
void this.#runWatchLoop(watch(this.#root));
}
async #runWatchLoop(
iter: AsyncIterable<{ filename: string | null }>
): Promise<void> {
for await (const event of iter) {
const parsed = this.#parseEventPath(event.filename);
if (parsed && this.#initContext) {
this.#initContext.emitLoaded(parsed.locale, parsed.namespace, {
backend: this.name,
});
}
}
}
#parseEventPath(
filename: string | null
): { locale: string; namespace: string } | undefined {
if (!filename) return undefined;
if (this.#layout === "nested") {
const m = filename.match(
new RegExp(`^([^/]+)/(.+)\\.${this.#extension}$`)
);
if (m) return { locale: m[1] as string, namespace: m[2] as string };
}
if (this.#layout === "flat") {
const m = filename.match(
new RegExp(`^([^.]+)\\.(.+)\\.${this.#extension}$`)
);
if (m) return { locale: m[1] as string, namespace: m[2] as string };
}
return undefined;
}
#checkAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new I18nError(
I18N_CODES.BACKEND_TIMEOUT,
"MultiFileFsBackend: operation aborted",
{ context: { backend: this.name, reason: signal.reason } }
);
}
}
}
function joinPath(...parts: string[]): string {
return parts
.filter((p) => p && p.length > 0)
.map((p, i) =>
i === 0
? p.replace(/\/+$/, "")
: p.replace(/^\/+/, "").replace(/\/+$/, "")
)
.join("/");
}
function classifyFsError(err: unknown, path: string): I18nError {
const code = (err as { code?: string }).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return new I18nError(
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
`MultiFileFsBackend: file not found ${path}`,
{ cause: err, context: { path } }
);
}
return new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`MultiFileFsBackend: read failed for ${path}`,
{ cause: err, context: { path } }
);
}
async function parsePayload(
bytes: Uint8Array,
extension: string,
path: string
): Promise<TranslationRecord> {
const ext = extension.toLowerCase();
const text = new TextDecoder().decode(bytes);
if (ext === "json") return parseJson(text, path);
if (ext === "json5") return parseWithPeer("json5", text, path);
if (ext === "yaml" || ext === "yml")
return parseWithPeer("yaml", text, path);
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`MultiFileFsBackend: unsupported file extension '${extension}'`,
{ context: { path, extension } }
);
}
function parseJson(text: string, path: string): TranslationRecord {
try {
const parsed = JSON.parse(text) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
throw new Error("payload is not an object record");
}
return parsed as TranslationRecord;
} catch (cause) {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`MultiFileFsBackend: JSON parse failed for ${path}`,
{ cause, context: { path } }
);
}
}
async function parseWithPeer(
peerName: string,
text: string,
path: string
): Promise<TranslationRecord> {
let mod: { [key: string]: unknown };
try {
mod = (await import(/* @vite-ignore */ peerName)) as Record<
string,
unknown
>;
} catch (cause) {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`MultiFileFsBackend: peer dependency '${peerName}' is not installed`,
{ cause, context: { path, peer: peerName } }
);
}
const fn = mod["parse"] as (input: string) => unknown;
const parsed = fn(text);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
throw new I18nError(
I18N_CODES.BACKEND_READ_FAILED,
`MultiFileFsBackend: ${peerName} returned non-record payload`,
{ context: { path } }
);
}
return parsed as TranslationRecord;
}
function encodePayload(
record: TranslationRecord,
extension: string
): Uint8Array {
const ext = extension.toLowerCase();
if (ext === "json" || ext === "json5") {
return new TextEncoder().encode(JSON.stringify(record, null, 2));
}
throw new I18nError(
I18N_CODES.BACKEND_WRITE_FAILED,
`MultiFileFsBackend: write not supported for extension '${extension}'`
);
}

View File

@@ -0,0 +1,50 @@
/**
* Wire-layout discriminator for {@link MultiFileFsBackend}. The default
* (`"nested"`) reads `<root>/<locale>/<namespace>.<ext>`, `"flat"`
* reads `<root>/<locale>.<namespace>.<ext>`, and `"glob"` substitutes
* caller-supplied `{locale}` / `{namespace}` placeholders into a
* pattern joined to `root`.
*/
export type MultiFileFsLayout = "nested" | "flat" | "glob";
/**
* Minimal `node:fs/promises` shape this backend depends on. Inject
* for tests or to swap in a different filesystem implementation
* (memfs, in-memory sandbox, etc.). Defaults to `node:fs/promises` via
* dynamic import.
*/
export interface MultiFileFsLike {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, data: Uint8Array | string): Promise<void>;
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
stat(path: string): Promise<{ mtimeMs: number }>;
watch?(path: string): AsyncIterable<{ filename: string | null }>;
}
/**
* Constructor options for {@link MultiFileFsBackend}.
*/
export interface MultiFileFsBackendOptions {
/** Root directory containing the locale tree. */
readonly root: string;
/** Wire layout. Defaults to `'nested'`. */
readonly layout?: MultiFileFsLayout;
/**
* Required when `layout: 'glob'`. Pattern with `{locale}` /
* `{namespace}` placeholders, e.g.
* `'i18n/{namespace}.{locale}.json'`.
*/
readonly globPattern?: string;
/** File extension (without dot). Defaults to `'json'`. */
readonly extension?: string;
/** Optional backend name. Defaults to `'multi-file-fs'`. */
readonly name?: string;
/**
* Enable file-watch mode. When `true`, the backend emits `loaded`
* events via `BackendInitContext.emitLoaded` whenever a file
* changes.
*/
readonly watch?: boolean;
/** Inject a filesystem shim. Defaults to `node:fs/promises`. */
readonly fs?: MultiFileFsLike;
}

View File

@@ -0,0 +1,12 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export {
MultiFileFsBackend,
type MultiFileFsBackendOptionsInput,
MultiFileFsBackendOptionsSchema,
} from "./backend.js";
export type {
MultiFileFsBackendOptions,
MultiFileFsLayout,
MultiFileFsLike,
} from "./backend.types.js";

View File

@@ -0,0 +1,72 @@
# `factory.ts` — `multiFileFsBackend()`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builder-style entry point that returns a fresh `MultiFileFsBackend`
per call. Functionally identical to `new MultiFileFsBackend(options)`,
but reads naturally in pipelines and configuration objects where the
caller is composing a backend chain alongside `memoryBackend()`,
`storageBackend()`, `httpBackend()`, and `chainedBackend()` — every
backend in the family ships a matching factory function for symmetry.
The factory has no internal state — every invocation returns a brand-new
instance. Options are validated by the same Zod schema as the
constructor; bad options throw `I18nError(I18N.INVALID_OPTIONS)`. There
is no caching, no singleton, and no hidden configuration: the factory
exists purely to flip the calling style from `new C(opts)` to
`factory(opts)` for ergonomic chain composition.
## Features
- Returns a fresh `MultiFileFsBackend` on every call.
- Forwards all options unchanged into the class constructor.
- Same validation surface — bad options throw `INVALID_OPTIONS`.
- Zero shared state, zero side effects.
## Usage: Basic Example
```ts
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
const backend = multiFileFsBackend({ root: "./locales" });
```
## Usage: Advanced Example
```ts
import { chainedBackend } from "@suaveplan/i18n-backend-chained";
import { multiFileFsBackend } from "@suaveplan/i18n-backend-fs-multifile";
import { memoryBackend } from "@suaveplan/i18n-backend-memory";
// Server-side: hot in-memory cache → fall through to disk on miss.
const chain = chainedBackend([
memoryBackend(),
multiFileFsBackend({ root: "./locales", watch: true }),
]);
await chain.init({
catalogName: "app",
emitLoaded: () => {},
});
const record = await chain.read("en", "auth");
```
## API Reference
- `multiFileFsBackend(options: MultiFileFsBackendOptions): MultiFileFsBackend`:
returns a new `MultiFileFsBackend` instance. Throws
`I18nError(I18N.INVALID_OPTIONS)` on schema failure or when
`layout: "glob"` is supplied without `globPattern`.
## Implementation Notes
- The factory is a thin wrapper — the line count is intentional. The
factory exists for symmetry with the rest of the i18n backend
family, where every concrete backend ships both a class export and
a `<thing>Backend()` factory function.
- The factory is server-tier — declaring this package's `engines.node`
at the package boundary covers the factory and the class equally.

View File

@@ -0,0 +1,74 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { MultiFileFsBackend } from "../backend/backend.js";
import { multiFileFsBackend } from "./factory.js";
let testRoot: string;
beforeAll(async () => {
testRoot = await mkdtemp(join(tmpdir(), "i18n-mfs-factory-"));
});
afterAll(async () => {
if (testRoot) await rm(testRoot, { recursive: true, force: true });
});
describe("multiFileFsBackend factory", () => {
it("factory returns MultiFileFsBackend instance", { timeout: 5000 }, () => {
const b = multiFileFsBackend({ root: testRoot });
expect(b).toBeInstanceOf(MultiFileFsBackend);
});
it("factory defaults name to multi-file-fs", { timeout: 5000 }, () => {
const b = multiFileFsBackend({ root: testRoot });
expect(b.name).toBe("multi-file-fs");
});
it("factory honours custom name", { timeout: 5000 }, () => {
const b = multiFileFsBackend({ root: testRoot, name: "custom" });
expect(b.name).toBe("custom");
});
it("factory propagates INVALID_OPTIONS for bad layout", {
timeout: 5000,
}, () => {
try {
const bad: unknown = "bogus";
multiFileFsBackend({
root: testRoot,
layout: bad as "nested",
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("factory rejects glob layout without globPattern", {
timeout: 5000,
}, () => {
try {
multiFileFsBackend({ root: testRoot, layout: "glob" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
}
});
it("factory returns a fresh instance per call", { timeout: 5000 }, () => {
const a = multiFileFsBackend({ root: testRoot });
const b = multiFileFsBackend({ root: testRoot });
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,13 @@
import { MultiFileFsBackend } from "../backend/backend.js";
import type { MultiFileFsBackendOptions } from "../backend/backend.types.js";
/**
* Factory form for callers that prefer builders over `new`. Returns a
* fresh `MultiFileFsBackend` per call — no internal singleton, no
* shared state. Equivalent to `new MultiFileFsBackend(options)`.
*/
export function multiFileFsBackend(
options: MultiFileFsBackendOptions
): MultiFileFsBackend {
return new MultiFileFsBackend(options);
}

View File

@@ -0,0 +1 @@
export { multiFileFsBackend } from "./factory.js";

View File

@@ -0,0 +1,120 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
const BACKEND_TS_PATH = fileURLToPath(
new URL("./backend/backend.ts", import.meta.url)
);
describe("@suaveplan/i18n-backend-fs-multifile — root barrel", () => {
it("re-exports MultiFileFsBackend", { timeout: 5000 }, () => {
expect(typeof root.MultiFileFsBackend).toBe("function");
});
it("re-exports multiFileFsBackend factory", { timeout: 5000 }, () => {
expect(typeof root.multiFileFsBackend).toBe("function");
});
it("re-exports MultiFileFsBackendOptionsSchema", { timeout: 5000 }, () => {
expect(root.MultiFileFsBackendOptionsSchema).toBeDefined();
});
it("re-exports observability helpers", { timeout: 5000 }, () => {
expect(typeof root.recordRead).toBe("function");
expect(typeof root.recordWrite).toBe("function");
expect(typeof root.getMultiFileFsBackendMetrics).toBe("function");
expect(typeof root.getMultiFileFsBackendMetricsState).toBe("function");
expect(typeof root.resetMultiFileFsBackendMetricsState).toBe(
"function"
);
expect(typeof root.resetMultiFileFsBackendMetricsCache).toBe(
"function"
);
});
});
describe("@suaveplan/i18n-backend-fs-multifile — every subpath resolves", () => {
it("subpath barrels resolve through the package", {
timeout: 5000,
}, async () => {
const backend = await import("./backend/index.js");
const factory = await import("./factory/index.js");
const observability = await import("./observability/index.js");
expect(typeof backend.MultiFileFsBackend).toBe("function");
expect(backend.MultiFileFsBackendOptionsSchema).toBeDefined();
expect(typeof factory.multiFileFsBackend).toBe("function");
expect(typeof observability.getMultiFileFsBackendMetrics).toBe(
"function"
);
expect(typeof observability.recordRead).toBe("function");
expect(typeof observability.recordWrite).toBe("function");
});
it("every documented subpath resolves", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
exports?: Record<string, unknown>;
};
const documented = [".", "./backend", "./factory", "./observability"];
for (const subpath of documented) {
expect(pkg.exports?.[subpath]).toBeDefined();
}
});
});
describe("@suaveplan/i18n-backend-fs-multifile — package.json", () => {
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
sideEffects?: boolean;
};
expect(pkg.sideEffects).toBe(false);
});
it("package.json declares engines.node and omits browser field", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
engines?: { node?: string };
browser?: unknown;
};
expect(pkg.engines?.node).toBe(">=20");
expect(pkg.browser).toBeUndefined();
});
it("yaml and json5 are optional peer deps not direct deps", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
};
const deps = pkg.dependencies ?? {};
expect(deps).not.toHaveProperty("yaml");
expect(deps).not.toHaveProperty("json5");
expect(deps).not.toHaveProperty("@suaveplan/i18n");
});
it("@suaveplan/testing is in devDependencies only (Rule 8)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf-8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(pkg.dependencies ?? {}).not.toHaveProperty("@suaveplan/testing");
expect(pkg.peerDependencies ?? {}).not.toHaveProperty(
"@suaveplan/testing"
);
expect(pkg.devDependencies ?? {}).toHaveProperty("@suaveplan/testing");
});
it("src/backend.ts carries the server-tier marker", {
timeout: 5000,
}, () => {
const src = readFileSync(BACKEND_TS_PATH, "utf-8");
expect(src).toContain("Server-tier package");
});
});

View File

@@ -0,0 +1,26 @@
// Root barrel for @suaveplan/i18n-backend-fs-multifile. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export {
MultiFileFsBackend,
type MultiFileFsBackendOptionsInput,
MultiFileFsBackendOptionsSchema,
} from "./backend/backend.js";
export type {
MultiFileFsBackendOptions,
MultiFileFsLayout,
MultiFileFsLike,
} from "./backend/backend.types.js";
export { multiFileFsBackend } from "./factory/factory.js";
export {
getMultiFileFsBackendMetrics,
getMultiFileFsBackendMetricsState,
type MultiFileFsBackendMetricSample,
type MultiFileFsBackendMetricsState,
type MultiFileFsReadOutcome,
type MultiFileFsWriteOutcome,
recordRead,
recordWrite,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "./observability/observability.js";

View File

@@ -0,0 +1,14 @@
export type {
MultiFileFsBackendMetricSample,
MultiFileFsBackendMetricsState,
MultiFileFsReadOutcome,
MultiFileFsWriteOutcome,
} from "./observability.js";
export {
getMultiFileFsBackendMetrics,
getMultiFileFsBackendMetricsState,
recordRead,
recordWrite,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "./observability.js";

View File

@@ -0,0 +1,92 @@
# `observability.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Self-contained OpenTelemetry instrument set for the
`@suaveplan/i18n-backend-fs-multifile` package. The module wires every
public counter and histogram emitted by `MultiFileFsBackend` and exposes
an in-memory state ring used by spec-conformance tests so production
code paths can be exercised without standing up a full OTel SDK.
The module is loaded lazily by `getMultiFileFsBackendMetrics()` — the
first call to any production path (`recordRead`, `recordWrite`) builds a
single shared `Meter` and caches the resulting `Histogram`/`Counter`
instances. Subsequent calls hit the cache. When no `MeterProvider` is
registered (the default in production), every instrument is a no-op,
keeping the cost path-neutral when telemetry is off.
## Features
- Lazily-built histograms and counters under the
`i18n.backend.fs_multifile.*` prefix.
- Five instruments: `read_duration_ms`, `read_total`,
`read_errors_total`, `write_duration_ms`, `write_total`.
- Every emission carries a `backend` label plus a `layout` label
(`nested` / `flat` / `glob`) so consumers can size watchers per
layout.
- In-memory state ring (`getMultiFileFsBackendMetricsState`,
`resetMultiFileFsBackendMetricsState`) so spec-conformance tests can
assert exact attribute payloads without an exporter.
- `resetMultiFileFsBackendMetricsCache` test hook to drop the cached
instruments between cases when a different MeterProvider is required.
## Usage: Basic Example
```ts
import {
recordRead,
recordWrite,
} from "@suaveplan/i18n-backend-fs-multifile/observability";
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.42);
recordWrite("multi-file-fs", "nested", "ok", 0.31);
```
## Usage: Advanced Example
```ts
import {
getMultiFileFsBackendMetricsState,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "@suaveplan/i18n-backend-fs-multifile/observability";
resetMultiFileFsBackendMetricsCache();
resetMultiFileFsBackendMetricsState();
// ... run code that hits the backend ...
const state = getMultiFileFsBackendMetricsState();
const outcomes = state.readTotal.map((s) => s.attributes["outcome"]);
expect(outcomes).toContain("hit");
```
## API Reference
- `recordRead(backend, locale, namespace, layout, outcome, durationMs, errorCode?)`:
emits the `read_duration_ms` histogram and `read_total` counter; if
`errorCode` is supplied, also increments `read_errors_total{code,layout}`.
- `recordWrite(backend, layout, outcome, durationMs)`: emits the
`write_duration_ms` histogram and `write_total` counter.
- `getMultiFileFsBackendMetrics()`: lazy build / return cached
instruments — idempotent.
- `getMultiFileFsBackendMetricsState()`: return the in-memory state
ring snapshot.
- `resetMultiFileFsBackendMetricsState()`: clear the in-memory state
ring.
- `resetMultiFileFsBackendMetricsCache()`: drop the cached instruments
so a fresh MeterProvider can take over (test-only escape hatch).
## Implementation Notes
- The `layout` attribute is mandatory on every counter and histogram so
operators can split SLOs per wire layout — `glob` patterns tend to
hide expensive resolutions behind dynamic substitution.
- The state ring is unbounded by design and is the test-only path. The
`resetMultiFileFsBackendMetricsState` helper is the supported way to
prevent ring growth across tests.
- The OTel meter is named `suaveplan.i18n-backend-fs-multifile` so
consumers can apply per-package views.

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getMultiFileFsBackendMetrics,
getMultiFileFsBackendMetricsState,
recordRead,
recordWrite,
resetMultiFileFsBackendMetricsCache,
resetMultiFileFsBackendMetricsState,
} from "./observability.js";
describe("MultiFileFsBackend metrics — instrument lifecycle", () => {
it("getMultiFileFsBackendMetrics is idempotent (cache hit path)", {
timeout: 5000,
}, () => {
resetMultiFileFsBackendMetricsCache();
const a = getMultiFileFsBackendMetrics();
const b = getMultiFileFsBackendMetrics();
expect(a).toBe(b);
});
it("resetMultiFileFsBackendMetricsCache rebuilds instruments", {
timeout: 5000,
}, () => {
const a = getMultiFileFsBackendMetrics();
resetMultiFileFsBackendMetricsCache();
const b = getMultiFileFsBackendMetrics();
expect(a).not.toBe(b);
});
});
describe("MultiFileFsBackend metrics — recordRead", () => {
it("records duration + total on hit (no error code)", {
timeout: 5000,
}, () => {
resetMultiFileFsBackendMetricsState();
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.42);
const state = getMultiFileFsBackendMetricsState();
expect(state.readDurationMs).toEqual([
{
attributes: {
backend: "multi-file-fs",
locale: "en",
namespace: "auth",
outcome: "hit",
layout: "nested",
},
},
]);
expect(state.readTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "hit",
layout: "nested",
},
},
]);
expect(state.readErrorsTotal).toEqual([]);
});
it("records error counter when errorCode supplied", {
timeout: 5000,
}, () => {
resetMultiFileFsBackendMetricsState();
recordRead(
"multi-file-fs",
"en",
"auth",
"flat",
"miss",
0.42,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
const state = getMultiFileFsBackendMetricsState();
expect(state.readTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "miss",
layout: "flat",
},
},
]);
expect(state.readErrorsTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
layout: "flat",
},
},
]);
});
it("records error outcome", { timeout: 5000 }, () => {
resetMultiFileFsBackendMetricsState();
recordRead(
"multi-file-fs",
"en",
"auth",
"glob",
"error",
0.42,
"I18N.BACKEND_READ_FAILED"
);
const state = getMultiFileFsBackendMetricsState();
expect(state.readTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "error",
layout: "glob",
},
},
]);
expect(state.readErrorsTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
code: "I18N.BACKEND_READ_FAILED",
layout: "glob",
},
},
]);
});
});
describe("MultiFileFsBackend metrics — recordWrite", () => {
it("records duration + total on ok", { timeout: 5000 }, () => {
resetMultiFileFsBackendMetricsState();
recordWrite("multi-file-fs", "nested", "ok", 0.42);
const state = getMultiFileFsBackendMetricsState();
expect(state.writeDurationMs).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "ok",
layout: "nested",
},
},
]);
expect(state.writeTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "ok",
layout: "nested",
},
},
]);
});
it("records duration + total on error", { timeout: 5000 }, () => {
resetMultiFileFsBackendMetricsState();
recordWrite("multi-file-fs", "flat", "error", 0.42);
const state = getMultiFileFsBackendMetricsState();
expect(state.writeTotal).toEqual([
{
attributes: {
backend: "multi-file-fs",
outcome: "error",
layout: "flat",
},
},
]);
});
});
describe("MultiFileFsBackend metrics — state ring helpers", () => {
it("resetMultiFileFsBackendMetricsState clears every ring", {
timeout: 5000,
}, () => {
recordRead("multi-file-fs", "en", "auth", "nested", "hit", 0.1);
recordRead(
"multi-file-fs",
"en",
"auth",
"nested",
"miss",
0.1,
"I18N.BACKEND_LOCALE_NOT_AVAILABLE"
);
recordWrite("multi-file-fs", "nested", "ok", 0.1);
resetMultiFileFsBackendMetricsState();
const state = getMultiFileFsBackendMetricsState();
expect(state.readDurationMs).toEqual([]);
expect(state.readTotal).toEqual([]);
expect(state.readErrorsTotal).toEqual([]);
expect(state.writeDurationMs).toEqual([]);
expect(state.writeTotal).toEqual([]);
});
});

View File

@@ -0,0 +1,175 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import type { MultiFileFsLayout } from "../backend/backend.types.js";
/**
* OTel instruments for `@suaveplan/i18n-backend-fs-multifile`.
*
* Built lazily via `metrics.getMeter('suaveplan.i18n-backend-fs-multifile')`.
* All instruments are no-ops when no MeterProvider is registered (the
* default), so consumers pay zero overhead unless they wire up the
* SDK.
*
* In addition to emitting via OTel, this module retains a small
* in-memory state ring so spec-conformance tests can assert exact
* attribute payloads without standing up a full SDK +
* InMemoryMetricExporter pipeline. Tests call
* `resetMultiFileFsBackendMetricsState()` between cases and read
* `getMultiFileFsBackendMetricsState()` to inspect counters /
* histogram counts.
*/
export type MultiFileFsReadOutcome = "hit" | "miss" | "error";
export type MultiFileFsWriteOutcome = "ok" | "error";
export interface MultiFileFsBackendMetricSample {
readonly attributes: Record<string, string>;
}
export interface MultiFileFsBackendMetricsState {
readonly readDurationMs: MultiFileFsBackendMetricSample[];
readonly readTotal: MultiFileFsBackendMetricSample[];
readonly readErrorsTotal: MultiFileFsBackendMetricSample[];
readonly writeDurationMs: MultiFileFsBackendMetricSample[];
readonly writeTotal: MultiFileFsBackendMetricSample[];
}
interface CachedInstruments {
readonly readDurationMs: Histogram;
readonly readTotal: Counter;
readonly readErrorsTotal: Counter;
readonly writeDurationMs: Histogram;
readonly writeTotal: Counter;
}
let cached: CachedInstruments | undefined;
const state: MultiFileFsBackendMetricsState = {
readDurationMs: [],
readTotal: [],
readErrorsTotal: [],
writeDurationMs: [],
writeTotal: [],
};
/**
* Build (or return cached) OTel instruments. Idempotent — safe to
* call from any production code path.
*/
export function getMultiFileFsBackendMetrics(): CachedInstruments {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-backend-fs-multifile");
cached = {
readDurationMs: meter.createHistogram(
"i18n.backend.fs_multifile.read_duration_ms",
{
description:
"Duration in ms of a MultiFileFsBackend.read call; labels: backend, locale, namespace, outcome, layout.",
unit: "ms",
}
),
readTotal: meter.createCounter("i18n.backend.fs_multifile.read_total", {
description:
"MultiFileFsBackend.read call count; labels: backend, outcome (hit | miss | error), layout.",
}),
readErrorsTotal: meter.createCounter(
"i18n.backend.fs_multifile.read_errors_total",
{
description:
"MultiFileFsBackend.read error count; labels: backend, code, layout.",
}
),
writeDurationMs: meter.createHistogram(
"i18n.backend.fs_multifile.write_duration_ms",
{
description:
"Duration in ms of a MultiFileFsBackend.save call; labels: backend, outcome, layout.",
unit: "ms",
}
),
writeTotal: meter.createCounter(
"i18n.backend.fs_multifile.write_total",
{
description:
"MultiFileFsBackend.save call count; labels: backend, outcome (ok | error), layout.",
}
),
};
return cached;
}
/**
* Record a `read` call's duration + outcome. Mirrors the
* `read_total{backend,outcome,layout}` counter and per-error-code
* `read_errors_total{backend,code,layout}` counter, pushing one
* sample into the in-memory state ring per counter touched.
*/
export function recordRead(
backend: string,
locale: string,
namespace: string,
layout: MultiFileFsLayout,
outcome: MultiFileFsReadOutcome,
durationMs: number,
errorCode?: string
): void {
const ins = getMultiFileFsBackendMetrics();
const histogramAttrs: Record<string, string> = {
backend,
locale,
namespace,
outcome,
layout,
};
ins.readDurationMs.record(durationMs, histogramAttrs);
state.readDurationMs.push({ attributes: histogramAttrs });
const aggregate: Record<string, string> = { backend, outcome, layout };
ins.readTotal.add(1, aggregate);
state.readTotal.push({ attributes: aggregate });
if (errorCode !== undefined) {
const errorAttrs: Record<string, string> = {
backend,
code: errorCode,
layout,
};
ins.readErrorsTotal.add(1, errorAttrs);
state.readErrorsTotal.push({ attributes: errorAttrs });
}
}
/**
* Record a `save` call's duration + outcome. Mirrors the
* `write_total{backend,outcome,layout}` counter into the in-memory
* state ring.
*/
export function recordWrite(
backend: string,
layout: MultiFileFsLayout,
outcome: MultiFileFsWriteOutcome,
durationMs: number
): void {
const ins = getMultiFileFsBackendMetrics();
const attrs: Record<string, string> = { backend, outcome, layout };
ins.writeDurationMs.record(durationMs, attrs);
state.writeDurationMs.push({ attributes: attrs });
ins.writeTotal.add(1, attrs);
state.writeTotal.push({ attributes: attrs });
}
/** Return a frozen snapshot of the in-memory state. */
export function getMultiFileFsBackendMetricsState(): MultiFileFsBackendMetricsState {
return state;
}
/** Reset the in-memory state ring. Tests call this between cases. */
export function resetMultiFileFsBackendMetricsState(): void {
state.readDurationMs.length = 0;
state.readTotal.length = 0;
state.readErrorsTotal.length = 0;
state.writeDurationMs.length = 0;
state.writeTotal.length = 0;
}
/** Test-only: drop the cached instruments so a fresh meter can be built. */
export function resetMultiFileFsBackendMetricsCache(): void {
cached = undefined;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-backend-fs-multifile",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"backend/index": "src/backend/index.ts",
"factory/index": "src/factory/index.ts",
"observability/index": "src/observability/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-shared",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
"yaml",
"json5",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 10000,
},
});