Files
suaveplan/openspec/archive/2026-05-16-add-storage-file-bun-fs-adapter/design.md
2026-05-27 23:29:53 +01:00

6.1 KiB

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<Uint8Array> — 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<number> — 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:

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:

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

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.