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

97 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).