Files
2026-05-27 23:29:53 +01:00

10 KiB
Raw Permalink Blame History

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: NodeFsStorage class extending AbstractStorage — full key-value storage rooted at a configurable rootDir with .data/.meta file pair per entry.
  • ADDED: createNodeFsStorage(options) factory.
  • ADDED: NodeFsStorageOptions interface + NodeFsStorageOptionsSchema Zod schema (Zod-validated boundary; throws STORAGE_FILE.INVALID_OPTIONS on malformed input).
  • ADDED: NodeFsLike structural interface + defaultNodeFs() adapter wrapping node: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_LONG over 1024 chars; normalises Windows \ separators to POSIX /.
  • ADDED: STORAGE_FILE_CODES registry via defineCodes("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> with key discriminant.
  • ADDED: Module-augmentation registry — declare module "@suaveplan/storage" adds file: NodeFsStorage to StorageProviderRegistry and file: NodeFsStorageOptions to StorageProviderConfig; registerNodeFsProvider() registers the constructor with the factory.
  • ADDED: Atomic write helper — temp-file-then-fs.rename for set and metadata writes; rollback unlinks temp on failure.
  • ADDED: @suaveplan/retry integration on transient filesystem errors (EBUSY/EMFILE/ENFILE/EAGAIN) — exponential backoff with injectable TimersLike.
  • ADDED: Native Web Streams via createReadStream/createWriteStream consuming node:fs.createReadStream/createWriteStream (declared capabilities.nativeStreams: true).
  • ADDED: getPartial(offset, length) via fs.read with a positioned FileHandle — declared capabilities.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-file tracer.

Out of scope

  • setPartial random-access writes — falls through to AbstractStorage.defaultSetPartial read-modify-write reference path. Reason: POSIX pwrite with sparse-file extension semantics is a follow-up; current callers don't require it and the reference fallback is correct.
  • Watch/notify mode — fs.watch integration belongs in a separate storage-file-watcher package; 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-level ulimit and 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 against AbstractStorage, capabilities matrix, OTel instruments observed via real in-memory MeterProvider, 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 to node:fs/promises, against a real fs.mkdtemp directory.
  • src/retry-helper/retry-helper.test.ts — transient classification (EBUSY/EMFILE/ENFILE/EAGAIN retry; ENOENT/EACCES not), exponential backoff timing under FakeClock + injectable timers, exhaustion → IO_FAILED.
  • src/otel/otel.test.ts — recordOp emits histogram + counter with the documented attribute set against a real InMemoryMetricExporter.
  • src/streams/streams.test.ts — large-file round-trip via Web Streams, AbortSignal mid-stream, structured error mapping.
  • src/registry/registry.test.ts — registers file provider, createStorage({ type: "file", config }) returns NodeFsStorage, 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-file returning the package itself).

Affected upstreams

  • @suaveplan/storage — StorageProviderRegistry module-augmented to add file: NodeFsStorage slot. Additive only; no breaking change. [x] no action needed (purely additive type slot).