Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"`
---

View File

@@ -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.