Files
suaveplan/openspec/archive/2026-05-12-storage-browser-opfs/specs/storage-browser-opfs/spec.md
2026-05-27 23:29:53 +01:00

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 AbstractStorage and 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) 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 OpfsError whose code === "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 OpfsError whose code === "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 OpfsError whose code === "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 and get(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 a QuotaExceededError DOMException
  • THEN storage.set(...) rejects with OpfsError whose code === "OPFS.QUOTA_EXCEEDED" and cause is the original DOMException

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 sentinel OpfsError(OPFS.UNAVAILABLE)
  • THEN any storage method called against that adapter rejects with the same OPFS.UNAVAILABLE code

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 calls writer.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 Uint8Array is exactly 100 bytes and byte-equal to value.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() then createStorage({ type: "opfs", config: { directory: "test", opfs } })
  • THEN the returned instance is instanceof OpfsStorage and 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-memory MeterProvider installed
  • THEN the collected metrics include storage_browser_opfs.writes_total{op:"set",outcome:"success"} = 1 and a single storage_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 OpfsError whose code === "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_CODES appears 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"