# Design: Bun-native filesystem adapter for `@suaveplan/storage-file` This document is non-normative. The normative contract lives in `./specs/storage-file/spec.md`. ## Background `NodeFsStorage` consumes a structural `NodeFsLike` interface that mirrors a strict subset of `node:fs/promises` plus the two `node:fs` stream constructors and `open`. The shape was chosen so the adapter is injectable for tests without `vi.mock` and so callers running outside Node-the-runtime can plug in a shim. `defaultNodeFs()` is the canonical implementation. It is a thin wrapper over `node:fs/promises`. Under Bun, every call goes through Bun's Node-compat layer — which is a real native (Zig) implementation under the hood for most methods, but `readFile` and `writeFile` specifically take a longer path through Buffer / `Uint8Array` reshaping than the Bun-native APIs. ## Bun's native fast path Bun exposes: - `Bun.file(path)` — a lazy `BunFile` handle. - `.bytes(): Promise` — returns the file contents as a fresh `Uint8Array`. This is the canonical replacement for `node:fs/promises.readFile(path)` when the caller wants a `Uint8Array`. - Throws `ENOENT`-shaped errors (with `.code === "ENOENT"`) when the file is missing — same surface `NodeFsStorage` already handles via `isEnoent`. - `Bun.write(path, data): Promise` — writes `data` (a `Uint8Array`, string, or `BunFile`) and returns the byte count. The byte count is discarded by the adapter (we only need void). These two methods are the only Bun-native fast paths that have no behaviourally identical `node:fs/promises` equivalent — every other method on `NodeFsLike` either has no Bun-native equivalent (`mkdir`, `readdir`, `stat`, `access`, `rename`, `unlink`, `open`) or already uses Bun's native implementation transparently via the Node-compat layer (streams). ## Adapter design `src/bun-fs/bun-fs.ts` exports `defaultBunFs(): NodeFsLike`. The factory returns a fresh object so callers can selectively override individual methods — same convention `defaultNodeFs()` uses. The implementation is intentionally small: ```text defaultBunFs(): readFile(path) → Bun.file(path).bytes() writeFile(path, data) → Bun.write(path, data); void every other method → node:fs/promises (same call defaultNodeFs makes) ``` The "every other method" set is large enough that we DO NOT duplicate `defaultNodeFs()`'s body. Instead `defaultBunFs()` constructs a base `NodeFsLike` via `defaultNodeFs()` and overrides `readFile`/`writeFile`. This keeps the diff between the two adapters minimal and audit-friendly. `writeFile` accepts `Uint8Array | string`. Bun.write accepts both natively. The adapter does not buffer or copy. `readFile` returns `Uint8Array`. `Bun.file(path).bytes()` already returns a fresh `Uint8Array`, so no copy is needed. ### Runtime detection in `resolveConfig` `resolveConfig()` (in `node-fs-storage.helpers.ts`) currently falls back to `defaultNodeFs()` when no `fs` is injected. This change makes the fallback runtime-aware via a `pickDefaultFs()` helper exported from `src/bun-fs/bun-fs.ts`: ```text pickDefaultFs(): if (typeof globalThis.Bun !== "undefined") return defaultBunFs() return defaultNodeFs() ``` `globalThis.Bun` is the documented detection probe. The detection is done at `resolveConfig` time, not at module-import time, so test harnesses can inject either factory deterministically regardless of host runtime. When a caller passes `fs` explicitly, the detection branch is not taken. This preserves every existing test that injects a fixture `NodeFsLike`. ## File layout ```text packages/storage/storage-file/src/bun-fs/ bun-fs.ts # defaultBunFs() + pickDefaultFs() bun-fs.test.ts # tests under @suaveplan/testing/runner bun-fs.md # sibling doc index.ts # explicit named re-export ``` `src/node-fs-storage/node-fs-storage.helpers.ts` switches its default-fs resolution from `defaultNodeFs()` to `pickDefaultFs()`. `src/index.ts` adds `defaultBunFs` and `pickDefaultFs` to the public surface. `package.json` `exports` map gains `./bun-fs/bun-fs`. ## Test plan - Unit test: round-trips bytes through `defaultBunFs().writeFile` / `defaultBunFs().readFile` on a real temp dir. The mirror test for the Node adapter is `src/node-fs/node-fs.test.ts::"forwards every method to node:fs/promises"`; the Bun test asserts the same invariants. - Unit test: `defaultBunFs()` forwards `mkdir`, `readdir`, `stat`, `access`, `rename`, `unlink`, `open`, and the two stream constructors to the `node:fs/promises`-backed delegate. Proven by composing on a real temp dir — no mocking. - Unit test: `pickDefaultFs()` returns a `NodeFsLike` whose `readFile` exists and is callable; under Bun the runtime probe selects the Bun factory (the test runs under Bun via `@suaveplan/testing/runner`). - Unit test: `NodeFsStorage` constructed without an `fs` option under Bun uses the Bun-backed default — verified end-to-end by setting and getting a value, then asserting the resulting bytes match. (This is the spec-conformance scenario "auto-picks the Bun default under Bun".) - All existing tests continue to pass — the runtime-aware default is backwards compatible with every fixture that injects `fs`. ## Trade-offs - We deliberately do NOT route `createReadStream` / `createWriteStream` through `Bun.file(path).stream()`. Bun's `BunFile.stream()` returns a Web `ReadableStream`, but `NodeFsLike.createReadStream` is typed as a Node `Readable`. Translating between the two introduces a copy and an API impedance mismatch that erases the speedup. The Node `node:fs` stream constructor already dispatches to Bun's native impl under Bun. - We do NOT introduce a separate `BunFsLike` interface. The whole point of the adapter is to satisfy the existing `NodeFsLike` contract so consumers do not branch on runtime. - `pickDefaultFs()` performs the runtime probe on every `resolveConfig` call. The cost is one `typeof` check and one object allocation — both trivial compared to the filesystem syscall the caller is about to make. Caching the choice in module scope would defeat test injection.