# 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 }`) + `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` 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`. | ### 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).