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

11 KiB
Raw Permalink Blame History

Capability Spec Delta: storage-file (storage-file)

Package: @suaveplan/storage-file Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: NodeFsStorage extends AbstractStorage

The package SHALL provide a NodeFsStorage class extending AbstractStorage from @suaveplan/storage. The class SHALL implement every abstract method on the base (get, set, delete, exists, list, clear, getWithMetadata, getMetadata, getSize, getPartial, setPartial, getItemSize, createReadStream, createWriteStream) with the signatures declared on the base, including the trailing signal?: AbortSignal parameter.

Scenario: instance is structurally an AbstractStorage

  • WHEN a caller constructs new NodeFsStorage({ rootDir })
  • THEN the instance is instanceof AbstractStorage and every abstract method is callable

Scenario: capabilities matrix declares native streams and partial reads

  • WHEN a caller reads storage.capabilities
  • THEN partialReads === true, nativeStreams === true, atomicWrites === true, partialWrites === false, contentAddressable === false

Verified by: src/node-fs-storage/node-fs-storage.test.ts::"implements every AbstractStorage abstract method" AND src/node-fs-storage/node-fs-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"


Requirement: CRUD operations under rootDir isolation

The adapter SHALL store every key under the configured rootDir. set SHALL create parent directories when mkdirp is enabled (default true). get SHALL return undefined for missing keys. delete SHALL return true on successful removal and false when the key did not exist. exists SHALL return a boolean without throwing on missing keys. list SHALL enumerate every key under an optional prefix, support limit/offset/continuationToken pagination, and return {keys, total, hasMore, continuationToken}. clear SHALL remove every data and metadata file under rootDir. Every method SHALL accept an optional AbortSignal and throw STORAGE_FILE.ABORTED when the signal is already aborted.

Scenario: round-trip set/get/delete preserves bytes

  • WHEN a caller await storage.set("k", bytes) then await storage.get("k")
  • THEN the returned Uint8Array is byte-equal to bytes; subsequent await storage.delete("k") returns true; final await storage.get("k") returns undefined

Scenario: AbortSignal short-circuits every method

  • WHEN a caller passes an already-aborted signal to any method
  • THEN the call rejects with StorageFileError whose code === "STORAGE_FILE.ABORTED" before any filesystem call is issued

Verified by: src/node-fs-storage/node-fs-storage.test.ts::"round-trips Uint8Array values" AND src/node-fs-storage/node-fs-storage.test.ts::"rejects with STORAGE_FILE.ABORTED when signal is pre-aborted"


Requirement: Atomic writes via temp-file-then-rename

set and metadata writes SHALL write to a temp file in the destination directory and fs.rename it onto the destination path. On write failure the temp file SHALL be unlinked. Concurrent readers SHALL never observe a torn write — they either see the previous version or the new version.

Scenario: temp file is removed after successful write

  • WHEN a caller await storage.set("k", bytes) completes
  • THEN no temp file (with prefix .tmp-) remains in rootDir

Scenario: temp file is removed when the underlying write throws

  • WHEN the injected NodeFsLike.writeFile throws after the temp file is created
  • THEN the temp file is unlinked and the destination path is unchanged

Verified by: src/node-fs-storage/node-fs-storage.test.ts::"writes atomically — no temp files remain after success" AND src/node-fs-storage/node-fs-storage.test.ts::"unlinks the temp file when the underlying writeFile throws"


Requirement: Strict key sanitisation at the boundary

Every method that accepts a key SHALL pass it through sanitizeKey before issuing any filesystem call. The sanitiser SHALL reject empty strings, keys containing .. segments, keys starting with / or matching a drive-letter prefix (C:\…), keys containing null bytes (), keys containing control chars (–, ), and keys longer than 1024 characters. Windows \ separators SHALL be normalised to POSIX /. Rejections SHALL throw StorageFileError with code STORAGE_FILE.INVALID_KEY or STORAGE_FILE.KEY_TOO_LONG.

Scenario: path-traversal attempt is rejected

  • WHEN a caller calls storage.get("../../etc/passwd")
  • THEN the call rejects with StorageFileError whose code === "STORAGE_FILE.INVALID_KEY" before any filesystem call is issued

Scenario: overlong key is rejected with KEY_TOO_LONG

  • WHEN a caller calls storage.set("a".repeat(1025), bytes)
  • THEN the call rejects with StorageFileError whose code === "STORAGE_FILE.KEY_TOO_LONG"

Verified by: src/key-sanitizer/key-sanitizer.test.ts::"rejects path traversal segments" AND src/key-sanitizer/key-sanitizer.test.ts::"rejects keys longer than 1024 chars with KEY_TOO_LONG"


Requirement: Transient error retry via @suaveplan/retry

Operations SHALL retry on transient filesystem errors (EBUSY, EMFILE, ENFILE, EAGAIN) using @suaveplan/retry.retry with exponential backoff. Non-transient errors (ENOENT, EACCES) SHALL NOT be retried. After maxAttempts (default 3) the operation SHALL throw StorageFileError with code STORAGE_FILE.IO_FAILED and cause set to the last underlying error. The retry loop SHALL accept an injectable TimersLike (setTimer/clearTimer) so that tests run deterministically.

