# 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 `DOMException`s 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` backed by `File.stream()` from `FileSystemFileHandle.getFile()`. `createWriteStream(key, signal?)` SHALL return a `WritableStream` 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"`