13 KiB
Capability Spec Delta: storage-browser-opfs (storage-browser-opfs)
Package: @suaveplan/storage-browser-opfs
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: OpfsStorage extends AbstractStorage
The package SHALL provide an OpfsStorage 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 OpfsStorage({ directory: "test", opfs }) - THEN the instance is
instanceof AbstractStorageand every abstract method is callable
Scenario: capabilities matrix declares native streams, partial reads, and partial writes
- WHEN a caller reads
storage.capabilities - THEN
partialReads === true,partialWrites === true,nativeStreams === true,atomicWrites === false,contentAddressable === false
Verified by: src/opfs-storage/opfs-storage.test.ts::"implements every AbstractStorage abstract method" AND src/opfs-storage/opfs-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"
Requirement: CRUD operations under directory isolation
The adapter SHALL store every key under a sub-directory of the OPFS root (default "storage", overridable via directory). set SHALL create the file if missing and overwrite if present. 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 pagination, and return {keys, total, hasMore}. clear SHALL remove every entry under the directory. Every method SHALL accept an optional AbortSignal and throw OPFS.ABORTED when the signal is already aborted before any OPFS call is issued.
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
OpfsErrorwhosecode === "OPFS.ABORTED"before any OPFS call is issued
Verified by: src/opfs-storage/opfs-storage.test.ts::"round-trips Uint8Array values" AND src/opfs-storage/opfs-storage.test.ts::"rejects with OPFS.ABORTED when signal is pre-aborted"
Requirement: Strict key sanitisation at the boundary
Every method that accepts a key SHALL pass it through sanitizeKey before issuing any OPFS call. The sanitiser SHALL reject empty strings, keys containing .. segments, keys starting with /, keys containing null bytes, keys containing other control chars (0x00-0x1F, 0x7F), and keys longer than 1024 characters. Windows \ separators SHALL be normalised to POSIX /. Rejections SHALL throw OpfsError with code OPFS.INVALID_KEY or OPFS.KEY_TOO_LONG.
Scenario: path-traversal attempt is rejected
- WHEN a caller calls
storage.get("../../etc/passwd") - THEN the call rejects with
OpfsErrorwhosecode === "OPFS.INVALID_KEY"before any OPFS 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
OpfsErrorwhosecode === "OPFS.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: Base64url filename codec round-trips arbitrary keys
The adapter SHALL encode caller keys to OPFS-safe filenames using RFC 4648 base64url over the UTF-8 byte representation. The codec SHALL satisfy decode(encode(k)) === k for every string k accepted by sanitizeKey, including unicode, mixed scripts, slashes, and any control chars that survive sanitisation.
Scenario: round-trips arbitrary unicode keys
- WHEN a caller stores values under keys
"héllo/wörld","日本語", and"a/b/c" - THEN
list()returns each key in its original form andget(key)returns the stored bytes
Verified by: src/filename-codec/filename-codec.test.ts::"round-trips unicode keys" AND src/filename-codec/filename-codec.test.ts::"fast-check: decode∘encode is identity for arbitrary strings"
Requirement: DOMException mapping at the OPFS boundary
The adapter SHALL map browser DOMExceptions thrown by OPFS calls to typed OpfsError codes at the boundary. NotFoundError on read SHALL surface as undefined/null/false per the abstract contract (not as an exception). NotAllowedError SHALL map to OPFS.PERMISSION_DENIED. QuotaExceededError SHALL map to OPFS.QUOTA_EXCEEDED. Any other DOMException SHALL map to OPFS.IO_FAILED with the original error preserved as cause. When navigator.storage.getDirectory is missing the adapter SHALL throw OPFS.UNAVAILABLE.
Scenario: QuotaExceededError surfaces as OPFS.QUOTA_EXCEEDED
- WHEN the underlying
createWritable()rejects with aQuotaExceededErrorDOMException - THEN
storage.set(...)rejects withOpfsErrorwhosecode === "OPFS.QUOTA_EXCEEDED"andcauseis the originalDOMException
Scenario: NotFoundError on read returns undefined
- WHEN a caller calls
storage.get("missing")against a directory that does not contain the key - THEN the call resolves to
undefined(no exception is thrown)
Scenario: Missing navigator.storage maps to OPFS.UNAVAILABLE
- WHEN the injected
OpfsLike.getDirectory()rejects with the sentinelOpfsError(OPFS.UNAVAILABLE) - THEN any storage method called against that adapter rejects with the same
OPFS.UNAVAILABLEcode
Verified by: src/opfs-storage/opfs-storage.test.ts::"maps QuotaExceededError to OPFS.QUOTA_EXCEEDED" AND src/opfs-storage/opfs-storage.test.ts::"get returns undefined for a missing key" AND src/opfs-adapter/opfs-adapter.test.ts::"defaultOpfs throws OPFS.UNAVAILABLE when navigator.storage is absent"
Requirement: Native streaming via Web Streams
createReadStream(key, signal?) SHALL return a ReadableStream<Uint8Array> backed by File.stream() from FileSystemFileHandle.getFile(). createWriteStream(key, signal?) SHALL return a WritableStream<Uint8Array> backed by FileSystemWritableFileStream from FileSystemFileHandle.createWritable(). Both streams SHALL honour the AbortSignal argument and the stream's own cancel()/abort() controls. The write stream's .close() SHALL commit the file atomically per the WHATWG File System Access spec.
Scenario: read stream emits the stored value as one or more Uint8Array chunks
- WHEN a caller stores bytes and then consumes
storage.createReadStream(key)to completion - THEN the concatenation of emitted chunks is byte-equal to the stored value
Scenario: write stream commits the value on close
- WHEN a caller writes chunks through
storage.createWriteStream(key).getWriter()and callswriter.close() - THEN
storage.get(key)returns the concatenation of the written chunks
Verified by: src/opfs-storage/opfs-storage.test.ts::"createReadStream emits the stored bytes" AND src/opfs-storage/opfs-storage.test.ts::"createWriteStream commits the written bytes on close"
Requirement: Partial reads via File.slice
getPartial(key, offset, length, signal?) SHALL read a byte range from the underlying file using File.slice(offset, offset + length).arrayBuffer(). The implementation SHALL avoid loading the full file into memory and SHALL declare capabilities.partialReads: true. Out-of-range or negative offsets SHALL throw OpfsError with code OPFS.INVALID_KEY.
Scenario: partial read of 100 bytes from a 1 MiB value
- WHEN a caller stores 1 MiB and calls
storage.getPartial(key, 1024, 100) - THEN the returned
Uint8Arrayis exactly 100 bytes and byte-equal tovalue.subarray(1024, 1124)
Verified by: src/opfs-storage/opfs-storage.test.ts::"getPartial returns the requested byte range"
Requirement: Partial writes via FileSystemWritableFileStream.seek
setPartial(key, offset, bytes, signal?) SHALL write bytes at the specified offset using FileSystemWritableFileStream.seek(offset) + .write(bytes). The implementation SHALL create the file if it does not exist and SHALL declare capabilities.partialWrites: true. Negative or non-integer offsets SHALL throw OpfsError with code OPFS.INVALID_KEY.
Scenario: partial write at offset 100 patches the stored bytes
- WHEN a caller stores a 1 KiB value of zeros, then calls
storage.setPartial(key, 100, new Uint8Array([1,2,3])) - THEN
storage.get(key)returns a 1 KiB value where bytes [100..103) are[1,2,3]and every other byte is zero
Verified by: src/opfs-storage/opfs-storage.test.ts::"setPartial patches bytes at the given offset"
Requirement: Module-augmentation registry hook
The package SHALL augment @suaveplan/storage/factory's StorageProviderRegistry interface with opfs: OpfsStorage and StorageProviderConfig with opfs: OpfsStorageOptions. The exported registerOpfsProvider() function SHALL be idempotent — repeated calls SHALL leave the registry in the same state and SHALL re-register cleanly after clearStorageProviders(). The package SHALL NOT auto-register at module-evaluation time (consumers using sideEffects: false bundlers would otherwise lose the registration).
Scenario: createStorage({ type: "opfs", config }) returns an OpfsStorage instance
- WHEN a consumer calls
registerOpfsProvider()thencreateStorage({ type: "opfs", config: { directory: "test", opfs } }) - THEN the returned instance is
instanceof OpfsStorageand operates against the injected adapter
Scenario: repeated registerOpfsProvider() calls are idempotent
- WHEN a consumer calls
registerOpfsProvider()three times - THEN the provider remains registered and no error is thrown
Verified by: src/registry/registry.test.ts::"registers the opfs provider and resolves it via createStorage" AND src/registry/registry.test.ts::"is idempotent across repeated registerOpfsProvider calls"
Requirement: OTel observability for every operation
Every public operation SHALL record one storage_browser_opfs.op_duration_ms{op,outcome} histogram observation and one storage_browser_opfs.{reads,writes,deletes,errors}_total{op,outcome} counter increment. Read and write operations SHALL additionally record storage_browser_opfs.io_bytes{op,direction} for the byte volume transferred. The instrument cache SHALL be rebuildable via setStorageBrowserOpfsMeterProvider(provider) so tests assert against a real in-memory MeterProvider.
Scenario: successful set records a write counter and duration histogram
- WHEN a caller
await storage.set("k", bytes)against an OpfsStorage with a real in-memoryMeterProviderinstalled - THEN the collected metrics include
storage_browser_opfs.writes_total{op:"set",outcome:"success"} = 1and a singlestorage_browser_opfs.op_duration_ms{op:"set",outcome:"success"}observation
Verified by: src/otel/otel.test.ts::"recordOp emits the documented counter and histogram set" AND src/opfs-storage/opfs-storage.spec-conformance.test.ts::"emits OTel instruments through an in-memory MeterProvider"
Requirement: Zod-validated options boundary
OpfsStorageOptions SHALL be validated at the constructor boundary by OpfsStorageOptionsSchema. Malformed input SHALL throw OpfsError with code OPFS.INVALID_OPTIONS and the underlying ZodError preserved as cause. Options: directory (string, default "storage"), opfs (optional OpfsLike, default defaultOpfs()), clock (optional Clock from @suaveplan/types).
Scenario: non-string directory rejects with OPFS.INVALID_OPTIONS
- WHEN a caller constructs
new OpfsStorage({ directory: 123 as never }) - THEN the constructor throws
OpfsErrorwhosecode === "OPFS.INVALID_OPTIONS"
Verified by: src/opfs-storage/opfs-storage.spec-conformance.test.ts::"rejects malformed options with OPFS.INVALID_OPTIONS"
Requirement: Error code registry is exhaustive
The OPFS_CODES registry SHALL include exactly the following codes, frozen via defineCodes: NOT_FOUND, PERMISSION_DENIED, QUOTA_EXCEEDED, UNAVAILABLE, INVALID_KEY, KEY_TOO_LONG, INVALID_OPTIONS, ABORTED, IO_FAILED. Every code SHALL be thrown from at least one production code path.
Scenario: every OPFS_CODES entry is thrown by production code
- WHEN the test suite exercises the full set of error paths
- THEN every code in
OPFS_CODESappears at least once as(err as OpfsError).code
Verified by: src/opfs-storage/opfs-storage.spec-conformance.test.ts::"every OPFS code is thrown by at least one production path"