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

200 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<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"`