Initial Commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user