10 KiB
Add @suaveplan/storage-file — Node.js filesystem AbstractStorage adapter
Package: @suaveplan/storage-file
Status: PROPOSED
Type: new-package
Why
Genesis has @suaveplan/storage (AbstractStorage core with in-memory adapter) and @suaveplan/storage-s3 (object-store adapter), but no filesystem-backed adapter for server-tier consumers (drive plugin, vault snapshots, i18n filesystem backends, integration test fixtures). The upstream ../suaveplan/packages/server/storage-file exists at 90% coverage against the old StorageLike shim and ships with no atomic writes, no AbortSignal, no retry on transient errors, no path-traversal guard, and no streaming. The genesis rebuild extends AbstractStorage, adds POSIX-atomic temp-rename writes, hardens key validation, threads AbortSignal everywhere, and wires @suaveplan/retry for EBUSY/EMFILE/ENFILE. This unblocks several downstream packages (drive-template hydration, vault encrypted snapshots, server-side i18n backends, integration test storage fixtures).
What changes
- ADDED:
NodeFsStorageclass extendingAbstractStorage— full key-value storage rooted at a configurablerootDirwith.data/.metafile pair per entry. - ADDED:
createNodeFsStorage(options)factory. - ADDED:
NodeFsStorageOptionsinterface +NodeFsStorageOptionsSchemaZod schema (Zod-validated boundary; throwsSTORAGE_FILE.INVALID_OPTIONSon malformed input). - ADDED:
NodeFsLikestructural interface +defaultNodeFs()adapter wrappingnode:fs/promises(mkdir,readFile,writeFile,rename,unlink,readdir,stat,access,createReadStream,createWriteStream). Injectable for tests. - ADDED:
sanitizeKey(key)strict key boundary — rejects empty,.., absolute paths (/, drive-prefixed), null bytes, control chars (0x00–0x1F, 0x7F),KEY_TOO_LONGover 1024 chars; normalises Windows\separators to POSIX/. - ADDED:
STORAGE_FILE_CODESregistry viadefineCodes("STORAGE_FILE", [...])— 8 codes:NOT_FOUND,PERMISSION_DENIED,IO_FAILED,INVALID_KEY,ATOMIC_WRITE_FAILED,INVALID_OPTIONS,ABORTED,KEY_TOO_LONG. - ADDED:
StorageFileError extends SuaveplanError<StorageFileErrorCode>withkeydiscriminant. - ADDED: Module-augmentation registry —
declare module "@suaveplan/storage"addsfile: NodeFsStoragetoStorageProviderRegistryandfile: NodeFsStorageOptionstoStorageProviderConfig;registerNodeFsProvider()registers the constructor with the factory. - ADDED: Atomic write helper — temp-file-then-
fs.renameforsetand metadata writes; rollback unlinks temp on failure. - ADDED:
@suaveplan/retryintegration on transient filesystem errors (EBUSY/EMFILE/ENFILE/EAGAIN) — exponential backoff with injectableTimersLike. - ADDED: Native Web Streams via
createReadStream/createWriteStreamconsumingnode:fs.createReadStream/createWriteStream(declaredcapabilities.nativeStreams: true). - ADDED:
getPartial(offset, length)viafs.readwith a positionedFileHandle— declaredcapabilities.partialReads: true. - ADDED: OTel surface —
storage_file.op_duration_ms{op,outcome}+storage_file.io_bytes{op,direction}histograms;storage_file.{reads,writes,deletes,errors}_total{outcome}counters; per-op span on@suaveplan/storage-filetracer.
Out of scope
setPartialrandom-access writes — falls through toAbstractStorage.defaultSetPartialread-modify-write reference path. Reason: POSIXpwritewith sparse-file extension semantics is a follow-up; current callers don't require it and the reference fallback is correct.- Watch/notify mode —
fs.watchintegration belongs in a separatestorage-file-watcherpackage; the i18n filesystem backend already covers its own watch needs (@suaveplan/i18n-backend-fs-multifile). - Encryption-at-rest — orthogonal concern handled by
@suaveplan/drive-plugin-chunks(AEAD per chunk) and@suaveplan/crypto-vault(sealed container). Storage layer stores bytes verbatim. - Quota enforcement beyond
AbstractStorage.maxValueSize/maxTotalSize— caller-facing concern; OS-levelulimitand filesystem quotas are out of scope.
Source mapping
This change derives from ../suaveplan/enterprise/storage-file.md (118 lines) and ports ../suaveplan/packages/server/storage-file/src/filesystem.ts (259 lines) to extend AbstractStorage rather than the legacy StorageLike shim.
| Requirement | Source lines | Notes |
|---|---|---|
| Node.js filesystem storage purpose | L10-L12 | "Node.js filesystem storage" — preserved as the package's single responsibility. |
| Zod request/response schemas | L39 | Reworded as Zod option boundary (NodeFsStorageOptionsSchema); the upstream phrasing references @suaveplan/api-schemas which is API-tier and not in scope here. |
| OpenTelemetry observability | L44 | Implemented as per-op spans, counters, and histograms emitted from production paths. |
Retries via @suaveplan/retry |
L45 | Mapped to transient EBUSY/EMFILE/ENFILE/EAGAIN retry with exponential backoff and injectable TimersLike. |
| Test coverage 100% | L52 | Adopted at the genesis-wide Istanbul 100/100/100/100 threshold. |
| File-size budget | L54 | Adopted: ≤300 LOC hard / ≤500 LOC test. |
| No mocks — real services | L55 | Tests use real fs.mkdtemp temp dirs and a real injectable NodeFsLike shim; no vi.mock. |
sideEffects: false |
L57 | Set in package.json. |
| Workspace deps + Bun catalog | L58 | All deps use workspace:^ or catalog: refs. |
| Bun overrides exact | L59 | Inherits root override policy. |
| Biome clean | L60 | Enforced by bun run check. |
TypeScript strict, no any |
L61 | Adopted; no any, no ts-ignore. |
| Public API stability via Zod | L62 | NodeFsStorageOptionsSchema published; runtime validation at constructor boundary. |
Error hierarchy via @suaveplan/error |
L63 | StorageFileError extends SuaveplanError<StorageFileErrorCode>. |
| Performance benchmarks | L74 | Mitata benchmarks under src/__benchmarks__/. |
| Resilience — retries, cancellation | L75 | Retry on transient errors; AbortSignal threaded through every public async method. |
| Security — input validation at boundary | L76 | sanitizeKey strict boundary; rootDir isolation rejects traversal before any fs call. |
Requirements with no source line
| Requirement | Justification |
|---|---|
Extend AbstractStorage not StorageLike |
The genesis storage core (@suaveplan/storage) replaces the legacy StorageLike shim with a richer AbstractStorage base class. Every adapter (memory, S3) extends it; this adapter MUST conform to the same contract for module-augmentation registry compatibility. |
| AbortSignal on every public async method | Required by AbstractStorage's abstract signatures. Upstream had no AbortSignal support. |
| Atomic writes via temp-file-then-rename | Required for crash safety on the filesystem tier. Concurrent readers must never observe a torn write. POSIX rename(2) is atomic on the same filesystem; this is the standard primitive (e.g. git, sqlite). Upstream wrote directly to the destination path. |
Path-traversal guard (.., absolute paths) |
Upstream had no key sanitisation. Without it, a caller-controlled key (../../etc/passwd) would escape rootDir. Required by genesis security model. |
| Module-augmentation registry hook | The genesis @suaveplan/storage factory uses module augmentation (StorageProviderRegistry) for typed adapter dispatch. New adapters must hook in via declare module + registerStorageProvider. |
Native streaming declared in capabilities |
AbstractStorage.capabilities discriminates backends that support native streams from those that fall through to the buffered reference implementation. node:fs.createReadStream/createWriteStream are native; consumers (drive-plugin-chunks) read this flag to decide whether to chain or to pre-buffer. |
| Partial reads via positioned FileHandle | capabilities.partialReads: true is honoured by drive-plugin-chunks for range reads. Implementing it natively avoids O(file-size) memory for large objects. |
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete it(...) titles in these files:
src/node-fs-storage/node-fs-storage.test.ts— CRUD, metadata, list, clear, getSize, getPartial, native streams, AbortSignal, key sanitisation integration, atomic write under crash, retry exhaustion, OTel happy paths.src/node-fs-storage/node-fs-storage.spec-conformance.test.ts— type-level conformance againstAbstractStorage, capabilities matrix, OTel instruments observed via real in-memoryMeterProvider, idempotent registry registration, error-code registry exhaustion.src/key-sanitizer/key-sanitizer.test.ts— empty, traversal, absolute, null bytes, control chars, overlong, separator normalisation, valid paths.src/node-fs/node-fs.test.ts—defaultNodeFs()adapter forwards every method tonode:fs/promises, against a realfs.mkdtempdirectory.src/retry-helper/retry-helper.test.ts— transient classification (EBUSY/EMFILE/ENFILE/EAGAINretry;ENOENT/EACCESnot), exponential backoff timing under FakeClock + injectable timers, exhaustion →IO_FAILED.src/otel/otel.test.ts—recordOpemits histogram + counter with the documented attribute set against a realInMemoryMetricExporter.src/streams/streams.test.ts— large-file round-trip via Web Streams, AbortSignal mid-stream, structured error mapping.src/registry/registry.test.ts— registersfileprovider,createStorage({ type: "file", config })returnsNodeFsStorage, idempotent re-registration.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-file to enumerate. The package is new — no dependents currently. No action needed.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-filereturning the package itself).
Affected upstreams
@suaveplan/storage—StorageProviderRegistrymodule-augmented to addfile: NodeFsStorageslot. Additive only; no breaking change. [x] no action needed (purely additive type slot).