first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# @suaveplan/storage-file - Node.js filesystem AbstractStorage adapter for @suaveplan/storage: atomic writes via temp-rename, AbortSignal everywhere, strict key sanitisation, native Web Streams, partial reads, transient-error retry via @suaveplan/retry, full OTel surface.
## Summary
Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every async method, strict key sanitisation, transient-error retry via `@suaveplan/retry`, native Web Streams via `node:fs.createReadStream`/`createWriteStream`, and positioned partial reads. Full OpenTelemetry surface: per-op spans, latency histograms, byte-flow histograms, kind-split counters.
<!-- begin:auto-docs -->
## Documentation tree
- [bun-fs](./src/bun-fs/bun-fs.md) — `bun-fs` is a sibling of `node-fs` that satisfies the same structural `NodeFsLike` contract consumed by `NodeFsStorage`…
- [bun-fs](./src/bun-fs/bun-fs.md) — `bun-fs` is a sibling of `node-fs` that satisfies the same structural `NodeFsLike` contract consumed by `NodeFsStorage`…
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
- [node-fs-storage.helpers](./src/node-fs-storage/node-fs-storage.helpers.md) — Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the…
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
- [node-fs-storage.ops](./src/node-fs-storage/node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suavep…
<!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs](./src/node-fs/node-fs.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs-storage.helpers](./src/node-fs-storage/node-fs-storage.helpers.md) — Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the…
- [node-fs-storage](./src/node-fs-storage/node-fs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-fs-storage.ops](./src/node-fs-storage/node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_ - [retry-helper](./src/retry-helper/retry-helper.md) — <!-- begin:auto-docs --> _(no submodules)_- [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_ - [streams](./src/streams/streams.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suavep…
## Installation
```bash
bun add @suaveplan/storage-file
```
## Quick Start
```ts
import { NodeFsStorage } from "@suaveplan/storage-file";
const storage = new NodeFsStorage({ rootDir: "/var/lib/app/storage" });
await storage.set("orders/42.json", new TextEncoder().encode("{\"id\":42}"));
const bytes = await storage.get("orders/42.json");
console.log(new TextDecoder().decode(bytes!));
// Streams + partial reads
const writer = storage.createWriteStream("large.bin").getWriter();
await writer.write(new Uint8Array(1024 * 1024));
await writer.close();
const slice = await storage.getPartial("large.bin", 0, 16);
// Factory registration (optional)
import { registerNodeFsProvider } from "@suaveplan/storage-file";
import { createStorage } from "@suaveplan/storage/factory";
registerNodeFsProvider();
const viaFactory = createStorage({ type: "file", config: { rootDir: "/tmp/store" } });
```
## Modules
- [`src/error-codes`](src/error-codes.md) — `STORAGE_FILE_CODES`, `StorageFileError`, `StorageFileErrorCode`
- [`src/key-sanitizer/key-sanitizer`](src/key-sanitizer/key-sanitizer.md) — `sanitizeKey` strict boundary
- [`src/node-fs/node-fs`](src/node-fs/node-fs.md) — `defaultNodeFs` adapter wrapping `node:fs/promises`
- [`src/bun-fs/bun-fs`](src/bun-fs/bun-fs.md) — `defaultBunFs` / `pickDefaultFs` — Bun-native `Bun.file` / `Bun.write` fast path
- [`src/retry-helper/retry-helper`](src/retry-helper/retry-helper.md) — `withFsRetry`, transient classifier
- [`src/otel/otel`](src/otel/otel.md) — OTel instruments and `recordOp`
- [`src/streams/streams`](src/streams/streams.md) — Web Streams bridge with atomic write
- [`src/node-fs-storage/node-fs-storage`](src/node-fs-storage/node-fs-storage.md) — main `NodeFsStorage` class
- [`src/node-fs-storage/node-fs-storage.helpers`](src/node-fs-storage/node-fs-storage.helpers.md) — pure helpers
- [`src/registry/registry`](src/registry/registry.md) — factory registration hook
- [`src/types/fs-adapter.types`](src/types/fs-adapter.types.md) — `NodeFsLike` interface
- [`src/types/node-fs-storage.types`](src/types/node-fs-storage.types.md) — options + Zod schema
## Architecture
- **Tier**: server (consumes `node:fs/promises` and `node:fs` stream constructors). Browser consumers should pick `@suaveplan/storage/memory`, an HTTP/IndexedDB adapter, or `@suaveplan/storage-s3`.
- **Base class**: extends `AbstractStorage` from `@suaveplan/storage/abstract`. Every consumer that talks to `AbstractStorage` (or the `StorageLike` shim) works against this adapter unchanged.
- **Atomicity**: every public write (`set`, metadata sidecar, `createWriteStream.close`) writes to a temp file in the destination directory and `fs.rename`s onto the destination. POSIX rename is atomic within a filesystem.
- **Cancellation**: every public async method accepts an optional `AbortSignal`. Pre-aborted signals throw `STORAGE_FILE.ABORTED` before any IO call.
- **Resilience**: transient errors (`EBUSY`, `EMFILE`, `ENFILE`, `EAGAIN`) are retried via `@suaveplan/retry` with exponential backoff and injectable `TimersLike`. Non-transient errors surface as `STORAGE_FILE.IO_FAILED` or `STORAGE_FILE.PERMISSION_DENIED`.
- **Security**: every public method passes its key through `sanitizeKey`. Path-traversal, absolute paths, drive-letter prefixes, null bytes, control chars, and over-length keys all surface as `STORAGE_FILE.INVALID_KEY`/`KEY_TOO_LONG` before any filesystem call.
- **Observability**: OTel counters, histograms, and a tracer emit from production paths. Tests assert against a real in-memory MeterProvider — no `vi.mock`.

View File

@@ -0,0 +1,126 @@
# `bun-fs` — Bun-native `NodeFsLike` adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bun-fs` is a sibling of [`node-fs`](../node-fs/node-fs.md) that satisfies the
same structural `NodeFsLike` contract consumed by `NodeFsStorage`. Where
`defaultNodeFs()` wires every method to `node:fs/promises` and the two
`node:fs` stream constructors, `defaultBunFs()` overrides only the two
methods that have a measurably faster Bun-native path — `readFile` (routed
through `Bun.file(path).bytes()`) and `writeFile` (routed through
`Bun.write(path, data)`). Every other method delegates to the Node base
because under Bun those calls already dispatch to Bun's native (Zig)
implementation transparently via the Node-compat layer, leaving no perf on
the table.
This module exists so Suaveplan — which targets Bun as its first
runtime — does not pay the Node-compat reshaping cost on the hot read/write
loop. It is additive: no behaviour changes for callers that inject a custom
`fs`, and the Node adapter remains the explicit choice when running outside
Bun or when parity testing under Bun.
## Features
- `defaultBunFs(): NodeFsLike` — Bun-native adapter. Throws a plain `Error`
if called outside a Bun runtime.
- `pickDefaultFs(): NodeFsLike` — runtime-probed selector. Returns
`defaultBunFs()` when `globalThis.Bun` is defined, otherwise
`defaultNodeFs()`. The probe runs on every call (never cached) so tests
can switch adapters deterministically.
- Returns a fresh object per call so callers can selectively override any
single method without mutating shared state.
- Zero runtime branching inside individual methods — composition over
conditionals.
## Usage: Basic Example
```ts
import { NodeFsStorage, defaultBunFs } from "@suaveplan/storage-file";
const storage = new NodeFsStorage({
rootDir: "/var/data",
fs: defaultBunFs(),
});
await storage.set("hello", new TextEncoder().encode("world"));
const bytes = await storage.get("hello");
console.log(new TextDecoder().decode(bytes!)); // "world"
```
## Usage: Advanced Example
Combine runtime detection with a custom override — useful when you want
the Bun fast path everywhere except a tracing wrapper around `writeFile`:
```ts
import {
NodeFsStorage,
pickDefaultFs,
type NodeFsLike,
} from "@suaveplan/storage-file";
function tracedFs(): NodeFsLike {
const base = pickDefaultFs();
return {
...base,
async writeFile(path, data) {
const start = performance.now();
try {
await base.writeFile(path, data);
} finally {
console.info("writeFile", path, performance.now() - start);
}
},
};
}
const storage = new NodeFsStorage({
rootDir: "/var/data",
fs: tracedFs(),
});
```
When you construct `NodeFsStorage` without passing `fs`, `resolveConfig`
calls `pickDefaultFs()` for you — Bun-first, with Node fallback.
## API Reference
### `defaultBunFs(): NodeFsLike`
- **Returns** a fresh `NodeFsLike` whose `readFile` is implemented as
`Bun.file(path).bytes()` and whose `writeFile` is implemented as
`Bun.write(path, data)`. Every other method (`mkdir`, `readdir`,
`rename`, `unlink`, `stat`, `access`, `open`, `createReadStream`,
`createWriteStream`) delegates to the `defaultNodeFs()` base.
- **Throws** a plain `Error` with the message
`defaultBunFs() requires a Bun runtime (globalThis.Bun is undefined)` if
the probe finds no Bun global. The intended entry point for adapter
selection is `pickDefaultFs`, which performs the probe first.
### `pickDefaultFs(): NodeFsLike`
- **Returns** `defaultBunFs()` when `globalThis.Bun` is an object, otherwise
`defaultNodeFs()`. Consumed by `resolveConfig` in
`node-fs-storage.helpers.ts` to wire the default `fs` for `NodeFsStorage`.
## Implementation Notes
- Streams are NOT routed through `Bun.file(path).stream()`. `BunFile.stream()`
returns a Web `ReadableStream`, but `NodeFsLike.createReadStream` is typed
as a Node `Readable`. Translating between the two introduces a copy that
erases the speedup, so the Node `node:fs` constructor is used unchanged —
it already dispatches to Bun's native impl under Bun.
- The detection probe uses `typeof globalThis.Bun === "object"` rather than
`"undefined" !== typeof Bun`. The former works without TypeScript ambient
globals and is robust to module-scope mocking via
`(globalThis as { Bun?: unknown }).Bun = undefined`.
- `Bun.write` accepts both `Uint8Array` and `string`, matching
`NodeFsLike.writeFile`'s union type. No buffer copying is performed by
the adapter.
- The adapter has zero `@suaveplan/*` runtime dependencies. It is internally
consumed by `node-fs-storage.helpers.resolveConfig`, which is the only
module that should call `pickDefaultFs` directly.

View File

@@ -0,0 +1,65 @@
# `error-codes`
## Purpose
Defines the frozen `STORAGE_FILE_CODES` registry and the `StorageFileError` class used by every public path in `@suaveplan/storage-file`. The registry is the contract: every code is intentionally thrown from at least one production path, and downstream consumers assert on `error.code` rather than parsing message text. The class extends `@suaveplan/error.SuaveplanError` so it integrates with the OTel span-event adapter and the error-envelope wire format.
## Features
- 8 frozen codes covering missing keys, permissions, IO failures, invalid input, atomic-write rollback, option validation, abort, and key length overrun.
- `StorageFileErrorCode` union type derived from the registry — exhaustive `switch` statements get compile-time enforcement.
- `StorageFileError` carries an optional `key` discriminant so callers can correlate a failure with the offending key without parsing strings.
- All codes are namespaced `STORAGE_FILE.*` for unambiguous registry filtering in centralised error reporters.
## Usage: Basic Example
```ts
import { StorageFileError, STORAGE_FILE_CODES } from "@suaveplan/storage-file";
try {
await storage.get("../../etc/passwd");
} catch (err) {
if (err instanceof StorageFileError && err.code === STORAGE_FILE_CODES.INVALID_KEY) {
// caller-friendly message
}
}
```
## Usage: Advanced Example
```ts
import { StorageFileError, STORAGE_FILE_CODES, type StorageFileErrorCode } from "@suaveplan/storage-file";
function classify(code: StorageFileErrorCode): "client" | "server" {
switch (code) {
case STORAGE_FILE_CODES.INVALID_KEY:
case STORAGE_FILE_CODES.KEY_TOO_LONG:
case STORAGE_FILE_CODES.INVALID_OPTIONS:
return "client";
case STORAGE_FILE_CODES.NOT_FOUND:
case STORAGE_FILE_CODES.PERMISSION_DENIED:
case STORAGE_FILE_CODES.IO_FAILED:
case STORAGE_FILE_CODES.ATOMIC_WRITE_FAILED:
case STORAGE_FILE_CODES.ABORTED:
return "server";
}
}
throw new StorageFileError(STORAGE_FILE_CODES.NOT_FOUND, "no such key", {
key: "/orders/42.json",
cause: undefined,
});
```
## API Reference
- `STORAGE_FILE_CODES` — frozen object map of code names to fully-qualified strings (`STORAGE_FILE.NOT_FOUND`, etc.). Exhaustive: 8 codes.
- `StorageFileErrorCode` — union derived from `(typeof STORAGE_FILE_CODES)[keyof typeof STORAGE_FILE_CODES]`.
- `StorageFileError` — class extending `SuaveplanError<StorageFileErrorCode>`. Constructor: `(code, message, options?)`. Properties: inherited `code`, inherited `cause`, additional `key?: string`.
- `StorageFileErrorOptions` — extends `SuaveplanErrorOptions` with optional `key`.
## Implementation Notes
- Codes are registered by `defineCodes("STORAGE_FILE", [...])`. The registration is idempotent: importing this module multiple times does not re-register or throw.
- The list is closed by design — adding a new failure mode requires an spec change so consumers can update their handlers in lockstep.
- `name` is set to `"StorageFileError"` so `instanceof` and `error.name` checks both work.

View File

@@ -0,0 +1,59 @@
# `key-sanitizer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates and normalises every caller-supplied storage key before any filesystem call. The sanitiser is the security boundary that prevents a caller-controlled key (`../../etc/passwd`) from escaping the configured `rootDir`. Every public method on `NodeFsStorage` passes its key through `sanitizeKey` before resolving any path or issuing any `node:fs` call. Failing the sanitiser is unrecoverable from the caller's perspective — it always reflects a programming error or a malicious input.
## Features
- Rejects empty strings, `..` traversal segments, POSIX absolute paths (`/foo`), Windows drive-letter prefixed paths (`C:\foo`, `c:/foo`).
- Rejects null bytes (`\x00`) and any control char in the C0 range (`0x00`–`0x1F`) plus DEL (`0x7F`).
- Enforces a maximum length of `MAX_KEY_LENGTH` (1024) characters; over-length keys surface as `STORAGE_FILE.KEY_TOO_LONG` (a distinct code from `INVALID_KEY` so callers can hint at the truncation).
- Normalises Windows `\` separators to POSIX `/` so the same key resolves identically on every platform.
- Pure function — no allocation beyond the normalised string return; safe to call on every operation.
## Usage: Basic Example
```ts
import { sanitizeKey } from "@suaveplan/storage-file";
const safe = sanitizeKey("orders/2026/05/12.json");
// → "orders/2026/05/12.json"
```
## Usage: Advanced Example
```ts
import { sanitizeKey, StorageFileError, STORAGE_FILE_CODES } from "@suaveplan/storage-file";
function safeKey(input: string): string | null {
try {
return sanitizeKey(input);
} catch (err) {
if (err instanceof StorageFileError && (err.code === STORAGE_FILE_CODES.INVALID_KEY || err.code === STORAGE_FILE_CODES.KEY_TOO_LONG)) {
return null;
}
throw err;
}
}
const normalised = sanitizeKey("orders\\2026\\05\\12.json");
// → "orders/2026/05/12.json"
```
## API Reference
- `sanitizeKey(key: string): string` — returns the normalised POSIX-separator form on success.
- Throws `StorageFileError` with code `STORAGE_FILE.INVALID_KEY` on empty input, control chars, absolute paths, drive-letter prefix, or `..` traversal.
- Throws `StorageFileError` with code `STORAGE_FILE.KEY_TOO_LONG` when input length exceeds `MAX_KEY_LENGTH` (1024).
## Implementation Notes
- The control-char check iterates the string code unit by code unit rather than using a regex with a control-char range. This keeps biome's `noControlCharactersInRegex` lint happy without a suppression comment and is also marginally faster on short keys.
- `..` is rejected segment-by-segment after `\\` normalisation, so both `foo\\..\\bar` and `foo/../bar` are caught.
- Empty segments produced by consecutive separators (`foo//bar`) are NOT rejected — they are allowed for caller convenience and resolve to the same path. The host filesystem normalises them internally.
- The key argument is not constrained beyond the rules above; arbitrary Unicode (including emoji and CJK) is permitted because `node:fs` accepts them and most modern filesystems do too.

View File

@@ -0,0 +1,52 @@
# `node-fs-storage.helpers`
## Purpose
Houses the pure helpers consumed by `NodeFsStorage` so the orchestrator class stays under the file-size budget and the helpers can be unit-tested without instantiating the full adapter. Every function here is either IO-free or wraps exactly one `NodeFsLike` call with documented error mapping.
## Features
- `buildTempPath(forPath)` — same-directory temp filename with the canonical `.tmp-` prefix, a base-36 timestamp, and 8 chars of entropy. Co-located placement is required for POSIX `rename` atomicity.
- `buildListPage(args)` — pure key-extraction + pagination over a `NodeFsLike.readdir` result. Sorts keys lexicographically before paginating.
- `resolveListOptions(options, total)` — normalises `StorageListOptions` undefined-aware so callers don't repeat `?? 0` / `?? prefix`.
- `readMetadataWithStatFallback(fs, metaPath, dataPath)` — reads the sidecar metadata file when present; falls back to synthesising `{ createdAt, modifiedAt, size }` from `fs.stat(dataPath)` when the sidecar is missing; returns `null` only when the data file is also missing.
- `syntheticMetadata(clock, size)` — minimal `{ createdAt, modifiedAt, size }` triple built from the injectable clock.
## Usage: Basic Example
```ts
import { buildTempPath, buildListPage } from "@suaveplan/storage-file";
const temp = buildTempPath("/tmp/store/key.data");
const page = buildListPage({ entries, fileExtension: ".data", prefix: "user/", offset: 0, limit: 50, continuationToken: undefined });
```
## Usage: Advanced Example
```ts
import { readMetadataWithStatFallback, defaultNodeFs } from "@suaveplan/storage-file";
const fs = defaultNodeFs();
const meta = await readMetadataWithStatFallback(fs, "/tmp/store/k.meta", "/tmp/store/k.data");
if (meta === null) {
// nothing exists for this key
} else {
// either a real sidecar or a synthesised stat record
}
```
## API Reference
- `buildTempPath(forPath: string): string` — temp filename in the same directory as `forPath`.
- `buildListPage(args: ListPageArgs): StorageListResult` — pure pagination over readdir entries.
- `resolveListOptions(options, total): ResolvedListOptions` — defaults `{prefix:"", offset:0, limit:total||1, continuationToken: undefined}`.
- `readMetadataWithStatFallback(fs, metaPath, dataPath): Promise<StorageMetadata | null>` — metadata read with stat fallback.
- `syntheticMetadata(clock: Clock, size: number): StorageMetadata` — synthetic timestamped record.
- `ListPageArgs`, `ResolvedListOptions` — explicit interfaces.
## Implementation Notes
- The temp filename embeds the destination base name so debugging an orphaned temp file in the directory is easy: `ls .tmp-*` lists every aborted write with its target.
- The `limit` default in `resolveListOptions` is `max(total, 1)` rather than `total` so an empty store still returns a meaningful page (zero keys, no continuation) without divide-by-zero or `slice(0, 0)` ambiguity.
- `readMetadataWithStatFallback` swallows ENOENT on both files (`return null`) but rethrows every other errno so callers don't need to repeat the classification.
- All helpers are stateless and re-entrant; they can be called concurrently against the same `NodeFsLike`.

View File

@@ -0,0 +1,77 @@
# `node-fs-storage`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - node-fs-storage.ops — Houses the file-system operat…
- [node-fs-storage.ops](./node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
- [node-fs-storage.ops](./node-fs-storage.ops.md) — Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call w…
## Purpose
`NodeFsStorage` is the package's headline export: a full `AbstractStorage` implementation backed by the Node.js filesystem with atomic writes, AbortSignal everywhere, strict key sanitisation, retry on transient errors, native Web Streams, and partial reads. It is the server-tier sibling of `MemoryStorage` from `@suaveplan/storage` and `S3` from `@suaveplan/storage-s3` — every consumer that talks to `AbstractStorage` works with it unchanged.
## Features
- Extends `AbstractStorage` and implements every abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`, `getMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`).
- Atomic writes via temp-file-then-`fs.rename`. The temp file lives in the same directory as the destination so the rename is POSIX-atomic; rollback unlinks the temp on any failure.
- AbortSignal threaded through every async method; pre-aborted signals surface as `STORAGE_FILE.ABORTED` before any IO call.
- Strict key sanitisation: `sanitizeKey` rejects empty keys, `..` traversal, absolute paths, drive-letter prefix, control chars, null bytes, and over-length keys before any path resolution.
- `@suaveplan/retry` integration on `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` with injectable `TimersLike` for deterministic test timing.
- Native Web Streams via `node:fs.createReadStream`/`createWriteStream`. Streamed writes use the same temp-rename atomic policy.
- Partial reads via positioned `fs.read` on a `FileHandle` — O(length) memory, never O(file-size).
- Side-car metadata file (`.meta`) for `StorageOptions.metadata`/`ttl`. Missing metadata falls back to a synthetic record from `fs.stat`.
- Full OTel surface — duration histogram, byte-flow histogram, per-kind counters, per-op tracer.
## Usage: Basic Example
```ts
import { NodeFsStorage } from "@suaveplan/storage-file";
const storage = new NodeFsStorage({ rootDir: "/tmp/store" });
await storage.set("hello", new TextEncoder().encode("world"));
const bytes = await storage.get("hello");
console.log(new TextDecoder().decode(bytes!));
```
## Usage: Advanced Example
```ts
import { createNodeFsStorage, defaultNodeFs } from "@suaveplan/storage-file";
import { fakeClock } from "@suaveplan/testing/runner";
const fc = fakeClock();
const storage = createNodeFsStorage({
rootDir: "/tmp/store",
mkdirp: true,
fileExtension: ".bin",
metadataExtension: ".manifest",
retry: { maxAttempts: 5, baseDelayMs: 25, maxDelayMs: 1_000 },
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
clock: () => fc.now(),
fs: defaultNodeFs(),
});
const controller = new AbortController();
const writer = storage.createWriteStream("large", controller.signal).getWriter();
await writer.write(new Uint8Array(1024 * 1024));
await writer.close();
const slice = await storage.getPartial("large", 1024, 256, controller.signal);
console.log(slice?.byteLength); // → 256
```
## API Reference
- `class NodeFsStorage extends AbstractStorage` — constructor `(options: NodeFsStorageOptions)`. Throws `STORAGE_FILE.INVALID_OPTIONS` on schema-validation failure.
- `createNodeFsStorage(options): NodeFsStorage` — convenience factory.
- `capabilities` — `{ partialReads: true, partialWrites: false, nativeStreams: true, atomicWrites: true, contentAddressable: false }`.
- All abstract `AbstractStorage` methods with the exact signatures declared on the base class.
## Implementation Notes
- Public methods sanitise their key argument before any internal operation. The sanitised form is what reaches `dataPath`/`metaPath`/`atomicWrite` so directory-traversal cannot be re-introduced by a private helper.
- `setPartial` delegates to `AbstractStorage.defaultSetPartial`, which performs a read-modify-write. Native random-access write is a follow-up — the reference path is correct but loads the full value into memory.
- `getSize` enumerates `list()` keys and reads each `getMetadata` — `O(N)` calls. This matches the upstream contract; future work may add a single-pass `du`-like helper.
- The metadata sidecar file is written atomically using the same temp-rename helper as the data file. A failed metadata write does NOT rollback the data write: the data is durable, the metadata is best-effort. Callers requiring the two to commit together can perform the second write themselves under `set(..., { metadata })`.
- `createReadStream` and `createWriteStream` return WHATWG Web Streams; consumers that prefer Node's Readable can pipe via `Readable.fromWeb`.
- The OTel `op_duration_ms` histogram timing is wall-clock via `Date.now()` — accurate at the millisecond resolution that OTel histograms typically bucket at. For sub-millisecond benchmarks, use the dedicated mitata suite under `src/__benchmarks__/`.

View File

@@ -0,0 +1,44 @@
# `node-fs-storage.ops`
## Purpose
Houses the file-system operation helpers consumed by `NodeFsStorage`. Each function here wraps a single boundary call with documented error mapping; the orchestrator class delegates so the class itself stays under the source-file size budget. Splitting these out also makes the operations independently testable in isolation.
## Features
- `readPositioned` — `fs.open` + `handle.read(offset, length)` + `handle.close()` with `null` on ENOENT.
- `assertPositiveInt` — argument-shape guard for the `offset`/`length` pair on `getPartial`.
- `buildReadStream` / `buildWriteStream` — thin wrappers that thread the optional `signal` into the underlying stream factories.
- `assertKeyExists` — pluggable strict variant of `exists` that throws `STORAGE_FILE.NOT_FOUND` when the key is absent.
## Usage: Basic Example
```ts
import { readPositioned, defaultNodeFs } from "@suaveplan/storage-file";
const fs = defaultNodeFs();
const slice = await readPositioned({ fs, path: "/tmp/k.data", offset: 0, length: 16, key: "k" });
```
## Usage: Advanced Example
```ts
import { assertKeyExists, NodeFsStorage } from "@suaveplan/storage-file";
const storage = new NodeFsStorage({ rootDir: "/tmp/store" });
await assertKeyExists("config", (k, signal) => storage.exists(k, signal));
```
## API Reference
- `readPositioned(args: PartialReadArgs): Promise<Uint8Array | null>` — positioned read.
- `assertPositiveInt(key, offset, length): void` — argument-shape guard.
- `buildReadStream(args: ReadStreamArgs): ReadableStream<Uint8Array>` — read stream.
- `buildWriteStream(args: WriteStreamArgs): WritableStream<Uint8Array>` — write stream.
- `assertKeyExists(key, exists, signal?): Promise<void>` — strict key check.
## Implementation Notes
- `readPositioned` always closes the handle via a `finally` block; the `.catch(() => undefined)` on `close()` swallows close-only failures so the caller never loses the read result.
- `buildWriteStream` fires `fs.mkdir` as a fire-and-forget — the stream constructor needs to return synchronously, so the `mkdir` runs in the background and any failure is swallowed (the subsequent stream open will surface the real error).
- `assertKeyExists` accepts the `exists` function as a parameter rather than the storage instance so the helper is decoupled from the class hierarchy and can be tested in isolation.

View File

@@ -0,0 +1,60 @@
# `node-fs`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the default `NodeFsLike` implementation backed by `node:fs/promises` and the two stream constructors from `node:fs`. The factory is the single binding point between this package and Node's filesystem APIs — every other module talks to the structural `NodeFsLike` interface so tests can substitute real in-memory or hooked adapters without `vi.mock`.
## Features
- Returns a fresh `NodeFsLike` object per call so callers can spread-and-override individual methods without mutating shared state.
- Forwards `readFile` results through `new Uint8Array(buffer, byteOffset, byteLength)` so consumers get a stable `Uint8Array` view regardless of the underlying Buffer subclass.
- Wraps `node:fs/promises.open` so the returned handle matches the structural `NodeFsFileHandleLike` interface (only `read` + `close`).
- Translates `readdir(path, { recursive: true, withFileTypes: true })` Dirent entries into the lighter `NodeFsReaddirEntry` shape exposed to the rest of the package.
- Translates `stat` into the lighter `NodeFsStatLike` shape exposing only the four fields the adapter reads.
## Usage: Basic Example
```ts
import { defaultNodeFs } from "@suaveplan/storage-file";
const fs = defaultNodeFs();
await fs.mkdir("/tmp/store", { recursive: true });
await fs.writeFile("/tmp/store/hello.data", new TextEncoder().encode("hi"));
const data = await fs.readFile("/tmp/store/hello.data");
```
## Usage: Advanced Example
```ts
import { defaultNodeFs, NodeFsStorage } from "@suaveplan/storage-file";
import type { NodeFsLike } from "@suaveplan/storage-file/types";
const base = defaultNodeFs();
// Wrap stat to ensure mtimes never go backwards under wall-clock skew.
const monotonicStat: NodeFsLike = {
...base,
async stat(path) {
const s = await base.stat(path);
return { ...s, mtimeMs: Math.max(s.mtimeMs, Date.now()) };
},
};
const storage = new NodeFsStorage({ rootDir: "/tmp/store", fs: monotonicStat });
```
## API Reference
- `defaultNodeFs(): NodeFsLike` — returns a fresh adapter object.
Every method on the returned object forwards to its `node:fs/promises` counterpart with minimal shape translation. See `fs-adapter.types.md` for the full method list.
## Implementation Notes
- The factory pattern (vs a module-level singleton) is deliberate: returning a fresh object lets tests construct independent adapters per `it(...)` block and verify ownership semantics.
- The factory is dependency-free at module evaluation time — every Node import is at the top of the file, but `defaultNodeFs()` itself doesn't reach into globals or open any handles. Callers can build the storage adapter at import time without paying any filesystem cost.
- The lazy buffer-view translation in `readFile` avoids a copy when callers operate on the result via DataView or pass it back into a hash function.

View File

@@ -0,0 +1,64 @@
# `otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises OpenTelemetry instrumentation for `@suaveplan/storage-file`. Every public path in `NodeFsStorage` records the operation outcome via `recordOp`, so a deployment that installs an OTel SDK gets per-operation counters, latency histograms, and byte-flow histograms for free — no plumbing in user code. The instruments are bound lazily so consumers can install a custom `MeterProvider`/`TracerProvider` before the first operation runs and have it picked up automatically.
## Features
- 4 counters: `storage_file.reads_total`, `storage_file.writes_total`, `storage_file.deletes_total`, `storage_file.errors_total` — all attributed by `op` and `outcome`.
- 2 histograms: `storage_file.op_duration_ms{op,outcome}` and `storage_file.io_bytes{op,direction}`.
- 1 tracer: `@suaveplan/storage-file` (spans created at the call site, not auto-recorded here, so a caller can decorate them with key/extras).
- Lazy instrument cache — `getInstruments()` builds the set on first call and reuses it thereafter. `resetInstruments()` blows the cache so tests can rebuild against a freshly-installed in-memory MeterProvider.
- Outcome enum closed to `success`/`error`/`aborted` — bounded cardinality for dashboards.
## Usage: Basic Example
```ts
import { recordOp } from "@suaveplan/storage-file";
const start = performance.now();
try {
await fs.writeFile(path, data);
recordOp({ op: "set", outcome: "success", durationMs: performance.now() - start, bytes: data.byteLength, direction: "write" });
} catch (err) {
recordOp({ op: "set", outcome: "error", durationMs: performance.now() - start });
throw err;
}
```
## Usage: Advanced Example
```ts
import { resetInstruments, getInstruments } from "@suaveplan/storage-file";
import { metrics } from "@opentelemetry/api";
class CollectingMeter { /* … real in-memory MeterProvider … */ }
const provider = new CollectingMeter();
metrics.setGlobalMeterProvider(provider as never);
resetInstruments(); // rebuild against the freshly-installed provider
const inst = getInstruments();
// emit ops through your call site, then read the recorded values via the provider's collector.
```
## API Reference
- `getInstruments(force?: boolean): StorageFileInstruments` — returns the lazily-built instrument set. Pass `force: true` to rebuild after installing a new MeterProvider.
- `recordOp(input: RecordOpInput): void` — emits the counter increment and histogram observations for one operation.
- `resetInstruments(): void` — clears the cache; the next `getInstruments()` rebuilds.
- `StorageFileOp` — closed string union enumerating the 14 instrumented operations.
- `StorageFileOpOutcome` — `"success" | "error" | "aborted"`.
- `RecordOpInput` — `{ op, outcome, durationMs, bytes?, direction? }`.
- `StorageFileInstruments` — `{ tracer, meter, readsTotal, writesTotal, deletesTotal, errorsTotal, opDurationMs, ioBytes }`.
## Implementation Notes
- The counters are split by operation kind (reads/writes/deletes) rather than rolled into one total counter so dashboards can compute hit/miss/error rates without joining histograms.
- `io_bytes` is recorded under a `direction` attribute (`"read"` or `"write"`) to keep both flows in one histogram while remaining splittable.
- `errors_total` counts both `outcome=error` and `outcome=aborted` ops — aborted operations are still failures from the caller's perspective.
- `resetInstruments` is exported only because tests need it; production code never calls it.

View File

@@ -0,0 +1,49 @@
# `registry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Registers `NodeFsStorage` against the module-augmentation registry maintained by `@suaveplan/storage/factory`. After `registerNodeFsProvider()` runs once, the `createStorage({ type: "file", config: NodeFsStorageOptions })` factory dispatches to `new NodeFsStorage(config)` with full TypeScript narrowing on the `file` slot.
## Features
- Registers the `file` provider type with the storage factory.
- Idempotent — repeated `registerNodeFsProvider()` calls are no-ops, so the function is safe to call from any consumer's startup path without worrying about double-registration.
- Declaration-merges `StorageProviderRegistry` and `StorageProviderConfig` so `createStorage` is fully typed.
## Usage: Basic Example
```ts
import { registerNodeFsProvider } from "@suaveplan/storage-file";
import { createStorage } from "@suaveplan/storage/factory";
registerNodeFsProvider();
const storage = createStorage({ type: "file", config: { rootDir: "/tmp/store" } });
```
## Usage: Advanced Example
```ts
import { registerNodeFsProvider, NodeFsStorage } from "@suaveplan/storage-file";
import { createStorage } from "@suaveplan/storage/factory";
// Safe in any startup module — idempotent.
registerNodeFsProvider();
registerNodeFsProvider();
const storage = createStorage({ type: "file", config: { rootDir: "/var/lib/app/storage", retry: { maxAttempts: 5 } } });
console.log(storage instanceof NodeFsStorage); // → true
```
## API Reference
- `registerNodeFsProvider(): void` — register the `file` provider. Idempotent.
## Implementation Notes
- The registration delegates to `registerStorageProvider("file", NodeFsStorage)` from `@suaveplan/storage/factory`. The cast through `unknown` is required because the factory's loose `ProviderCtor` signature accepts `unknown` for config — `NodeFsStorage` accepts a stricter type but is structurally compatible.
- Registration is NOT triggered on import — consumers must call `registerNodeFsProvider()` explicitly. This keeps `import "@suaveplan/storage-file"` side-effect-free per `"sideEffects": false`.
- The module-augmentation `declare module "@suaveplan/storage/factory"` is type-only — it does not require the consumer to import this file at runtime to benefit from the typing.

View File

@@ -0,0 +1,60 @@
# `retry-helper`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps `@suaveplan/retry.retry` with the transient-error classifier specific to filesystem operations and the error-mapping policy specific to this package. Every public `NodeFsStorage` operation that performs IO routes through `withFsRetry` so the retry policy lives in one place. Tests can drive the retry loop deterministically by injecting a `TimersLike` pair (typically the FakeClock-backed pair from `@suaveplan/testing`).
## Features
- Classifies `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN` as transient; every other errno is fatal.
- Maps `EACCES`/`EPERM` to `STORAGE_FILE.PERMISSION_DENIED` after exhausting retries.
- Maps any other terminal error to `STORAGE_FILE.IO_FAILED` and preserves `cause`.
- Lets a previously-thrown `StorageFileError` bubble through unwrapped so error codes don't get clobbered.
- Defaults to `globalThis.setTimeout`/`clearTimeout` but accepts an injectable `TimersLike` pair for deterministic timing.
## Usage: Basic Example
```ts
import { withFsRetry } from "@suaveplan/storage-file";
const data = await withFsRetry(() => fs.readFile("/tmp/store/k.data"));
```
## Usage: Advanced Example
```ts
import { withFsRetry, type WithFsRetryOptions } from "@suaveplan/storage-file";
import { fakeClock } from "@suaveplan/testing/runner";
const fc = fakeClock();
const options: WithFsRetryOptions = {
retry: { maxAttempts: 5, baseDelayMs: 10, maxDelayMs: 200 },
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
signal: controller.signal,
key: "k",
};
await withFsRetry(async () => {
throw Object.assign(new Error("EBUSY"), { code: "EBUSY" });
}, options).catch((err) => {
// → StorageFileError(STORAGE_FILE.IO_FAILED, cause: { code: "EBUSY" })
});
```
## API Reference
- `withFsRetry<T>(fn, options?): Promise<T>` — runs `fn` with retry on transient fs errors. Throws `StorageFileError` with `IO_FAILED` or `PERMISSION_DENIED` after exhaustion.
- `WithFsRetryOptions` — `{ retry?, timers?, signal?, key? }`. `retry.maxAttempts` default 3, `retry.baseDelayMs` default 50, `retry.maxDelayMs` default 5000.
- `isTransientFsError(err): boolean` — true when `err.code` is one of `EBUSY`/`EMFILE`/`ENFILE`/`EAGAIN`.
- `isPermissionFsError(err): boolean` — true when `err.code` is `EACCES` or `EPERM`.
## Implementation Notes
- The exponential backoff is built via `createExponentialBackoff` from `@suaveplan/retry`. The jitter policy is the package default (equal jitter).
- `signal` is forwarded to the inner `retry` call so an abort surfaces as a `RetryAbortedError` from the retry package; the outer `catch` rewraps it via the IO_FAILED path.
- `key` (when provided) is propagated into the rethrown `StorageFileError` so OTel attribute extraction works downstream without additional context plumbing.
- Pre-existing `StorageFileError` instances are NOT rewrapped — this keeps a `STORAGE_FILE.INVALID_KEY` raised inside `fn` visible as `INVALID_KEY` at the call site, not as a meaningless `IO_FAILED` wrapper.

View File

@@ -0,0 +1,63 @@
# `streams`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bridges `node:fs` Readable/Writable streams to Web `ReadableStream`/`WritableStream` for use by `NodeFsStorage.createReadStream`/`createWriteStream`. The bridge threads AbortSignal end-to-end and applies the same temp-file-then-rename atomic-write policy as the buffered `set` path, so a large streamed write never leaves a partial file at the destination path.
## Features
- `createNodeReadStream` returns a `ReadableStream<Uint8Array>` backed by `fs.createReadStream`. Forwards `data`/`end`/`error` events; surfaces both transport failures and abort signals as `StorageFileError`.
- `createNodeWriteStream` returns a `WritableStream<Uint8Array>` backed by `fs.createWriteStream` writing to a caller-supplied `tempPath`. On `close` it `fs.rename`s onto `destinationPath`. On abort or error it unlinks the temp file.
- Both bridges destroy the underlying Node stream on cancel/abort so file descriptors don't leak. On the read path, `cancel()` also installs a no-op error listener on the inner Node stream before destroying it — when cancel races an in-flight `open()`, Node may still emit a late `"error"` (typically ENOENT if the file was removed between creation and cancel) after our own error handler has been torn down. Without the absorber that error would surface as an unhandled exception and could crash a vitest worker.
- The temp-path validation enforces the canonical `.tmp-` prefix at boundary entry — passing a malformed path surfaces as `STORAGE_FILE.ATOMIC_WRITE_FAILED` before any IO.
## Usage: Basic Example
```ts
import { createNodeReadStream, defaultNodeFs } from "@suaveplan/storage-file";
const fs = defaultNodeFs();
const reader = createNodeReadStream({ fs, path: "/tmp/store/large.data", key: "large" });
for await (const chunk of reader) {
console.log(chunk.byteLength);
}
```
## Usage: Advanced Example
```ts
import { createNodeWriteStream, defaultNodeFs } from "@suaveplan/storage-file";
const fs = defaultNodeFs();
const controller = new AbortController();
const writer = createNodeWriteStream({
fs,
destinationPath: "/tmp/store/k.data",
tempPath: "/tmp/store/.tmp-k.data",
signal: controller.signal,
key: "k",
});
const sink = writer.getWriter();
await sink.write(new Uint8Array(1024 * 1024));
await sink.write(new Uint8Array(1024 * 1024));
await sink.close();
// → /tmp/store/k.data now contains 2 MiB; no .tmp-k.data remains.
```
## API Reference
- `createNodeReadStream({ fs, path, signal?, key }): ReadableStream<Uint8Array>` — Web ReadableStream over `node:fs.createReadStream(path)`.
- `createNodeWriteStream({ fs, destinationPath, tempPath, signal?, key }): WritableStream<Uint8Array>` — Web WritableStream that writes to `tempPath` and renames onto `destinationPath` on close. Throws `STORAGE_FILE.ATOMIC_WRITE_FAILED` if `tempPath` does not contain the canonical temp prefix.
- `CreateReadStreamArgs`, `CreateWriteStreamArgs` — explicit interface bags for the two factories.
## Implementation Notes
- The read bridge copies each chunk into a fresh `Uint8Array(buffer, byteOffset, byteLength)` view so consumers don't accidentally retain a reference to Node's Buffer pool.
- The write bridge installs the abort listener inside `start` and removes it in `close`/`abort` — both branches are exercised by the test suite.
- `abort()` on the WritableStream is best-effort: the temp file unlink is `.catch(() => undefined)` so we never throw an abort error and lose the cleanup signal.
- On a successful `close` the temp file is renamed atomically. On a failed rename the temp file is removed and an `ATOMIC_WRITE_FAILED` surfaces — never both an orphaned temp and an incomplete destination.

View File

@@ -0,0 +1,64 @@
# `fs-adapter.types`
## Purpose
Declares the `NodeFsLike` structural interface that `NodeFsStorage` consumes for every filesystem call. The interface is the seam that keeps the adapter injectable in tests without `vi.mock`/`vi.stubGlobal`: production code resolves to a real `node:fs/promises`-backed implementation, tests substitute a real in-memory or hooked implementation that points at a real `fs.mkdtemp` directory or selectively overrides one method to simulate a transient `EBUSY`.
## Features
- Mirrors only the subset of `node:fs` methods `NodeFsStorage` actually uses — no leaky abstraction.
- Returns Node's `Readable` and `Writable` from the two stream constructors so consumers can convert to/from Web Streams without `as` casts.
- `NodeFsStatLike` exposes only `size`, `mtimeMs`, `birthtimeMs`, `isFile()`, `isDirectory()` — the four properties the adapter reads.
- `NodeFsFileHandleLike` exposes the positioned `read` + `close` pair needed for `getPartial`.
## Usage: Basic Example
```ts
import type { NodeFsLike } from "@suaveplan/storage-file/types";
import { defaultNodeFs } from "@suaveplan/storage-file";
const fs: NodeFsLike = defaultNodeFs();
const stat = await fs.stat("/tmp/example.data");
console.log(stat.size);
```
## Usage: Advanced Example
```ts
import type { NodeFsLike } from "@suaveplan/storage-file/types";
import { defaultNodeFs, NodeFsStorage } from "@suaveplan/storage-file";
// A hooked adapter that injects a single transient failure on writeFile.
function hookOneEbusy(base: NodeFsLike): NodeFsLike {
let fired = false;
return {
...base,
async writeFile(path, data) {
if (!fired) {
fired = true;
const err = Object.assign(new Error("EBUSY"), { code: "EBUSY" });
throw err;
}
return base.writeFile(path, data);
},
};
}
const storage = new NodeFsStorage({ rootDir: "/tmp/store", fs: hookOneEbusy(defaultNodeFs()) });
await storage.set("k", new Uint8Array([1, 2, 3])); // retried once, succeeds
```
## API Reference
- `NodeFsLike` — structural interface with 11 methods (`mkdir`, `readFile`, `writeFile`, `rename`, `unlink`, `readdir`, `stat`, `access`, `open`, `createReadStream`, `createWriteStream`).
- `NodeFsStatLike` — `{ size, mtimeMs, birthtimeMs, isFile(), isDirectory() }`.
- `NodeFsFileHandleLike` — `{ read(buffer, offset, length, position), close() }`.
- `NodeFsReaddirEntry` — `{ name, isFile(), isDirectory() }`.
- `NodeFsCreateReadStreamOptions` — `{ start?, end?, highWaterMark? }`.
- `NodeFsCreateWriteStreamOptions` — `{ flags?, highWaterMark? }`.
## Implementation Notes
- Declared as `interface`s (not `type` aliases) so downstream consumers can declaration-merge if they need to extend the surface for a specialised backend (e.g. a fuse mount with a custom flag).
- Returns `Promise<string | undefined>` from `mkdir` to match Node's API — `recursive: true` returns the first path created or `undefined` if nothing was created. The adapter never reads the value.
- `readdir` is fixed to `withFileTypes: true` for type narrowing; the adapter never uses the legacy string-array shape.

View File

@@ -0,0 +1,55 @@
# `node-fs-storage.types`
## Purpose
Declares the option shape and Zod schema consumed by `NodeFsStorage`'s constructor. The schema is the runtime boundary — every option that flows into the adapter is parsed exactly once, at construction time, and a parse failure surfaces as `STORAGE_FILE.INVALID_OPTIONS`. Co-locating the type and the schema in a single file keeps inferred type ⇄ schema in lockstep across edits.
## Features
- `NodeFsStorageOptions` interface enumerating every constructor option with inline JSDoc.
- `NodeFsStorageOptionsSchema` Zod object schema validating the option bag at the public boundary.
- `NodeFsStorageRetryOptions` carrying the retry knobs forwarded to `@suaveplan/retry`.
- `TimersLike` interface — the injectable timer pair the retry loop consumes (`setTimer`/`clearTimer`) — declared here once so consumers can ship deterministic FakeClock-backed fakes without importing test-only helpers.
- Loose `unknown` shape for the injectable adapters (`fs`, `timers`, `clock`) — Zod cannot fully describe their structural shape without losing the structural contract, so we accept any value and rely on TypeScript at the call site.
## Usage: Basic Example
```ts
import { NodeFsStorageOptionsSchema } from "@suaveplan/storage-file";
const parsed = NodeFsStorageOptionsSchema.parse({ rootDir: "/tmp/store" });
console.log(parsed.rootDir);
```
## Usage: Advanced Example
```ts
import { NodeFsStorage, type NodeFsStorageOptions } from "@suaveplan/storage-file";
import { fakeClock } from "@suaveplan/testing/runner";
const fc = fakeClock({ now: 1_700_000_000_000 });
const options: NodeFsStorageOptions = {
rootDir: "/tmp/store",
mkdirp: true,
fileExtension: ".bin",
retry: { maxAttempts: 5, baseDelayMs: 25, maxDelayMs: 1_000 },
clock: () => fc.now(),
timers: { setTimer: fc.setTimeout, clearTimer: fc.clearTimeout },
};
const storage = new NodeFsStorage(options);
```
## API Reference
- `NodeFsStorageOptions` — interface; required `rootDir`, optional `mkdirp`, `fileExtension`, `metadataExtension`, `retry`, `fs`, `timers`, `clock`.
- `NodeFsStorageRetryOptions` — interface; optional `maxAttempts`, `baseDelayMs`, `maxDelayMs`.
- `NodeFsStorageOptionsSchema` — Zod schema for `NodeFsStorageOptions`. Throws on missing `rootDir`, empty `rootDir`/`fileExtension`/`metadataExtension`, non-positive `maxAttempts`/`maxDelayMs`, or negative `baseDelayMs`.
- `NodeFsStorageRetryOptionsSchema` — Zod schema for the retry sub-bag.
- `TimersLike` — interface; `setTimer(handler, ms)` and `clearTimer(id)`. Compatible with `globalThis.setTimeout`/`clearTimeout` (the default).
## Implementation Notes
- `rootDir` is validated `min(1)` — empty strings are rejected to avoid ambiguous "current directory" semantics.
- `fileExtension` and `metadataExtension` are also `min(1)` so callers cannot accidentally collide every key into the same path.
- The adapter validates `fs`/`timers`/`clock` only structurally — passing `{} as never` will surface as a missing-method TypeError on the first call. This is intentional: forcing every option to have a Zod-describable shape would prevent injecting real callable functions.
- The schema accepts `retry: undefined` and `retry: {}` — both produce default retry behaviour.