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

11 KiB
Raw Permalink Blame History

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).