138 lines
6.1 KiB
Markdown
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.
|