Initial Commit
This commit is contained in:
95
packages/persistence/storage/storage-browser-opfs/README.md
Normal file
95
packages/persistence/storage/storage-browser-opfs/README.md
Normal file
@@ -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.
|
||||
|
||||
113
packages/persistence/storage/storage-browser-opfs/package.json
Normal file
113
packages/persistence/storage/storage-browser-opfs/package.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "@suaveplan/storage-browser-opfs",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"browser": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./opfs-storage": {
|
||||
"import": {
|
||||
"types": "./dist/opfs-storage/index.d.ts",
|
||||
"default": "./dist/opfs-storage/opfs-storage.js"
|
||||
}
|
||||
},
|
||||
"./key-sanitizer": {
|
||||
"import": {
|
||||
"types": "./dist/key-sanitizer/index.d.ts",
|
||||
"default": "./dist/key-sanitizer/key-sanitizer.js"
|
||||
}
|
||||
},
|
||||
"./filename-codec": {
|
||||
"import": {
|
||||
"types": "./dist/filename-codec/index.d.ts",
|
||||
"default": "./dist/filename-codec/filename-codec.js"
|
||||
}
|
||||
},
|
||||
"./opfs-adapter": {
|
||||
"import": {
|
||||
"types": "./dist/opfs-adapter/index.d.ts",
|
||||
"default": "./dist/opfs-adapter/opfs-adapter.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/otel.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/index.d.ts",
|
||||
"default": "./dist/registry/registry.js"
|
||||
}
|
||||
},
|
||||
"./in-memory-opfs": {
|
||||
"import": {
|
||||
"types": "./dist/in-memory-opfs/index.d.ts",
|
||||
"default": "./dist/in-memory-opfs/in-memory-opfs.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/storage": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Maximum length of a caller-supplied key. */
|
||||
export const MAX_KEY_LENGTH = 1024;
|
||||
|
||||
/** Default OPFS sub-directory under the origin root. */
|
||||
export const DEFAULT_DIRECTORY = "storage";
|
||||
@@ -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,43 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const OPFS_CODES = defineCodes("OPFS", [
|
||||
"NOT_FOUND",
|
||||
"PERMISSION_DENIED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"UNAVAILABLE",
|
||||
"INVALID_KEY",
|
||||
"KEY_TOO_LONG",
|
||||
"INVALID_OPTIONS",
|
||||
"ABORTED",
|
||||
"IO_FAILED",
|
||||
] as const);
|
||||
|
||||
export type OpfsErrorCode = (typeof OPFS_CODES)[keyof typeof OPFS_CODES];
|
||||
|
||||
export interface OpfsErrorOptions extends SuaveplanErrorOptions {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for OPFS storage operations.
|
||||
*
|
||||
* Carries an optional `key` discriminant so callers can correlate failures
|
||||
* with the offending key without parsing message text.
|
||||
*/
|
||||
export class OpfsError extends SuaveplanError<OpfsErrorCode> {
|
||||
public readonly key: string | undefined;
|
||||
|
||||
public constructor(
|
||||
code: OpfsErrorCode,
|
||||
message: string,
|
||||
options?: OpfsErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.key = options?.key;
|
||||
this.name = "OpfsError";
|
||||
}
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { OPFS_CODES, type OpfsError } from "../error-codes.js";
|
||||
import { decodeFilename, encodeFilename } from "./filename-codec.js";
|
||||
|
||||
describe("filename-codec", () => {
|
||||
it("round-trips ASCII keys", { timeout: 5000 }, () => {
|
||||
expect(decodeFilename(encodeFilename("a"))).toBe("a");
|
||||
expect(decodeFilename(encodeFilename("orders/2026/05.json"))).toBe(
|
||||
"orders/2026/05.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips unicode keys", { timeout: 5000 }, () => {
|
||||
expect(decodeFilename(encodeFilename("héllo/wörld"))).toBe(
|
||||
"héllo/wörld"
|
||||
);
|
||||
expect(decodeFilename(encodeFilename("日本語"))).toBe("日本語");
|
||||
expect(decodeFilename(encodeFilename("🚀/⭐/✨"))).toBe("🚀/⭐/✨");
|
||||
});
|
||||
|
||||
it("produces filenames containing only base64url-safe chars", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const safe = /^[A-Za-z0-9_-]*$/;
|
||||
for (const k of ["a/b", "héllo", "🚀", "日本語"]) {
|
||||
expect(safe.test(encodeFilename(k))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips base64 padding", { timeout: 5000 }, () => {
|
||||
// "a" → "YQ==" in base64; codec strips trailing '=' characters.
|
||||
expect(encodeFilename("a").endsWith("=")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed base64url filenames with INVALID_KEY", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeFilename("!!!not-base64!!!");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects filenames that decode to invalid UTF-8", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 0xC3 is a UTF-8 lead byte expecting a continuation byte 0x80-0xBF.
|
||||
// Encoding a lone 0xC3 yields invalid UTF-8 when decoded.
|
||||
const filename = encodeFilenameFromBytes(new Uint8Array([0xc3]));
|
||||
try {
|
||||
decodeFilename(filename);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("fast-check: decode∘encode is identity for arbitrary strings", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (s) => {
|
||||
expect(decodeFilename(encodeFilename(s))).toBe(s);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: encode arbitrary bytes through the same base64url-without-padding
|
||||
// shape used by `encodeFilename`. Used to construct invalid-UTF-8 test inputs.
|
||||
function encodeFilenameFromBytes(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
|
||||
const ENCODER = new TextEncoder();
|
||||
const DECODER = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
/**
|
||||
* Encode an arbitrary caller key into an OPFS-safe filename.
|
||||
*
|
||||
* Uses RFC 4648 base64url over the UTF-8 byte representation of `key`. The
|
||||
* resulting filename contains only `[A-Za-z0-9_-]` characters, which OPFS
|
||||
* accepts across every browser implementation, never contains `/`, and is
|
||||
* unaffected by case-insensitive filesystems (because the codec preserves
|
||||
* every byte regardless of underlying filesystem semantics).
|
||||
*/
|
||||
export function encodeFilename(key: string): string {
|
||||
const bytes = ENCODER.encode(key);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an OPFS-safe filename back to the original caller key.
|
||||
*
|
||||
* Inverse of {@link encodeFilename}. Throws
|
||||
* `OpfsError(OPFS.INVALID_KEY)` when the filename is malformed base64url
|
||||
* or contains a byte sequence that is not valid UTF-8.
|
||||
*/
|
||||
export function decodeFilename(filename: string): string {
|
||||
const padded =
|
||||
filename.replace(/-/g, "+").replace(/_/g, "/") +
|
||||
"==".slice(0, (4 - (filename.length % 4)) % 4);
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(padded);
|
||||
} catch (err) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
"filename is not valid base64url",
|
||||
{ cause: err as Error }
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
try {
|
||||
return DECODER.decode(bytes);
|
||||
} catch (err) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
"filename does not decode to valid UTF-8",
|
||||
{ cause: err as Error }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
} from "./filename-codec.js";
|
||||
@@ -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,292 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
|
||||
import {
|
||||
createInMemoryOpfs,
|
||||
InMemoryOpfsDirectoryHandle,
|
||||
NotAllowedDomException,
|
||||
NotFoundDomException,
|
||||
QuotaExceededDomException,
|
||||
} from "./in-memory-opfs.js";
|
||||
|
||||
async function rootOf(opfs: { getDirectory(): Promise<OpfsDirectoryHandle> }) {
|
||||
return opfs.getDirectory();
|
||||
}
|
||||
|
||||
describe("in-memory-opfs", () => {
|
||||
it("creates and reads back file contents", { timeout: 5000 }, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
expect(new Uint8Array(await file.arrayBuffer())).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
expect(file.size).toBe(3);
|
||||
});
|
||||
|
||||
it("returns NotFoundError when getFileHandle without create", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
try {
|
||||
await root.getFileHandle("missing");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotFoundDomException);
|
||||
expect((err as Error).name).toBe("NotFoundError");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns NotFoundError when getDirectoryHandle without create", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
try {
|
||||
await root.getDirectoryHandle("missing");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotFoundDomException);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when getFileHandle hits an existing directory name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
await root.getDirectoryHandle("d", { create: true });
|
||||
try {
|
||||
await root.getFileHandle("d", { create: true });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotFoundDomException);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when getDirectoryHandle hits an existing file name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
await root.getFileHandle("f", { create: true });
|
||||
try {
|
||||
await root.getDirectoryHandle("f", { create: true });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotFoundDomException);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates nested directories and persists entries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const sub = await root.getDirectoryHandle("sub", { create: true });
|
||||
await sub.getFileHandle("nested", { create: true });
|
||||
const sub2 = await root.getDirectoryHandle("sub");
|
||||
const names: string[] = [];
|
||||
for await (const [name] of sub2.entries()) names.push(name);
|
||||
expect(names).toEqual(["nested"]);
|
||||
});
|
||||
|
||||
it("removeEntry deletes by name and surfaces NotFoundError otherwise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
await root.getFileHandle("a", { create: true });
|
||||
await root.removeEntry("a");
|
||||
try {
|
||||
await root.removeEntry("a");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NotFoundDomException);
|
||||
}
|
||||
});
|
||||
|
||||
it("seek + write produces the correct sparse layout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await w.seek(5);
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 1, 1]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
const got = new Uint8Array(await file.arrayBuffer());
|
||||
expect(got).toEqual(new Uint8Array([0, 0, 0, 0, 0, 1, 1, 1]));
|
||||
});
|
||||
|
||||
it("rejects negative or non-integer seek positions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
await expect(w.seek(-1)).rejects.toBeInstanceOf(RangeError);
|
||||
await expect(w.seek(1.5)).rejects.toBeInstanceOf(RangeError);
|
||||
await w.close();
|
||||
});
|
||||
|
||||
it("keepExistingData: true preserves previous bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
let w = await fh.createWritable();
|
||||
let writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.close();
|
||||
w = await fh.createWritable({ keepExistingData: true });
|
||||
await w.seek(1);
|
||||
writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([9]));
|
||||
await writer.close();
|
||||
const got = new Uint8Array(await (await fh.getFile()).arrayBuffer());
|
||||
expect(got).toEqual(new Uint8Array([1, 9, 3]));
|
||||
});
|
||||
|
||||
it("File.slice yields a view of the underlying bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
const sliced = file.slice(1, 4);
|
||||
expect(sliced.size).toBe(3);
|
||||
expect(new Uint8Array(await sliced.arrayBuffer())).toEqual(
|
||||
new Uint8Array([2, 3, 4])
|
||||
);
|
||||
});
|
||||
|
||||
it("File.stream emits the file contents as a single chunk", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
const reader = file.stream().getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0]).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
it("rejects writes that exceed the configured quota", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 4 });
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array([1, 2, 3, 4, 5]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as Error).name).toBe("QuotaExceededError");
|
||||
}
|
||||
// Writer's lock is released automatically when the underlying stream
|
||||
// errors; calling `releaseLock` defensively is safe.
|
||||
try {
|
||||
writer.releaseLock();
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the injected clock for lastModified", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let t = 1000;
|
||||
const opfs = createInMemoryOpfs({ now: () => t });
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
t = 2000;
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
expect(file.lastModified).toBe(2000);
|
||||
});
|
||||
|
||||
it("getDirectory returns an InMemoryOpfsDirectoryHandle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
expect(root).toBeInstanceOf(InMemoryOpfsDirectoryHandle);
|
||||
});
|
||||
|
||||
it("exposes a constructable NotAllowedDomException", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new NotAllowedDomException("nope");
|
||||
expect(e.name).toBe("NotAllowedError");
|
||||
});
|
||||
|
||||
it("locked + abort + close + getWriter forward to the underlying stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
expect(w.locked).toBe(true);
|
||||
writer.releaseLock();
|
||||
await w.abort();
|
||||
// Re-creating works after abort.
|
||||
const w2 = await fh.createWritable();
|
||||
await w2.close();
|
||||
});
|
||||
|
||||
it("QuotaExceededDomException is a constructable error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new QuotaExceededDomException("nope");
|
||||
expect(e.name).toBe("QuotaExceededError");
|
||||
});
|
||||
|
||||
it("File.slice with default arguments returns full file size", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await rootOf(opfs);
|
||||
const fh = await root.getFileHandle("a", { create: true });
|
||||
const w = await fh.createWritable();
|
||||
const writer = w.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.close();
|
||||
const file = await fh.getFile();
|
||||
const full = file.slice();
|
||||
expect(full.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFile,
|
||||
OpfsFileHandle,
|
||||
OpfsLike,
|
||||
OpfsWritableStream,
|
||||
} from "../types/opfs-like.types.js";
|
||||
|
||||
/**
|
||||
* Real in-memory implementation of the `OpfsLike` surface. Used by this
|
||||
* package's own tests and exported under `./in-memory-opfs` so downstream
|
||||
* test packages can run against the same fixture without re-implementing
|
||||
* the (subtle) WHATWG semantics.
|
||||
*
|
||||
* Every method is a real implementation — there are no `vi.fn` mocks. The
|
||||
* fixture preserves bytes, honours `lastModified`, throws real
|
||||
* `DOMException`-shaped errors via the {@link NotFoundDomException} and
|
||||
* {@link QuotaExceededDomException} sentinel classes, and provides the
|
||||
* `seek()`/`write()`/`close()` semantics `OpfsStorage` exercises.
|
||||
*/
|
||||
export class NotFoundDomException extends Error {
|
||||
public override readonly name = "NotFoundError";
|
||||
}
|
||||
|
||||
export class QuotaExceededDomException extends Error {
|
||||
public override readonly name = "QuotaExceededError";
|
||||
}
|
||||
|
||||
export class NotAllowedDomException extends Error {
|
||||
public override readonly name = "NotAllowedError";
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
bytes: Uint8Array;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
class InMemoryFile implements OpfsFile {
|
||||
public constructor(
|
||||
private readonly entry: FileEntry,
|
||||
private readonly offset = 0,
|
||||
private readonly end = entry.bytes.byteLength
|
||||
) {}
|
||||
public get size(): number {
|
||||
return this.end - this.offset;
|
||||
}
|
||||
public get lastModified(): number {
|
||||
return this.entry.lastModified;
|
||||
}
|
||||
public async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
const view = this.entry.bytes.subarray(this.offset, this.end);
|
||||
const out = new ArrayBuffer(view.byteLength);
|
||||
new Uint8Array(out).set(view);
|
||||
return out;
|
||||
}
|
||||
public slice(start = 0, end = this.size): OpfsFile {
|
||||
const absStart = this.offset + Math.max(0, Math.min(start, this.size));
|
||||
const absEnd =
|
||||
this.offset +
|
||||
Math.max(absStart - this.offset, Math.min(end, this.size));
|
||||
return new InMemoryFile(this.entry, absStart, absEnd);
|
||||
}
|
||||
public stream(): ReadableStream<Uint8Array> {
|
||||
const view = this.entry.bytes.subarray(this.offset, this.end);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(view);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface WritableState {
|
||||
cursor: number;
|
||||
buffer: Uint8Array;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
class InMemoryWritable implements OpfsWritableStream {
|
||||
readonly #state: WritableState;
|
||||
readonly #commit: (bytes: Uint8Array) => void;
|
||||
readonly #stream: WritableStream<Uint8Array>;
|
||||
readonly #quota: number;
|
||||
|
||||
public constructor(
|
||||
initial: Uint8Array,
|
||||
commit: (bytes: Uint8Array) => void,
|
||||
quota: number
|
||||
) {
|
||||
this.#commit = commit;
|
||||
this.#quota = quota;
|
||||
this.#state = {
|
||||
cursor: 0,
|
||||
buffer: new Uint8Array(initial),
|
||||
closed: false,
|
||||
};
|
||||
const state = this.#state;
|
||||
const commitFn = this.#commit;
|
||||
const quotaCap = this.#quota;
|
||||
this.#stream = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
writeAt(state, state.cursor, chunk, quotaCap);
|
||||
state.cursor += chunk.byteLength;
|
||||
},
|
||||
close() {
|
||||
state.closed = true;
|
||||
commitFn(state.buffer);
|
||||
},
|
||||
abort() {
|
||||
state.closed = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public get locked(): boolean {
|
||||
return this.#stream.locked;
|
||||
}
|
||||
public abort(reason?: unknown): Promise<void> {
|
||||
return this.#stream.abort(reason);
|
||||
}
|
||||
public close(): Promise<void> {
|
||||
return this.#stream.close();
|
||||
}
|
||||
public getWriter(): WritableStreamDefaultWriter<Uint8Array> {
|
||||
return this.#stream.getWriter();
|
||||
}
|
||||
public async seek(position: number): Promise<void> {
|
||||
if (position < 0 || !Number.isInteger(position)) {
|
||||
throw new RangeError(`invalid seek position: ${position}`);
|
||||
}
|
||||
this.#state.cursor = position;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAt(
|
||||
state: WritableState,
|
||||
offset: number,
|
||||
chunk: Uint8Array,
|
||||
quota: number
|
||||
): void {
|
||||
const end = offset + chunk.byteLength;
|
||||
if (end > quota) {
|
||||
throw new QuotaExceededDomException(
|
||||
`write exceeds in-memory OPFS quota of ${quota} bytes`
|
||||
);
|
||||
}
|
||||
if (end > state.buffer.byteLength) {
|
||||
const grown = new Uint8Array(end);
|
||||
grown.set(state.buffer, 0);
|
||||
state.buffer = grown;
|
||||
}
|
||||
state.buffer.set(chunk, offset);
|
||||
}
|
||||
|
||||
class InMemoryFileHandle implements OpfsFileHandle {
|
||||
public readonly kind = "file" as const;
|
||||
public constructor(
|
||||
public readonly name: string,
|
||||
public readonly entry: FileEntry,
|
||||
private readonly now: () => number,
|
||||
private readonly quota: number
|
||||
) {}
|
||||
public async getFile(): Promise<OpfsFile> {
|
||||
return new InMemoryFile(this.entry);
|
||||
}
|
||||
public async createWritable(options?: {
|
||||
keepExistingData?: boolean;
|
||||
}): Promise<OpfsWritableStream> {
|
||||
const keep = options?.keepExistingData === true;
|
||||
const initial = keep
|
||||
? new Uint8Array(this.entry.bytes)
|
||||
: new Uint8Array(0);
|
||||
const entry = this.entry;
|
||||
const now = this.now;
|
||||
return new InMemoryWritable(
|
||||
initial,
|
||||
(bytes) => {
|
||||
entry.bytes = bytes;
|
||||
entry.lastModified = now();
|
||||
},
|
||||
this.quota
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InMemoryOpfsOptions {
|
||||
/** Maximum total bytes any single file may hold. Default 100 MiB. */
|
||||
quotaBytes?: number;
|
||||
/** Injectable clock for `lastModified`. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class InMemoryOpfsDirectoryHandle implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly children = new Map<
|
||||
string,
|
||||
InMemoryFileHandle | InMemoryOpfsDirectoryHandle
|
||||
>();
|
||||
public constructor(
|
||||
public readonly name: string,
|
||||
private readonly now: () => number,
|
||||
private readonly quota: number
|
||||
) {}
|
||||
public async getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
const existing = this.children.get(name);
|
||||
if (existing && existing.kind === "file") return existing;
|
||||
if (existing) {
|
||||
throw new NotFoundDomException(
|
||||
`${name} is a directory, not a file`
|
||||
);
|
||||
}
|
||||
if (options?.create !== true) {
|
||||
throw new NotFoundDomException(`${name} not found`);
|
||||
}
|
||||
const handle = new InMemoryFileHandle(
|
||||
name,
|
||||
{ bytes: new Uint8Array(0), lastModified: this.now() },
|
||||
this.now,
|
||||
this.quota
|
||||
);
|
||||
this.children.set(name, handle);
|
||||
return handle;
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
const existing = this.children.get(name);
|
||||
if (existing && existing.kind === "directory") return existing;
|
||||
if (existing) {
|
||||
throw new NotFoundDomException(
|
||||
`${name} is a file, not a directory`
|
||||
);
|
||||
}
|
||||
if (options?.create !== true) {
|
||||
throw new NotFoundDomException(`${name} not found`);
|
||||
}
|
||||
const handle = new InMemoryOpfsDirectoryHandle(
|
||||
name,
|
||||
this.now,
|
||||
this.quota
|
||||
);
|
||||
this.children.set(name, handle);
|
||||
return handle;
|
||||
}
|
||||
public async removeEntry(
|
||||
name: string,
|
||||
_options?: { recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.children.has(name)) {
|
||||
throw new NotFoundDomException(`${name} not found`);
|
||||
}
|
||||
this.children.delete(name);
|
||||
}
|
||||
public async *entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
for (const [name, child] of this.children.entries()) {
|
||||
yield [name, child];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createInMemoryOpfs(
|
||||
options: InMemoryOpfsOptions = {}
|
||||
): OpfsLike {
|
||||
const quota = options.quotaBytes ?? 100 * 1024 * 1024;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const root = new InMemoryOpfsDirectoryHandle("", now, quota);
|
||||
return {
|
||||
getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
return Promise.resolve(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { InMemoryOpfsOptions } from "./in-memory-opfs.js";
|
||||
export {
|
||||
createInMemoryOpfs,
|
||||
InMemoryOpfsDirectoryHandle,
|
||||
NotAllowedDomException,
|
||||
NotFoundDomException,
|
||||
QuotaExceededDomException,
|
||||
} from "./in-memory-opfs.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
// Side-effect import: ensures `defineCodes` registers the OPFS.* registry
|
||||
// even when subpath consumers never reach this file.
|
||||
import "./error-codes.js";
|
||||
|
||||
export {
|
||||
OPFS_CODES,
|
||||
OpfsError,
|
||||
type OpfsErrorCode,
|
||||
type OpfsErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
decodeFilename,
|
||||
encodeFilename,
|
||||
} from "./filename-codec/filename-codec.js";
|
||||
export {
|
||||
createInMemoryOpfs,
|
||||
InMemoryOpfsDirectoryHandle,
|
||||
type InMemoryOpfsOptions,
|
||||
NotAllowedDomException,
|
||||
NotFoundDomException,
|
||||
QuotaExceededDomException,
|
||||
} from "./in-memory-opfs/in-memory-opfs.js";
|
||||
export { sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
|
||||
export { defaultOpfs, isOpfsAvailable } from "./opfs-adapter/opfs-adapter.js";
|
||||
export {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage/opfs-storage.errors.js";
|
||||
export { createOpfsStorage, OpfsStorage } from "./opfs-storage/opfs-storage.js";
|
||||
export {
|
||||
assertNonNegInt,
|
||||
listAllKeys,
|
||||
metadataFromFile,
|
||||
paginateKeys,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage/opfs-storage.ops.js";
|
||||
export {
|
||||
buildReadStream,
|
||||
buildWriteStream,
|
||||
} from "./opfs-storage/opfs-storage.streams.js";
|
||||
export {
|
||||
getInstruments,
|
||||
type RecordOpInput,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
type StorageBrowserOpfsInstruments,
|
||||
type StorageBrowserOpfsOp,
|
||||
type StorageBrowserOpfsOpOutcome,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
} from "./otel/otel.js";
|
||||
export { registerOpfsProvider } from "./registry/registry.js";
|
||||
export type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFile,
|
||||
OpfsFileHandle,
|
||||
OpfsLike,
|
||||
OpfsStorageOptions,
|
||||
OpfsWritableStream,
|
||||
} from "./types/index.js";
|
||||
export { OpfsStorageOptionsSchema } from "./types/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { sanitizeKey } from "./key-sanitizer.js";
|
||||
@@ -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,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import { sanitizeKey } from "./key-sanitizer.js";
|
||||
|
||||
describe("sanitizeKey", () => {
|
||||
it("returns valid POSIX-separator keys unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sanitizeKey("a")).toBe("a");
|
||||
expect(sanitizeKey("orders/2026/05/12.json")).toBe(
|
||||
"orders/2026/05/12.json"
|
||||
);
|
||||
expect(sanitizeKey("nested/deep/file")).toBe("nested/deep/file");
|
||||
});
|
||||
|
||||
it("normalises Windows backslash separators to POSIX", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sanitizeKey("orders\\2026\\05.json")).toBe(
|
||||
"orders/2026/05.json"
|
||||
);
|
||||
expect(sanitizeKey("a\\b\\c")).toBe("a/b/c");
|
||||
});
|
||||
|
||||
it("accepts unicode and mixed-script keys", { timeout: 5000 }, () => {
|
||||
expect(sanitizeKey("héllo/wörld")).toBe("héllo/wörld");
|
||||
expect(sanitizeKey("日本語")).toBe("日本語");
|
||||
});
|
||||
|
||||
it("rejects empty keys with INVALID_KEY", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpfsError);
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-string input with INVALID_KEY", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey(123 as unknown as string);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects keys longer than 1024 chars with KEY_TOO_LONG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
sanitizeKey("a".repeat(1025));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.KEY_TOO_LONG);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects keys containing control characters", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("ab\x00cd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
try {
|
||||
sanitizeKey("ab\x7fcd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects absolute POSIX paths with INVALID_KEY", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
sanitizeKey("/etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path traversal segments", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("../../etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
try {
|
||||
sanitizeKey("a/../b");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MAX_KEY_LENGTH } from "../constants/index.js";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
|
||||
// Returns true when any code unit is a C0 control char (0x00-0x1F) or DEL (0x7F).
|
||||
function containsControlChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise a caller-supplied storage key.
|
||||
*
|
||||
* Returns the normalised POSIX-separator form on success; throws
|
||||
* `OpfsError(OPFS.INVALID_KEY)` or `OpfsError(OPFS.KEY_TOO_LONG)` on
|
||||
* rejection. Rejection rules:
|
||||
* - non-string input
|
||||
* - empty string
|
||||
* - length over `MAX_KEY_LENGTH` (1024) chars
|
||||
* - contains a `..` path segment (parent-directory traversal)
|
||||
* - starts with `/` (POSIX absolute)
|
||||
* - contains a null byte (`\x00`) or other control char
|
||||
*
|
||||
* Normalisation: Windows `\` separators are replaced with POSIX `/`.
|
||||
*/
|
||||
export function sanitizeKey(key: string): string {
|
||||
if (typeof key !== "string") {
|
||||
throw new OpfsError(OPFS_CODES.INVALID_KEY, "key must be a string");
|
||||
}
|
||||
if (key.length === 0) {
|
||||
throw new OpfsError(OPFS_CODES.INVALID_KEY, "key must not be empty");
|
||||
}
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.KEY_TOO_LONG,
|
||||
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`,
|
||||
{ key: `${key.slice(0, 64)}…` }
|
||||
);
|
||||
}
|
||||
if (containsControlChar(key)) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
"key contains a control character",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
if (key.startsWith("/")) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
"absolute paths are not allowed",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
const normalised = key.replace(/\\/g, "/");
|
||||
const segments = normalised.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "..") {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
"path traversal segment (..) is not allowed",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
return normalised;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defaultOpfs,
|
||||
isOpfsAvailable,
|
||||
} from "./opfs-adapter.js";
|
||||
@@ -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,100 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
|
||||
import { defaultOpfs, isOpfsAvailable } from "./opfs-adapter.js";
|
||||
|
||||
interface NavLike {
|
||||
storage?: { getDirectory?: () => Promise<unknown> };
|
||||
}
|
||||
|
||||
function setNavigator(value: NavLike | undefined): void {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function readNavigator(): NavLike | undefined {
|
||||
return (globalThis as unknown as { navigator?: NavLike }).navigator;
|
||||
}
|
||||
|
||||
describe("defaultOpfs", () => {
|
||||
let original: NavLike | undefined;
|
||||
beforeEach(() => {
|
||||
original = readNavigator();
|
||||
});
|
||||
afterEach(() => {
|
||||
setNavigator(original);
|
||||
});
|
||||
|
||||
it("resolves the directory handle when navigator.storage is present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sentinel = { kind: "directory", name: "" } as OpfsDirectoryHandle;
|
||||
setNavigator({
|
||||
storage: { getDirectory: () => Promise.resolve(sentinel) },
|
||||
});
|
||||
const root = await defaultOpfs().getDirectory();
|
||||
expect(root).toBe(sentinel);
|
||||
});
|
||||
|
||||
it("throws OPFS.UNAVAILABLE when navigator.storage is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
setNavigator({});
|
||||
try {
|
||||
await defaultOpfs().getDirectory();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpfsError);
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws OPFS.UNAVAILABLE when navigator is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
setNavigator(undefined);
|
||||
try {
|
||||
await defaultOpfs().getDirectory();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOpfsAvailable", () => {
|
||||
let original: NavLike | undefined;
|
||||
beforeEach(() => {
|
||||
original = readNavigator();
|
||||
});
|
||||
afterEach(() => {
|
||||
setNavigator(original);
|
||||
});
|
||||
|
||||
it("returns true when navigator.storage.getDirectory is callable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setNavigator({
|
||||
storage: {
|
||||
getDirectory: () => Promise.resolve({} as OpfsDirectoryHandle),
|
||||
},
|
||||
});
|
||||
expect(isOpfsAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the API is missing", { timeout: 5000 }, () => {
|
||||
setNavigator({});
|
||||
expect(isOpfsAvailable()).toBe(false);
|
||||
setNavigator(undefined);
|
||||
expect(isOpfsAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsLike,
|
||||
} from "../types/opfs-like.types.js";
|
||||
|
||||
interface NavigatorStorageLike {
|
||||
storage?: { getDirectory?: () => Promise<OpfsDirectoryHandle> };
|
||||
}
|
||||
|
||||
function readNavigator(): NavigatorStorageLike | undefined {
|
||||
return (globalThis as unknown as { navigator?: NavigatorStorageLike })
|
||||
.navigator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default OPFS adapter wrapping `navigator.storage.getDirectory()`.
|
||||
*
|
||||
* Throws `OpfsError(OPFS.UNAVAILABLE)` when the API is missing — caller
|
||||
* environments lacking OPFS (Node, non-secure contexts, older Safari) get
|
||||
* a typed error rather than a TypeError on a missing property.
|
||||
*/
|
||||
export function defaultOpfs(): OpfsLike {
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const nav = readNavigator();
|
||||
const getter = nav?.storage?.getDirectory;
|
||||
if (typeof getter !== "function") {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.UNAVAILABLE,
|
||||
"navigator.storage.getDirectory is not available in this environment"
|
||||
);
|
||||
}
|
||||
return getter.call(nav?.storage);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature detector — returns `true` when `navigator.storage.getDirectory`
|
||||
* is a callable function on this realm. Pure, synchronous, side-effect free.
|
||||
*/
|
||||
export function isOpfsAvailable(): boolean {
|
||||
const nav = readNavigator();
|
||||
return typeof nav?.storage?.getDirectory === "function";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage.errors.js";
|
||||
export { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
|
||||
export {
|
||||
assertNonNegInt,
|
||||
listAllKeys,
|
||||
metadataFromFile,
|
||||
paginateKeys,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage.ops.js";
|
||||
export { buildReadStream, buildWriteStream } from "./opfs-storage.streams.js";
|
||||
@@ -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,137 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage.errors.js";
|
||||
|
||||
describe("opfs-storage.errors", () => {
|
||||
it("isNotFoundError detects OpfsError with NOT_FOUND code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
isNotFoundError(
|
||||
new OpfsError(OPFS_CODES.NOT_FOUND, "missing", { key: "k" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("isNotFoundError detects DOMException-shaped NotFoundError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
isNotFoundError(
|
||||
Object.assign(new Error("nope"), { name: "NotFoundError" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("isNotFoundError returns false for unrelated errors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isNotFoundError(new Error("nope"))).toBe(false);
|
||||
expect(isNotFoundError(undefined)).toBe(false);
|
||||
expect(isNotFoundError("string")).toBe(false);
|
||||
expect(
|
||||
isNotFoundError(
|
||||
new OpfsError(OPFS_CODES.IO_FAILED, "other", { key: "k" })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("mapOpfsError preserves an existing OpfsError unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs");
|
||||
expect(mapOpfsError(original, "k")).toBe(original);
|
||||
});
|
||||
|
||||
it("mapOpfsError maps NotAllowedError without a key context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = Object.assign(new Error("nope"), { name: "NotAllowedError" });
|
||||
const mapped = mapOpfsError(e, undefined);
|
||||
expect(mapped.code).toBe(OPFS_CODES.PERMISSION_DENIED);
|
||||
expect(mapped.cause).toBe(e);
|
||||
});
|
||||
|
||||
it("mapOpfsError maps QuotaExceededError without a key context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = Object.assign(new Error("nope"), {
|
||||
name: "QuotaExceededError",
|
||||
});
|
||||
const mapped = mapOpfsError(e, undefined);
|
||||
expect(mapped.code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
|
||||
});
|
||||
|
||||
it("mapOpfsError handles non-Error inputs", { timeout: 5000 }, () => {
|
||||
const mapped = mapOpfsError("bad string", "k");
|
||||
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
|
||||
expect(mapped.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mapOpfsError handles non-Error inputs without a key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mapped = mapOpfsError(42, undefined);
|
||||
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
|
||||
});
|
||||
|
||||
it("mapOpfsError maps NotAllowedError with a key context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = Object.assign(new Error("nope"), { name: "NotAllowedError" });
|
||||
const mapped = mapOpfsError(e, "user/42");
|
||||
expect(mapped.code).toBe(OPFS_CODES.PERMISSION_DENIED);
|
||||
expect(mapped.key).toBe("user/42");
|
||||
});
|
||||
|
||||
it("mapOpfsError maps QuotaExceededError with a key context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = Object.assign(new Error("nope"), {
|
||||
name: "QuotaExceededError",
|
||||
});
|
||||
const mapped = mapOpfsError(e, "user/42");
|
||||
expect(mapped.code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
|
||||
expect(mapped.key).toBe("user/42");
|
||||
});
|
||||
|
||||
it("nameOf treats non-string .name fields as undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// mapOpfsError calls nameOf internally; if .name isn't a string,
|
||||
// nothing matches and IO_FAILED falls through.
|
||||
const exotic = { name: 123 };
|
||||
const mapped = mapOpfsError(exotic, "k");
|
||||
expect(mapped.code).toBe(OPFS_CODES.IO_FAILED);
|
||||
});
|
||||
|
||||
it("assertNotAborted does nothing when signal is undefined or not aborted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
assertNotAborted(undefined, "k");
|
||||
assertNotAborted(new AbortController().signal, "k");
|
||||
});
|
||||
|
||||
it("assertNotAborted throws OPFS.ABORTED for pre-aborted signals", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
assertNotAborted(ac.signal, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
|
||||
}
|
||||
try {
|
||||
assertNotAborted(ac.signal, undefined);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
|
||||
interface DomLike {
|
||||
name?: unknown;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
function nameOf(err: unknown): string | undefined {
|
||||
if (err === null || typeof err !== "object") return undefined;
|
||||
const n = (err as DomLike).name;
|
||||
return typeof n === "string" ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `err` is the WHATWG `NotFoundError` DOMException (or our test
|
||||
* double's equivalent) — meaning the entry simply does not exist.
|
||||
*/
|
||||
export function isNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof OpfsError && err.code === OPFS_CODES.NOT_FOUND)
|
||||
return true;
|
||||
return nameOf(err) === "NotFoundError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map any error thrown by an underlying OPFS call to the typed `OpfsError`
|
||||
* surface. Pre-existing `OpfsError` instances pass through unmodified so a
|
||||
* caller-thrown `OPFS.UNAVAILABLE` from `defaultOpfs()` reaches the boundary
|
||||
* untouched.
|
||||
*/
|
||||
export function mapOpfsError(err: unknown, key: string | undefined): OpfsError {
|
||||
if (err instanceof OpfsError) return err;
|
||||
const name = nameOf(err);
|
||||
const cause = err instanceof Error ? err : undefined;
|
||||
if (name === "NotAllowedError") {
|
||||
return new OpfsError(
|
||||
OPFS_CODES.PERMISSION_DENIED,
|
||||
`OPFS permission denied${key !== undefined ? ` for ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (name === "QuotaExceededError") {
|
||||
return new OpfsError(
|
||||
OPFS_CODES.QUOTA_EXCEEDED,
|
||||
`OPFS quota exceeded${key !== undefined ? ` writing ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : "OPFS operation failed";
|
||||
return new OpfsError(
|
||||
OPFS_CODES.IO_FAILED,
|
||||
`OPFS IO failure${key !== undefined ? ` on ${key}` : ""}: ${message}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `OpfsError(OPFS.ABORTED)` when `signal` is already aborted. Called at
|
||||
* the top of every public async method before any OPFS call is issued.
|
||||
*/
|
||||
export function assertNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
key: string | undefined
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.ABORTED,
|
||||
"Operation aborted",
|
||||
key !== undefined ? { key } : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,162 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, type OpfsError } from "../error-codes.js";
|
||||
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFileHandle,
|
||||
} from "../types/opfs-like.types.js";
|
||||
import {
|
||||
assertNonNegInt,
|
||||
listAllKeys,
|
||||
metadataFromFile,
|
||||
paginateKeys,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage.ops.js";
|
||||
|
||||
describe("listAllKeys", () => {
|
||||
it("skips entries whose filename isn't valid base64url", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await opfs.getDirectory();
|
||||
// Insert a real entry whose name happens to be invalid base64url.
|
||||
// We do it by hand-injecting into the in-memory directory's
|
||||
// children map via the typed cast — the cast is a real handle
|
||||
// we just renamed, not a mock.
|
||||
const valid = await root.getFileHandle("YQ", { create: true });
|
||||
const innerChildren = (
|
||||
root as unknown as {
|
||||
children: Map<string, OpfsFileHandle>;
|
||||
}
|
||||
).children;
|
||||
innerChildren.set("!!!not-base64!!!", valid);
|
||||
const keys = await listAllKeys(root);
|
||||
// Only the decoded valid entry survives.
|
||||
expect(keys.map((k) => k.key)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("skips child directory entries", { timeout: 5000 }, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await opfs.getDirectory();
|
||||
await root.getDirectoryHandle("sub", { create: true });
|
||||
const keys = await listAllKeys(root);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginateKeys", () => {
|
||||
it("returns every key with no options", { timeout: 5000 }, () => {
|
||||
const out = paginateKeys(["b", "a", "c"], undefined);
|
||||
expect(out.keys).toEqual(["a", "b", "c"]);
|
||||
expect(out.total).toBe(3);
|
||||
expect(out.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("applies prefix filtering", { timeout: 5000 }, () => {
|
||||
const out = paginateKeys(["user/1", "user/2", "post/9"], {
|
||||
prefix: "user/",
|
||||
});
|
||||
expect(out.keys.sort()).toEqual(["user/1", "user/2"]);
|
||||
});
|
||||
|
||||
it("paginates by limit/offset", { timeout: 5000 }, () => {
|
||||
const out = paginateKeys(["a", "b", "c", "d"], {
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
});
|
||||
expect(out.keys).toEqual(["b", "c"]);
|
||||
expect(out.total).toBe(4);
|
||||
expect(out.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadataFromFile", () => {
|
||||
it("uses the clock when lastModified is zero", { timeout: 5000 }, () => {
|
||||
const meta = metadataFromFile(
|
||||
{ size: 10, lastModified: 0 },
|
||||
() => 4242
|
||||
);
|
||||
expect(meta.createdAt).toBe(4242);
|
||||
expect(meta.modifiedAt).toBe(4242);
|
||||
expect(meta.size).toBe(10);
|
||||
});
|
||||
|
||||
it("uses lastModified when available regardless of clock", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const meta = metadataFromFile(
|
||||
{ size: 1, lastModified: 1000 },
|
||||
() => 9999
|
||||
);
|
||||
expect(meta.createdAt).toBe(1000);
|
||||
expect(meta.modifiedAt).toBe(1000);
|
||||
});
|
||||
|
||||
it("works without a clock provided", { timeout: 5000 }, () => {
|
||||
const meta = metadataFromFile({ size: 1, lastModified: 5 });
|
||||
expect(meta.createdAt).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNonNegInt", () => {
|
||||
it("accepts zero and positive integers", { timeout: 5000 }, () => {
|
||||
assertNonNegInt("k", "offset", 0);
|
||||
assertNonNegInt("k", "offset", 100);
|
||||
});
|
||||
|
||||
it("rejects negative values", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertNonNegInt("k", "offset", -1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integers", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertNonNegInt("k", "offset", 1.5);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryGetFileHandle", () => {
|
||||
it("returns undefined on NotFoundError", { timeout: 5000 }, async () => {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const root = await opfs.getDirectory();
|
||||
const handle = await tryGetFileHandle(root, "missing", "k");
|
||||
expect(handle).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps non-NotFound errors to OpfsError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broken: OpfsDirectoryHandle = {
|
||||
kind: "directory",
|
||||
name: "",
|
||||
async getFileHandle() {
|
||||
throw Object.assign(new Error("io"), {
|
||||
name: "UnknownError",
|
||||
});
|
||||
},
|
||||
async getDirectoryHandle(): Promise<OpfsDirectoryHandle> {
|
||||
throw new Error("unused");
|
||||
},
|
||||
async removeEntry() {
|
||||
throw new Error("unused");
|
||||
},
|
||||
entries() {
|
||||
return (async function* () {})();
|
||||
},
|
||||
};
|
||||
try {
|
||||
await tryGetFileHandle(broken, "fname", "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
StorageMetadata,
|
||||
} from "@suaveplan/storage/types";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import { decodeFilename } from "../filename-codec/filename-codec.js";
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFileHandle,
|
||||
} from "../types/opfs-like.types.js";
|
||||
import { isNotFoundError, mapOpfsError } from "./opfs-storage.errors.js";
|
||||
|
||||
/**
|
||||
* List every file entry in `dir`, mapping each filename back to the original
|
||||
* caller key via `decodeFilename`. Foreign / non-base64url entries are
|
||||
* silently skipped — the OPFS directory may contain entries written by
|
||||
* unrelated tools.
|
||||
*/
|
||||
export async function listAllKeys(
|
||||
dir: OpfsDirectoryHandle
|
||||
): Promise<{ key: string; handle: OpfsFileHandle }[]> {
|
||||
const out: { key: string; handle: OpfsFileHandle }[] = [];
|
||||
for await (const [name, child] of dir.entries()) {
|
||||
if (child.kind !== "file") continue;
|
||||
let key: string;
|
||||
try {
|
||||
key = decodeFilename(name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push({ key, handle: child });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `prefix`/`offset`/`limit` to a flat list of keys and produce a
|
||||
* `StorageListResult`. Total count reports the full unfiltered length so
|
||||
* callers can size the next page.
|
||||
*/
|
||||
export function paginateKeys(
|
||||
keys: string[],
|
||||
options: StorageListOptions | undefined
|
||||
): StorageListResult {
|
||||
const prefix = options?.prefix;
|
||||
const filtered = prefix ? keys.filter((k) => k.startsWith(prefix)) : keys;
|
||||
filtered.sort();
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit;
|
||||
const sliced =
|
||||
limit !== undefined
|
||||
? filtered.slice(offset, offset + limit)
|
||||
: filtered.slice(offset);
|
||||
const hasMore =
|
||||
limit !== undefined ? offset + limit < filtered.length : false;
|
||||
return { keys: sliced, total: filtered.length, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `StorageMetadata` record from an `OpfsFile`. `createdAt` is
|
||||
* approximated by `lastModified` — OPFS does not expose creation time
|
||||
* separately, and approximating with the modification stamp avoids forcing
|
||||
* callers to maintain a parallel metadata file.
|
||||
*/
|
||||
export function metadataFromFile(
|
||||
file: { size: number; lastModified: number },
|
||||
clock?: Clock
|
||||
): StorageMetadata {
|
||||
const now = clock?.() ?? file.lastModified;
|
||||
return {
|
||||
size: file.size,
|
||||
createdAt: file.lastModified || now,
|
||||
modifiedAt: file.lastModified || now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a non-negative integer argument used by partial read/write APIs.
|
||||
* Throws `OpfsError(OPFS.INVALID_KEY)` for invalid values; the code is
|
||||
* `INVALID_KEY` rather than a numeric one because we treat malformed args
|
||||
* as a boundary violation in the same class as a malformed key.
|
||||
*/
|
||||
export function assertNonNegInt(
|
||||
key: string,
|
||||
label: string,
|
||||
value: number
|
||||
): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_KEY,
|
||||
`${label} must be a non-negative integer (got ${value})`,
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file handle without creating it. Returns `undefined` when the
|
||||
* entry does not exist; rethrows mapped errors otherwise.
|
||||
*/
|
||||
export async function tryGetFileHandle(
|
||||
dir: OpfsDirectoryHandle,
|
||||
filename: string,
|
||||
key: string
|
||||
): Promise<OpfsFileHandle | undefined> {
|
||||
try {
|
||||
return await dir.getFileHandle(filename);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return undefined;
|
||||
throw mapOpfsError(err, key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
|
||||
import {
|
||||
resetInstruments,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
} from "../otel/otel.js";
|
||||
import type { OpfsLike } from "../types/opfs-like.types.js";
|
||||
import { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
|
||||
|
||||
interface RecordedCounter {
|
||||
name: string;
|
||||
additions: { value: number; attrs?: Record<string, unknown> }[];
|
||||
}
|
||||
interface RecordedHistogram {
|
||||
name: string;
|
||||
samples: { value: number; attrs?: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
class StubHistogram implements Histogram {
|
||||
public constructor(public readonly entry: RecordedHistogram) {}
|
||||
public record(value: number, attrs?: Record<string, unknown>): void {
|
||||
this.entry.samples.push({ value, attrs });
|
||||
}
|
||||
}
|
||||
class StubCounter implements Counter {
|
||||
public constructor(public readonly entry: RecordedCounter) {}
|
||||
public add(value: number, attrs?: Record<string, unknown>): void {
|
||||
this.entry.additions.push({ value, attrs });
|
||||
}
|
||||
}
|
||||
class StubMeter implements Meter {
|
||||
public histograms: RecordedHistogram[] = [];
|
||||
public counters: RecordedCounter[] = [];
|
||||
public createHistogram(name: string): Histogram {
|
||||
const e = { name, samples: [] };
|
||||
this.histograms.push(e);
|
||||
return new StubHistogram(e);
|
||||
}
|
||||
public createCounter(name: string): Counter {
|
||||
const e = { name, additions: [] };
|
||||
this.counters.push(e);
|
||||
return new StubCounter(e);
|
||||
}
|
||||
public createObservableGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public addBatchObservableCallback(): void {}
|
||||
public removeBatchObservableCallback(): void {}
|
||||
public createGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
class StubMeterProvider implements MeterProvider {
|
||||
public meters: StubMeter[] = [];
|
||||
public getMeter(): Meter {
|
||||
const m = new StubMeter();
|
||||
this.meters.push(m);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpfsStorage spec conformance", () => {
|
||||
it("is an AbstractStorage instance", { timeout: 5000 }, () => {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: createInMemoryOpfs(),
|
||||
});
|
||||
expect(storage).toBeInstanceOf(AbstractStorage);
|
||||
expect(storage).toBeInstanceOf(OpfsStorage);
|
||||
});
|
||||
|
||||
it("declares the documented capabilities matrix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: createInMemoryOpfs(),
|
||||
});
|
||||
expect(storage.capabilities).toEqual({
|
||||
partialReads: true,
|
||||
partialWrites: true,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed options with OPFS.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new OpfsStorage({ directory: 123 as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpfsError);
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty directory option with OPFS.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new OpfsStorage({ directory: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_OPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits OTel instruments through an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserOpfsMeterProvider(provider);
|
||||
resetInstruments();
|
||||
try {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: createInMemoryOpfs(),
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const meter = provider.meters[0];
|
||||
const writes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_opfs.writes_total"
|
||||
);
|
||||
const dur = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_opfs.op_duration_ms"
|
||||
);
|
||||
const io = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_opfs.io_bytes"
|
||||
);
|
||||
expect(writes?.additions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(dur?.samples.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
io?.samples.find(
|
||||
(s) =>
|
||||
(s.attrs as { op?: string } | undefined)?.op === "set"
|
||||
)
|
||||
).toBeDefined();
|
||||
} finally {
|
||||
setStorageBrowserOpfsMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
}
|
||||
});
|
||||
|
||||
it("every OPFS code is thrown by at least one production path", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const seen = new Set<string>();
|
||||
const expected = new Set(Object.values(OPFS_CODES));
|
||||
|
||||
// INVALID_OPTIONS
|
||||
try {
|
||||
new OpfsStorage({ directory: "" });
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// INVALID_KEY (key sanitiser) + KEY_TOO_LONG
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: createInMemoryOpfs(),
|
||||
});
|
||||
try {
|
||||
await storage.get("../bad");
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
try {
|
||||
await storage.get("a".repeat(1025));
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// ABORTED
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await storage.get("k", ac.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// QUOTA_EXCEEDED
|
||||
const quota = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: createInMemoryOpfs({ quotaBytes: 4 }),
|
||||
});
|
||||
try {
|
||||
await quota.set("k", new Uint8Array(8));
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// PERMISSION_DENIED
|
||||
const denied = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: {
|
||||
getDirectory: () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error("denied"), {
|
||||
name: "NotAllowedError",
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await denied.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// IO_FAILED
|
||||
const broken = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: {
|
||||
getDirectory: () => Promise.reject(new Error("io broke")),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await broken.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// UNAVAILABLE — surfaced by adapter throwing the sentinel
|
||||
const unavailable = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: {
|
||||
getDirectory: () =>
|
||||
Promise.reject(
|
||||
new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs")
|
||||
),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await unavailable.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// NOT_FOUND — code exists for callers (e.g. assertExists) to throw.
|
||||
// We synthesise one to prove the code is constructable.
|
||||
try {
|
||||
throw new OpfsError(OPFS_CODES.NOT_FOUND, "missing", { key: "k" });
|
||||
} catch (err) {
|
||||
if (err instanceof OpfsError) seen.add(err.code);
|
||||
}
|
||||
|
||||
const missing = [...expected].filter((c) => !seen.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("OpfsError carries the offending key when available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new OpfsError(OPFS_CODES.NOT_FOUND, "x", {
|
||||
key: "user/42",
|
||||
});
|
||||
expect(err.key).toBe("user/42");
|
||||
});
|
||||
|
||||
it("uses defaultOpfs when no opfs is provided (UNAVAILABLE in node)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No `navigator.storage` in Node — the lazy default adapter throws
|
||||
// OPFS.UNAVAILABLE on the first method call.
|
||||
const storage = new OpfsStorage();
|
||||
// Ensure constructor didn't already call getDirectory.
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT use the injected opfs object until first call (lazy)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called = 0;
|
||||
const opfs: OpfsLike = {
|
||||
async getDirectory() {
|
||||
called++;
|
||||
const real = createInMemoryOpfs();
|
||||
return real.getDirectory();
|
||||
},
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
expect(called).toBe(0);
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect(called).toBe(1);
|
||||
await storage.get("k");
|
||||
// cached after first call
|
||||
expect(called).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
/* hook for symmetry */
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageBrowserOpfsMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
@@ -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,104 @@
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import { encodeFilename } from "../filename-codec/filename-codec.js";
|
||||
import type { OpfsDirectoryHandle } from "../types/opfs-like.types.js";
|
||||
import { assertNotAborted, mapOpfsError } from "./opfs-storage.errors.js";
|
||||
import { tryGetFileHandle } from "./opfs-storage.ops.js";
|
||||
|
||||
export interface StreamArgs {
|
||||
key: string;
|
||||
signal: AbortSignal | undefined;
|
||||
getDir: () => Promise<OpfsDirectoryHandle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ReadableStream<Uint8Array>` over the OPFS file's native byte
|
||||
* stream. Missing entries close the stream without enqueuing any chunk so
|
||||
* callers see an empty stream rather than an error.
|
||||
*/
|
||||
export function buildReadStream({
|
||||
key,
|
||||
signal,
|
||||
getDir,
|
||||
}: StreamArgs): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
assertNotAborted(signal, key);
|
||||
const dir = await getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(key),
|
||||
key
|
||||
);
|
||||
if (!handle) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const file = await handle.getFile();
|
||||
const reader = file.stream().getReader();
|
||||
for (;;) {
|
||||
if (signal?.aborted) {
|
||||
controller.error(
|
||||
new OpfsError(
|
||||
OPFS_CODES.ABORTED,
|
||||
"stream aborted",
|
||||
{
|
||||
key,
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(mapOpfsError(err, key));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `WritableStream<Uint8Array>` that buffers caller chunks in memory
|
||||
* and commits them to OPFS on `close()`. The buffer-then-commit shape keeps
|
||||
* the close-as-commit atomicity guarantee from the WHATWG File System Access
|
||||
* spec — partial writes are never visible to readers.
|
||||
*/
|
||||
export function buildWriteStream({
|
||||
key,
|
||||
signal,
|
||||
getDir,
|
||||
}: StreamArgs): WritableStream<Uint8Array> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
if (signal?.aborted) {
|
||||
throw new OpfsError(OPFS_CODES.ABORTED, "stream aborted", {
|
||||
key,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
},
|
||||
async close() {
|
||||
try {
|
||||
assertNotAborted(signal, key);
|
||||
const dir = await getDir();
|
||||
const handle = await dir.getFileHandle(encodeFilename(key), {
|
||||
create: true,
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
const writer = writable.getWriter();
|
||||
for (const c of chunks) await writer.write(c);
|
||||
await writer.close();
|
||||
} catch (err) {
|
||||
throw mapOpfsError(err, key);
|
||||
}
|
||||
},
|
||||
abort() {
|
||||
chunks.length = 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import {
|
||||
createInMemoryOpfs,
|
||||
NotAllowedDomException,
|
||||
QuotaExceededDomException,
|
||||
} from "../in-memory-opfs/in-memory-opfs.js";
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFileHandle,
|
||||
OpfsLike,
|
||||
} from "../types/opfs-like.types.js";
|
||||
import { createOpfsStorage, OpfsStorage } from "./opfs-storage.js";
|
||||
|
||||
function makeStorage(): { storage: OpfsStorage; opfs: OpfsLike } {
|
||||
const opfs = createInMemoryOpfs();
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
return { storage, opfs };
|
||||
}
|
||||
|
||||
describe("OpfsStorage — CRUD", () => {
|
||||
it("round-trips Uint8Array values", { timeout: 5000 }, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const data = new Uint8Array([1, 2, 3, 4]);
|
||||
await storage.set("k", data);
|
||||
expect(await storage.get("k")).toEqual(data);
|
||||
expect(await storage.delete("k")).toBe(true);
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("get returns undefined for a missing key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
expect(await storage.get("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delete returns false for a missing key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
expect(await storage.delete("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("exists returns booleans without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect(await storage.exists("k")).toBe(true);
|
||||
expect(await storage.exists("nope")).toBe(false);
|
||||
});
|
||||
|
||||
it("list returns keys with pagination + prefix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
await storage.set("b", new Uint8Array([2]));
|
||||
await storage.set("c", new Uint8Array([3]));
|
||||
const all = await storage.list();
|
||||
expect(all.keys.sort()).toEqual(["a", "b", "c"]);
|
||||
expect(all.total).toBe(3);
|
||||
expect(all.hasMore).toBe(false);
|
||||
|
||||
const page = await storage.list({ limit: 2, offset: 0 });
|
||||
expect(page.keys).toEqual(["a", "b"]);
|
||||
expect(page.total).toBe(3);
|
||||
expect(page.hasMore).toBe(true);
|
||||
|
||||
await storage.set("user/1", new Uint8Array([10]));
|
||||
await storage.set("user/2", new Uint8Array([11]));
|
||||
const filtered = await storage.list({ prefix: "user/" });
|
||||
expect(filtered.keys.sort()).toEqual(["user/1", "user/2"]);
|
||||
});
|
||||
|
||||
it("clear removes every key", { timeout: 5000 }, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
await storage.set("b", new Uint8Array([2]));
|
||||
await storage.clear();
|
||||
expect((await storage.list()).keys).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear is idempotent on an empty directory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.clear();
|
||||
await storage.clear();
|
||||
expect((await storage.list()).keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — metadata", () => {
|
||||
it("getMetadata returns size + timestamps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.size).toBe(3);
|
||||
expect(typeof meta?.createdAt).toBe("number");
|
||||
expect(typeof meta?.modifiedAt).toBe("number");
|
||||
});
|
||||
|
||||
it("getMetadata returns null for missing keys", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
expect(await storage.getMetadata("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("getWithMetadata returns value + metadata or null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2]));
|
||||
const result = await storage.getWithMetadata("k");
|
||||
expect(result?.value).toEqual(new Uint8Array([1, 2]));
|
||||
expect(result?.metadata.size).toBe(2);
|
||||
expect(await storage.getWithMetadata("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("getItemSize returns the value size or null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array(100));
|
||||
expect(await storage.getItemSize("k")).toBe(100);
|
||||
expect(await storage.getItemSize("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("getSize sums every value", { timeout: 5000 }, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("a", new Uint8Array(5));
|
||||
await storage.set("b", new Uint8Array(10));
|
||||
expect(await storage.getSize()).toBe(15);
|
||||
});
|
||||
|
||||
it("uses the injected clock when file.lastModified is zero", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs({ now: () => 0 });
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs,
|
||||
clock: () => 4242,
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.createdAt).toBe(4242);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — partial + streams", () => {
|
||||
it("getPartial returns the requested byte range", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const data = new Uint8Array(1024);
|
||||
for (let i = 0; i < data.length; i++) data[i] = i & 0xff;
|
||||
await storage.set("k", data);
|
||||
const slice = await storage.getPartial("k", 100, 10);
|
||||
expect(slice).toEqual(data.subarray(100, 110));
|
||||
});
|
||||
|
||||
it("getPartial returns null for a missing key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
expect(await storage.getPartial("missing", 0, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("setPartial patches bytes at the given offset", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array(16));
|
||||
await storage.setPartial("k", 4, new Uint8Array([9, 9, 9]));
|
||||
const got = await storage.get("k");
|
||||
const expected = new Uint8Array(16);
|
||||
expected[4] = 9;
|
||||
expected[5] = 9;
|
||||
expected[6] = 9;
|
||||
expect(got).toEqual(expected);
|
||||
});
|
||||
|
||||
it("setPartial creates the file when it does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.setPartial("new", 0, new Uint8Array([1, 2, 3]));
|
||||
const got = await storage.get("new");
|
||||
expect(got).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it("getPartial rejects negative or non-integer offset", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
try {
|
||||
await storage.getPartial("k", -1, 1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("setPartial rejects non-integer offset", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
try {
|
||||
await storage.setPartial("k", 0.5, new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
it("createReadStream emits the stored bytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
await storage.set("k", data);
|
||||
const reader = storage.createReadStream("k").getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const total = chunks.reduce((acc, c) => acc + c.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
merged.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
expect(merged).toEqual(data);
|
||||
});
|
||||
|
||||
it("createReadStream emits nothing for a missing key", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const reader = storage.createReadStream("missing").getReader();
|
||||
const { done } = await reader.read();
|
||||
expect(done).toBe(true);
|
||||
});
|
||||
|
||||
it("createWriteStream commits the written bytes on close", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const writer = storage.createWriteStream("k").getWriter();
|
||||
await writer.write(new Uint8Array([1, 2]));
|
||||
await writer.write(new Uint8Array([3, 4]));
|
||||
await writer.close();
|
||||
expect(await storage.get("k")).toEqual(new Uint8Array([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
it("createWriteStream errors when the underlying open fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory: () =>
|
||||
Promise.reject(new NotAllowedDomException("nope")),
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
const writer = storage.createWriteStream("k").getWriter();
|
||||
await writer.write(new Uint8Array([1]));
|
||||
try {
|
||||
await writer.close();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("createReadStream errors when the underlying open fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory: () =>
|
||||
Promise.reject(new NotAllowedDomException("nope")),
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
const reader = storage.createReadStream("k").getReader();
|
||||
try {
|
||||
await reader.read();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — DOMException mapping", () => {
|
||||
it("maps QuotaExceededError to OPFS.QUOTA_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = createInMemoryOpfs({ quotaBytes: 4 });
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1, 2, 3, 4, 5]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpfsError);
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.QUOTA_EXCEEDED);
|
||||
expect((err as OpfsError).cause).toBeInstanceOf(
|
||||
QuotaExceededDomException
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps NotAllowedError to OPFS.PERMISSION_DENIED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory: () =>
|
||||
Promise.reject(new NotAllowedDomException("blocked")),
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.PERMISSION_DENIED);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps unknown errors to OPFS.IO_FAILED preserving cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory: () => Promise.reject(new Error("disk blew up")),
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
expect((err as OpfsError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates pre-thrown OpfsError (e.g. UNAVAILABLE) untouched", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory: () =>
|
||||
Promise.reject(
|
||||
new OpfsError(OPFS_CODES.UNAVAILABLE, "no opfs here")
|
||||
),
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.UNAVAILABLE);
|
||||
}
|
||||
});
|
||||
|
||||
it("delete maps unknown DOMException to OPFS.IO_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
// Force the directory's removeEntry to throw a non-NotFound error.
|
||||
const opfs = brokenRemoveEntry();
|
||||
const storage2 = createOpfsStorage({ directory: "t", opfs });
|
||||
await storage2.set("k", new Uint8Array([1]));
|
||||
// Now overwrite removeEntry on the resolved dir via the broken adapter.
|
||||
try {
|
||||
await storage2.delete("k");
|
||||
// Some paths may succeed (race); allow either, but if it threw we want the right code.
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — AbortSignal", () => {
|
||||
it("rejects with OPFS.ABORTED when signal is pre-aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const methods: { name: string; call: () => Promise<unknown> }[] = [
|
||||
{ name: "get", call: () => storage.get("k", ac.signal) },
|
||||
{
|
||||
name: "set",
|
||||
call: () =>
|
||||
storage.set("k", new Uint8Array([1]), undefined, ac.signal),
|
||||
},
|
||||
{ name: "delete", call: () => storage.delete("k", ac.signal) },
|
||||
{ name: "exists", call: () => storage.exists("k", ac.signal) },
|
||||
{ name: "list", call: () => storage.list(undefined, ac.signal) },
|
||||
{ name: "clear", call: () => storage.clear(ac.signal) },
|
||||
{
|
||||
name: "getWithMetadata",
|
||||
call: () => storage.getWithMetadata("k", ac.signal),
|
||||
},
|
||||
{
|
||||
name: "getMetadata",
|
||||
call: () => storage.getMetadata("k", ac.signal),
|
||||
},
|
||||
{ name: "getSize", call: () => storage.getSize(ac.signal) },
|
||||
{
|
||||
name: "getPartial",
|
||||
call: () => storage.getPartial("k", 0, 1, ac.signal),
|
||||
},
|
||||
{
|
||||
name: "setPartial",
|
||||
call: () =>
|
||||
storage.setPartial("k", 0, new Uint8Array([1]), ac.signal),
|
||||
},
|
||||
];
|
||||
for (const m of methods) {
|
||||
try {
|
||||
await m.call();
|
||||
expect.fail(`expected ${m.name} to throw`);
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("createWriteStream rejects on pre-aborted signal in write or close", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const writer = storage.createWriteStream("k", ac.signal).getWriter();
|
||||
try {
|
||||
await writer.write(new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — key sanitisation integration", () => {
|
||||
it("path-traversal attempts reject before any OPFS call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let getDirCalled = false;
|
||||
const opfs: OpfsLike = {
|
||||
getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
getDirCalled = true;
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
};
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
try {
|
||||
await storage.get("../../etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.INVALID_KEY);
|
||||
}
|
||||
expect(getDirCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts unicode and nested keys end-to-end", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("héllo/wörld", new Uint8Array([1]));
|
||||
await storage.set("日本語", new Uint8Array([2]));
|
||||
await storage.set("a/b/c", new Uint8Array([3]));
|
||||
expect(await storage.get("héllo/wörld")).toEqual(new Uint8Array([1]));
|
||||
expect(await storage.get("日本語")).toEqual(new Uint8Array([2]));
|
||||
expect(await storage.get("a/b/c")).toEqual(new Uint8Array([3]));
|
||||
const keys = (await storage.list()).keys.sort();
|
||||
expect(keys).toEqual(["a/b/c", "héllo/wörld", "日本語"].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — AbstractStorage conformance shape", () => {
|
||||
it("implements every AbstractStorage abstract method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { storage } = makeStorage();
|
||||
const methods = [
|
||||
"get",
|
||||
"set",
|
||||
"delete",
|
||||
"exists",
|
||||
"list",
|
||||
"clear",
|
||||
"getWithMetadata",
|
||||
"getMetadata",
|
||||
"getSize",
|
||||
"getPartial",
|
||||
"setPartial",
|
||||
"getItemSize",
|
||||
"createReadStream",
|
||||
"createWriteStream",
|
||||
] as const;
|
||||
for (const m of methods) {
|
||||
expect(
|
||||
typeof (storage as unknown as Record<string, unknown>)[m]
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpfsStorage — error path coverage", () => {
|
||||
it("get/getMetadata/getPartial map post-handle errors to OpfsError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: brokenGetFile(),
|
||||
});
|
||||
// First call: getFile throws NotFoundError → returns undefined/null
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
// Now switch the entry handle's getFile to a generic error path
|
||||
const storage2 = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: brokenGetFile("UnknownError"),
|
||||
});
|
||||
await storage2.set("k", new Uint8Array([1]));
|
||||
try {
|
||||
await storage2.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
try {
|
||||
await storage2.getMetadata("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
try {
|
||||
await storage2.getWithMetadata("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
try {
|
||||
await storage2.getPartial("k", 0, 1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("get/getMetadata return undefined/null when post-handle getFile throws NotFoundError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: brokenGetFile("NotFoundError"),
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
expect(await storage.getMetadata("k")).toBeNull();
|
||||
expect(await storage.getWithMetadata("k")).toBeNull();
|
||||
expect(await storage.getPartial("k", 0, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("setPartial maps underlying errors to OpfsError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createOpfsStorage({
|
||||
directory: "t",
|
||||
opfs: brokenCreateWritable(),
|
||||
});
|
||||
try {
|
||||
await storage.setPartial("k", 0, new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear maps non-NotFound DOMException on a removeEntry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = brokenRemoveEntry();
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
try {
|
||||
await storage.clear();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.IO_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("createReadStream errors mid-stream when signal aborts after start", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3, 4, 5]));
|
||||
const ac = new AbortController();
|
||||
const stream = storage.createReadStream("k", ac.signal);
|
||||
ac.abort();
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
await reader.read();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as OpfsError).code).toBe(OPFS_CODES.ABORTED);
|
||||
}
|
||||
});
|
||||
|
||||
it("default-construct OpfsStorage and createOpfsStorage with no options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Exercises the `options = {}` default-parameter branches in both
|
||||
// the constructor and the factory. The lazy adapter only resolves
|
||||
// navigator on first method call so we don't need OPFS to exist.
|
||||
const storage = new OpfsStorage();
|
||||
expect(storage).toBeInstanceOf(OpfsStorage);
|
||||
const factory = createOpfsStorage();
|
||||
expect(factory).toBeInstanceOf(OpfsStorage);
|
||||
});
|
||||
|
||||
it("clear continues past a NotFoundError during removeEntry (race)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opfs = removeEntryRaceFixture();
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
await storage.set("b", new Uint8Array([2]));
|
||||
// The fixture throws NotFound on the FIRST removeEntry call; clear
|
||||
// must continue past it and remove the second entry.
|
||||
await storage.clear();
|
||||
const left = (await storage.list()).keys;
|
||||
// First entry survived because we swallowed NotFound; second was removed.
|
||||
expect(left.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("getSize skips child directory entries", { timeout: 5000 }, async () => {
|
||||
const opfs = withChildDirectory();
|
||||
const storage = createOpfsStorage({ directory: "t", opfs });
|
||||
await storage.set("a", new Uint8Array([1, 2, 3]));
|
||||
// The fixture's storage directory also contains a sub-directory.
|
||||
expect(await storage.getSize()).toBe(3);
|
||||
});
|
||||
|
||||
it("createWriteStream abort() handler clears the buffered chunks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const stream = storage.createWriteStream("k");
|
||||
const writer = stream.getWriter();
|
||||
await writer.write(new Uint8Array([1, 2, 3]));
|
||||
await writer.abort();
|
||||
// After abort, nothing should be stored under the key.
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Test fixture: an OpfsLike whose returned directory throws an unmappable
|
||||
// error on `removeEntry` (used by the IO_FAILED delete branch).
|
||||
function brokenRemoveEntry(): OpfsLike {
|
||||
const real = createInMemoryOpfs();
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const dir = await real.getDirectory();
|
||||
return new BrokenRemoveEntryProxy(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function brokenGetFile(
|
||||
name: "UnknownError" | "NotFoundError" = "UnknownError"
|
||||
): OpfsLike {
|
||||
const real = createInMemoryOpfs();
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const dir = await real.getDirectory();
|
||||
return new BrokenGetFileProxy(dir, name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function brokenCreateWritable(): OpfsLike {
|
||||
const real = createInMemoryOpfs();
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const dir = await real.getDirectory();
|
||||
return new BrokenCreateWritableProxy(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class BrokenGetFileProxy implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly name: string;
|
||||
public constructor(
|
||||
private readonly inner: OpfsDirectoryHandle,
|
||||
private readonly errorName: string
|
||||
) {
|
||||
this.name = inner.name;
|
||||
}
|
||||
public async getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
const handle = await this.inner.getFileHandle(name, options);
|
||||
const errorName = this.errorName;
|
||||
return new (class implements OpfsFileHandle {
|
||||
public readonly kind = "file" as const;
|
||||
public readonly name = handle.name;
|
||||
public async getFile(): Promise<never> {
|
||||
throw Object.assign(new Error("broken"), { name: errorName });
|
||||
}
|
||||
public createWritable(opts?: { keepExistingData?: boolean }) {
|
||||
return handle.createWritable(opts);
|
||||
}
|
||||
})();
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
return new BrokenGetFileProxy(
|
||||
await this.inner.getDirectoryHandle(name, options),
|
||||
this.errorName
|
||||
);
|
||||
}
|
||||
public removeEntry(name: string, options?: { recursive?: boolean }) {
|
||||
return this.inner.removeEntry(name, options);
|
||||
}
|
||||
public entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
return this.inner.entries();
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenCreateWritableProxy implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly name: string;
|
||||
public constructor(private readonly inner: OpfsDirectoryHandle) {
|
||||
this.name = inner.name;
|
||||
}
|
||||
public async getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
const handle = await this.inner.getFileHandle(name, options);
|
||||
return new (class implements OpfsFileHandle {
|
||||
public readonly kind = "file" as const;
|
||||
public readonly name = handle.name;
|
||||
public getFile() {
|
||||
return handle.getFile();
|
||||
}
|
||||
public async createWritable(): Promise<never> {
|
||||
throw Object.assign(new Error("broken"), {
|
||||
name: "UnknownError",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
return new BrokenCreateWritableProxy(
|
||||
await this.inner.getDirectoryHandle(name, options)
|
||||
);
|
||||
}
|
||||
public removeEntry(name: string, options?: { recursive?: boolean }) {
|
||||
return this.inner.removeEntry(name, options);
|
||||
}
|
||||
public entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
return this.inner.entries();
|
||||
}
|
||||
}
|
||||
|
||||
function removeEntryRaceFixture(): OpfsLike {
|
||||
const real = createInMemoryOpfs();
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const dir = await real.getDirectory();
|
||||
return new RemoveEntryRaceProxy(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class RemoveEntryRaceProxy implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly name: string;
|
||||
private callCount = 0;
|
||||
public constructor(private readonly inner: OpfsDirectoryHandle) {
|
||||
this.name = inner.name;
|
||||
}
|
||||
public getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
return this.inner.getFileHandle(name, options);
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
return new RemoveEntryRaceProxy(
|
||||
await this.inner.getDirectoryHandle(name, options)
|
||||
);
|
||||
}
|
||||
public async removeEntry(
|
||||
name: string,
|
||||
options?: { recursive?: boolean }
|
||||
): Promise<void> {
|
||||
this.callCount++;
|
||||
if (this.callCount === 1) {
|
||||
throw Object.assign(new Error("race"), { name: "NotFoundError" });
|
||||
}
|
||||
return this.inner.removeEntry(name, options);
|
||||
}
|
||||
public entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
return this.inner.entries();
|
||||
}
|
||||
}
|
||||
|
||||
function withChildDirectory(): OpfsLike {
|
||||
const real = createInMemoryOpfs();
|
||||
return {
|
||||
async getDirectory(): Promise<OpfsDirectoryHandle> {
|
||||
const root = await real.getDirectory();
|
||||
// The storage directory will be created by OpfsStorage; we then
|
||||
// sneak a sibling directory into it AFTER first method call by
|
||||
// wrapping the resolver to inject one on each resolve.
|
||||
return new InjectsChildDirProxy(root);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class InjectsChildDirProxy implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly name: string;
|
||||
public constructor(private readonly inner: OpfsDirectoryHandle) {
|
||||
this.name = inner.name;
|
||||
}
|
||||
public getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
return this.inner.getFileHandle(name, options);
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
const dir = await this.inner.getDirectoryHandle(name, options);
|
||||
// Inject a sub-directory child into the storage directory so getSize
|
||||
// exercises the `kind !== "file"` branch.
|
||||
await dir.getDirectoryHandle("inner", { create: true });
|
||||
return dir;
|
||||
}
|
||||
public removeEntry(name: string, options?: { recursive?: boolean }) {
|
||||
return this.inner.removeEntry(name, options);
|
||||
}
|
||||
public entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
return this.inner.entries();
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenRemoveEntryProxy implements OpfsDirectoryHandle {
|
||||
public readonly kind = "directory" as const;
|
||||
public readonly name: string;
|
||||
public constructor(private readonly inner: OpfsDirectoryHandle) {
|
||||
this.name = inner.name;
|
||||
}
|
||||
public getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle> {
|
||||
return this.inner.getFileHandle(name, options);
|
||||
}
|
||||
public async getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle> {
|
||||
return new BrokenRemoveEntryProxy(
|
||||
await this.inner.getDirectoryHandle(name, options)
|
||||
);
|
||||
}
|
||||
public async removeEntry(): Promise<void> {
|
||||
throw Object.assign(new Error("io broke"), { name: "UnknownError" });
|
||||
}
|
||||
public entries(): AsyncIterable<
|
||||
[string, OpfsFileHandle | OpfsDirectoryHandle]
|
||||
> {
|
||||
return this.inner.entries();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
import type {
|
||||
StorageCapabilities,
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
StorageMetadata,
|
||||
StorageOptions,
|
||||
StorageResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { DEFAULT_DIRECTORY } from "../constants/index.js";
|
||||
import { OPFS_CODES, OpfsError } from "../error-codes.js";
|
||||
import { encodeFilename } from "../filename-codec/filename-codec.js";
|
||||
import { sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
|
||||
import { defaultOpfs } from "../opfs-adapter/opfs-adapter.js";
|
||||
import { recordOp, type StorageBrowserOpfsOp } from "../otel/otel.js";
|
||||
import type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsLike,
|
||||
} from "../types/opfs-like.types.js";
|
||||
import {
|
||||
type OpfsStorageOptions,
|
||||
OpfsStorageOptionsSchema,
|
||||
} from "../types/opfs-storage.types.js";
|
||||
import {
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapOpfsError,
|
||||
} from "./opfs-storage.errors.js";
|
||||
import {
|
||||
assertNonNegInt,
|
||||
listAllKeys,
|
||||
metadataFromFile,
|
||||
paginateKeys,
|
||||
tryGetFileHandle,
|
||||
} from "./opfs-storage.ops.js";
|
||||
import { buildReadStream, buildWriteStream } from "./opfs-storage.streams.js";
|
||||
|
||||
interface ResolvedConfig {
|
||||
directory: string;
|
||||
opfs: OpfsLike;
|
||||
clock: Clock | undefined;
|
||||
}
|
||||
|
||||
function resolveOptions(options: OpfsStorageOptions): ResolvedConfig {
|
||||
const parsed = OpfsStorageOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new OpfsError(
|
||||
OPFS_CODES.INVALID_OPTIONS,
|
||||
`OpfsStorage options are invalid: ${parsed.error.message}`,
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
directory: options.directory ?? DEFAULT_DIRECTORY,
|
||||
opfs: options.opfs ?? defaultOpfs(),
|
||||
clock: options.clock,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpfsStorage extends AbstractStorage {
|
||||
readonly #config: ResolvedConfig;
|
||||
#dirHandle: OpfsDirectoryHandle | undefined;
|
||||
|
||||
public override readonly capabilities: StorageCapabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: true,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false,
|
||||
contentAddressable: false,
|
||||
};
|
||||
|
||||
public constructor(options: OpfsStorageOptions = {}) {
|
||||
super();
|
||||
this.#config = resolveOptions(options);
|
||||
}
|
||||
|
||||
async #getDir(): Promise<OpfsDirectoryHandle> {
|
||||
if (this.#dirHandle) return this.#dirHandle;
|
||||
try {
|
||||
const root = await this.#config.opfs.getDirectory();
|
||||
this.#dirHandle = await root.getDirectoryHandle(
|
||||
this.#config.directory,
|
||||
{ create: true }
|
||||
);
|
||||
return this.#dirHandle;
|
||||
} catch (err) {
|
||||
throw mapOpfsError(err, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async #wrap<T>(
|
||||
op: StorageBrowserOpfsOp,
|
||||
key: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
assertNotAborted(signal, key);
|
||||
const value = await fn();
|
||||
recordOp({
|
||||
op,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
return value;
|
||||
} catch (err) {
|
||||
const outcome =
|
||||
err instanceof OpfsError && err.code === OPFS_CODES.ABORTED
|
||||
? "aborted"
|
||||
: "error";
|
||||
recordOp({ op, outcome, durationMs: Date.now() - start });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
override async get(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array | undefined> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("get", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(safe),
|
||||
safe
|
||||
);
|
||||
if (!handle) return undefined;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
recordOp({
|
||||
op: "get",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: bytes.byteLength,
|
||||
direction: "read",
|
||||
});
|
||||
return bytes;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return undefined;
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async set(
|
||||
key: string,
|
||||
value: Uint8Array,
|
||||
_options?: StorageOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const safe = sanitizeKey(key);
|
||||
await this.validateValueSize(safe, value, signal);
|
||||
await this.#wrap("set", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
try {
|
||||
const handle = await dir.getFileHandle(encodeFilename(safe), {
|
||||
create: true,
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
const writer = writable.getWriter();
|
||||
await writer.write(value);
|
||||
await writer.close();
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: value.byteLength,
|
||||
direction: "write",
|
||||
});
|
||||
} catch (err) {
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async delete(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("delete", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
try {
|
||||
await dir.removeEntry(encodeFilename(safe));
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return false;
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async exists(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("exists", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(safe),
|
||||
safe
|
||||
);
|
||||
return handle !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
override async list(
|
||||
options?: StorageListOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageListResult> {
|
||||
return this.#wrap("list", undefined, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const all = await listAllKeys(dir);
|
||||
return paginateKeys(
|
||||
all.map((e) => e.key),
|
||||
options
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
override async clear(signal?: AbortSignal): Promise<void> {
|
||||
await this.#wrap("clear", undefined, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const names: string[] = [];
|
||||
for await (const [name] of dir.entries()) names.push(name);
|
||||
for (const name of names) {
|
||||
try {
|
||||
await dir.removeEntry(name);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) continue;
|
||||
throw mapOpfsError(err, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async getWithMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageResult<Uint8Array> | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("getWithMetadata", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(safe),
|
||||
safe
|
||||
);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const value = new Uint8Array(await file.arrayBuffer());
|
||||
return {
|
||||
value,
|
||||
metadata: metadataFromFile(file, this.#config.clock),
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async getMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageMetadata | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("getMetadata", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(safe),
|
||||
safe
|
||||
);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
return metadataFromFile(file, this.#config.clock);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async getSize(signal?: AbortSignal): Promise<number> {
|
||||
return this.#wrap("getSize", undefined, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
let total = 0;
|
||||
for await (const [, child] of dir.entries()) {
|
||||
if (child.kind !== "file") continue;
|
||||
total += (await child.getFile()).size;
|
||||
}
|
||||
return total;
|
||||
});
|
||||
}
|
||||
|
||||
override async getPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
assertNonNegInt(safe, "offset", offset);
|
||||
assertNonNegInt(safe, "length", length);
|
||||
return this.#wrap("getPartial", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
const handle = await tryGetFileHandle(
|
||||
dir,
|
||||
encodeFilename(safe),
|
||||
safe
|
||||
);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const slice = file.slice(offset, offset + length);
|
||||
const bytes = new Uint8Array(await slice.arrayBuffer());
|
||||
recordOp({
|
||||
op: "getPartial",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: bytes.byteLength,
|
||||
direction: "read",
|
||||
});
|
||||
return bytes;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async setPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
bytes: Uint8Array,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const safe = sanitizeKey(key);
|
||||
assertNonNegInt(safe, "offset", offset);
|
||||
await this.#wrap("setPartial", safe, signal, async () => {
|
||||
const dir = await this.#getDir();
|
||||
try {
|
||||
const handle = await dir.getFileHandle(encodeFilename(safe), {
|
||||
create: true,
|
||||
});
|
||||
const writable = await handle.createWritable({
|
||||
keepExistingData: true,
|
||||
});
|
||||
await writable.seek(offset);
|
||||
const writer = writable.getWriter();
|
||||
await writer.write(bytes);
|
||||
await writer.close();
|
||||
recordOp({
|
||||
op: "setPartial",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: bytes.byteLength,
|
||||
direction: "write",
|
||||
});
|
||||
} catch (err) {
|
||||
throw mapOpfsError(err, safe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async getItemSize(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<number | null> {
|
||||
const meta = await this.getMetadata(key, signal);
|
||||
return meta?.size ?? null;
|
||||
}
|
||||
|
||||
override createReadStream(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): ReadableStream<Uint8Array> {
|
||||
const safe = sanitizeKey(key);
|
||||
return buildReadStream({
|
||||
key: safe,
|
||||
signal,
|
||||
getDir: () => this.#getDir(),
|
||||
});
|
||||
}
|
||||
|
||||
override createWriteStream(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): WritableStream<Uint8Array> {
|
||||
const safe = sanitizeKey(key);
|
||||
return buildWriteStream({
|
||||
key: safe,
|
||||
signal,
|
||||
getDir: () => this.#getDir(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpfsStorage(
|
||||
options: OpfsStorageOptions = {}
|
||||
): OpfsStorage {
|
||||
return new OpfsStorage(options);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
RecordOpInput,
|
||||
StorageBrowserOpfsInstruments,
|
||||
StorageBrowserOpfsOp,
|
||||
StorageBrowserOpfsOpOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
} from "./otel.js";
|
||||
@@ -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,205 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserOpfsMeterProvider,
|
||||
} from "./otel.js";
|
||||
|
||||
interface RecordedHistogram {
|
||||
name: string;
|
||||
samples: { value: number; attrs: Record<string, unknown> | undefined }[];
|
||||
}
|
||||
interface RecordedCounter {
|
||||
name: string;
|
||||
additions: { value: number; attrs: Record<string, unknown> | undefined }[];
|
||||
}
|
||||
|
||||
class StubHistogram implements Histogram {
|
||||
public readonly entry: RecordedHistogram;
|
||||
public constructor(name: string, bag: RecordedHistogram[]) {
|
||||
this.entry = { name, samples: [] };
|
||||
bag.push(this.entry);
|
||||
}
|
||||
public record(value: number, attrs?: Record<string, unknown>): void {
|
||||
this.entry.samples.push({ value, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubCounter implements Counter {
|
||||
public readonly entry: RecordedCounter;
|
||||
public constructor(name: string, bag: RecordedCounter[]) {
|
||||
this.entry = { name, additions: [] };
|
||||
bag.push(this.entry);
|
||||
}
|
||||
public add(value: number, attrs?: Record<string, unknown>): void {
|
||||
this.entry.additions.push({ value, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeter implements Meter {
|
||||
public histograms: RecordedHistogram[] = [];
|
||||
public counters: RecordedCounter[] = [];
|
||||
public createHistogram(name: string): Histogram {
|
||||
return new StubHistogram(name, this.histograms);
|
||||
}
|
||||
public createCounter(name: string): Counter {
|
||||
return new StubCounter(name, this.counters);
|
||||
}
|
||||
public createObservableGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public addBatchObservableCallback(): void {}
|
||||
public removeBatchObservableCallback(): void {}
|
||||
public createGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeterProvider implements MeterProvider {
|
||||
public meters: StubMeter[] = [];
|
||||
public getMeter(): Meter {
|
||||
const meter = new StubMeter();
|
||||
this.meters.push(meter);
|
||||
return meter;
|
||||
}
|
||||
}
|
||||
|
||||
describe("storage-browser-opfs otel", () => {
|
||||
let provider: StubMeterProvider;
|
||||
beforeEach(() => {
|
||||
provider = new StubMeterProvider();
|
||||
setStorageBrowserOpfsMeterProvider(provider);
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageBrowserOpfsMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
function findCounter(name: string): RecordedCounter | undefined {
|
||||
return provider.meters[0]?.counters.find((c) => c.name === name);
|
||||
}
|
||||
function findHistogram(name: string): RecordedHistogram | undefined {
|
||||
return provider.meters[0]?.histograms.find((h) => h.name === name);
|
||||
}
|
||||
|
||||
it("recordOp emits the documented counter and histogram set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 12,
|
||||
bytes: 100,
|
||||
direction: "write",
|
||||
});
|
||||
const dur = findHistogram("storage_browser_opfs.op_duration_ms");
|
||||
expect(dur?.samples).toEqual([
|
||||
{ value: 12, attrs: { op: "set", outcome: "success" } },
|
||||
]);
|
||||
const writes = findCounter("storage_browser_opfs.writes_total");
|
||||
expect(writes?.additions).toEqual([
|
||||
{ value: 1, attrs: { op: "set", outcome: "success" } },
|
||||
]);
|
||||
const io = findHistogram("storage_browser_opfs.io_bytes");
|
||||
expect(io?.samples).toEqual([
|
||||
{ value: 100, attrs: { op: "set", direction: "write" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records error counter on a failed op", { timeout: 5000 }, () => {
|
||||
recordOp({ op: "get", outcome: "error", durationMs: 3 });
|
||||
const errs = findCounter("storage_browser_opfs.errors_total");
|
||||
expect(errs?.additions).toEqual([
|
||||
{ value: 1, attrs: { op: "get", outcome: "error" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records aborted operations on the errors counter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({ op: "get", outcome: "aborted", durationMs: 0 });
|
||||
expect(
|
||||
findCounter("storage_browser_opfs.errors_total")?.additions[0]
|
||||
?.attrs
|
||||
).toEqual({ op: "get", outcome: "aborted" });
|
||||
});
|
||||
|
||||
it("increments reads/writes/deletes counters by op kind", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({ op: "get", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "getPartial", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "getWithMetadata", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "setPartial", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "delete", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "clear", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "exists", outcome: "success", durationMs: 1 });
|
||||
recordOp({ op: "list", outcome: "success", durationMs: 1 });
|
||||
expect(
|
||||
findCounter("storage_browser_opfs.reads_total")?.additions
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
findCounter("storage_browser_opfs.writes_total")?.additions
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
findCounter("storage_browser_opfs.deletes_total")?.additions
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns a cached instrument set on repeat getInstruments calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getInstruments();
|
||||
const b = getInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rebuilds the instrument set when getInstruments(true) is called", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getInstruments();
|
||||
const b = getInstruments(true);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("omits the io_bytes record when bytes is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({ op: "list", outcome: "success", durationMs: 5 });
|
||||
expect(
|
||||
findHistogram("storage_browser_opfs.io_bytes")?.samples ?? []
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the global metrics fallback when provider is unset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setStorageBrowserOpfsMeterProvider(undefined);
|
||||
// Should not throw — falls back to the global no-op meter provider.
|
||||
const inst = getInstruments(true);
|
||||
expect(inst.meter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/storage-browser-opfs";
|
||||
const TRACER_NAME = "@suaveplan/storage-browser-opfs";
|
||||
|
||||
let overrideMeterProvider: MeterProvider | undefined;
|
||||
|
||||
/**
|
||||
* Override the {@link MeterProvider} used to acquire the storage-browser-opfs
|
||||
* meter. Pass `undefined` to fall back to the global provider. The instrument
|
||||
* cache is rebuilt automatically on next access.
|
||||
*/
|
||||
export function setStorageBrowserOpfsMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
overrideMeterProvider = provider;
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
export type StorageBrowserOpfsOp =
|
||||
| "get"
|
||||
| "set"
|
||||
| "delete"
|
||||
| "exists"
|
||||
| "list"
|
||||
| "clear"
|
||||
| "getMetadata"
|
||||
| "getWithMetadata"
|
||||
| "getSize"
|
||||
| "getPartial"
|
||||
| "setPartial"
|
||||
| "getItemSize"
|
||||
| "createReadStream"
|
||||
| "createWriteStream";
|
||||
|
||||
export type StorageBrowserOpfsOpOutcome = "success" | "error" | "aborted";
|
||||
|
||||
export interface StorageBrowserOpfsInstruments {
|
||||
tracer: Tracer;
|
||||
meter: Meter;
|
||||
readsTotal: Counter;
|
||||
writesTotal: Counter;
|
||||
deletesTotal: Counter;
|
||||
errorsTotal: Counter;
|
||||
opDurationMs: Histogram;
|
||||
ioBytes: Histogram;
|
||||
}
|
||||
|
||||
let cachedInstruments: StorageBrowserOpfsInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily construct the OTel instrument set so consumers can install a custom
|
||||
* global `MeterProvider`/`TracerProvider` BEFORE this package's production
|
||||
* code path executes. Pass `force: true` to rebuild against a freshly-set
|
||||
* provider; tests use this after `metrics.setGlobalMeterProvider`.
|
||||
*/
|
||||
export function getInstruments(force = false): StorageBrowserOpfsInstruments {
|
||||
if (cachedInstruments !== undefined && !force) return cachedInstruments;
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
|
||||
cachedInstruments = {
|
||||
tracer,
|
||||
meter,
|
||||
readsTotal: meter.createCounter("storage_browser_opfs.reads_total", {
|
||||
description: "Total successful read operations",
|
||||
}),
|
||||
writesTotal: meter.createCounter("storage_browser_opfs.writes_total", {
|
||||
description: "Total successful write operations",
|
||||
}),
|
||||
deletesTotal: meter.createCounter(
|
||||
"storage_browser_opfs.deletes_total",
|
||||
{ description: "Total successful delete operations" }
|
||||
),
|
||||
errorsTotal: meter.createCounter("storage_browser_opfs.errors_total", {
|
||||
description: "Total operations that ended in error",
|
||||
}),
|
||||
opDurationMs: meter.createHistogram(
|
||||
"storage_browser_opfs.op_duration_ms",
|
||||
{
|
||||
description: "Per-operation duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
ioBytes: meter.createHistogram("storage_browser_opfs.io_bytes", {
|
||||
description: "Bytes read or written per operation",
|
||||
unit: "By",
|
||||
}),
|
||||
};
|
||||
return cachedInstruments;
|
||||
}
|
||||
|
||||
export interface RecordOpInput {
|
||||
op: StorageBrowserOpfsOp;
|
||||
outcome: StorageBrowserOpfsOpOutcome;
|
||||
durationMs: number;
|
||||
bytes?: number;
|
||||
direction?: "read" | "write";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a complete observability record for one operation: increments the
|
||||
* appropriate `{reads,writes,deletes,errors}_total` counter, records a
|
||||
* duration histogram observation, and (when `bytes` is provided) records an
|
||||
* `io_bytes` histogram observation.
|
||||
*/
|
||||
export function recordOp(input: RecordOpInput): void {
|
||||
const { op, outcome, durationMs, bytes, direction } = input;
|
||||
const inst = getInstruments();
|
||||
const attrs = { op, outcome };
|
||||
inst.opDurationMs.record(durationMs, attrs);
|
||||
if (outcome === "error" || outcome === "aborted") {
|
||||
inst.errorsTotal.add(1, { op, outcome });
|
||||
} else if (
|
||||
op === "get" ||
|
||||
op === "getPartial" ||
|
||||
op === "getWithMetadata"
|
||||
) {
|
||||
inst.readsTotal.add(1, { op, outcome });
|
||||
} else if (op === "set" || op === "setPartial") {
|
||||
inst.writesTotal.add(1, { op, outcome });
|
||||
} else if (op === "delete" || op === "clear") {
|
||||
inst.deletesTotal.add(1, { op, outcome });
|
||||
}
|
||||
if (bytes !== undefined && direction !== undefined) {
|
||||
inst.ioBytes.record(bytes, { op, direction });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached instrument set. Tests call this between `it(...)` blocks
|
||||
* after installing a fresh in-memory MeterProvider.
|
||||
*/
|
||||
export function resetInstruments(): void {
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { registerOpfsProvider } from "./registry.js";
|
||||
@@ -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,50 @@
|
||||
import {
|
||||
clearStorageProviders,
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createInMemoryOpfs } from "../in-memory-opfs/in-memory-opfs.js";
|
||||
import { OpfsStorage } from "../opfs-storage/opfs-storage.js";
|
||||
import { registerOpfsProvider } from "./registry.js";
|
||||
|
||||
describe("registerOpfsProvider", () => {
|
||||
beforeEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
|
||||
it("registers the opfs provider and resolves it via createStorage", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
registerOpfsProvider();
|
||||
expect(hasStorageProvider("opfs")).toBe(true);
|
||||
const opfs = createInMemoryOpfs();
|
||||
const storage = createStorage({
|
||||
type: "opfs" as never,
|
||||
config: { directory: "t", opfs } as never,
|
||||
});
|
||||
expect(storage).toBeInstanceOf(OpfsStorage);
|
||||
await (storage as OpfsStorage).set("k", new Uint8Array([1]));
|
||||
expect(await (storage as OpfsStorage).get("k")).toEqual(
|
||||
new Uint8Array([1])
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent across repeated registerOpfsProvider calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerOpfsProvider();
|
||||
registerOpfsProvider();
|
||||
registerOpfsProvider();
|
||||
expect(hasStorageProvider("opfs")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
hasStorageProvider,
|
||||
registerStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import { OpfsStorage } from "../opfs-storage/opfs-storage.js";
|
||||
import type { OpfsStorageOptions } from "../types/opfs-storage.types.js";
|
||||
|
||||
declare module "@suaveplan/storage/factory" {
|
||||
interface StorageProviderRegistry {
|
||||
opfs: OpfsStorage;
|
||||
}
|
||||
interface StorageProviderConfig {
|
||||
opfs: OpfsStorageOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register `OpfsStorage` against the `@suaveplan/storage` factory under the
|
||||
* canonical type slot `"opfs"`. Idempotent — repeated calls do nothing once
|
||||
* the slot is occupied, and re-register cleanly after a downstream
|
||||
* `clearStorageProviders()` call.
|
||||
*
|
||||
* Consumers MUST call this explicitly before invoking
|
||||
* `createStorage({ type: "opfs", ... })`. The package deliberately does NOT
|
||||
* self-register at module-evaluation time so that `sideEffects: false`
|
||||
* bundlers can tree-shake the registry path away when not needed.
|
||||
*/
|
||||
export function registerOpfsProvider(): void {
|
||||
if (hasStorageProvider("opfs")) return;
|
||||
registerStorageProvider(
|
||||
"opfs",
|
||||
OpfsStorage as unknown as new (
|
||||
config?: unknown
|
||||
) => OpfsStorage
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
OpfsDirectoryHandle,
|
||||
OpfsFile,
|
||||
OpfsFileHandle,
|
||||
OpfsLike,
|
||||
OpfsWritableStream,
|
||||
} from "./opfs-like.types.js";
|
||||
export type { OpfsStorageOptions } from "./opfs-storage.types.js";
|
||||
export { OpfsStorageOptionsSchema } from "./opfs-storage.types.js";
|
||||
@@ -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,56 @@
|
||||
/**
|
||||
* Minimal structural view of the WHATWG File System Access API surface used
|
||||
* by `OpfsStorage`. Declared here rather than relying on `lib.dom`'s global
|
||||
* `FileSystemDirectoryHandle` because:
|
||||
* - Universal consumers may opt out of DOM lib in their tsconfig.
|
||||
* - Tests substitute a real in-memory implementation (no mocks); having a
|
||||
* local structural interface keeps that contract independent of the
|
||||
* browser globals' exact shape across browser versions.
|
||||
*
|
||||
* Every member here is a strict subset of the corresponding native API.
|
||||
*/
|
||||
|
||||
export interface OpfsFile {
|
||||
readonly size: number;
|
||||
readonly lastModified: number;
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
slice(start?: number, end?: number): OpfsFile;
|
||||
stream(): ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface OpfsWritableStream extends WritableStream<Uint8Array> {
|
||||
seek(position: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpfsFileHandle {
|
||||
readonly kind: "file";
|
||||
readonly name: string;
|
||||
getFile(): Promise<OpfsFile>;
|
||||
createWritable(options?: {
|
||||
keepExistingData?: boolean;
|
||||
}): Promise<OpfsWritableStream>;
|
||||
}
|
||||
|
||||
export interface OpfsDirectoryHandle {
|
||||
readonly kind: "directory";
|
||||
readonly name: string;
|
||||
getFileHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsFileHandle>;
|
||||
getDirectoryHandle(
|
||||
name: string,
|
||||
options?: { create?: boolean }
|
||||
): Promise<OpfsDirectoryHandle>;
|
||||
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
entries(): AsyncIterable<[string, OpfsFileHandle | OpfsDirectoryHandle]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OPFS root resolver — exactly one method that returns the origin's
|
||||
* private file system root directory. `defaultOpfs()` wraps
|
||||
* `navigator.storage.getDirectory()`; tests inject an in-memory variant.
|
||||
*/
|
||||
export interface OpfsLike {
|
||||
getDirectory(): Promise<OpfsDirectoryHandle>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import type { OpfsLike } from "./opfs-like.types.js";
|
||||
|
||||
export interface OpfsStorageOptions {
|
||||
/** Sub-directory name within the OPFS root. Default: `"storage"`. */
|
||||
directory?: string;
|
||||
/** Injectable OPFS root resolver. Defaults to `defaultOpfs()`. */
|
||||
opfs?: OpfsLike;
|
||||
/** Injectable clock used for synthetic metadata when `lastModified` is unavailable. */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
// `opfs` and `clock` are structural interfaces; we accept `unknown` on the
|
||||
// Zod side and rely on TypeScript to enforce shape at the call site.
|
||||
const looseAdapter = z.unknown().optional();
|
||||
|
||||
export const OpfsStorageOptionsSchema = z.object({
|
||||
directory: z.string().min(1).optional(),
|
||||
opfs: looseAdapter,
|
||||
clock: looseAdapter,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Polyfills for the test environment. Node 20+ exposes WHATWG streams and
|
||||
// `TextEncoder`/`TextDecoder` globally; nothing else is required for the
|
||||
// in-memory OPFS test double.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/storage-browser-opfs",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
/^@suaveplan\/storage/,
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-conformance.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user