Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# @suaveplan/storage-browser-opfs - Browser OPFS AbstractStorage adapter for @suaveplan/storage: origin-scoped persistent file storage via navigator.storage.getDirectory(), AbortSignal everywhere, strict key sanitisation, native Web Streams, native partial reads + partial writes, DOMException → typed OpfsError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consumers (vault snapshots, drive chunk caches, in-app i18n catalogues, SQLite-WASM backing files), with full `AbstractStorage` conformance, native Web Streams, native partial reads + partial writes, AbortSignal threading, Zod-validated options, structured `OpfsError` boundary, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
|
||||
<!-- end:auto-docs -->
|
||||
- [filename-codec](./src/filename-codec/filename-codec.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-opfs](./src/in-memory-opfs/in-memory-opfs.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-adapter](./src/opfs-adapter/opfs-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.errors](./src/opfs-storage/opfs-storage.errors.md) — Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes th…
|
||||
- [opfs-storage](./src/opfs-storage/opfs-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opfs-storage.ops](./src/opfs-storage/opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./src/opfs-storage/opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-opfs
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
isOpfsAvailable,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (!isOpfsAvailable()) {
|
||||
throw new Error("This browser does not support OPFS");
|
||||
}
|
||||
const storage = createOpfsStorage({ directory: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerOpfsProvider();
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerOpfsProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `true` | Via `File.slice(...).arrayBuffer()`. |
|
||||
| `partialWrites` | `true` | Via `FileSystemWritableFileStream.seek()`. |
|
||||
| `nativeStreams` | `true` | `File.stream()` for read; buffered close-on-commit for write. |
|
||||
| `atomicWrites` | `false` | Close-as-commit semantics within a single realm; no cross-realm locking. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.ts`](./src/error-codes.md) — `OPFS_CODES` registry + `OpfsError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/filename-codec/filename-codec.ts`](./src/filename-codec/filename-codec.md) — caller-key ↔ OPFS-filename round-trip.
|
||||
- [`src/opfs-adapter/opfs-adapter.ts`](./src/opfs-adapter/opfs-adapter.md) — `defaultOpfs()` + `isOpfsAvailable()`.
|
||||
- [`src/opfs-storage/opfs-storage.ts`](./src/opfs-storage/opfs-storage.md) — the `OpfsStorage` class.
|
||||
- [`src/opfs-storage/opfs-storage.errors.ts`](./src/opfs-storage/opfs-storage.errors.md) — DOMException mapping.
|
||||
- [`src/opfs-storage/opfs-storage.ops.ts`](./src/opfs-storage/opfs-storage.ops.md) — internal helpers.
|
||||
- [`src/opfs-storage/opfs-storage.streams.ts`](./src/opfs-storage/opfs-storage.streams.md) — `ReadableStream`/`WritableStream` builders.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerOpfsProvider()`.
|
||||
- [`src/in-memory-opfs/in-memory-opfs.ts`](./src/in-memory-opfs/in-memory-opfs.md) — real-implementation test double, exposed under `./in-memory-opfs` for downstream test packages.
|
||||
- [`src/types/opfs-like.types.ts`](./src/types/opfs-like.types.md) — structural OPFS surface.
|
||||
- [`src/types/opfs-storage.types.ts`](./src/types/opfs-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# `error-codes` — OPFS error registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every error code the `@suaveplan/storage-browser-opfs` package can throw. The registry uses `defineCodes("OPFS", [...])` from `@suaveplan/error` so each code is namespaced (`OPFS.NOT_FOUND`, `OPFS.QUOTA_EXCEEDED`, etc.), frozen at module load, and surfaced via a typed `OpfsErrorCode` union. Centralising the registry keeps the boundary error vocabulary tiny, prevents drift between the implementation and the spec, and lets callers pattern-match on a stable string code rather than parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`.
|
||||
- `OpfsError` extends `SuaveplanError<OpfsErrorCode>` with an optional `key` discriminant.
|
||||
- `cause` chain preserved from upstream `DOMException` instances.
|
||||
- TypeScript-narrowed `OpfsErrorCode` union for exhaustive switches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
throw new OpfsError(OPFS_CODES.NOT_FOUND, "missing entry", { key: "user/42" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
type OpfsErrorCode,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function handle(err: unknown): "retry" | "fail" | "ignore" {
|
||||
if (!(err instanceof OpfsError)) return "fail";
|
||||
const code: OpfsErrorCode = err.code;
|
||||
switch (code) {
|
||||
case OPFS_CODES.NOT_FOUND:
|
||||
return "ignore";
|
||||
case OPFS_CODES.QUOTA_EXCEEDED:
|
||||
case OPFS_CODES.IO_FAILED:
|
||||
return "retry";
|
||||
default:
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OPFS_CODES` — frozen `Readonly<Record<CodeName, "OPFS.CodeName">>` produced by `defineCodes`.
|
||||
- `OpfsErrorCode` — the union type of every member of `OPFS_CODES`.
|
||||
- `OpfsError extends SuaveplanError<OpfsErrorCode>` — constructor `(code, message, options?)` where `options.key` carries the offending key for correlation and `options.cause` chains the original error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is frozen at module load — adding a new code requires changing this file, which forces the spec delta to be updated in lockstep. `OpfsError.name` is set to `"OpfsError"` so devtools display useful labels. The `key` discriminant is the only optional field on the error class because no other piece of error context is universal across the nine paths; callers needing more should consult `cause`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `filename-codec` — caller key ↔ OPFS filename
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OPFS filenames inherit filesystem-level constraints that caller keys must not (case-insensitivity on some platforms, no embedded `/` separator within a single entry name, byte-encoding ambiguity across browsers). This module wraps each key in RFC 4648 base64url over its UTF-8 byte representation, producing OPFS-safe filenames that round-trip every caller key — including unicode, mixed scripts, slashes, and any byte the caller threw at us. Storing the codec as a separate module keeps the algorithm independently testable (property-based round-trip suite) and replaceable (a future hash-addressed mode can plug in without touching `OpfsStorage`).
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 4648 base64url with `+`→`-`, `/`→`_`, padding stripped.
|
||||
- UTF-8 fatal decode rejects malformed byte sequences with a typed `OpfsError`.
|
||||
- Round-trip invariant proven by a `fast-check` property test (200 random inputs).
|
||||
- Output regex: `^[A-Za-z0-9_-]*$` — accepted by every OPFS implementation.
|
||||
- Pure / synchronous / zero workspace dependencies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
} from "@suaveplan/storage-browser-opfs/filename-codec";
|
||||
|
||||
const filename = encodeFilename("orders/2026/05.json"); // "b3JkZXJzLzIwMjYvMDUuanNvbg"
|
||||
const original = decodeFilename(filename); // "orders/2026/05.json"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function listKeysSafely(entries: { name: string }[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
out.push(decodeFilename(entry.name));
|
||||
} catch (err) {
|
||||
// Skip entries that aren't ours — a foreign tool may have written
|
||||
// something to the OPFS directory we share.
|
||||
if (
|
||||
err instanceof OpfsError &&
|
||||
err.code === OPFS_CODES.INVALID_KEY
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeFilename(key: string): string` — converts to base64url-without-padding.
|
||||
- `decodeFilename(filename: string): string` — throws `OpfsError(OPFS.INVALID_KEY)` for malformed base64 or invalid UTF-8.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codec is deliberately byte-identical to the WHATWG spec for base64url to keep filenames stable across runtimes — the same OPFS directory written from Chrome must round-trip from Safari and Firefox. `btoa`/`atob` are universally available in browsers and in Node.js 20+, so no polyfill is needed at runtime. The fatal `TextDecoder` makes the codec self-healing: a foreign tool that writes non-base64 entries to the OPFS directory will be skipped by `decodeFilename`-using callers rather than corrupting their key view.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `in-memory-opfs` — real `OpfsLike` test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A faithful in-memory implementation of the `OpfsLike` structural interface and its companion directory / file / writable-stream surfaces. The double exists because the test suite for this package — and for any downstream consumer of `OpfsStorage` — needs to exercise the full OPFS API without booting a real browser. CLAUDE.md forbids `vi.mock` / `vi.stubGlobal`, so the test double is a real implementation that the test code constructs and injects via the `opfs` option. The implementation preserves bytes verbatim, honours per-file quotas, supports the seek-then-write pattern `OpfsStorage.setPartial` exercises, and emits the same `DOMException`-shaped errors (`NotFoundError`, `QuotaExceededError`, `NotAllowedError`) the real browser throws.
|
||||
|
||||
Exposing the double under the `./in-memory-opfs` subpath lets downstream packages (e.g. test fixtures, contract conformance suites) reuse it without re-implementing the (subtle) WHATWG semantics around `keepExistingData`, `seek`, and stream lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- Full coverage of every `OpfsLike` / `OpfsDirectoryHandle` / `OpfsFileHandle` / `OpfsFile` / `OpfsWritableStream` method that `OpfsStorage` calls.
|
||||
- Faithful `seek()` semantics: position cursor on the underlying buffer, growable on overflow, zero-fill on sparse extension.
|
||||
- `keepExistingData: true` honoured for the seek-then-patch flow.
|
||||
- Configurable per-file quota; over-quota writes throw `QuotaExceededDomException` matching the real browser's error name.
|
||||
- Injectable clock for deterministic `lastModified` in tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
import { OpfsStorage } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs();
|
||||
const storage = new OpfsStorage({ opfs });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryOpfs,
|
||||
QuotaExceededDomException,
|
||||
} from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
import { OpfsStorage, OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 16, now: () => 1_700_000_000_000 });
|
||||
const storage = new OpfsStorage({ opfs });
|
||||
try {
|
||||
await storage.set("k", new Uint8Array(32));
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) {
|
||||
// err.code === OPFS_CODES.QUOTA_EXCEEDED, err.cause instanceof QuotaExceededDomException
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInMemoryOpfs(options?: InMemoryOpfsOptions): OpfsLike` — `options.quotaBytes` (default 100 MiB), `options.now` (default `Date.now`).
|
||||
- `InMemoryOpfsDirectoryHandle` — the concrete root-directory class.
|
||||
- `NotFoundDomException`, `QuotaExceededDomException`, `NotAllowedDomException` — `Error` subclasses with the real browser's `.name` strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The double's buffer grows on demand (`Uint8Array` reallocation) to support sparse writes from `seek` past the current end-of-file — the gap is implicitly zero-filled because `Uint8Array` defaults to zero. The writable stream wraps a real `WritableStream<Uint8Array>` so callers get genuine back-pressure and abort semantics; `seek` mutates the cursor state shared between the writer closure and the wrapper class. Per-file quota is enforced at write time so over-quota writes fail synchronously, matching the real browser. `entries()` yields children in insertion order — the WHATWG spec doesn't guarantee an order, but stable iteration keeps the test suite deterministic.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `key-sanitizer` — strict caller-key boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any OPFS call is issued. It enforces the storage-browser-opfs boundary contract: keys must be non-empty strings of bounded length, must not contain control characters or path-traversal segments, must not be absolute, and must use POSIX separators internally. Centralising this guard prevents adapter callers from accidentally exfiltrating data outside the configured OPFS sub-directory or constructing filenames the OPFS API will silently misinterpret.
|
||||
|
||||
## Features
|
||||
|
||||
- Rejects empty strings, non-string input, keys over 1024 chars, control chars (0x00-0x1F, 0x7F), absolute POSIX paths, and `..` traversal segments.
|
||||
- Normalises Windows `\` separators to POSIX `/` so cross-platform callers see consistent layout.
|
||||
- Throws typed `OpfsError(OPFS.INVALID_KEY | OPFS.KEY_TOO_LONG)` with the offending key captured for log correlation.
|
||||
- Pure / synchronous / dependency-free — safe to call in hot paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-opfs/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("orders/2026/05/12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
sanitizeKey,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
function safeKeyOrNull(input: unknown): string | null {
|
||||
if (typeof input !== "string") return null;
|
||||
try {
|
||||
return sanitizeKey(input);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof OpfsError &&
|
||||
(err.code === OPFS_CODES.INVALID_KEY ||
|
||||
err.code === OPFS_CODES.KEY_TOO_LONG)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`sanitizeKey(key: string): string`
|
||||
|
||||
- **Throws** `OpfsError(OPFS.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
|
||||
- **Throws** `OpfsError(OPFS.KEY_TOO_LONG)` for keys with `length > 1024`.
|
||||
- **Returns** the normalised POSIX-separator key on success.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The control-char scan is a single linear pass over `charCodeAt(i)` — it costs less than a regex compile for typical key sizes. The traversal check splits on `/` after backslash normalisation to ensure forms like `a\..\b` are rejected, not just `a/../b`. Windows drive-letter prefixes (`C:\…`) are NOT rejected here because OPFS has no notion of drive letters and the leading `:` is harmless to OPFS; `storage-file` does reject them because the Node.js fs layer would interpret them.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `opfs-adapter` — default `OpfsLike` and feature detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the production binding of the `OpfsLike` structural interface to the browser's `navigator.storage.getDirectory()` API, plus a side-effect-free feature detector consumers call before constructing an `OpfsStorage`. Keeping this in its own module — rather than inside `OpfsStorage` — lets the storage class take an injected `OpfsLike` and stay environment-agnostic, while the production path resolves the real browser API lazily on first `getDirectory()`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultOpfs()` returns an `OpfsLike` whose `getDirectory()` calls the live browser API.
|
||||
- Missing API surfaces as `OpfsError(OPFS.UNAVAILABLE)` rather than `TypeError` from a missing-property access.
|
||||
- `isOpfsAvailable()` is synchronous, has no side effects, and never throws — safe to call during render.
|
||||
- Resolves `navigator` from `globalThis` so the module loads cleanly in Node, Bun, and worker environments.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultOpfs,
|
||||
isOpfsAvailable,
|
||||
OpfsStorage,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (!isOpfsAvailable()) {
|
||||
// Fall back to MemoryStorage or another adapter.
|
||||
}
|
||||
const storage = new OpfsStorage({ opfs: defaultOpfs() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultOpfs,
|
||||
isOpfsAvailable,
|
||||
MemoryStorage,
|
||||
OpfsStorage,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
import type { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
|
||||
export function pickStorage(): AbstractStorage {
|
||||
if (isOpfsAvailable()) return new OpfsStorage({ opfs: defaultOpfs() });
|
||||
return new MemoryStorage();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultOpfs(): OpfsLike` — `getDirectory()` throws `OpfsError(OPFS.UNAVAILABLE)` when missing.
|
||||
- `isOpfsAvailable(): boolean` — pure feature detector.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defaultOpfs()` returns a fresh `OpfsLike` object on every call so callers can hold different references; the underlying `getDirectory` getter is read at each invocation so consumers that polyfill `navigator.storage` after this module loads still benefit. The Boolean detector deliberately checks `typeof getDirectory === "function"` rather than `"getDirectory" in storage` to weed out polyfills that stub the property without a callable.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `opfs-storage.errors` — DOMException → OpfsError boundary
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the mapping between WHATWG `DOMException` shapes thrown by the OPFS API and the typed `OpfsError` codes the storage class surfaces. Centralising the mapping keeps the main `OpfsStorage` class focused on the happy path and prevents the boundary contract from drifting as new error kinds emerge. The helpers also handle the `AbortSignal` short-circuit so every method invocation can call a single `assertNotAborted` guard at the top.
|
||||
|
||||
## Features
|
||||
|
||||
- `isNotFoundError(err)` — detects browser `NotFoundError` (including from the in-memory test double).
|
||||
- `mapOpfsError(err, key)` — maps `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause` preserved. Pre-existing `OpfsError` instances pass through unchanged so callee-thrown codes (e.g. `OPFS.UNAVAILABLE` from `defaultOpfs()`) reach the boundary.
|
||||
- `assertNotAborted(signal, key)` — throws `OPFS.ABORTED` when the signal is already aborted before any OPFS call is issued.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage.errors.js";
|
||||
|
||||
async function readBytes(handle: OpfsFileHandle, signal?: AbortSignal) {
|
||||
assertNotAborted(signal, "k");
|
||||
try {
|
||||
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return undefined;
|
||||
throw mapOpfsError(err, "k");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OPFS_CODES, OpfsError } from "@suaveplan/storage-browser-opfs";
|
||||
import { mapOpfsError } from "./opfs-storage.errors.js";
|
||||
|
||||
try {
|
||||
await someOpfsOperation();
|
||||
} catch (err) {
|
||||
const mapped = mapOpfsError(err, "user/42");
|
||||
if (mapped.code === OPFS_CODES.QUOTA_EXCEEDED) {
|
||||
// Surface a user-facing "you're out of space" message.
|
||||
}
|
||||
throw mapped;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `isNotFoundError(err: unknown): boolean`.
|
||||
- `mapOpfsError(err: unknown, key: string | undefined): OpfsError`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `OpfsError(OPFS.ABORTED)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The detection branch tests `err.name === "NotFoundError"` rather than `err instanceof DOMException` because the in-memory test double cannot extend `DOMException` (the global isn't constructable in every runtime). Using `.name` lets the mapping work uniformly against both real `DOMException` instances and the test double's `Error` subclasses. `mapOpfsError` always preserves the underlying error via `cause` so callers can inspect the original `DOMException` when needed.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `opfs-storage` — `OpfsStorage` AbstractStorage adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - opfs-storage.ops — Hosts pure / nearly-pure helpers…
|
||||
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
- [opfs-storage.ops](./opfs-storage.ops.md) — Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard bud…
|
||||
- [opfs-storage.streams](./opfs-storage.streams.md) — Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays un…
|
||||
|
||||
## Purpose
|
||||
|
||||
`OpfsStorage` is the browser-tier implementation of `AbstractStorage` from `@suaveplan/storage`, backed by the Origin Private File System (OPFS) — the standard origin-scoped persistent file storage primitive accessed via `navigator.storage.getDirectory()`. It exposes the full abstract surface (`get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`), threads `AbortSignal` through every async path, validates every caller key against `sanitizeKey`, maps `DOMException`s to typed `OpfsError` codes at the boundary, and records OTel metrics for every operation.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `AbstractStorage` byte-for-byte — `instanceof AbstractStorage` is `true` and every abstract method is implemented natively.
|
||||
- `capabilities: { partialReads: true, partialWrites: true, nativeStreams: true, atomicWrites: false, contentAddressable: false }`.
|
||||
- Lazy directory handle: `navigator.storage.getDirectory()` and `getDirectoryHandle(directory, { create: true })` run only on the first method call.
|
||||
- `defaultOpfs()` adapter resolves the real browser API; tests inject `createInMemoryOpfs()`.
|
||||
- Zod-validated `OpfsStorageOptions` at the constructor boundary throws `OPFS.INVALID_OPTIONS` on malformed input.
|
||||
- DOMException mapping: `NotFoundError → undefined/false`, `NotAllowedError → OPFS.PERMISSION_DENIED`, `QuotaExceededError → OPFS.QUOTA_EXCEEDED`, anything else → `OPFS.IO_FAILED` with `cause`.
|
||||
- OTel: per-op span via `getInstruments().tracer`, duration histogram, op-kind counter, IO-bytes histogram for read/write paths.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
isOpfsAvailable,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
if (isOpfsAvailable()) {
|
||||
const storage = createOpfsStorage({ directory: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
console.log(new TextDecoder().decode(back));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createOpfsStorage,
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
import { createInMemoryOpfs } from "@suaveplan/storage-browser-opfs/in-memory-opfs";
|
||||
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 1 << 20 });
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const storage = createOpfsStorage({ directory: "test", opfs, clock });
|
||||
|
||||
// Streaming write
|
||||
const ac = new AbortController();
|
||||
const writer = storage.createWriteStream("blob", ac.signal).getWriter();
|
||||
await writer.write(new Uint8Array(32_000));
|
||||
await writer.close();
|
||||
|
||||
// Partial read of bytes [10..20) without loading the full value
|
||||
const slice = await storage.getPartial("blob", 10, 10);
|
||||
|
||||
try {
|
||||
await storage.set("big", new Uint8Array(2 << 20)); // exceeds quota
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError && err.code === OPFS_CODES.QUOTA_EXCEEDED) {
|
||||
// surface a "you're out of space" message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class OpfsStorage extends AbstractStorage` — constructor `(options?: OpfsStorageOptions)`.
|
||||
- `createOpfsStorage(options?: OpfsStorageOptions): OpfsStorage`.
|
||||
- Every abstract method from `AbstractStorage`. All accept `signal?: AbortSignal`.
|
||||
- `capabilities`: literal-typed `StorageCapabilities` (see Features).
|
||||
- Throws `OpfsError` with one of the 9 frozen `OPFS_CODES` at any boundary.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The directory handle is cached after the first resolution to amortise the cost of `navigator.storage.getDirectory()` (the WHATWG spec doesn't require it to be cheap; some browsers do disk IO). The cache is per-instance, so multiple `OpfsStorage` instances over the same directory each resolve independently — sharing is the caller's responsibility. `set` performs a single `createWritable` + `write` + `close` so the write commits atomically per spec; we set `capabilities.atomicWrites: false` anyway because the abstract contract's "atomic" means "atomic with respect to concurrent readers within the same realm" and OPFS does not provide cross-realm locking. `setPartial` opens with `keepExistingData: true` so the seek-then-write doesn't truncate. `createReadStream`/`createWriteStream` are delegated to `opfs-storage.streams` to keep the class file within budget.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `opfs-storage.ops` — internal helpers for OpfsStorage
|
||||
|
||||
## Purpose
|
||||
|
||||
Hosts pure / nearly-pure helpers that `OpfsStorage` calls but that would push the class file over the 300-line hard budget. Extracting them keeps the class focused on lifecycle orchestration and the helpers individually testable in isolation. Each helper has a single responsibility — listing keys, paginating a list result, building a metadata record, validating numeric arguments, or feature-gating `getFileHandle` to a non-throwing variant.
|
||||
|
||||
## Features
|
||||
|
||||
- `listAllKeys(dir)` — enumerates file entries and decodes their filenames back to caller keys, silently skipping foreign entries.
|
||||
- `paginateKeys(keys, options)` — applies `prefix`/`offset`/`limit` and produces a `StorageListResult` with stable sort order.
|
||||
- `metadataFromFile(file, clock?)` — builds the `StorageMetadata` shape required by `AbstractStorage.getMetadata`.
|
||||
- `assertNonNegInt(key, label, value)` — boundary guard for partial-read/write offset and length.
|
||||
- `tryGetFileHandle(dir, filename, key)` — non-throwing `getFileHandle` that returns `undefined` for missing entries and maps other errors.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
listAllKeys,
|
||||
paginateKeys,
|
||||
metadataFromFile,
|
||||
} from "./opfs-storage.ops.js";
|
||||
|
||||
const all = await listAllKeys(dir);
|
||||
const page = paginateKeys(all.map((e) => e.key), { prefix: "user/" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
metadataFromFile,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage.ops.js";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
async function safeStat(dir: OpfsDirectoryHandle, key: string, fname: string, clock: Clock) {
|
||||
const handle = await tryGetFileHandle(dir, fname, key);
|
||||
if (!handle) return null;
|
||||
const file = await handle.getFile();
|
||||
return metadataFromFile(file, clock);
|
||||
}
|
||||
|
||||
assertNonNegInt("user/42", "offset", 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `listAllKeys(dir): Promise<{ key, handle }[]>` — silently skips entries whose filename isn't valid base64url.
|
||||
- `paginateKeys(keys, options?): StorageListResult`.
|
||||
- `metadataFromFile(file, clock?): StorageMetadata`.
|
||||
- `assertNonNegInt(key, label, value): void` — throws `OpfsError(OPFS.INVALID_KEY)`.
|
||||
- `tryGetFileHandle(dir, filename, key): Promise<OpfsFileHandle | undefined>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`paginateKeys` sorts the filtered slice before paginating because `OpfsDirectoryHandle.entries()` makes no ordering guarantees. Sorting ensures `list` is deterministic across repeated calls without forcing the storage class to memoise. `metadataFromFile` falls back to `clock?.now()` only when the file's `lastModified` is missing (zero) — the in-memory test double always returns a valid stamp, so this fallback exists for unusual real-browser cases. `tryGetFileHandle` is preferred over a direct `try/catch` at every call site because the error-mapping logic deserves a single home.
|
||||
@@ -0,0 +1,49 @@
|
||||
# `opfs-storage.streams` — Web Streams over OPFS
|
||||
|
||||
## Purpose
|
||||
|
||||
Factors the `createReadStream` / `createWriteStream` builders out of the main `OpfsStorage` class so the class stays under the 300-line file budget and the stream contract is independently testable. Both builders honour `AbortSignal`, map underlying errors through `mapOpfsError`, and preserve the close-as-commit semantic of the WHATWG File System Access spec (the writable stream buffers chunks and writes them in `close()`, so a half-written file is never observable).
|
||||
|
||||
## Features
|
||||
|
||||
- `buildReadStream({ key, signal, getDir })` — returns a `ReadableStream<Uint8Array>` over `File.stream()`; missing entries close cleanly instead of erroring.
|
||||
- `buildWriteStream({ key, signal, getDir })` — returns a `WritableStream<Uint8Array>` that buffers caller chunks and commits them atomically on `close()`.
|
||||
- `AbortSignal` honoured before each chunk read/write — already-aborted signals throw `OPFS.ABORTED` synchronously.
|
||||
- `mapOpfsError` invoked at every error boundary so callers receive typed `OpfsError` instances.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildReadStream } from "./opfs-storage.streams.js";
|
||||
|
||||
const stream = buildReadStream({ key: "a", signal: undefined, getDir });
|
||||
const reader = stream.getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
// process value
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildWriteStream } from "./opfs-storage.streams.js";
|
||||
|
||||
const ac = new AbortController();
|
||||
const stream = buildWriteStream({ key: "a", signal: ac.signal, getDir });
|
||||
const writer = stream.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.write(new Uint8Array([4, 5]));
|
||||
await writer.close(); // single OPFS write happens here
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamArgs { key, signal, getDir }`.
|
||||
- `buildReadStream(args): ReadableStream<Uint8Array>`.
|
||||
- `buildWriteStream(args): WritableStream<Uint8Array>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildWriteStream` collects chunks in a plain array instead of streaming them straight to OPFS because OPFS's `FileSystemWritableFileStream` does NOT expose a public byte-pipe-through-WHATWG surface in every browser yet — Safari historically required `keepExistingData`-or-`truncate` decisions up front. Buffering is consistent across browsers, and the memory cost is bounded by the caller's writer back-pressure. For very large writes consumers should use `set()` with a pre-built `Uint8Array` or `setPartial()` for streaming updates.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `otel` — OpenTelemetry instrumentation for OPFS storage
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises every metric, histogram, and span emitted by `OpfsStorage` behind a single lazy instrument factory. Lazy construction lets consumers install a custom global `MeterProvider` / `TracerProvider` before the storage path executes, and lets tests pin a real in-memory `MeterProvider` against which the recorded attribute set is asserted. The module exposes a small `recordOp({op, outcome, durationMs, bytes?, direction?})` helper that every operation in the storage class calls once.
|
||||
|
||||
## Features
|
||||
|
||||
- Six instruments: 4 counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and 2 histograms (`op_duration_ms`, `io_bytes`).
|
||||
- Stable label space: `{op, outcome}` on every counter / duration, `{op, direction}` on `io_bytes`.
|
||||
- Aborted operations count toward `errors_total` with `outcome: "aborted"` so SLO dashboards can split them out.
|
||||
- `setStorageBrowserOpfsMeterProvider(provider)` for runtime provider injection (used by `@suaveplan/opentelemetry`).
|
||||
- `resetInstruments()` rebuilds the cache between test blocks.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordOp } from "@suaveplan/storage-browser-opfs/otel";
|
||||
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 12, bytes: 100, direction: "write" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
getInstruments,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
resetInstruments,
|
||||
} from "@suaveplan/storage-browser-opfs/otel";
|
||||
|
||||
// In a Bun/Node test:
|
||||
class CapturingMeterProvider { /* real in-memory MeterProvider */ }
|
||||
const provider = new CapturingMeterProvider();
|
||||
setStorageBrowserOpfsMeterProvider(provider);
|
||||
resetInstruments();
|
||||
const { writesTotal } = getInstruments(true);
|
||||
writesTotal.add(1, { op: "set", outcome: "success" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `setStorageBrowserOpfsMeterProvider(provider | undefined): void`.
|
||||
- `getInstruments(force?: boolean): StorageBrowserOpfsInstruments` — cached unless `force` is `true`.
|
||||
- `recordOp(input: RecordOpInput): void`.
|
||||
- `resetInstruments(): void`.
|
||||
- Types: `StorageBrowserOpfsOp` (14 op names), `StorageBrowserOpfsOpOutcome` (`success | error | aborted`), `RecordOpInput`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dispatch in `recordOp` is intentionally a flat `if/else` chain rather than a lookup table: TypeScript can statically verify exhaustiveness over `StorageBrowserOpfsOp`, and the cost of a 14-arm chain is negligible compared to the underlying counter add. `aborted` outcomes are routed to `errors_total` (not a separate counter) on purpose — operationally they're failures from the caller's perspective even though they're not exceptions on the server side.
|
||||
@@ -0,0 +1,50 @@
|
||||
# `registry` — factory registration for `OpfsStorage`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Hooks `OpfsStorage` into `@suaveplan/storage`'s factory registry via module augmentation so callers can construct the adapter through `createStorage({ type: "opfs", config })`. Registration is gated behind an explicit `registerOpfsProvider()` function instead of an unconditional side-effect import: `sideEffects: false` bundlers must be able to tree-shake the registry path when a consumer holds a direct `new OpfsStorage(...)` reference but never goes through the factory.
|
||||
|
||||
## Features
|
||||
|
||||
- `declare module "@suaveplan/storage/factory"` adds typed slots `opfs: OpfsStorage` and `opfs: OpfsStorageOptions`.
|
||||
- `registerOpfsProvider()` is idempotent — repeat calls are no-ops.
|
||||
- Re-registers cleanly after `clearStorageProviders()`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerOpfsProvider();
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
clearStorageProviders,
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
|
||||
// In a fresh test isolate:
|
||||
clearStorageProviders();
|
||||
registerOpfsProvider();
|
||||
console.log(hasStorageProvider("opfs")); // true
|
||||
const storage = createStorage({ type: "opfs", config: { directory: "test" } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerOpfsProvider(): void` — idempotent registration.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module augmentation is in the same file as the registration function so consumers get the type slot the moment they `import` either symbol. Idempotency is enforced by a `hasStorageProvider("opfs")` short-circuit before re-registering — the underlying factory map handles ties safely either way, but the short-circuit makes the contract explicit and observable.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `opfs-like.types` — structural view of the OPFS API surface
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares a minimal structural interface (`OpfsLike`, `OpfsDirectoryHandle`, `OpfsFileHandle`, `OpfsFile`, `OpfsWritableStream`) that `OpfsStorage` consumes. Working against a local interface — rather than `lib.dom`'s globals directly — keeps the adapter consumable from packages that compile without DOM lib, lets tests substitute a real in-memory implementation, and pins the contract to the subset of methods we actually call (so future browser additions don't ripple through this package as breakages).
|
||||
|
||||
## Features
|
||||
|
||||
- One interface per OPFS surface: directory handle, file handle, file, writable stream.
|
||||
- Every method is a strict subset of the corresponding WHATWG File System Access API method, so the browser globals satisfy our interface without `unknown`-casts.
|
||||
- `OpfsLike` is the smallest possible root: a single `getDirectory()` method, lazy by design.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { OpfsLike } from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
declare const myOpfs: OpfsLike;
|
||||
const root = await myOpfs.getDirectory();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsLike,
|
||||
} from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
class StaticDirOpfs implements OpfsLike {
|
||||
public constructor(private readonly handle: OpfsDirectoryHandle) {}
|
||||
public getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
return Promise.resolve(this.handle);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `OpfsLike.getDirectory(): Promise<OpfsDirectoryHandle>` — resolves the origin's OPFS root.
|
||||
- `OpfsDirectoryHandle.getFileHandle(name, { create? })` / `getDirectoryHandle(name, { create? })` / `removeEntry(name, { recursive? })` / `entries()`.
|
||||
- `OpfsFileHandle.getFile()` / `createWritable({ keepExistingData? })`.
|
||||
- `OpfsFile.size`, `lastModified`, `arrayBuffer()`, `slice(start?, end?)`, `stream()`.
|
||||
- `OpfsWritableStream extends WritableStream<Uint8Array>` with `seek(position)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface intentionally omits any method `OpfsStorage` does not call. Adding new operations (e.g. `move`, `remove`) requires updating both this interface and the in-memory test double in lockstep. The native browser types are nominally compatible at the structural level — assignability flows from `FileSystemDirectoryHandle` to `OpfsDirectoryHandle` automatically.
|
||||
@@ -0,0 +1,46 @@
|
||||
# `opfs-storage.types` — option boundary for OpfsStorage
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the constructor option shape `OpfsStorageOptions` and the matching `OpfsStorageOptionsSchema` Zod schema. The schema is the runtime gate at the constructor boundary — malformed input becomes `OpfsError(OPFS.INVALID_OPTIONS)` with the underlying `ZodError` preserved as `cause`. This keeps invalid configuration from corrupting the OPFS layer with hard-to-diagnose downstream failures.
|
||||
|
||||
## Features
|
||||
|
||||
- `directory`: caller-overridable sub-directory under OPFS root (default `"storage"`).
|
||||
- `opfs`: injectable `OpfsLike` adapter for tests and progressive enhancement.
|
||||
- `clock`: injectable `Clock` so metadata timestamps are deterministic in tests.
|
||||
- Zod schema accepts structural adapter shapes as `unknown` (Zod cannot fully model interfaces), letting TypeScript enforce the contract at call sites while still validating the literal-typed fields.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
OpfsStorage,
|
||||
type OpfsStorageOptions,
|
||||
} from "@suaveplan/storage-browser-opfs";
|
||||
|
||||
const options: OpfsStorageOptions = { directory: "myapp" };
|
||||
const storage = new OpfsStorage(options);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OpfsStorageOptionsSchema } from "@suaveplan/storage-browser-opfs/types";
|
||||
|
||||
function loadOpfsConfigFromEnv(): OpfsStorageOptions {
|
||||
const raw = JSON.parse(process.env.OPFS_CONFIG ?? "{}");
|
||||
const parsed = OpfsStorageOptionsSchema.safeParse(raw);
|
||||
if (!parsed.success) throw parsed.error;
|
||||
return parsed.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface OpfsStorageOptions { directory?: string; opfs?: OpfsLike; clock?: Clock }`.
|
||||
- `OpfsStorageOptionsSchema: ZodObject` — `directory: string().min(1).optional()`, structural adapters loose-typed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema-level looseness on `opfs`/`clock` is intentional: Zod cannot fully describe the structural shape of an interface like `OpfsLike` without losing the link to the TypeScript type, and runtime checks of every adapter method would impose a per-call cost without catching anything TypeScript doesn't already catch. The `directory.min(1)` rule is the one boundary that needs runtime enforcement because OPFS itself accepts the empty string and returns the root, which would silently break key isolation.
|
||||
Reference in New Issue
Block a user