Initial Commit
This commit is contained in:
96
openspec/archive/2026-05-12-storage-browser-opfs/proposal.md
Normal file
96
openspec/archive/2026-05-12-storage-browser-opfs/proposal.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Add @suaveplan/storage-browser-opfs — Browser OPFS AbstractStorage adapter
|
||||
|
||||
**Package**: `@suaveplan/storage-browser-opfs`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/storage` (AbstractStorage core with in-memory adapter), `@suaveplan/storage-s3` (object-store adapter) and `@suaveplan/storage-file` (Node filesystem adapter), but no browser-tier persistent backend. Browser consumers (vault snapshots, drive-template chunk caches, i18n-backend-storage when running in-app, future client-side SQLite via `@sqlite.org/sqlite-wasm`) need an origin-scoped persistent file store. The Origin Private File System (OPFS) — `navigator.storage.getDirectory()` — is the standard browser primitive for this. The upstream `../suaveplan/packages/browser/storage-browser-opfs` ships an `OPFSStorage` that pre-dates the genesis `AbstractStorage` contract: it uses the legacy abstract surface, has no AbortSignal, throws raw `StorageError` with stringly codes, lacks a quota-error mapping, lacks OTel, has no Zod boundary, side-effect-registers in `index.ts` (which breaks under `sideEffects: false` bundlers — documented in the upstream README as a known limitation), and has no `getPartial`/`setPartial`/`createReadStream`/`createWriteStream`. This change rebuilds it fresh against the current `AbstractStorage` contract.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `OpfsStorage` class extending `AbstractStorage` — origin-scoped key-value storage rooted at a configurable sub-directory of `navigator.storage.getDirectory()`.
|
||||
- ADDED: `createOpfsStorage(options)` factory.
|
||||
- ADDED: `OpfsStorageOptions` interface + `OpfsStorageOptionsSchema` Zod schema; throws `OPFS.INVALID_OPTIONS` on malformed input.
|
||||
- ADDED: `OpfsLike` structural interface (`{ getDirectory(): Promise<FileSystemDirectoryHandle> }`) + `defaultOpfs()` adapter pointing at `navigator.storage.getDirectory()`. Injectable so tests substitute a real in-memory `FileSystemDirectoryHandle`-shaped implementation rather than mocking the DOM.
|
||||
- ADDED: `sanitizeKey(key)` strict key boundary — rejects empty, `..`, absolute paths (`/`), null bytes, control chars (0x00–0x1F, 0x7F), and `KEY_TOO_LONG` over 1024 chars; normalises Windows `\` separators to POSIX `/`.
|
||||
- ADDED: `OPFS_CODES` registry via `defineCodes("OPFS", [...])` — 9 codes: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`.
|
||||
- ADDED: `OpfsError extends SuaveplanError<OpfsErrorCode>` with `key` discriminant.
|
||||
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage/factory"` adds `opfs: OpfsStorage` to `StorageProviderRegistry` and `opfs: OpfsStorageOptions` to `StorageProviderConfig`; `registerOpfsProvider()` registers the constructor with the factory.
|
||||
- ADDED: Native Web Streams via `createReadStream` (`File.stream()` from `FileSystemFileHandle.getFile()`) and `createWriteStream` (`FileSystemWritableFileStream` from `FileSystemFileHandle.createWritable()`); declared `capabilities.nativeStreams: true`.
|
||||
- ADDED: `getPartial(offset, length)` via `File.slice(offset, offset+length).arrayBuffer()` — declared `capabilities.partialReads: true`.
|
||||
- ADDED: `setPartial(offset, bytes)` via `FileSystemWritableFileStream.seek()` + `.write()` — declared `capabilities.partialWrites: true`.
|
||||
- ADDED: DOMException mapping — `NotFoundError` → `undefined`/`false` return per AbstractStorage contract; `NotAllowedError` → `OPFS.PERMISSION_DENIED`; `QuotaExceededError` → `OPFS.QUOTA_EXCEEDED`; missing `navigator.storage` → `OPFS.UNAVAILABLE`.
|
||||
- ADDED: Filename codec — base64url over UTF-8 bytes — preserves any caller key (slashes, unicode, control-safe) through the OPFS filename layer; `encode`/`decode` round-trip property test.
|
||||
- ADDED: OTel surface — `storage_browser_opfs.op_duration_ms{op,outcome}` + `storage_browser_opfs.io_bytes{op,direction}` histograms; `storage_browser_opfs.{reads,writes,deletes,errors}_total{outcome}` counters; per-op span on `@suaveplan/storage-browser-opfs` tracer.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Quota-pre-flight via `navigator.storage.estimate()` — listed in the upstream enterprise plan, but `QuotaExceededError` mapping at the boundary covers the observable failure mode and avoids racing the estimate against an in-flight write. Can be added as a follow-up if a consumer needs it.
|
||||
- `FileSystemSyncAccessHandle` (Web Workers only) — the async OPFS API covers every public method on `AbstractStorage`; sync access handles are a perf optimisation for SQLite-WASM, which already opens them directly via `@sqlite.org/sqlite-wasm`.
|
||||
- Encryption-at-rest — orthogonal concern handled by `@suaveplan/drive-plugin-chunks` (AEAD per chunk) and `@suaveplan/crypto-vault` (sealed container). Storage layer stores bytes verbatim.
|
||||
- Cross-tab coordination / locking — out of scope; OPFS has no built-in lock primitive, and consumers needing it should layer `@suaveplan/idempotency` or `BroadcastChannel` on top.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/storage-browser-opfs.md` (120 lines) and rebuilds `../suaveplan/packages/browser/storage-browser-opfs/src/opfs.ts` (232 lines) against the current `AbstractStorage` contract.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| OPFS browser backend purpose | L10-L12 | "Origin Private File System (OPFS) storage backend — persistent, origin-scoped, browser-only file storage with quota awareness and streaming read/write." — preserved as the package's single responsibility. |
|
||||
| Browser-only enforcement | L37 | `"browser"` field in `package.json`; tier-guarded by no Node-only imports in module graph. |
|
||||
| Storage adapter conformance | L38 | `OpfsStorage extends AbstractStorage`; same conformance suite shape as `storage-file`. |
|
||||
| Quota awareness via QuotaExceededError mapping | L39 | Implemented as boundary error mapping; pre-flight estimate is explicitly out of scope (see above). |
|
||||
| Streaming read/write | L40 | Native Web Streams via `createReadStream`/`createWriteStream`; `capabilities.nativeStreams: true`. |
|
||||
| Atomic writes (write-to-temp-then-rename) | L41 | Genesis deviation: OPFS does not expose `rename` between sibling files in the same directory in all browsers; `FileSystemWritableFileStream` writes are committed atomically on `.close()` per the spec, so we set `capabilities.atomicWrites: false` and document the close-as-commit semantic. |
|
||||
| Zod schemas for options | L42 | `OpfsStorageOptionsSchema` published; runtime validation at constructor boundary throws `OPFS.INVALID_OPTIONS`. |
|
||||
| Property-based tests | L43 | fast-check suite covering filename codec round-trip and key sanitiser. |
|
||||
| Deterministic — injectable clock | L44 | Constructor takes optional `clock: Clock`; metadata `createdAt`/`modifiedAt` use it. |
|
||||
| OpenTelemetry instrumentation | L45 | Per-op spans, counters, histograms emitted from production paths. |
|
||||
| Structured errors with stable codes | L46 | `defineCodes("OPFS", [9 codes])`. |
|
||||
| AbortSignal + backpressure | L47 | AbortSignal threaded through every public async method; pre-aborted signals throw `OPFS.ABORTED` before issuing any OPFS call. |
|
||||
| Progressive enhancement | L50 | `OpfsLike` adapter throws `OPFS.UNAVAILABLE` when `navigator.storage.getDirectory` is missing; consumers can also call exported `isOpfsAvailable()`. |
|
||||
| File-size budget | L56 | ≤300 LOC hard / ≤500 LOC test. |
|
||||
| No mocks | L57 | Tests use a real in-memory `FileSystemDirectoryHandle`-shaped implementation. |
|
||||
| `sideEffects: false` | L59 | Set in `package.json`. |
|
||||
| Workspace deps + Bun catalog | L60 | All deps use `workspace:^` or `catalog:` refs. |
|
||||
| Biome clean | L62 | Enforced by `bun run check`. |
|
||||
| TypeScript strict, no `any` | L63 | Adopted. |
|
||||
| Public API stability via Zod | L64 | `OpfsStorageOptionsSchema` exported. |
|
||||
| Error hierarchy via `@suaveplan/error` | L65 | `OpfsError extends SuaveplanError<OpfsErrorCode>`. |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Extend `AbstractStorage` not legacy `StorageLike` | The genesis storage core replaces the legacy interface with a richer abstract base. Every genesis adapter (`memory`, `s3`, `file`) extends `AbstractStorage`; OPFS MUST conform for module-augmentation registry compatibility. |
|
||||
| Explicit `registerOpfsProvider()` function | Upstream `index.ts` calls `registerStorageProvider("opfs", …)` at module-evaluation time, which breaks under `sideEffects: false` tree-shaking. Genesis exports an explicit registration function (matching `registerNodeFsProvider()` in `storage-file`) so consumers control when registration happens. |
|
||||
| `getPartial`/`setPartial`/streams declared in capabilities | `AbstractStorage` declares these abstract — they must have native implementations. OPFS supports both natively via `File.slice` and `FileSystemWritableFileStream.seek`. |
|
||||
| Base64url filename codec | OPFS filenames cannot contain `/` (OPFS treats `/` as nested-dir separator) and have other restrictions per browser. Upstream used base64 with `+`/`/` → `-`/`_` substitutions. We adopt RFC 4648 base64url unmodified for stable, well-tested round-trip. |
|
||||
| `OPFS.UNAVAILABLE` separate code | Lets consumers feature-detect cleanly (try-construct, catch `OPFS.UNAVAILABLE`, fall back to `MemoryStorage`) without confusing IO errors with the absent-API case. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete `it(...)` titles in these files:
|
||||
|
||||
- `src/opfs-storage/opfs-storage.test.ts` — CRUD, metadata, list, clear, getSize, getPartial, setPartial, native streams, AbortSignal, key sanitisation integration, DOMException mapping, OTel happy paths.
|
||||
- `src/opfs-storage/opfs-storage.spec-conformance.test.ts` — type-level conformance against `AbstractStorage`, capabilities matrix, OTel instruments observed via real in-memory `MeterProvider`, idempotent registry registration, error-code registry exhaustion.
|
||||
- `src/key-sanitizer/key-sanitizer.test.ts` — empty, traversal, absolute, null bytes, control chars, overlong, separator normalisation, valid paths.
|
||||
- `src/opfs-adapter/opfs-adapter.test.ts` — `defaultOpfs()` throws `OPFS.UNAVAILABLE` when `navigator.storage` is absent; resolves the directory handle when present.
|
||||
- `src/filename-codec/filename-codec.test.ts` — base64url round-trip; fast-check property: `decode(encode(k)) === k` for arbitrary string keys including unicode, slashes, control chars.
|
||||
- `src/otel/otel.test.ts` — `recordOp` emits histogram + counter with the documented attribute set against a real in-memory `MeterProvider`.
|
||||
- `src/registry/registry.test.ts` — registers `opfs` provider, `createStorage({ type: "opfs", config })` returns `OpfsStorage`, idempotent re-registration.
|
||||
- `src/in-memory-opfs/in-memory-opfs.test.ts` — the in-memory `FileSystemDirectoryHandle`-shaped test double itself, ensuring it faithfully implements every OPFS surface exercised by `OpfsStorage` (no contract drift).
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
The package is new — no dependents currently. No action needed.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-browser-opfs` returning the package itself).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/storage` — `StorageProviderRegistry` module-augmented to add `opfs: OpfsStorage` slot. Additive only; no breaking change. [x] no action needed (purely additive type slot).
|
||||
@@ -0,0 +1,199 @@
|
||||
<!-- 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"`
|
||||
80
openspec/archive/2026-05-12-storage-browser-opfs/tasks.md
Normal file
80
openspec/archive/2026-05-12-storage-browser-opfs/tasks.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Tasks — storage-browser-opfs
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/storage-browser-opfs.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/specs/storage/spec.md` and confirm capability boundary
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table
|
||||
- [x] 1.6 Draft `specs/storage-browser-opfs/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [x] 2.1 Create `packages/storage/storage-browser-opfs/` mirroring `packages/storage/storage-file/` conventions
|
||||
- [x] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, `"browser"` field, subpath exports per feature module
|
||||
- [x] 2.3 Wire deps: `@suaveplan/error`, `@suaveplan/storage`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib`
|
||||
- [x] 2.4 Wire devDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [x] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
|
||||
- [x] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [x] 3.1 `src/error-codes.ts` — `defineCodes("OPFS", [9 codes])`, `OpfsError` class, `key` discriminant
|
||||
- [x] 3.2 `src/types/opfs-storage.types.ts` — `OpfsStorageOptions` interface + `OpfsStorageOptionsSchema` Zod schema
|
||||
- [x] 3.3 `src/types/opfs-like.types.ts` — `OpfsLike` structural interface + companion `FileSystemDirectoryHandle`-shaped types
|
||||
- [x] 3.4 `src/opfs-adapter/opfs-adapter.ts` — `defaultOpfs()` factory + `isOpfsAvailable()` feature detector
|
||||
- [x] 3.5 `src/key-sanitizer/key-sanitizer.ts` — `sanitizeKey(key)` strict boundary
|
||||
- [x] 3.6 `src/filename-codec/filename-codec.ts` — base64url encode/decode over UTF-8 bytes
|
||||
- [x] 3.7 `src/otel/otel.ts` — meters/histograms/counters/tracer, `recordOp(...)`, `setStorageBrowserOpfsMeterProvider`
|
||||
- [x] 3.8 `src/opfs-storage/opfs-storage.ts` — `OpfsStorage extends AbstractStorage`, all abstract methods, AbortSignal, capabilities matrix, DOMException mapping
|
||||
- [x] 3.9 `src/in-memory-opfs/in-memory-opfs.ts` — test-double directory handle exposed under `./in-memory-opfs` subpath for downstream test packages
|
||||
- [x] 3.10 `src/registry/registry.ts` — `declare module "@suaveplan/storage/factory"` + `registerOpfsProvider()`
|
||||
- [x] 3.11 `src/index.ts` — explicit named re-exports per §6a
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [x] 4.1 `src/key-sanitizer/key-sanitizer.test.ts` — valid, traversal, absolute, null bytes, control chars, overlong, separator normalisation
|
||||
- [x] 4.2 `src/filename-codec/filename-codec.test.ts` — round-trip, unicode, fast-check identity property
|
||||
- [x] 4.3 `src/opfs-adapter/opfs-adapter.test.ts` — `defaultOpfs` resolves dir handle when present; throws `OPFS.UNAVAILABLE` when absent; `isOpfsAvailable` returns boolean without throwing
|
||||
- [x] 4.4 `src/otel/otel.test.ts` — metrics observed via real in-memory `MeterProvider`
|
||||
- [x] 4.5 `src/opfs-storage/opfs-storage.test.ts` — full CRUD + metadata + list + clear + getSize + getPartial + setPartial + streams + AbortSignal + DOMException mapping
|
||||
- [x] 4.6 `src/opfs-storage/opfs-storage.spec-conformance.test.ts` — type conformance, capabilities, OTel instruments emitted, options validation, error-code exhaustion
|
||||
- [x] 4.7 `src/registry/registry.test.ts` — registers `opfs` slot, factory dispatch, idempotency
|
||||
- [x] 4.8 `src/in-memory-opfs/in-memory-opfs.test.ts` — the test double itself
|
||||
- [x] 4.9 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 5.2 `README.md` links every module under `## Modules`
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- [x] 6.1 `bun run check`
|
||||
- [x] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-browser-opfs`
|
||||
- [x] 6.3 `bunx turbo test:coverage --filter=@suaveplan/storage-browser-opfs` (100/100/100/100)
|
||||
- [x] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/storage-browser-opfs`
|
||||
- [x] 6.5 `bunx turbo build --filter=@suaveplan/storage-browser-opfs`
|
||||
|
||||
## 7. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 7.1 Structural injectable seams (`OpfsLike`, `Clock`)
|
||||
- [x] 7.2 Zod schemas wired at every declared boundary
|
||||
- [x] 7.3 Every error code thrown by at least one production path
|
||||
- [x] 7.4 OTel instruments emitted from production paths; asserted via real in-memory `MeterProvider`
|
||||
- [x] 7.5 Capabilities matrix matches the spec (partialReads/partialWrites/nativeStreams = true; atomicWrites/contentAddressable = false)
|
||||
- [x] 7.6 No `export *` in feature-module barrels (§6a)
|
||||
- [x] 7.7 No direct `vitest` / `bun:test` imports in tests (§7)
|
||||
- [x] 7.8 No banned patterns in `src/` (`TODO`/`FIXME`/`ts-ignore`/`.skip`/`.only`)
|
||||
- [x] 7.9 README + sibling `.md` files complete
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [x] 8.1 Fold spec delta into `openspec/specs/storage-browser-opfs/spec.md`
|
||||
- [x] 8.2 Move `openspec/changes/storage-browser-opfs/` → `openspec/archive/<yyyy-mm-dd>-storage-browser-opfs/`
|
||||
- [x] 8.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [x] 8.4 Update CLAUDE.md package listing under storage family
|
||||
Reference in New Issue
Block a user