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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Add Bun-native filesystem adapter to `@suaveplan/storage-file`
|
||||
|
||||
- **Capability**: `storage-file`
|
||||
- **Status**: PROPOSED
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-file/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/storage-file` currently ships a single `NodeFsLike` implementation —
|
||||
`defaultNodeFs()` in `src/node-fs/node-fs.ts` — bound to `node:fs/promises` plus
|
||||
the two `node:fs` stream constructors. Genesis is a Bun-first monorepo. Bun
|
||||
provides a faster, non-emulated filesystem path for the common read-and-write
|
||||
hot loop via `Bun.file()` / `Bun.write()` that skips the `node:fs`
|
||||
emulation layer entirely. Today every read/write inside `NodeFsStorage` takes
|
||||
the slower compatibility path even when running under Bun.
|
||||
|
||||
This change adds a sibling `defaultBunFs()` factory that satisfies the same
|
||||
`NodeFsLike` structural contract but routes `readFile`/`writeFile` through
|
||||
Bun's native APIs. Operations that have no Bun-specific equivalent
|
||||
(`mkdir`, `readdir`, `rename`, `unlink`, `stat`, `access`, `open`,
|
||||
`createReadStream`, `createWriteStream`) continue to use `node:fs/promises` —
|
||||
under Bun those calls already dispatch to Bun's native Zig implementation.
|
||||
|
||||
`resolveConfig` gains a runtime-probed default: when the caller does not
|
||||
inject an `fs` and `globalThis.Bun` is present, `defaultBunFs()` is used;
|
||||
otherwise `defaultNodeFs()` is used. Both factories remain explicitly
|
||||
exported so callers can wire either one directly (e.g. forcing the Node
|
||||
path in tests, or in mixed-runtime SSR pipelines).
|
||||
|
||||
## What changes
|
||||
|
||||
- New module `src/bun-fs/bun-fs.ts` exporting `defaultBunFs(): NodeFsLike`.
|
||||
- `readFile` uses `Bun.file(path).bytes()`.
|
||||
- `writeFile` uses `Bun.write(path, data)`.
|
||||
- All other methods delegate to `node:fs/promises` (the same calls
|
||||
`defaultNodeFs()` makes), so the surface stays identical.
|
||||
- New module `src/bun-fs/bun-fs.md` (sibling doc, §4 conformant).
|
||||
- New module `src/bun-fs/bun-fs.test.ts` (`@suaveplan/testing/runner`).
|
||||
- New barrel `src/bun-fs/index.ts` (explicit named re-export per §6a).
|
||||
- `resolveConfig` in `src/node-fs-storage/node-fs-storage.helpers.ts` gains a
|
||||
Bun-vs-Node default selector. Behaviour is unchanged when callers pass
|
||||
`fs` explicitly.
|
||||
- `src/index.ts` re-exports `defaultBunFs` next to `defaultNodeFs`.
|
||||
- `package.json` `exports` map gains `./bun-fs/bun-fs`.
|
||||
- `README.md` Modules section gets a new row.
|
||||
|
||||
## Migration
|
||||
|
||||
No consumer behaviour changes if `fs` is passed explicitly. Consumers that
|
||||
relied on the implicit default got a `node:fs/promises`-backed implementation
|
||||
before this change and continue to get one when running under Node. Under Bun
|
||||
they transparently get the faster path. The `NodeFsLike` contract, all
|
||||
`STORAGE_FILE.*` codes, the `recordOp` instrumentation, and the spec-conformance
|
||||
suite are unchanged.
|
||||
|
||||
To force the Node path under Bun (e.g. for parity testing):
|
||||
|
||||
```ts
|
||||
import { defaultNodeFs, NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const storage = new NodeFsStorage({ rootDir, fs: defaultNodeFs() });
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-file` to enumerate. As of
|
||||
authoring, `@suaveplan/storage-file` has no in-repo dependents (it is consumed
|
||||
by the upstream `@suaveplan/storage` factory via the optional registry path).
|
||||
|
||||
- No in-repo dependents — verified by `bunx turbo ls --filter=...@suaveplan/storage-file`.
|
||||
Storage-cdn-bunnycdn and storage-cdn-rackspace consume `@suaveplan/storage`,
|
||||
not `storage-file` directly; their behaviour is unaffected.
|
||||
@@ -0,0 +1,90 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-file (bun-fs adapter)
|
||||
|
||||
**Package**: `@suaveplan/storage-file`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: defaultBunFs satisfies NodeFsLike via Bun-native read/write
|
||||
|
||||
The package SHALL export a `defaultBunFs(): NodeFsLike` factory from
|
||||
`src/bun-fs/bun-fs.ts`. The returned object's `readFile(path)` SHALL be
|
||||
implemented in terms of `Bun.file(path).bytes()` and its `writeFile(path, data)`
|
||||
SHALL be implemented in terms of `Bun.write(path, data)`. Every other method
|
||||
on the returned `NodeFsLike` (`mkdir`, `readdir`, `rename`, `unlink`, `stat`,
|
||||
`access`, `open`, `createReadStream`, `createWriteStream`) SHALL delegate to
|
||||
the same `node:fs/promises` / `node:fs` calls used by `defaultNodeFs()`. The
|
||||
factory SHALL return a fresh object per call so callers can selectively
|
||||
override individual methods without mutating shared state.
|
||||
|
||||
#### Scenario: readFile round-trips Bun.write output
|
||||
|
||||
- **WHEN** a caller calls `defaultBunFs().writeFile(path, bytes)` followed by
|
||||
`defaultBunFs().readFile(path)` against a real temp dir
|
||||
- **THEN** the returned `Uint8Array` is byte-equal to `bytes`
|
||||
|
||||
#### Scenario: every other method forwards to node:fs/promises behaviour
|
||||
|
||||
- **WHEN** a caller exercises `mkdir`, `readdir`, `stat`, `access`, `rename`,
|
||||
`unlink`, `open`, `createReadStream`, and `createWriteStream` on a
|
||||
`defaultBunFs()` instance against a real temp dir
|
||||
- **THEN** every call returns the same shape and side effects as the
|
||||
matching method on `defaultNodeFs()` (`stat.isFile()/isDirectory()` work,
|
||||
`readdir` entries expose `isFile`/`isDirectory`/`parentPath`, the file
|
||||
handle from `open` supports positioned `read` and `close`, the streams
|
||||
flow bytes end-to-end)
|
||||
|
||||
**Verified by:** `src/bun-fs/bun-fs.test.ts::"round-trips bytes through Bun.write and Bun.file"` AND `src/bun-fs/bun-fs.test.ts::"forwards mkdir, readdir, stat, access, rename, unlink, open, and the stream constructors"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: pickDefaultFs selects the Bun adapter at runtime
|
||||
|
||||
The package SHALL export a `pickDefaultFs(): NodeFsLike` factory from
|
||||
`src/bun-fs/bun-fs.ts` that returns `defaultBunFs()` when `globalThis.Bun`
|
||||
is defined and `defaultNodeFs()` otherwise. The probe SHALL be performed on
|
||||
each call (never cached at module scope) so test harnesses can deterministically
|
||||
switch adapters by injecting an explicit `fs` option to `NodeFsStorage`.
|
||||
|
||||
#### Scenario: probe selects the Bun adapter under Bun
|
||||
|
||||
- **WHEN** the test runs under Bun (the `bun` condition in
|
||||
`@suaveplan/testing/runner`) and the caller invokes `pickDefaultFs()`
|
||||
- **THEN** the returned `NodeFsLike` `readFile` and `writeFile` are sourced
|
||||
from `defaultBunFs()`, observable by performing a round-trip and confirming
|
||||
the byte contents are preserved against a real temp file
|
||||
|
||||
**Verified by:** `src/bun-fs/bun-fs.test.ts::"pickDefaultFs returns a working NodeFsLike under Bun"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: NodeFsStorage default fs honours runtime probe
|
||||
|
||||
`resolveConfig` in `src/node-fs-storage/node-fs-storage.helpers.ts` SHALL
|
||||
resolve a missing `options.fs` by calling `pickDefaultFs()` rather than
|
||||
`defaultNodeFs()`. The behaviour SHALL be backwards compatible: when the
|
||||
caller passes `options.fs` explicitly, the injected adapter is used
|
||||
unchanged. No existing scenario in the canonical `storage-file` spec is
|
||||
modified — every assertion against `NodeFsStorage`'s public surface
|
||||
continues to hold.
|
||||
|
||||
#### Scenario: explicit fs option overrides the runtime probe
|
||||
|
||||
- **WHEN** a caller constructs `new NodeFsStorage({ rootDir, fs: customFs })`
|
||||
- **THEN** every filesystem call routes through `customFs` and
|
||||
`pickDefaultFs()` is not consulted
|
||||
|
||||
#### Scenario: missing fs option under Bun uses defaultBunFs
|
||||
|
||||
- **WHEN** a caller constructs `new NodeFsStorage({ rootDir })` under Bun and
|
||||
performs `await storage.set("k", bytes)` followed by `await storage.get("k")`
|
||||
- **THEN** the returned `Uint8Array` is byte-equal to `bytes` and the
|
||||
spec-conformance suite for `NodeFsStorage` continues to pass
|
||||
|
||||
**Verified by:** `src/bun-fs/bun-fs.test.ts::"NodeFsStorage round-trips through the auto-picked Bun fs"` AND `src/node-fs-storage/node-fs-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"`
|
||||
|
||||
---
|
||||
@@ -0,0 +1,48 @@
|
||||
# Tasks: add-storage-file-bun-fs-adapter
|
||||
|
||||
## Implementation
|
||||
|
||||
- [ ] 1. Author `src/bun-fs/bun-fs.ts` exporting `defaultBunFs(): NodeFsLike`
|
||||
and `pickDefaultFs(): NodeFsLike`. `defaultBunFs()` composes over the
|
||||
`defaultNodeFs()` base, overriding `readFile` to `Bun.file(path).bytes()`
|
||||
and `writeFile` to `Bun.write(path, data)`.
|
||||
- [ ] 2. Author `src/bun-fs/bun-fs.test.ts` using
|
||||
`@suaveplan/testing/runner`. Cover: round-trip via Bun.write+Bun.file,
|
||||
forwarding of every delegated method on a real temp dir, and
|
||||
`pickDefaultFs()` returning a callable `NodeFsLike`.
|
||||
- [ ] 3. Author `src/bun-fs/bun-fs.md` (sibling doc, §4 conformant — Purpose,
|
||||
Features, Usage Basic, Usage Advanced, API Reference,
|
||||
Implementation Notes; ≥200 words excl. code).
|
||||
- [ ] 4. Author `src/bun-fs/index.ts` with explicit named re-exports
|
||||
(§6a — no `export *`).
|
||||
- [ ] 5. Switch `resolveConfig` in
|
||||
`src/node-fs-storage/node-fs-storage.helpers.ts` from
|
||||
`defaultNodeFs()` to `pickDefaultFs()`. Behaviour unchanged when
|
||||
`options.fs` is provided.
|
||||
- [ ] 6. Add `defaultBunFs` and `pickDefaultFs` to `src/index.ts` exports.
|
||||
- [ ] 7. Add `./bun-fs/bun-fs` to `package.json` `exports` map.
|
||||
- [ ] 8. Update `README.md` Modules section to list the new module.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] 9. `bunx turbo lint typecheck test --filter=@suaveplan/storage-file`
|
||||
— all green.
|
||||
- [ ] 10. `bunx turbo test:coverage --filter=@suaveplan/storage-file` —
|
||||
100/100/100/100 on every file in `src/`.
|
||||
- [ ] 11. `bunx turbo build --filter=@suaveplan/storage-file` — green.
|
||||
- [ ] 12. Banned-pattern scan in `src/` (zero output expected):
|
||||
`grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/storage/storage-file/src/ --include="*.ts"`.
|
||||
- [ ] 13. Test-runner import scan (zero output):
|
||||
`grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/storage/storage-file/src/`.
|
||||
- [ ] 14. Subpath export count matches feature-module count.
|
||||
- [ ] 15. Sibling `.md` exists for every implementation `.ts` in
|
||||
`src/bun-fs/`.
|
||||
|
||||
## Archive
|
||||
|
||||
- [ ] 16. Fold `specs/storage-file/spec.md` into
|
||||
`openspec/specs/storage-file/spec.md`.
|
||||
- [ ] 17. Move `openspec/changes/add-storage-file-bun-fs-adapter/` to
|
||||
`openspec/archive/<yyyy-mm-dd>-add-storage-file-bun-fs-adapter/`.
|
||||
- [ ] 18. Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
|
||||
and run `bunx markdownlint-cli2 --fix` on touched docs.
|
||||
Reference in New Issue
Block a user