11 KiB
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:
OpfsStorageclass extendingAbstractStorage— origin-scoped key-value storage rooted at a configurable sub-directory ofnavigator.storage.getDirectory(). - ADDED:
createOpfsStorage(options)factory. - ADDED:
OpfsStorageOptionsinterface +OpfsStorageOptionsSchemaZod schema; throwsOPFS.INVALID_OPTIONSon malformed input. - ADDED:
OpfsLikestructural interface ({ getDirectory(): Promise<FileSystemDirectoryHandle> }) +defaultOpfs()adapter pointing atnavigator.storage.getDirectory(). Injectable so tests substitute a real in-memoryFileSystemDirectoryHandle-shaped implementation rather than mocking the DOM. - ADDED:
sanitizeKey(key)strict key boundary — rejects empty,.., absolute paths (/), null bytes, control chars (0x00–0x1F, 0x7F), andKEY_TOO_LONGover 1024 chars; normalises Windows\separators to POSIX/. - ADDED:
OPFS_CODESregistry viadefineCodes("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>withkeydiscriminant. - ADDED: Module-augmentation registry —
declare module "@suaveplan/storage/factory"addsopfs: OpfsStoragetoStorageProviderRegistryandopfs: OpfsStorageOptionstoStorageProviderConfig;registerOpfsProvider()registers the constructor with the factory. - ADDED: Native Web Streams via
createReadStream(File.stream()fromFileSystemFileHandle.getFile()) andcreateWriteStream(FileSystemWritableFileStreamfromFileSystemFileHandle.createWritable()); declaredcapabilities.nativeStreams: true. - ADDED:
getPartial(offset, length)viaFile.slice(offset, offset+length).arrayBuffer()— declaredcapabilities.partialReads: true. - ADDED:
setPartial(offset, bytes)viaFileSystemWritableFileStream.seek()+.write()— declaredcapabilities.partialWrites: true. - ADDED: DOMException mapping —
NotFoundError→undefined/falsereturn per AbstractStorage contract;NotAllowedError→OPFS.PERMISSION_DENIED;QuotaExceededError→OPFS.QUOTA_EXCEEDED; missingnavigator.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/decoderound-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-opfstracer.
Out of scope
- Quota-pre-flight via
navigator.storage.estimate()— listed in the upstream enterprise plan, butQuotaExceededErrormapping 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 onAbstractStorage; 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/idempotencyorBroadcastChannelon 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 againstAbstractStorage, capabilities matrix, OTel instruments observed via real in-memoryMeterProvider, 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()throwsOPFS.UNAVAILABLEwhennavigator.storageis absent; resolves the directory handle when present.src/filename-codec/filename-codec.test.ts— base64url round-trip; fast-check property:decode(encode(k)) === kfor arbitrary string keys including unicode, slashes, control chars.src/otel/otel.test.ts—recordOpemits histogram + counter with the documented attribute set against a real in-memoryMeterProvider.src/registry/registry.test.ts— registersopfsprovider,createStorage({ type: "opfs", config })returnsOpfsStorage, idempotent re-registration.src/in-memory-opfs/in-memory-opfs.test.ts— the in-memoryFileSystemDirectoryHandle-shaped test double itself, ensuring it faithfully implements every OPFS surface exercised byOpfsStorage(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-opfsreturning the package itself).
Affected upstreams
@suaveplan/storage—StorageProviderRegistrymodule-augmented to addopfs: OpfsStorageslot. Additive only; no breaking change. [x] no action needed (purely additive type slot).