Initial Commit
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}'`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { multiFileFsBackend } from "./factory.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
MultiFileFsBackendMetricSample,
|
||||
MultiFileFsBackendMetricsState,
|
||||
MultiFileFsReadOutcome,
|
||||
MultiFileFsWriteOutcome,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getMultiFileFsBackendMetrics,
|
||||
getMultiFileFsBackendMetricsState,
|
||||
recordRead,
|
||||
recordWrite,
|
||||
resetMultiFileFsBackendMetricsCache,
|
||||
resetMultiFileFsBackendMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user