Scenario: EBUSY is retried then succeeds on the second attempt

  • WHEN the injected NodeFsLike.writeFile fails once with EBUSY and succeeds on retry
  • THEN the overall storage.set(...) resolves and recordOp reports outcome="success"

Scenario: EACCES is not retried

  • WHEN the injected NodeFsLike.writeFile throws EACCES
  • THEN the call rejects after a single attempt with StorageFileError whose code === "STORAGE_FILE.PERMISSION_DENIED"

Verified by: src/retry-helper/retry-helper.test.ts::"retries on EBUSY and resolves on success" AND src/retry-helper/retry-helper.test.ts::"does not retry on EACCES"


Requirement: Native Web Streams for read and write

createReadStream(key, signal?) SHALL return a ReadableStream<Uint8Array> backed by node:fs.createReadStream. createWriteStream(key, signal?) SHALL return a WritableStream<Uint8Array> backed by node:fs.createWriteStream writing to a temp file then renaming on close. Aborting the AbortSignal mid-stream SHALL cancel the underlying Node stream and reject the consumer with STORAGE_FILE.ABORTED.

Scenario: large file round-trips through streams without buffering

  • WHEN a caller writes 1 MiB of data through createWriteStream then reads it back through createReadStream
  • THEN the concatenated bytes are byte-equal to the original

Scenario: AbortSignal mid-write cleans up the temp file

  • WHEN a caller aborts the signal halfway through a write
  • THEN no temp file remains in rootDir and the destination path is unchanged

Verified by: src/streams/streams.test.ts::"round-trips a 1 MiB payload" AND src/streams/streams.test.ts::"aborts mid-write and removes the temp file"


Requirement: Partial reads via positioned read

getPartial(key, offset, length, signal?) SHALL open the data file, read length bytes starting at offset, and close the handle. The result SHALL be a Uint8Array of at most length bytes (truncated at EOF). Memory usage SHALL be O(length), not O(file-size).

Scenario: partial read returns the requested slice

  • WHEN a caller await storage.getPartial("k", 10, 5) on a 100-byte file
  • THEN the returned Uint8Array is byte-equal to bytes 10..14 of the stored value

Scenario: partial read past EOF returns a truncated slice

  • WHEN a caller await storage.getPartial("k", 95, 20) on a 100-byte file
  • THEN the returned Uint8Array has length 5

Verified by: src/node-fs-storage/node-fs-storage.test.ts::"reads a positioned slice via getPartial" AND src/node-fs-storage/node-fs-storage.test.ts::"truncates getPartial at EOF"


Requirement: Zod-validated options at construction

The constructor SHALL parse its options through NodeFsStorageOptionsSchema and throw StorageFileError with code STORAGE_FILE.INVALID_OPTIONS on parse failure. Required fields: rootDir: string. Optional fields: mkdirp: boolean (default true), fileExtension: string (default ".data"), metadataExtension: string (default ".meta"), retry: { maxAttempts, baseDelayMs, maxDelayMs }, fs: NodeFsLike, timers: TimersLike, clock: Clock.

Scenario: missing rootDir is rejected with INVALID_OPTIONS

  • WHEN a caller calls new NodeFsStorage({} as never)
  • THEN the constructor throws StorageFileError whose code === "STORAGE_FILE.INVALID_OPTIONS"

Verified by: src/node-fs-storage/node-fs-storage.test.ts::"rejects missing rootDir with INVALID_OPTIONS" AND src/node-fs-storage/node-fs-storage.spec-conformance.test.ts::"parses options via NodeFsStorageOptionsSchema at construction"


Requirement: OpenTelemetry observability

The package SHALL emit per-operation OTel signals from production paths: a span on the @suaveplan/storage-file tracer named storage_file.<op>, a counter increment on storage_file.<reads|writes|deletes|errors>_total{outcome}, a histogram observation on storage_file.op_duration_ms{op,outcome}, and a histogram observation on storage_file.io_bytes{op,direction} when bytes flow. The outcome attribute SHALL be one of success/error/aborted.

Scenario: a successful set records the expected metrics

  • WHEN a caller await storage.set("k", bytes) against a real in-memory MeterProvider
  • THEN storage_file.writes_total{outcome="success"} increments by 1 AND storage_file.op_duration_ms{op="set"} records a single observation AND storage_file.io_bytes{op="set",direction="write"} records bytes.byteLength

Verified by: src/otel/otel.test.ts::"records duration and byte counters on a successful op" AND src/node-fs-storage/node-fs-storage.spec-conformance.test.ts::"emits storage_file.writes_total counter on successful set"


Requirement: Module-augmentation registry hook

The package SHALL augment @suaveplan/storage's StorageProviderRegistry and StorageProviderConfig with a file slot keyed to NodeFsStorage/NodeFsStorageOptions. The package SHALL export registerNodeFsProvider() which calls registerStorageProvider("file", NodeFsStorage) and is idempotent — repeated calls SHALL NOT throw.

Scenario: factory resolves file to NodeFsStorage after registration

  • WHEN a caller registerNodeFsProvider(); const s = createStorage({ type: "file", config: { rootDir } });
  • THEN s instanceof NodeFsStorage is true

Verified by: src/registry/registry.test.ts::"registers the file provider and resolves it via createStorage" AND src/registry/registry.test.ts::"is idempotent across repeated registerNodeFsProvider calls"