Files
2026-05-27 23:29:53 +01:00

138 lines
6.1 KiB
Markdown

# 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:
```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.