11 KiB
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 AbstractStorageand 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)thenawait storage.get("k") - THEN the returned
Uint8Arrayis byte-equal tobytes; subsequentawait storage.delete("k")returnstrue; finalawait storage.get("k")returnsundefined
Scenario: AbortSignal short-circuits every method
- WHEN a caller passes an already-aborted signal to any method
- THEN the call rejects with
StorageFileErrorwhosecode === "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 inrootDir
Scenario: temp file is removed when the underlying write throws
- WHEN the injected
NodeFsLike.writeFilethrows 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
StorageFileErrorwhosecode === "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
StorageFileErrorwhosecode === "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.writeFilefails once withEBUSYand succeeds on retry - THEN the overall
storage.set(...)resolves andrecordOpreportsoutcome="success"
Scenario: EACCES is not retried
- WHEN the injected
NodeFsLike.writeFilethrowsEACCES - THEN the call rejects after a single attempt with
StorageFileErrorwhosecode === "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
createWriteStreamthen reads it back throughcreateReadStream - 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
rootDirand 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
StorageFileErrorwhosecode === "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-memoryMeterProvider - THEN
storage_file.writes_total{outcome="success"}increments by 1 ANDstorage_file.op_duration_ms{op="set"}records a single observation ANDstorage_file.io_bytes{op="set",direction="write"}recordsbytes.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 NodeFsStorageistrue
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"