Initial Commit
This commit is contained in:
26
packages/persistence/storage/README.md
Normal file
26
packages/persistence/storage/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# `storage`
|
||||
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `storage`
|
||||
|
||||
14 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/storage`](./storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
|
||||
| [`@suaveplan/storage-browser-indexeddb`](./storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
|
||||
| [`@suaveplan/storage-browser-localstorage`](./storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
|
||||
| [`@suaveplan/storage-browser-opfs`](./storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
|
||||
| [`@suaveplan/storage-cdn`](./storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
|
||||
| [`@suaveplan/storage-cdn-bunnycdn`](./storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
|
||||
| [`@suaveplan/storage-cdn-rackspace`](./storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
|
||||
| [`@suaveplan/storage-file`](./storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
|
||||
| [`@suaveplan/storage-http`](./storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
|
||||
| [`@suaveplan/storage-plugin-cache`](./storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
|
||||
| [`@suaveplan/storage-s3`](./storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
|
||||
| [`@suaveplan/storage-uri`](./storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
|
||||
| [`@suaveplan/storage-uri-plugin-bundle`](./storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
|
||||
| [`@suaveplan/storage-uri-plugin-fallback`](./storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/persistence/storage/_intro.md
Normal file
1
packages/persistence/storage/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
@@ -0,0 +1,93 @@
|
||||
# @suaveplan/storage-browser-indexeddb - Browser IndexedDB AbstractStorage adapter for @suaveplan/storage: structured-clone key-value storage via globalThis.indexedDB, AbortSignal everywhere, strict key sanitisation, TTL via expiresAt index, metadata storage, partial reads, DOMException → typed IndexedDbError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for browser consumers — vault snapshots, drive chunk caches, in-app i18n catalogues, password manager records — with full `AbstractStorage` conformance, TTL via an `expiresAt` index, AbortSignal threading, strict key sanitisation, Zod-validated options, structured `IndexedDbError` boundary, DOMException → typed-code mapping, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.config](./src/indexeddb-storage/indexeddb-storage.config.md) — This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` valid…
|
||||
- [indexeddb-storage.crud](./src/indexeddb-storage/indexeddb-storage.crud.md) — This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out…
|
||||
- [indexeddb-storage.errors](./src/indexeddb-storage/indexeddb-storage.errors.md) — This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on.…
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.ops](./src/indexeddb-storage/indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [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 -->
|
||||
- [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 -->
|
||||
<!-- end:auto-docs -->
|
||||
- [error-codes](./src/error-codes/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idb-adapter](./src/idb-adapter/idb-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [indexeddb-storage.config](./src/indexeddb-storage/indexeddb-storage.config.md) — This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` valid…
|
||||
- [indexeddb-storage.crud](./src/indexeddb-storage/indexeddb-storage.crud.md) — This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out…
|
||||
- [indexeddb-storage.errors](./src/indexeddb-storage/indexeddb-storage.errors.md) — This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on.…
|
||||
- [indexeddb-storage](./src/indexeddb-storage/indexeddb-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [indexeddb-storage.ops](./src/indexeddb-storage/indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [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)_- [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)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-indexeddb
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
isIndexedDbAvailable,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
if (!isIndexedDbAvailable()) {
|
||||
throw new Error("This browser does not support IndexedDB");
|
||||
}
|
||||
|
||||
const storage = createIndexedDbStorage({ dbName: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerIndexedDbProvider();
|
||||
const storage = createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerIndexedDbProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `true` | Via `Uint8Array.subarray(...)` on the in-memory copy of the stored row. |
|
||||
| `partialWrites` | `false` | IndexedDB has no random-access put; `setPartial` uses the `AbstractStorage` read-modify-write fallback. |
|
||||
| `nativeStreams` | `false` | IndexedDB has no native streaming surface; `createReadStream` / `createWriteStream` use the buffered fallback. |
|
||||
| `atomicWrites` | `true` | Each `objectStore.put` is a single-transaction commit. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes/error-codes.ts`](./src/error-codes/error-codes.md) — `INDEXEDDB_CODES` registry + `IndexedDbError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/idb-adapter/idb-adapter.ts`](./src/idb-adapter/idb-adapter.md) — `defaultIndexedDB()` + `isIndexedDbAvailable()`.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.ts`](./src/indexeddb-storage/indexeddb-storage.md) — the `IndexedDbStorage` class.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.config.ts`](./src/indexeddb-storage/indexeddb-storage.config.md) — option resolver + OTel wrapper.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.crud.ts`](./src/indexeddb-storage/indexeddb-storage.crud.md) — CRUD primitives.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.errors.ts`](./src/indexeddb-storage/indexeddb-storage.errors.md) — DOMException mapping.
|
||||
- [`src/indexeddb-storage/indexeddb-storage.ops.ts`](./src/indexeddb-storage/indexeddb-storage.ops.md) — transaction + open + cursor helpers.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerIndexedDbProvider()`.
|
||||
- [`src/types/idb-like.types.ts`](./src/types/idb-like.types.md) — structural IndexedDB surface.
|
||||
- [`src/types/indexeddb-storage.types.ts`](./src/types/indexeddb-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "@suaveplan/storage-browser-indexeddb",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Browser IndexedDB AbstractStorage adapter for @suaveplan/storage: structured-clone key-value storage via globalThis.indexedDB, AbortSignal everywhere, strict key sanitisation, TTL via expiresAt index, metadata storage, partial reads, DOMException → typed IndexedDbError 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"
|
||||
}
|
||||
},
|
||||
"./indexeddb-storage": {
|
||||
"import": {
|
||||
"types": "./dist/indexeddb-storage/index.d.ts",
|
||||
"default": "./dist/indexeddb-storage/indexeddb-storage.js"
|
||||
}
|
||||
},
|
||||
"./key-sanitizer": {
|
||||
"import": {
|
||||
"types": "./dist/key-sanitizer/index.d.ts",
|
||||
"default": "./dist/key-sanitizer/key-sanitizer.js"
|
||||
}
|
||||
},
|
||||
"./idb-adapter": {
|
||||
"import": {
|
||||
"types": "./dist/idb-adapter/index.d.ts",
|
||||
"default": "./dist/idb-adapter/idb-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"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.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",
|
||||
"fake-indexeddb": "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,14 @@
|
||||
/** Maximum length of a caller-supplied key. */
|
||||
export const MAX_KEY_LENGTH = 1024;
|
||||
|
||||
/** Default IndexedDB database name. */
|
||||
export const DEFAULT_DB_NAME = "suaveplan";
|
||||
|
||||
/** Default object-store name within the database. */
|
||||
export const DEFAULT_STORE_NAME = "storage";
|
||||
|
||||
/** Default schema version. Bump when migrating the object store layout. */
|
||||
export const DEFAULT_VERSION = 1;
|
||||
|
||||
/** Object-store index name used to query expired entries by `expiresAt`. */
|
||||
export const EXPIRES_AT_INDEX = "expiresAt";
|
||||
@@ -0,0 +1,72 @@
|
||||
# `INDEXEDDB_CODES` + `IndexedDbError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the structured-error surface for `@suaveplan/storage-browser-indexeddb`. The frozen `INDEXEDDB_CODES` registry, declared once via `defineCodes("INDEXEDDB", […])`, is the single source of truth for every failure mode the package reports. Consumers programmatically dispatch on the typed `IndexedDbErrorCode` union without parsing message text, which makes upstream retry policies, OTel attributes, and `i18n`-driven user messages robust against wording drift. Errors thrown anywhere inside the package — at the schema boundary, in the key sanitiser, during a `transaction.commit` round-trip, or while mapping a `DOMException` — are all instances of `IndexedDbError` carrying both the registered code and an optional `key` discriminant.
|
||||
|
||||
## Features
|
||||
|
||||
- `INDEXEDDB_CODES` frozen registry: 11 codes (`NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`).
|
||||
- `IndexedDbError extends SuaveplanError<IndexedDbErrorCode>` — preserves `cause`, exposes the typed `code`, attaches an optional `key` discriminant for correlation with the offending storage key.
|
||||
- Static factory shape: every error constructed inside the package goes through `new IndexedDbError(code, message, { key, cause })` — no plain `Error` ever escapes the boundary.
|
||||
- Compatible with the project-wide OTel span-event adapter on `SuaveplanError`, so failures get logged with `error.code`, `error.key`, and the cause chain attached automatically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
function rejectEmptyKey(key: string): void {
|
||||
if (key.length === 0) {
|
||||
throw new IndexedDbError(INDEXEDDB_CODES.INVALID_KEY, "key must not be empty");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
async function safeWrite(write: () => Promise<void>, key: string): Promise<void> {
|
||||
try {
|
||||
await write();
|
||||
} catch (err) {
|
||||
// Programmatic dispatch — never parses the message.
|
||||
if (err instanceof IndexedDbError) {
|
||||
if (err.code === INDEXEDDB_CODES.QUOTA_EXCEEDED) {
|
||||
// trigger an LRU eviction pass and retry once
|
||||
return;
|
||||
}
|
||||
if (err.code === INDEXEDDB_CODES.ABORTED) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new IndexedDbError(INDEXEDDB_CODES.IO_FAILED, `write failed on ${key}`, {
|
||||
key,
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `INDEXEDDB_CODES` — `Readonly<Record<keyof, string>>` frozen by `defineCodes`. Each value is the stable string code (`"INDEXEDDB.NOT_FOUND"`, …).
|
||||
- `IndexedDbErrorCode` — union of every `INDEXEDDB_CODES[key]` literal.
|
||||
- `IndexedDbErrorOptions extends SuaveplanErrorOptions` — adds optional `key?: string` discriminant alongside the inherited `cause?`.
|
||||
- `IndexedDbError` — class. Constructor `(code: IndexedDbErrorCode, message: string, options?: IndexedDbErrorOptions)`. Exposes `code`, `key`, `cause`, `name === "IndexedDbError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 11-code surface intentionally covers DOMException-mapped failures (`QUOTA_EXCEEDED`, `PERMISSION_DENIED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`), boundary failures (`INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`), lifecycle failures (`ABORTED`, `UNAVAILABLE`), and unclassified IO (`IO_FAILED`). `NOT_FOUND` is constructable but not thrown from production read paths — those return `undefined`/`false`/`null` per the `AbstractStorage` contract. Callers that need a thrown `NOT_FOUND` use the code directly (e.g. `assertExists` wrappers). Adding a code is a public-API change and requires bumping the spec change.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "./error-codes.js";
|
||||
|
||||
describe("INDEXEDDB_CODES + IndexedDbError", () => {
|
||||
it("declares every documented code", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(INDEXEDDB_CODES).sort()).toEqual(
|
||||
[
|
||||
"ABORTED",
|
||||
"INVALID_KEY",
|
||||
"INVALID_OPTIONS",
|
||||
"IO_FAILED",
|
||||
"KEY_TOO_LONG",
|
||||
"NOT_FOUND",
|
||||
"PERMISSION_DENIED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"TRANSACTION_ABORTED",
|
||||
"UNAVAILABLE",
|
||||
"VERSION_ERROR",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs IndexedDbError with code, key, cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new IndexedDbError(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED,
|
||||
"oversize write",
|
||||
{ key: "user/42", cause }
|
||||
);
|
||||
expect(err.code).toBe(INDEXEDDB_CODES.QUOTA_EXCEEDED);
|
||||
expect(err.key).toBe("user/42");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.name).toBe("IndexedDbError");
|
||||
});
|
||||
|
||||
it("constructs without optional key discriminant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new IndexedDbError(INDEXEDDB_CODES.IO_FAILED, "io fail");
|
||||
expect(err.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("every code is constructable", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(INDEXEDDB_CODES)) {
|
||||
const err = new IndexedDbError(code, `test ${code}`);
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const INDEXEDDB_CODES = defineCodes("INDEXEDDB", [
|
||||
"NOT_FOUND",
|
||||
"PERMISSION_DENIED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"UNAVAILABLE",
|
||||
"INVALID_KEY",
|
||||
"KEY_TOO_LONG",
|
||||
"INVALID_OPTIONS",
|
||||
"ABORTED",
|
||||
"IO_FAILED",
|
||||
"VERSION_ERROR",
|
||||
"TRANSACTION_ABORTED",
|
||||
] as const);
|
||||
|
||||
export type IndexedDbErrorCode =
|
||||
(typeof INDEXEDDB_CODES)[keyof typeof INDEXEDDB_CODES];
|
||||
|
||||
export interface IndexedDbErrorOptions extends SuaveplanErrorOptions {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for the browser IndexedDB storage adapter.
|
||||
*
|
||||
* Carries an optional `key` discriminant so callers can correlate failures
|
||||
* with the offending key without parsing message text.
|
||||
*/
|
||||
export class IndexedDbError extends SuaveplanError<IndexedDbErrorCode> {
|
||||
public readonly key: string | undefined;
|
||||
|
||||
public constructor(
|
||||
code: IndexedDbErrorCode,
|
||||
message: string,
|
||||
options?: IndexedDbErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.key = options?.key;
|
||||
this.name = "IndexedDbError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
IndexedDbErrorCode,
|
||||
IndexedDbErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# `defaultIndexedDB` + `isIndexedDbAvailable`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `idb-adapter` module exposes the only place inside the package that touches `globalThis.indexedDB`. Everything else — the `IndexedDbStorage` constructor, the open helper, the transaction wrapper — accepts an `IdbFactoryLike` adapter and routes every call through it. Keeping the default lookup behind a function means SSR and Node-side smoke tests can import the package safely (the `globalThis.indexedDB` lookup is lazy, performed only when a method is invoked) and unit tests can swap in a real in-memory `fake-indexeddb` factory without `vi.stubGlobal`.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultIndexedDB()` returns an `IdbFactoryLike` view of `globalThis.indexedDB` and throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` lazily — only when a method on the returned adapter is invoked — never at module evaluation time.
|
||||
- `isIndexedDbAvailable()` exposes a synchronous boolean for progressive-enhancement guards.
|
||||
- Adapter is purely structural — no class instances, no inheritance, swap-in friendly with `fake-indexeddb`'s `IDBFactory` instance.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultIndexedDB,
|
||||
isIndexedDbAvailable,
|
||||
} from "@suaveplan/storage-browser-indexeddb/idb-adapter";
|
||||
|
||||
if (!isIndexedDbAvailable()) {
|
||||
throw new Error("IndexedDB unavailable");
|
||||
}
|
||||
const factory = defaultIndexedDB();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
defaultIndexedDB,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
// Production: rely on the lazy global lookup.
|
||||
const storage = createIndexedDbStorage({ dbName: "app" });
|
||||
|
||||
// Tests: inject a real fake-indexeddb factory.
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
const fake = new IDBFactory();
|
||||
const testStorage = createIndexedDbStorage({
|
||||
dbName: "app-test",
|
||||
indexedDB: { open: (n, v) => fake.open(n, v) },
|
||||
});
|
||||
|
||||
// Fallback: detect at runtime and gracefully degrade.
|
||||
const factory = defaultIndexedDB();
|
||||
// `factory.open(...)` will throw INDEXEDDB.UNAVAILABLE if indexedDB is missing.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defaultIndexedDB(): IdbFactoryLike` — returns an adapter that proxies `open(name, version?)` to `globalThis.indexedDB.open(name, version?)`. Throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` when invoked in an environment that does not expose `globalThis.indexedDB`.
|
||||
- `isIndexedDbAvailable(): boolean` — returns `true` when `globalThis.indexedDB` is defined; does NOT execute any IndexedDB call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy lookup pattern matches `@suaveplan/storage-browser-opfs`'s `defaultOpfs` — the package can be imported in Node test runners that lack `globalThis.indexedDB` without throwing at module load. Constructing `IndexedDbStorage` in a Node test without injecting an adapter is safe; the `UNAVAILABLE` error only surfaces at the first method call. The structural `IdbFactoryLike` interface lets a `fake-indexeddb` factory be passed directly without any cast — `IDBFactory.open` matches the `open(name, version?)` shape exactly. Calling `globalThis.indexedDB.open` (rather than capturing the factory once) ensures the runtime always reflects late assignments, which matters in test environments that install the global lazily.
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import { defaultIndexedDB, isIndexedDbAvailable } from "./idb-adapter.js";
|
||||
|
||||
describe("defaultIndexedDB + isIndexedDbAvailable", () => {
|
||||
let original: unknown;
|
||||
beforeEach(() => {
|
||||
original = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) {
|
||||
delete (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
} else {
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("isIndexedDbAvailable reflects the global", { timeout: 5000 }, () => {
|
||||
delete (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
expect(isIndexedDbAvailable()).toBe(false);
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open: () => null,
|
||||
};
|
||||
expect(isIndexedDbAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("throws INDEXEDDB.UNAVAILABLE lazily when globalThis.indexedDB is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
delete (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
// Constructing the adapter is side-effect-free.
|
||||
const adapter = defaultIndexedDB();
|
||||
try {
|
||||
adapter.open("db");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(IndexedDbError);
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.UNAVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("proxies open() to globalThis.indexedDB when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: { name: string; version?: number }[] = [];
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open(name: string, version?: number) {
|
||||
calls.push({ name, version });
|
||||
return { sentinel: true };
|
||||
},
|
||||
};
|
||||
const adapter = defaultIndexedDB();
|
||||
const req = adapter.open("my-db", 2) as unknown as {
|
||||
sentinel: boolean;
|
||||
};
|
||||
expect(req.sentinel).toBe(true);
|
||||
expect(calls).toEqual([{ name: "my-db", version: 2 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import type { IdbFactoryLike } from "../types/idb-like.types.js";
|
||||
|
||||
/**
|
||||
* Lazy default adapter. Returns an `IdbFactoryLike` view of
|
||||
* `globalThis.indexedDB`. When indexedDB is missing (e.g. running in Node
|
||||
* without `fake-indexeddb` installed globally) every method throws
|
||||
* `IndexedDbError(INDEXEDDB.UNAVAILABLE)` on first use — never at module
|
||||
* evaluation time so SSR / smoke-tests can import the package safely.
|
||||
*/
|
||||
export function defaultIndexedDB(): IdbFactoryLike {
|
||||
return {
|
||||
open(name: string, version?: number) {
|
||||
const idb = (globalThis as { indexedDB?: IdbFactoryLike })
|
||||
.indexedDB;
|
||||
if (idb === undefined) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.UNAVAILABLE,
|
||||
"globalThis.indexedDB is not available in this environment"
|
||||
);
|
||||
}
|
||||
return idb.open(name, version);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously report whether the running environment exposes the
|
||||
* IndexedDB API on `globalThis`. Browser-tier consumers use this to gate
|
||||
* progressive enhancement; the package never calls it internally — every
|
||||
* production path delegates to the injected `IdbFactoryLike`.
|
||||
*/
|
||||
export function isIndexedDbAvailable(): boolean {
|
||||
return (
|
||||
typeof (globalThis as { indexedDB?: unknown }).indexedDB !== "undefined"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defaultIndexedDB,
|
||||
isIndexedDbAvailable,
|
||||
} from "./idb-adapter.js";
|
||||
@@ -0,0 +1,74 @@
|
||||
export {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
type IndexedDbErrorCode,
|
||||
type IndexedDbErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
defaultIndexedDB,
|
||||
isIndexedDbAvailable,
|
||||
} from "./idb-adapter/idb-adapter.js";
|
||||
export {
|
||||
type ResolvedConfig,
|
||||
recordedOp,
|
||||
resolveOptions,
|
||||
} from "./indexeddb-storage/indexeddb-storage.config.js";
|
||||
export {
|
||||
type CrudContext,
|
||||
clearStore,
|
||||
deleteEntry,
|
||||
getEntry,
|
||||
isLive,
|
||||
listKeys,
|
||||
nowMs,
|
||||
partialSlice,
|
||||
putEntry,
|
||||
sizeAcrossLive,
|
||||
valueFromEntry,
|
||||
withMetadata,
|
||||
} from "./indexeddb-storage/indexeddb-storage.crud.js";
|
||||
export {
|
||||
assertNonNegInt,
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapIndexedDbError,
|
||||
} from "./indexeddb-storage/indexeddb-storage.errors.js";
|
||||
export {
|
||||
createIndexedDbStorage,
|
||||
IndexedDbStorage,
|
||||
} from "./indexeddb-storage/indexeddb-storage.js";
|
||||
export {
|
||||
collectLiveEntries,
|
||||
type OpenDbInput,
|
||||
openDatabase,
|
||||
paginateKeys,
|
||||
promisifyRequest,
|
||||
promisifyTransaction,
|
||||
type WithStoreInput,
|
||||
withStore,
|
||||
} from "./indexeddb-storage/indexeddb-storage.ops.js";
|
||||
export { sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
|
||||
export {
|
||||
getInstruments,
|
||||
type RecordOpInput,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
type StorageBrowserIndexedDbInstruments,
|
||||
type StorageBrowserIndexedDbOp,
|
||||
type StorageBrowserIndexedDbOpOutcome,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "./otel/otel.js";
|
||||
export { registerIndexedDbProvider } from "./registry/registry.js";
|
||||
export type {
|
||||
IdbCursorWithValueLike,
|
||||
IdbDatabaseLike,
|
||||
IdbFactoryLike,
|
||||
IdbObjectStoreLike,
|
||||
IdbOpenDbRequestLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
IdbVersionChangeEventLike,
|
||||
IndexedDbEntry,
|
||||
IndexedDbStorageOptions,
|
||||
} from "./types/index.js";
|
||||
export { IndexedDbStorageOptionsSchema } from "./types/index.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
type ResolvedConfig,
|
||||
recordedOp,
|
||||
resolveOptions,
|
||||
} from "./indexeddb-storage.config.js";
|
||||
export {
|
||||
type CrudContext,
|
||||
clearStore,
|
||||
deleteEntry,
|
||||
getEntry,
|
||||
isLive,
|
||||
listKeys,
|
||||
nowMs,
|
||||
partialSlice,
|
||||
putEntry,
|
||||
sizeAcrossLive,
|
||||
valueFromEntry,
|
||||
withMetadata,
|
||||
} from "./indexeddb-storage.crud.js";
|
||||
export {
|
||||
assertNonNegInt,
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapIndexedDbError,
|
||||
} from "./indexeddb-storage.errors.js";
|
||||
export {
|
||||
createIndexedDbStorage,
|
||||
IndexedDbStorage,
|
||||
} from "./indexeddb-storage.js";
|
||||
export {
|
||||
collectLiveEntries,
|
||||
type OpenDbInput,
|
||||
openDatabase,
|
||||
paginateKeys,
|
||||
promisifyRequest,
|
||||
promisifyTransaction,
|
||||
type WithStoreInput,
|
||||
withStore,
|
||||
} from "./indexeddb-storage.ops.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# Configuration + OTel wrapper
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes the two building blocks every public method of `IndexedDbStorage` relies on: `resolveOptions` validates caller-supplied options at the Zod boundary and applies defaults, and `recordedOp` wraps a per-operation closure with the canonical OTel observation pattern (assert-abort → run → record success / error / aborted). Pulling these into a sibling module keeps the storage class file under the 300-line hard limit and gives every adapter operation a single auditable entry point for observability.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveOptions(options)` — Zod-validate and apply defaults; throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` with the original Zod error attached as `cause`.
|
||||
- `recordedOp(op, key?, signal?, fn)` — generic wrapper. Asserts a pre-aborted signal as `INDEXEDDB.ABORTED` before invoking the closure; records duration + outcome to the OTel meter for every code path including success.
|
||||
- Defaults centralised in `../constants/index.ts` (`DEFAULT_DB_NAME`, `DEFAULT_STORE_NAME`, `DEFAULT_VERSION`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordedOp, resolveOptions } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
const config = resolveOptions({ dbName: "app" });
|
||||
|
||||
await recordedOp("get", "user/1", undefined, async () => {
|
||||
// caller's work here
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordedOp,
|
||||
resolveOptions,
|
||||
type ResolvedConfig,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
function describe(config: ResolvedConfig): string {
|
||||
return `${config.dbName}/${config.storeName}@v${config.version}`;
|
||||
}
|
||||
|
||||
async function timedRead<T>(
|
||||
cfg: ResolvedConfig,
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
body: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return recordedOp("get", key, signal, body);
|
||||
}
|
||||
|
||||
describe(resolveOptions({ dbName: "app", storeName: "main" }));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ResolvedConfig` — `{ dbName: string; storeName: string; version: number; factory: IdbFactoryLike; clock: Clock | undefined }`. The post-defaults concrete configuration.
|
||||
- `resolveOptions(options: IndexedDbStorageOptions): ResolvedConfig` — Zod-validates `options`; throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on failure with the Zod error preserved as `cause`. Fills in `DEFAULT_DB_NAME` / `DEFAULT_STORE_NAME` / `DEFAULT_VERSION` for missing fields and `defaultIndexedDB()` for the missing factory.
|
||||
- `recordedOp<T>(op, key, signal, fn): Promise<T>` — runs `fn()` inside a try/catch that emits an OTel observation on every path. Pre-aborted signals throw `INDEXEDDB.ABORTED` and increment `errors_total{outcome: "aborted"}`; thrown errors increment `errors_total{outcome: "error"}`. Successful runs increment the appropriate `{reads,writes,deletes}_total` counter via `recordOp(...)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`resolveOptions` re-checks the field shape via Zod even though TypeScript already enforces it at the call site — this is the standard boundary pattern, defending against JavaScript callers passing malformed input and against future fields added without explicit defaulting. The Zod schema declares `indexedDB` and `clock` as `z.unknown().optional()` because their structural shape is too involved to encode in Zod cleanly; the TypeScript signature catches the misuses Zod cannot. `recordedOp` does NOT clamp the operation duration when the closure throws synchronously — `Date.now() - start` is the wall-clock for the entire wrapped call regardless of outcome, which is the right semantic for an operation-latency histogram.
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
DEFAULT_DB_NAME,
|
||||
DEFAULT_STORE_NAME,
|
||||
DEFAULT_VERSION,
|
||||
} from "../constants/index.js";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import { defaultIndexedDB } from "../idb-adapter/idb-adapter.js";
|
||||
import { recordOp, type StorageBrowserIndexedDbOp } from "../otel/otel.js";
|
||||
import type { IdbFactoryLike } from "../types/idb-like.types.js";
|
||||
import {
|
||||
type IndexedDbStorageOptions,
|
||||
IndexedDbStorageOptionsSchema,
|
||||
} from "../types/indexeddb-storage.types.js";
|
||||
import { assertNotAborted } from "./indexeddb-storage.errors.js";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
dbName: string;
|
||||
storeName: string;
|
||||
version: number;
|
||||
factory: IdbFactoryLike;
|
||||
clock: Clock | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate caller-supplied `IndexedDbStorageOptions` and freeze the
|
||||
* concrete configuration with defaults applied. Throws
|
||||
* `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on schema failure.
|
||||
*/
|
||||
export function resolveOptions(
|
||||
options: IndexedDbStorageOptions
|
||||
): ResolvedConfig {
|
||||
const parsed = IndexedDbStorageOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_OPTIONS,
|
||||
`IndexedDbStorage options are invalid: ${parsed.error.message}`,
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
dbName: options.dbName ?? DEFAULT_DB_NAME,
|
||||
storeName: options.storeName ?? DEFAULT_STORE_NAME,
|
||||
version: options.version ?? DEFAULT_VERSION,
|
||||
factory: options.indexedDB ?? defaultIndexedDB(),
|
||||
clock: options.clock,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common OTel-recording wrapper used by every public method on the
|
||||
* storage class. Asserts the abort signal first, then runs the work,
|
||||
* then records success / error / aborted to the meter.
|
||||
*/
|
||||
export async function recordedOp<T>(
|
||||
op: StorageBrowserIndexedDbOp,
|
||||
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 IndexedDbError &&
|
||||
err.code === INDEXEDDB_CODES.ABORTED
|
||||
? "aborted"
|
||||
: "error";
|
||||
recordOp({ op, outcome, durationMs: Date.now() - start });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# CRUD primitives
|
||||
|
||||
## Purpose
|
||||
|
||||
This module hosts the pure CRUD primitives that `IndexedDbStorage` composes its public methods from. Splitting them out keeps the class file under the 300-line hard limit and makes each operation independently testable through the public class — every primitive flows through a `withStore(...)` transaction and surfaces typed `IndexedDbError`s. The split also documents the natural seams of the adapter so future migrations (e.g. structured-clone payload mode, batch transactions) have an obvious extension point.
|
||||
|
||||
## Features
|
||||
|
||||
- `getEntry(ctx, key)` — read a single row by primary key.
|
||||
- `putEntry(ctx, key, value, options?)` — write a row; computes `createdAt`/`modifiedAt`/`expiresAt` against the injected clock; emits the `set` write-byte sample.
|
||||
- `deleteEntry(ctx, key)` — `false` when the key was absent, `true` when it existed and was deleted; uses a read-before-write probe so callers can rely on a stable `delete()` return.
|
||||
- `clearStore(ctx)` — wipe every entry.
|
||||
- `listKeys(ctx, options?)` — `paginateKeys(...)` over a cursor walk that skips expired rows.
|
||||
- `sizeAcrossLive(ctx)` — sum `metadata.size` across live entries.
|
||||
- `valueFromEntry(entry)` / `withMetadata(entry)` / `partialSlice(entry, offset, length)` — pure helpers that materialise the public-facing return shape and emit the right OTel sample.
|
||||
- `isLive(entry, atMs)` — TTL discriminator.
|
||||
- `nowMs(clock)` — wall-clock helper that honours the injected `Clock`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEntry,
|
||||
putEntry,
|
||||
type CrudContext,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function roundtrip(ctx: CrudContext, key: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
||||
await putEntry(ctx, key, bytes, undefined);
|
||||
const entry = await getEntry(ctx, key);
|
||||
return entry ? new Uint8Array(entry.data) : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
clearStore,
|
||||
deleteEntry,
|
||||
listKeys,
|
||||
sizeAcrossLive,
|
||||
type CrudContext,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function compactExpired(ctx: CrudContext): Promise<number> {
|
||||
const before = await sizeAcrossLive(ctx);
|
||||
const { keys } = await listKeys(ctx, undefined);
|
||||
for (const key of keys) {
|
||||
await deleteEntry(ctx, key);
|
||||
}
|
||||
await clearStore(ctx);
|
||||
return before;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CrudContext` — `{ db: IdbDatabaseLike; storeName: string; clock: Clock | undefined }`.
|
||||
- `isLive(entry: IndexedDbEntry, atMs: number): boolean` — `true` when no `expiresAt` is set OR `expiresAt > atMs`.
|
||||
- `getEntry(ctx, key): Promise<IndexedDbEntry | undefined>` — single `objectStore.get`.
|
||||
- `putEntry(ctx, key, value, options?): Promise<void>` — single `objectStore.put`; emits the write OTel sample.
|
||||
- `deleteEntry(ctx, key): Promise<boolean>` — probe-then-delete; returns existence.
|
||||
- `clearStore(ctx): Promise<void>` — `objectStore.clear`.
|
||||
- `listKeys(ctx, options?): Promise<StorageListResult>` — cursor walk → `paginateKeys`.
|
||||
- `sizeAcrossLive(ctx): Promise<number>` — cursor walk summing `metadata.size`.
|
||||
- `valueFromEntry(entry: IndexedDbEntry): Uint8Array` — clones the stored buffer; emits the read OTel sample.
|
||||
- `withMetadata(entry): StorageResult<Uint8Array>` — clones + projects the metadata.
|
||||
- `partialSlice(entry, offset, length): Uint8Array` — bounded `subarray`; emits the partial-read OTel sample.
|
||||
- `nowMs(clock?: Clock): number` — `clock() ?? Date.now()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`putEntry`'s metadata builder uses an `expiresAt`-spread to avoid emitting `expiresAt: undefined` in JSON-serialised contexts; the same approach is mirrored on the `IndexedDbEntry` itself for parity with the schema. `deleteEntry`'s probe step is a deliberate cost — IndexedDB's `objectStore.delete` returns `undefined` whether or not the entry existed, so a single round-trip cannot satisfy the `AbstractStorage.delete: Promise<boolean>` contract. Bundling the probe inside the same `readonly` transaction would not be safe with the subsequent `readwrite` transaction either, so we accept the two-round-trip cost for a correct `boolean`. `valueFromEntry` clones the stored buffer (`new Uint8Array(entry.data)`) so callers cannot mutate the IndexedDB-owned bytes — `fake-indexeddb` returns wrapped arrays whose mutation would corrupt subsequent reads.
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
StorageMetadata,
|
||||
StorageOptions,
|
||||
StorageResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { recordOp } from "../otel/otel.js";
|
||||
import type { IdbDatabaseLike } from "../types/idb-like.types.js";
|
||||
import type { IndexedDbEntry } from "../types/indexeddb-storage.types.js";
|
||||
import { mapIndexedDbError } from "./indexeddb-storage.errors.js";
|
||||
import {
|
||||
collectLiveEntries,
|
||||
paginateKeys,
|
||||
promisifyRequest,
|
||||
withStore,
|
||||
} from "./indexeddb-storage.ops.js";
|
||||
|
||||
export interface CrudContext {
|
||||
db: IdbDatabaseLike;
|
||||
storeName: string;
|
||||
clock: Clock | undefined;
|
||||
}
|
||||
|
||||
function now(clock: Clock | undefined): number {
|
||||
return clock?.() ?? Date.now();
|
||||
}
|
||||
|
||||
export function isLive(entry: IndexedDbEntry, atMs: number): boolean {
|
||||
return entry.expiresAt === undefined || entry.expiresAt > atMs;
|
||||
}
|
||||
|
||||
export function getEntry(
|
||||
ctx: CrudContext,
|
||||
key: string
|
||||
): Promise<IndexedDbEntry | undefined> {
|
||||
return withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readonly",
|
||||
run: async (store) =>
|
||||
promisifyRequest<IndexedDbEntry | undefined>(store.get(key)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function putEntry(
|
||||
ctx: CrudContext,
|
||||
key: string,
|
||||
value: Uint8Array,
|
||||
options: StorageOptions | undefined
|
||||
): Promise<void> {
|
||||
const stamp = now(ctx.clock);
|
||||
const expiresAt =
|
||||
options?.ttl !== undefined ? stamp + options.ttl * 1000 : undefined;
|
||||
const metadata: StorageMetadata = {
|
||||
size: value.byteLength,
|
||||
createdAt: stamp,
|
||||
modifiedAt: stamp,
|
||||
metadata: options?.metadata,
|
||||
...(expiresAt !== undefined ? { expiresAt } : {}),
|
||||
};
|
||||
const entry: IndexedDbEntry = {
|
||||
key,
|
||||
data: value,
|
||||
metadata,
|
||||
expiresAt,
|
||||
};
|
||||
try {
|
||||
await withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readwrite",
|
||||
run: async (store) => promisifyRequest<unknown>(store.put(entry)),
|
||||
});
|
||||
} catch (err) {
|
||||
throw mapIndexedDbError(err, key);
|
||||
}
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: value.byteLength,
|
||||
direction: "write",
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEntry(
|
||||
ctx: CrudContext,
|
||||
key: string
|
||||
): Promise<boolean> {
|
||||
const existed = await getEntry(ctx, key);
|
||||
if (!existed) return false;
|
||||
try {
|
||||
await withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readwrite",
|
||||
run: async (store) => promisifyRequest<unknown>(store.delete(key)),
|
||||
});
|
||||
} catch (err) {
|
||||
throw mapIndexedDbError(err, key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearStore(ctx: CrudContext): Promise<void> {
|
||||
try {
|
||||
await withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readwrite",
|
||||
run: async (store) => promisifyRequest<unknown>(store.clear()),
|
||||
});
|
||||
} catch (err) {
|
||||
throw mapIndexedDbError(err, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listKeys(
|
||||
ctx: CrudContext,
|
||||
options: StorageListOptions | undefined
|
||||
): Promise<StorageListResult> {
|
||||
const entries = await withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readonly",
|
||||
run: async (store) => collectLiveEntries(store, now(ctx.clock)),
|
||||
});
|
||||
return paginateKeys(
|
||||
entries.map((e) => e.key),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export async function sizeAcrossLive(ctx: CrudContext): Promise<number> {
|
||||
const entries = await withStore({
|
||||
db: ctx.db,
|
||||
storeName: ctx.storeName,
|
||||
mode: "readonly",
|
||||
run: async (store) => collectLiveEntries(store, now(ctx.clock)),
|
||||
});
|
||||
let total = 0;
|
||||
for (const entry of entries) total += entry.metadata.size;
|
||||
return total;
|
||||
}
|
||||
|
||||
export function valueFromEntry(entry: IndexedDbEntry): Uint8Array {
|
||||
const bytes = new Uint8Array(entry.data);
|
||||
recordOp({
|
||||
op: "get",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: bytes.byteLength,
|
||||
direction: "read",
|
||||
});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function withMetadata(entry: IndexedDbEntry): StorageResult<Uint8Array> {
|
||||
return { value: new Uint8Array(entry.data), metadata: entry.metadata };
|
||||
}
|
||||
|
||||
export function partialSlice(
|
||||
entry: IndexedDbEntry,
|
||||
offset: number,
|
||||
length: number
|
||||
): Uint8Array {
|
||||
const data = new Uint8Array(entry.data);
|
||||
const start = Math.min(offset, data.byteLength);
|
||||
const end = Math.min(start + length, data.byteLength);
|
||||
const slice = data.subarray(start, end);
|
||||
recordOp({
|
||||
op: "getPartial",
|
||||
outcome: "success",
|
||||
durationMs: 0,
|
||||
bytes: slice.byteLength,
|
||||
direction: "read",
|
||||
});
|
||||
return slice;
|
||||
}
|
||||
|
||||
export function nowMs(clock: Clock | undefined): number {
|
||||
return now(clock);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# IndexedDB error mapping helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module concentrates the DOMException-to-`IndexedDbError` translation that the `IndexedDbStorage` class relies on. Concentrating it here keeps the storage class itself focused on transaction orchestration and makes the mapping table the single auditable surface for "which IDB DOMException becomes which typed code." The helpers also export the small `assertNotAborted` / `assertNonNegInt` guards used at the top of every public async method.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapIndexedDbError(err, key?)` — translates an unknown error into a typed `IndexedDbError`. Recognises `NotAllowedError` → `PERMISSION_DENIED`, `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `QUOTA_EXCEEDED`, `VersionError` → `VERSION_ERROR`, `AbortError` → `ABORTED`, `TransactionInactiveError` → `TRANSACTION_ABORTED`. Falls back to `IO_FAILED` with the original error attached as `cause`.
|
||||
- `isNotFoundError(err)` — discriminator used by `get` / `delete` paths to return `undefined` / `false` rather than throw.
|
||||
- `assertNotAborted(signal, key?)` — throws `INDEXEDDB.ABORTED` immediately when a pre-aborted signal is observed.
|
||||
- `assertNonNegInt(key, label, value)` — throws `INDEXEDDB.INVALID_KEY` when a partial-read offset/length is not a non-negative integer.
|
||||
- Pre-existing `IndexedDbError` instances are passed through untouched — a caller-thrown `INDEXEDDB.UNAVAILABLE` from the adapter never gets reclassified.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapIndexedDbError } from "@suaveplan/storage-browser-indexeddb";
|
||||
import { IndexedDbError } from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
|
||||
try {
|
||||
await doIdbOperation();
|
||||
} catch (raw) {
|
||||
const mapped: IndexedDbError = mapIndexedDbError(raw, "user/42");
|
||||
throw mapped;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapIndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function safeReadSlice(
|
||||
open: () => Promise<{ value: Uint8Array } | undefined>,
|
||||
key: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Uint8Array | null> {
|
||||
assertNotAborted(signal, key);
|
||||
assertNonNegInt(key, "offset", offset);
|
||||
assertNonNegInt(key, "length", length);
|
||||
try {
|
||||
const row = await open();
|
||||
if (row === undefined) return null;
|
||||
return row.value.subarray(offset, offset + length);
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
throw mapIndexedDbError(err, key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `mapIndexedDbError(err: unknown, key?: string): IndexedDbError` — converts any error to a typed error. Existing `IndexedDbError` instances pass through unmodified. Recognised DOMException `name`s map to specific codes; unknown errors map to `IO_FAILED` preserving `cause`.
|
||||
- `isNotFoundError(err: unknown): boolean` — `true` when `err` is a `NotFoundError` DOMException OR an `IndexedDbError(INDEXEDDB.NOT_FOUND)`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key?: string): void` — throws `IndexedDbError(INDEXEDDB.ABORTED)` when `signal.aborted` is true.
|
||||
- `assertNonNegInt(key: string, label: string, value: number): void` — throws `IndexedDbError(INDEXEDDB.INVALID_KEY)` when `value` is not a non-negative integer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Firefox-specific quota error name `NS_ERROR_DOM_QUOTA_REACHED` is preserved alongside the spec-compliant `QuotaExceededError`. The catch-all `IO_FAILED` mapping intentionally keeps the original error's `message` in the thrown message so log readers can correlate the typed error with the underlying DOMException without rebuilding the cause chain. `assertNonNegInt` uses `Number.isInteger` (NOT `value % 1 === 0`) so floating-point representations of negative values like `-0` are rejected — `Number.isInteger(-0)` is `true`, but `-0 < 0` is `false`; we accept this edge case to align with the OPFS sibling's behavior.
|
||||
@@ -0,0 +1,120 @@
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/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
|
||||
* sentinel) — meaning the underlying entry simply does not exist.
|
||||
*/
|
||||
export function isNotFoundError(err: unknown): boolean {
|
||||
if (err instanceof IndexedDbError && err.code === INDEXEDDB_CODES.NOT_FOUND)
|
||||
return true;
|
||||
return nameOf(err) === "NotFoundError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map any error raised by an underlying IndexedDB call to the typed
|
||||
* `IndexedDbError` surface. Pre-existing `IndexedDbError` instances pass
|
||||
* through unmodified so a caller-thrown `INDEXEDDB.UNAVAILABLE` from
|
||||
* `defaultIndexedDB()` reaches the boundary untouched.
|
||||
*/
|
||||
export function mapIndexedDbError(
|
||||
err: unknown,
|
||||
key: string | undefined
|
||||
): IndexedDbError {
|
||||
if (err instanceof IndexedDbError) return err;
|
||||
const name = nameOf(err);
|
||||
const cause = err instanceof Error ? err : undefined;
|
||||
if (name === "NotAllowedError") {
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.PERMISSION_DENIED,
|
||||
`IndexedDB permission denied${key !== undefined ? ` for ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (
|
||||
name === "QuotaExceededError" ||
|
||||
name === "NS_ERROR_DOM_QUOTA_REACHED"
|
||||
) {
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED,
|
||||
`IndexedDB quota exceeded${key !== undefined ? ` writing ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (name === "VersionError") {
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.VERSION_ERROR,
|
||||
"IndexedDB version error during open",
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (name === "AbortError") {
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.ABORTED,
|
||||
"IndexedDB operation aborted",
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (name === "TransactionInactiveError") {
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.TRANSACTION_ABORTED,
|
||||
"IndexedDB transaction aborted",
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : "IndexedDB operation failed";
|
||||
return new IndexedDbError(
|
||||
INDEXEDDB_CODES.IO_FAILED,
|
||||
`IndexedDB IO failure${key !== undefined ? ` on ${key}` : ""}: ${message}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `IndexedDbError(INDEXEDDB.ABORTED)` when `signal` is already
|
||||
* aborted. Called at the top of every public async method before any IDB
|
||||
* call is issued.
|
||||
*/
|
||||
export function assertNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
key: string | undefined
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.ABORTED,
|
||||
"Operation aborted",
|
||||
key !== undefined ? { key } : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a non-negative integer used for partial-read offsets/lengths.
|
||||
* Throws `IndexedDbError(INDEXEDDB.INVALID_KEY)` for invalid values; the
|
||||
* code matches the OPFS sibling's choice — malformed numeric args are 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 IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
`${label} must be a non-negative integer (got ${value})`,
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
IdbObjectStoreLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
} from "../types/idb-like.types.js";
|
||||
import {
|
||||
assertNonNegInt,
|
||||
assertNotAborted,
|
||||
isNotFoundError,
|
||||
mapIndexedDbError,
|
||||
} from "./indexeddb-storage.errors.js";
|
||||
import {
|
||||
collectLiveEntries,
|
||||
promisifyRequest,
|
||||
promisifyTransaction,
|
||||
} from "./indexeddb-storage.ops.js";
|
||||
|
||||
function makeReq(): IdbRequestLike {
|
||||
return {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapIndexedDbError", () => {
|
||||
it("passes IndexedDbError through unchanged", { timeout: 5000 }, () => {
|
||||
const original = new IndexedDbError(
|
||||
INDEXEDDB_CODES.UNAVAILABLE,
|
||||
"original"
|
||||
);
|
||||
const mapped = mapIndexedDbError(original, "k");
|
||||
expect(mapped).toBe(original);
|
||||
});
|
||||
|
||||
it("maps NotAllowedError → PERMISSION_DENIED with key + cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = Object.assign(new Error("denied"), {
|
||||
name: "NotAllowedError",
|
||||
});
|
||||
const mapped = mapIndexedDbError(cause, "user/1");
|
||||
expect(mapped.code).toBe(INDEXEDDB_CODES.PERMISSION_DENIED);
|
||||
expect(mapped.key).toBe("user/1");
|
||||
expect(mapped.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("maps NotAllowedError without key", { timeout: 5000 }, () => {
|
||||
const mapped = mapIndexedDbError(
|
||||
Object.assign(new Error("denied"), { name: "NotAllowedError" }),
|
||||
undefined
|
||||
);
|
||||
expect(mapped.code).toBe(INDEXEDDB_CODES.PERMISSION_DENIED);
|
||||
expect(mapped.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps QuotaExceededError → QUOTA_EXCEEDED", { timeout: 5000 }, () => {
|
||||
const cause = Object.assign(new Error("quota"), {
|
||||
name: "QuotaExceededError",
|
||||
});
|
||||
expect(mapIndexedDbError(cause, "k").code).toBe(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED
|
||||
);
|
||||
});
|
||||
|
||||
it("maps NS_ERROR_DOM_QUOTA_REACHED (Firefox) → QUOTA_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = Object.assign(new Error("ff quota"), {
|
||||
name: "NS_ERROR_DOM_QUOTA_REACHED",
|
||||
});
|
||||
expect(mapIndexedDbError(cause, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED
|
||||
);
|
||||
});
|
||||
|
||||
it("maps VersionError → VERSION_ERROR", { timeout: 5000 }, () => {
|
||||
const cause = Object.assign(new Error("v"), { name: "VersionError" });
|
||||
expect(mapIndexedDbError(cause, "k").code).toBe(
|
||||
INDEXEDDB_CODES.VERSION_ERROR
|
||||
);
|
||||
expect(mapIndexedDbError(cause, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.VERSION_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("maps AbortError → ABORTED", { timeout: 5000 }, () => {
|
||||
const cause = Object.assign(new Error("a"), { name: "AbortError" });
|
||||
expect(mapIndexedDbError(cause, "k").code).toBe(
|
||||
INDEXEDDB_CODES.ABORTED
|
||||
);
|
||||
expect(mapIndexedDbError(cause, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.ABORTED
|
||||
);
|
||||
});
|
||||
|
||||
it("maps TransactionInactiveError → TRANSACTION_ABORTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = Object.assign(new Error("tx"), {
|
||||
name: "TransactionInactiveError",
|
||||
});
|
||||
expect(mapIndexedDbError(cause, "k").code).toBe(
|
||||
INDEXEDDB_CODES.TRANSACTION_ABORTED
|
||||
);
|
||||
expect(mapIndexedDbError(cause, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.TRANSACTION_ABORTED
|
||||
);
|
||||
});
|
||||
|
||||
it("maps unknown DOMException name → IO_FAILED", { timeout: 5000 }, () => {
|
||||
const cause = Object.assign(new Error("?"), { name: "WhateverError" });
|
||||
const mapped = mapIndexedDbError(cause, "k");
|
||||
expect(mapped.code).toBe(INDEXEDDB_CODES.IO_FAILED);
|
||||
expect(mapped.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("maps non-Error throwables → IO_FAILED", { timeout: 5000 }, () => {
|
||||
const mapped = mapIndexedDbError("a string", "k");
|
||||
expect(mapped.code).toBe(INDEXEDDB_CODES.IO_FAILED);
|
||||
expect(mapped.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps null/undefined → IO_FAILED", { timeout: 5000 }, () => {
|
||||
expect(mapIndexedDbError(null, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
expect(mapIndexedDbError(undefined, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an object whose name is non-string → IO_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Triggers the `typeof n !== "string"` branch in nameOf.
|
||||
const oddObj = { name: 42, message: "?" } as unknown;
|
||||
expect(mapIndexedDbError(oddObj, undefined).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotFoundError", () => {
|
||||
it("recognises a NotFoundError DOMException", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
isNotFoundError(
|
||||
Object.assign(new Error("missing"), { name: "NotFoundError" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises IndexedDbError(NOT_FOUND)", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
isNotFoundError(
|
||||
new IndexedDbError(INDEXEDDB_CODES.NOT_FOUND, "missing")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", { timeout: 5000 }, () => {
|
||||
expect(isNotFoundError(new Error("other"))).toBe(false);
|
||||
expect(isNotFoundError(null)).toBe(false);
|
||||
expect(isNotFoundError("string")).toBe(false);
|
||||
expect(
|
||||
isNotFoundError(new IndexedDbError(INDEXEDDB_CODES.IO_FAILED, "io"))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNotAborted + assertNonNegInt", () => {
|
||||
it("assertNotAborted no-ops without a signal", { timeout: 5000 }, () => {
|
||||
assertNotAborted(undefined, undefined);
|
||||
});
|
||||
|
||||
it("assertNotAborted no-ops with a non-aborted signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
assertNotAborted(ac.signal, "k");
|
||||
});
|
||||
|
||||
it("assertNotAborted throws ABORTED on pre-aborted signal with key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
assertNotAborted(ac.signal, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(INDEXEDDB_CODES.ABORTED);
|
||||
expect((err as IndexedDbError).key).toBe("k");
|
||||
}
|
||||
});
|
||||
|
||||
it("assertNotAborted throws ABORTED on pre-aborted signal without key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
assertNotAborted(ac.signal, undefined);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(INDEXEDDB_CODES.ABORTED);
|
||||
}
|
||||
});
|
||||
|
||||
it("assertNonNegInt accepts 0 and positives", { timeout: 5000 }, () => {
|
||||
assertNonNegInt("k", "offset", 0);
|
||||
assertNonNegInt("k", "offset", 42);
|
||||
});
|
||||
|
||||
it("assertNonNegInt rejects negatives", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertNonNegInt("k", "offset", -1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("assertNonNegInt rejects non-integers", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertNonNegInt("k", "offset", 1.5);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("promisifyRequest", () => {
|
||||
it("resolves with the result on onsuccess", { timeout: 5000 }, async () => {
|
||||
const req = makeReq();
|
||||
const p = promisifyRequest<number>(req);
|
||||
(req as { result: unknown }).result = 42;
|
||||
req.onsuccess?.(new Event("success"));
|
||||
expect(await p).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects with the request's error on onerror", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const req = makeReq();
|
||||
const p = promisifyRequest(req);
|
||||
(req as { error: unknown }).error = Object.assign(new Error("err"), {
|
||||
name: "MyError",
|
||||
}) as unknown as DOMException;
|
||||
req.onerror?.(new Event("error"));
|
||||
await expect(p).rejects.toMatchObject({ message: "err" });
|
||||
});
|
||||
|
||||
it("rejects with a synthetic Error when request.error is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const req = makeReq();
|
||||
const p = promisifyRequest(req);
|
||||
req.onerror?.(new Event("error"));
|
||||
await expect(p).rejects.toThrow(/IndexedDB request error/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promisifyTransaction", () => {
|
||||
function makeTx(): IdbTransactionLike {
|
||||
return {
|
||||
objectStore: () => ({}) as IdbObjectStoreLike,
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
abort() {},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves on oncomplete", { timeout: 5000 }, async () => {
|
||||
const tx = makeTx();
|
||||
const p = promisifyTransaction(tx);
|
||||
tx.oncomplete?.(new Event("complete"));
|
||||
await p;
|
||||
});
|
||||
|
||||
it("rejects on onerror with the transaction's error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tx = makeTx();
|
||||
const p = promisifyTransaction(tx);
|
||||
(tx as { error: unknown }).error = new Error("tx err") as DOMException;
|
||||
tx.onerror?.(new Event("error"));
|
||||
await expect(p).rejects.toThrow(/tx err/);
|
||||
});
|
||||
|
||||
it("rejects on onerror with synthetic error when null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tx = makeTx();
|
||||
const p = promisifyTransaction(tx);
|
||||
tx.onerror?.(new Event("error"));
|
||||
await expect(p).rejects.toThrow(/IndexedDB transaction error/);
|
||||
});
|
||||
|
||||
it("rejects on onabort", { timeout: 5000 }, async () => {
|
||||
const tx = makeTx();
|
||||
const p = promisifyTransaction(tx);
|
||||
tx.onabort?.(new Event("abort"));
|
||||
await expect(p).rejects.toThrow(/IndexedDB transaction aborted/);
|
||||
});
|
||||
|
||||
it("rejects on onabort with the transaction's error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tx = makeTx();
|
||||
const p = promisifyTransaction(tx);
|
||||
(tx as { error: unknown }).error = new Error(
|
||||
"explicit abort"
|
||||
) as DOMException;
|
||||
tx.onabort?.(new Event("abort"));
|
||||
await expect(p).rejects.toThrow(/explicit abort/);
|
||||
});
|
||||
});
|
||||
describe("collectLiveEntries", () => {
|
||||
it("rejects when the cursor errors with no error attached", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeStore: IdbObjectStoreLike = {
|
||||
...stubStore(),
|
||||
openCursor: () => {
|
||||
const req = makeReq();
|
||||
setTimeout(() => req.onerror?.(new Event("error")), 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
await expect(collectLiveEntries(fakeStore, 0)).rejects.toThrow(
|
||||
/IndexedDB cursor error/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with the request's error", { timeout: 5000 }, async () => {
|
||||
const fakeStore: IdbObjectStoreLike = {
|
||||
...stubStore(),
|
||||
openCursor: () => {
|
||||
const req = makeReq();
|
||||
setTimeout(() => {
|
||||
(req as { error: unknown }).error = new Error("c err");
|
||||
req.onerror?.(new Event("error"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
await expect(collectLiveEntries(fakeStore, 0)).rejects.toThrow(/c err/);
|
||||
});
|
||||
});
|
||||
|
||||
function stubStore(): IdbObjectStoreLike {
|
||||
const req = makeReq();
|
||||
return {
|
||||
put: () => req,
|
||||
get: () => req,
|
||||
delete: () => req,
|
||||
clear: () => req,
|
||||
count: () => req,
|
||||
openCursor: () => req,
|
||||
createIndex: () => undefined,
|
||||
index: () => undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# `IndexedDbStorage`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - indexeddb-storage.ops — This module owns the Promis…
|
||||
- [indexeddb-storage.ops](./indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
- [indexeddb-storage.ops](./indexeddb-storage.ops.md) — This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separatin…
|
||||
|
||||
## Purpose
|
||||
|
||||
`IndexedDbStorage` is the public `AbstractStorage` adapter exposed by `@suaveplan/storage-browser-indexeddb`. It provides persistent, origin-scoped, key-value storage over the W3C IndexedDB API for browser-tier consumers — vault snapshots, drive chunk caches, in-app i18n catalogues, password manager records — with the same surface every other storage backend implements. The implementation routes every public method through the lazy database opener, transaction-aware CRUD primitives, the canonical OTel recorder, and the strict key sanitiser, so callers get observability, AbortSignal support, structured errors, and TTL on every operation without opting in.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every `AbstractStorage` abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getMetadata`, `getWithMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`).
|
||||
- AbortSignal threaded through every async surface; pre-aborted signals throw `INDEXEDDB.ABORTED` before any IDB call.
|
||||
- Configurable database name / object-store name / version with sensible defaults (`suaveplan` / `storage` / `1`).
|
||||
- TTL via `expiresAt` field + IndexedDB `expiresAt` index; expired rows are skipped on read and lazily deleted.
|
||||
- Per-record metadata (`size`, `createdAt`, `modifiedAt`, optional `expiresAt`, arbitrary `metadata` from `StorageOptions.metadata`).
|
||||
- Prefix-based listing with pagination via `paginateKeys` (sorted, offset/limit-aware).
|
||||
- Strict key sanitisation (no control chars, no `..`, no absolute paths, ≤ 1024 chars).
|
||||
- Injectable `IDBFactory` adapter and injectable `Clock` for deterministic tests.
|
||||
- Lifecycle dedup: concurrent `get`/`set`/`delete` calls share a single in-flight `open` promise.
|
||||
- DOMException → typed `IndexedDbError` mapping (`QuotaExceededError`, `NS_ERROR_DOM_QUOTA_REACHED`, `NotAllowedError`, `VersionError`, `AbortError`).
|
||||
- Idempotent `close()` — re-opens on next call.
|
||||
- Capabilities: `partialReads: true`, `partialWrites: false`, `nativeStreams: false`, `atomicWrites: true`, `contentAddressable: false`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createIndexedDbStorage } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
const storage = createIndexedDbStorage({ dbName: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const value = await storage.get("user/42");
|
||||
console.log(new TextDecoder().decode(value));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { IndexedDbStorage } from "@suaveplan/storage-browser-indexeddb";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
|
||||
const fakeFactory = new IDBFactory();
|
||||
const storage = new IndexedDbStorage({
|
||||
dbName: "app-test",
|
||||
storeName: "kv",
|
||||
version: 1,
|
||||
indexedDB: { open: (n, v) => fakeFactory.open(n, v) },
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
// TTL — entry expires 60s after the injected clock.
|
||||
await storage.set(
|
||||
"session/123",
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{ ttl: 60, metadata: { userId: "u-42" } },
|
||||
);
|
||||
|
||||
const metadata = await storage.getMetadata("session/123");
|
||||
console.log(metadata?.expiresAt); // 1_700_000_060_000
|
||||
|
||||
const partial = await storage.getPartial("session/123", 1, 2);
|
||||
console.log(partial); // Uint8Array(2)[2, 3]
|
||||
|
||||
await storage.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IndexedDbStorage extends AbstractStorage` — class. Constructor `(options?: IndexedDbStorageOptions)`. Throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on malformed options. All abstract methods from `AbstractStorage` are implemented.
|
||||
- `createIndexedDbStorage(options?): IndexedDbStorage` — convenience factory.
|
||||
- `capabilities` — `{ partialReads: true; partialWrites: false; nativeStreams: false; atomicWrites: true; contentAddressable: false }`.
|
||||
- `close(): Promise<void>` — closes the underlying database connection; idempotent.
|
||||
- Every method takes a final optional `signal?: AbortSignal` that aborts the operation with `IndexedDbError(INDEXEDDB.ABORTED)` when pre-aborted.
|
||||
- `set(key, value, options?)` — `options.ttl` is seconds; `options.metadata` is a free-form record persisted under `metadata.metadata`.
|
||||
- `getPartial(key, offset, length, signal?)` — returns a `Uint8Array.subarray` view into the stored bytes (caller MUST treat the return value as read-only; mutating it does NOT update IndexedDB). Out-of-range offsets clamp to the end of the buffer; the result may be shorter than `length` when the underlying value is smaller.
|
||||
- `setPartial(key, offset, bytes, signal?)` — read-modify-write fallback from `AbstractStorage.defaultSetPartial`. IndexedDB has no random-access write API so the entire row is rewritten on every call.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The database open is deduplicated via a private in-flight promise (`#openPromise`) so concurrent first-touch operations share a single `IDBOpenDBRequest`. The `expiresAt` index is declared in `onupgradeneeded` and currently used as a metadata field; a follow-up change can wire an idle-time sweep against the index to delete expired rows proactively. The TTL is evaluated against the injected `Clock` (defaults to `Date.now()`) on every read; expired rows are lazily deleted in the read transaction's wake (no `await` is necessary on the cleanup because the storage class returns `undefined`/`null` immediately). The `delete()` method probes the row first so it can return a stable `boolean` — IndexedDB's `objectStore.delete` resolves successfully whether or not the entry existed, so we accept the second round-trip in exchange for a correct AbstractStorage contract. Streams use the `AbstractStorage.defaultCreateReadStream` / `defaultCreateWriteStream` fallbacks — IndexedDB does not expose a native streaming surface, so `capabilities.nativeStreams` is reported as `false`. `setPartial` uses `defaultSetPartial` because there is no random-access write API on an object-store row.
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
type IndexedDbError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
IdbDatabaseLike,
|
||||
IdbObjectStoreLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
} from "../types/idb-like.types.js";
|
||||
import { deleteEntry } from "./indexeddb-storage.crud.js";
|
||||
import { openDatabase } from "./indexeddb-storage.ops.js";
|
||||
|
||||
function makeReq(): IdbRequestLike {
|
||||
return {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("openDatabase", () => {
|
||||
it("rejects with mapped error when the request emits onerror", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory = {
|
||||
open() {
|
||||
const req = {
|
||||
result: null as unknown as IdbDatabaseLike,
|
||||
error: null as unknown as DOMException,
|
||||
onsuccess: null as
|
||||
| ((this: unknown, ev: Event) => unknown)
|
||||
| null,
|
||||
onerror: null as
|
||||
| ((this: unknown, ev: Event) => unknown)
|
||||
| null,
|
||||
onupgradeneeded: null,
|
||||
onblocked: null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
(req as { error: unknown }).error = Object.assign(
|
||||
new Error("open fail"),
|
||||
{ name: "VersionError" }
|
||||
) as unknown as DOMException;
|
||||
req.onerror?.(new Event("error"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
try {
|
||||
await openDatabase({
|
||||
factory,
|
||||
dbName: "x",
|
||||
storeName: "s",
|
||||
version: 1,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.VERSION_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates object store + index on first upgradeneeded", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const createdIndexes: string[] = [];
|
||||
const fakeStore = {
|
||||
createIndex(name: string) {
|
||||
createdIndexes.push(name);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const fakeDb = {
|
||||
name: "x",
|
||||
version: 1,
|
||||
close: () => undefined,
|
||||
objectStoreNames: { contains: () => false },
|
||||
transaction: () => ({}) as IdbTransactionLike,
|
||||
createObjectStore: () => fakeStore as never,
|
||||
};
|
||||
const factory = {
|
||||
open() {
|
||||
const req = {
|
||||
result: fakeDb,
|
||||
error: null as unknown as DOMException,
|
||||
onsuccess: null as
|
||||
| ((this: unknown, ev: Event) => unknown)
|
||||
| null,
|
||||
onerror: null,
|
||||
onupgradeneeded: null as
|
||||
| ((this: unknown, ev: never) => unknown)
|
||||
| null,
|
||||
onblocked: null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
req.onupgradeneeded?.(new Event("upgrade") as never);
|
||||
req.onsuccess?.(new Event("success"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
await openDatabase({
|
||||
factory,
|
||||
dbName: "x",
|
||||
storeName: "s",
|
||||
version: 1,
|
||||
});
|
||||
expect(createdIndexes).toEqual(["expiresAt"]);
|
||||
});
|
||||
|
||||
it("skips creating the object store when it already exists", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const fakeDb = {
|
||||
name: "x",
|
||||
version: 1,
|
||||
close: () => undefined,
|
||||
objectStoreNames: { contains: () => true },
|
||||
transaction: () => ({}) as IdbTransactionLike,
|
||||
createObjectStore: (() => {
|
||||
calls.push("created");
|
||||
return {} as never;
|
||||
}) as never,
|
||||
};
|
||||
const factory = {
|
||||
open() {
|
||||
const req = {
|
||||
result: fakeDb,
|
||||
error: null as unknown as DOMException,
|
||||
onsuccess: null as
|
||||
| ((this: unknown, ev: Event) => unknown)
|
||||
| null,
|
||||
onerror: null,
|
||||
onupgradeneeded: null as
|
||||
| ((this: unknown, ev: never) => unknown)
|
||||
| null,
|
||||
onblocked: null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
req.onupgradeneeded?.(new Event("upgrade") as never);
|
||||
req.onsuccess?.(new Event("success"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
await openDatabase({
|
||||
factory,
|
||||
dbName: "x",
|
||||
storeName: "s",
|
||||
version: 1,
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteEntry — error path", () => {
|
||||
it("maps a failing delete via mapIndexedDbError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingStore: IdbObjectStoreLike = {
|
||||
put: () => makeReq(),
|
||||
clear: () => makeReq(),
|
||||
count: () => makeReq(),
|
||||
openCursor: () => makeReq(),
|
||||
createIndex: () => undefined,
|
||||
index: () => undefined,
|
||||
get: () => {
|
||||
const req = makeReq();
|
||||
setTimeout(() => {
|
||||
(req as { result: unknown }).result = {
|
||||
key: "k",
|
||||
data: new Uint8Array(),
|
||||
metadata: { size: 0, createdAt: 0, modifiedAt: 0 },
|
||||
expiresAt: undefined,
|
||||
};
|
||||
req.onsuccess?.(new Event("success"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
delete: () => {
|
||||
const req = makeReq();
|
||||
setTimeout(() => {
|
||||
(req as { error: unknown }).error = Object.assign(
|
||||
new Error("del fail"),
|
||||
{ name: "UnknownError" }
|
||||
) as unknown as DOMException;
|
||||
req.onerror?.(new Event("error"));
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
name: "x",
|
||||
version: 1,
|
||||
objectStoreNames: { contains: () => true },
|
||||
close: () => undefined,
|
||||
transaction: () => {
|
||||
const newTx: IdbTransactionLike = {
|
||||
objectStore: () => failingStore,
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
abort: () => undefined,
|
||||
error: null,
|
||||
};
|
||||
setTimeout(
|
||||
() => newTx.oncomplete?.(new Event("complete")),
|
||||
10
|
||||
);
|
||||
return newTx;
|
||||
},
|
||||
createObjectStore: () => failingStore,
|
||||
} as IdbDatabaseLike,
|
||||
storeName: "s",
|
||||
clock: undefined,
|
||||
};
|
||||
try {
|
||||
await deleteEntry(ctx, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
# IndexedDB transaction helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the Promise-bridging glue around the callback-shaped `IDBRequest` and `IDBTransaction` APIs. Separating these helpers from the storage class keeps the main implementation focused on public-method semantics — every method ends up calling `withStore(...)` with a small `run` closure rather than open-coding the transaction lifecycle. The module also owns `openDatabase` (open + upgrade with the `expiresAt` index), the cursor walker that filters expired entries, and the pure pagination helper.
|
||||
|
||||
## Features
|
||||
|
||||
- `promisifyRequest<T>(req)` — generic Promise wrapper for any `IDBRequest`-shaped value.
|
||||
- `promisifyTransaction(tx)` — awaits `oncomplete` / rejects on `onerror` / `onabort`.
|
||||
- `openDatabase({ factory, dbName, storeName, version })` — opens the DB, runs the `onupgradeneeded` migration that creates the object store with `keyPath: "key"` and the `expiresAt` index.
|
||||
- `withStore({ db, storeName, mode, run })` — opens a transaction, runs the caller's work, and awaits the transaction's commit before resolving.
|
||||
- `paginateKeys(keys, options)` — pure prefix/offset/limit pagination producing a `StorageListResult`.
|
||||
- `collectLiveEntries(store, now)` — cursor walker that drops entries whose `expiresAt` is in the past.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
openDatabase,
|
||||
withStore,
|
||||
} from "@suaveplan/storage-browser-indexeddb";
|
||||
import type { IdbFactoryLike } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
async function readOne(factory: IdbFactoryLike, key: string): Promise<unknown> {
|
||||
const db = await openDatabase({
|
||||
factory,
|
||||
dbName: "suaveplan",
|
||||
storeName: "storage",
|
||||
version: 1,
|
||||
});
|
||||
return withStore({
|
||||
db,
|
||||
storeName: "storage",
|
||||
mode: "readonly",
|
||||
run: async (store) =>
|
||||
new Promise((resolve) => {
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(undefined);
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { collectLiveEntries, withStore } from "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
async function listKeys(db: import("@suaveplan/storage-browser-indexeddb/types").IdbDatabaseLike): Promise<string[]> {
|
||||
return withStore({
|
||||
db,
|
||||
storeName: "storage",
|
||||
mode: "readonly",
|
||||
run: async (store) => {
|
||||
const entries = await collectLiveEntries(store, Date.now());
|
||||
return entries.map((e) => e.key);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `promisifyRequest<T>(req: IdbRequestLike): Promise<T>` — Resolves with `req.result` on `onsuccess`, rejects with `req.error` (or a synthetic `Error`) on `onerror`.
|
||||
- `promisifyTransaction(tx: IdbTransactionLike): Promise<void>` — Resolves on `oncomplete`; rejects on `onerror` / `onabort`.
|
||||
- `OpenDbInput` — `{ factory, dbName, storeName, version }`.
|
||||
- `openDatabase(input: OpenDbInput): Promise<IdbDatabaseLike>` — Opens (or creates) the database, ensures the object store + `expiresAt` index exist via `onupgradeneeded`, surfaces failures through `mapIndexedDbError`.
|
||||
- `WithStoreInput<T>` — `{ db, storeName, mode, run }`.
|
||||
- `withStore<T>(input: WithStoreInput<T>): Promise<T>` — Runs `input.run(store)` inside a transaction and awaits commit before resolving.
|
||||
- `paginateKeys(keys: string[], options?: StorageListOptions): StorageListResult` — Pure pagination over sorted, prefix-filtered keys.
|
||||
- `collectLiveEntries(store, now: number): Promise<IndexedDbEntry[]>` — Cursor walker that skips entries whose `expiresAt` ≤ `now`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withStore` awaits both the caller's work AND the transaction's `oncomplete` event because IndexedDB only guarantees durability when the transaction commits. A caller that awaited only the `run` closure could observe a successful read followed by an abort that rolled back the just-completed transaction. `collectLiveEntries` captures `now` once so a slow cursor walk doesn't span the TTL boundary mid-iteration (otherwise list could return entry A as live and entry B as expired even though both expired during the call). The `openDatabase` upgrade logic is intentionally conservative: it only creates the store and index when missing, so a future migration is a forward addition rather than a destructive rebuild — when we bump `version` and need to migrate, that work lives in the same `onupgradeneeded` callback with a `request.transaction.oldVersion` check.
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import { EXPIRES_AT_INDEX } from "../constants/index.js";
|
||||
import type {
|
||||
IdbDatabaseLike,
|
||||
IdbFactoryLike,
|
||||
IdbObjectStoreLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
} from "../types/idb-like.types.js";
|
||||
import type { IndexedDbEntry } from "../types/indexeddb-storage.types.js";
|
||||
import { mapIndexedDbError } from "./indexeddb-storage.errors.js";
|
||||
|
||||
/**
|
||||
* Wrap an `IDBRequest`-shaped object in a Promise. Resolves with the
|
||||
* request's `result` on success; rejects with the request's `error` on
|
||||
* failure (or `Error("idb request error")` when `error` is null).
|
||||
*/
|
||||
export function promisifyRequest<T>(req: IdbRequestLike): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("IndexedDB request error"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an `IDBTransaction`-shaped object so callers can await its
|
||||
* `oncomplete` event. Resolves on `oncomplete`; rejects on `onerror` /
|
||||
* `onabort`.
|
||||
*/
|
||||
export function promisifyTransaction(tx: IdbTransactionLike): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () =>
|
||||
reject(tx.error ?? new Error("IndexedDB transaction error"));
|
||||
tx.onabort = () =>
|
||||
reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
export interface OpenDbInput {
|
||||
factory: IdbFactoryLike;
|
||||
dbName: string;
|
||||
storeName: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or create + upgrade) the database, ensuring the configured
|
||||
* object store exists with the `expiresAt` index. Resolves with the
|
||||
* connected `IdbDatabaseLike`.
|
||||
*/
|
||||
export function openDatabase(input: OpenDbInput): Promise<IdbDatabaseLike> {
|
||||
return new Promise<IdbDatabaseLike>((resolve, reject) => {
|
||||
let request: import("../types/idb-like.types.js").IdbOpenDbRequestLike;
|
||||
try {
|
||||
request = input.factory.open(input.dbName, input.version);
|
||||
} catch (err) {
|
||||
reject(mapIndexedDbError(err, undefined));
|
||||
return;
|
||||
}
|
||||
request.onerror = () =>
|
||||
reject(mapIndexedDbError(request.error, undefined));
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(input.storeName)) {
|
||||
const store = db.createObjectStore(input.storeName, {
|
||||
keyPath: "key",
|
||||
});
|
||||
store.createIndex(EXPIRES_AT_INDEX, "expiresAt", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
export interface WithStoreInput<T> {
|
||||
db: IdbDatabaseLike;
|
||||
storeName: string;
|
||||
mode: "readonly" | "readwrite";
|
||||
run: (store: IdbObjectStoreLike) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a transaction in the requested mode, run the caller's work, and
|
||||
* await the transaction's `oncomplete` event before resolving. Any
|
||||
* thrown error inside `run` is rethrown after the transaction settles.
|
||||
*/
|
||||
export async function withStore<T>(input: WithStoreInput<T>): Promise<T> {
|
||||
const tx = input.db.transaction([input.storeName], input.mode);
|
||||
const store = tx.objectStore(input.storeName);
|
||||
const work = input.run(store);
|
||||
const complete = promisifyTransaction(tx);
|
||||
const [value] = await Promise.all([work, complete]);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `prefix` / `offset` / `limit` to a flat list of keys and produce
|
||||
* a `StorageListResult`. The total reflects the unfiltered prefix-matched
|
||||
* length so callers can size their 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the object store via cursor, collecting only live (non-expired)
|
||||
* entries. `now` is captured once so a single list call can't span the
|
||||
* expiry boundary mid-cursor.
|
||||
*/
|
||||
export function collectLiveEntries(
|
||||
store: IdbObjectStoreLike,
|
||||
now: number
|
||||
): Promise<IndexedDbEntry[]> {
|
||||
return new Promise<IndexedDbEntry[]>((resolve, reject) => {
|
||||
const out: IndexedDbEntry[] = [];
|
||||
const req = store.openCursor();
|
||||
req.onerror = () =>
|
||||
reject(req.error ?? new Error("IndexedDB cursor error"));
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result as
|
||||
| { value: IndexedDbEntry; continue(): void }
|
||||
| null
|
||||
| undefined;
|
||||
if (cursor === null || cursor === undefined) {
|
||||
resolve(out);
|
||||
return;
|
||||
}
|
||||
const entry = cursor.value;
|
||||
if (entry.expiresAt === undefined || entry.expiresAt > now) {
|
||||
out.push(entry);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
fakeFactory,
|
||||
injectingFactory,
|
||||
syncThrowingFactory,
|
||||
uniqueDbName,
|
||||
} from "../../tests/fixtures.js";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
resetInstruments,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "../otel/otel.js";
|
||||
import type {
|
||||
IdbFactoryLike,
|
||||
IdbOpenDbRequestLike,
|
||||
} from "../types/idb-like.types.js";
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
IndexedDbStorage,
|
||||
} from "./indexeddb-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;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setStorageBrowserIndexedDbMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — spec conformance", () => {
|
||||
it("is an AbstractStorage instance", { timeout: 5000 }, () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
expect(storage).toBeInstanceOf(AbstractStorage);
|
||||
expect(storage).toBeInstanceOf(IndexedDbStorage);
|
||||
});
|
||||
|
||||
it("declares the documented capabilities matrix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
expect(storage.capabilities).toEqual({
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: false,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed options with INDEXEDDB.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new IndexedDbStorage({ dbName: 123 as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(IndexedDbError);
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty dbName option with INDEXEDDB.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new IndexedDbStorage({ dbName: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("implements every AbstractStorage abstract method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
for (const m of [
|
||||
"get",
|
||||
"set",
|
||||
"delete",
|
||||
"exists",
|
||||
"list",
|
||||
"clear",
|
||||
"getWithMetadata",
|
||||
"getMetadata",
|
||||
"getSize",
|
||||
"getPartial",
|
||||
"setPartial",
|
||||
"getItemSize",
|
||||
"createReadStream",
|
||||
"createWriteStream",
|
||||
] as const) {
|
||||
expect(
|
||||
typeof (storage as unknown as Record<string, unknown>)[m]
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits OTel instruments through an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(provider);
|
||||
resetInstruments();
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const meter = provider.meters[0];
|
||||
const writes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.writes_total"
|
||||
);
|
||||
const dur = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_indexeddb.op_duration_ms"
|
||||
);
|
||||
const io = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_indexeddb.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();
|
||||
});
|
||||
|
||||
it("concurrent first-touch shares a single open promise", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let opens = 0;
|
||||
const inner = fakeFactory();
|
||||
const factory: IdbFactoryLike = {
|
||||
open(name, version): IdbOpenDbRequestLike {
|
||||
opens++;
|
||||
return inner.open(name, version);
|
||||
},
|
||||
};
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: factory,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
storage.set(`k${i}`, new Uint8Array([i]))
|
||||
)
|
||||
);
|
||||
expect(opens).toBe(1);
|
||||
});
|
||||
|
||||
it("every INDEXEDDB code is thrown by at least one production path", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const seen = new Set<string>();
|
||||
const expected = new Set(Object.values(INDEXEDDB_CODES));
|
||||
|
||||
// INVALID_OPTIONS
|
||||
try {
|
||||
new IndexedDbStorage({ dbName: "" });
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// INVALID_KEY + KEY_TOO_LONG
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
try {
|
||||
await storage.get("../bad");
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
try {
|
||||
await storage.get("a".repeat(1025));
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// ABORTED
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await storage.get("k", ac.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// QUOTA_EXCEEDED — via injected DOMException
|
||||
const quotaStorage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({ failPutOn: "QuotaExceededError" }),
|
||||
});
|
||||
try {
|
||||
await quotaStorage.set("k", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// PERMISSION_DENIED
|
||||
const denied = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({ failPutOn: "NotAllowedError" }),
|
||||
});
|
||||
try {
|
||||
await denied.set("k", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// VERSION_ERROR
|
||||
const versionFail = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: syncThrowingFactory("VersionError"),
|
||||
});
|
||||
try {
|
||||
await versionFail.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// IO_FAILED (unknown DOMException)
|
||||
const ioFail = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: syncThrowingFactory("UnknownError"),
|
||||
});
|
||||
try {
|
||||
await ioFail.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// TRANSACTION_ABORTED
|
||||
const txAbort = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: syncThrowingFactory("TransactionInactiveError"),
|
||||
});
|
||||
try {
|
||||
await txAbort.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// UNAVAILABLE — via the lazy default adapter
|
||||
try {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.UNAVAILABLE,
|
||||
"adapter unavailable"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) 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 IndexedDbError(INDEXEDDB_CODES.NOT_FOUND, "missing", {
|
||||
key: "k",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError) seen.add(err.code);
|
||||
}
|
||||
|
||||
const missing = [...expected].filter((c) => !seen.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps QuotaExceededError to INDEXEDDB.QUOTA_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({ failPutOn: "QuotaExceededError" }),
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED
|
||||
);
|
||||
expect((err as IndexedDbError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps NS_ERROR_DOM_QUOTA_REACHED (Firefox name) to QUOTA_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({
|
||||
failPutOn: "NS_ERROR_DOM_QUOTA_REACHED",
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.QUOTA_EXCEEDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps NotAllowedError to INDEXEDDB.PERMISSION_DENIED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({ failPutOn: "NotAllowedError" }),
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.PERMISSION_DENIED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps unknown errors to INDEXEDDB.IO_FAILED preserving cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: syncThrowingFactory("UnknownError"),
|
||||
});
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
expect((err as IndexedDbError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates pre-thrown IndexedDbError (e.g. UNAVAILABLE) untouched", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const factory: IdbFactoryLike = {
|
||||
open() {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.UNAVAILABLE,
|
||||
"no idb here"
|
||||
);
|
||||
},
|
||||
};
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: factory,
|
||||
});
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.UNAVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps clear failures to typed IO_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: injectingFactory({ failClearWith: "UnknownError" }),
|
||||
});
|
||||
try {
|
||||
await storage.clear();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.IO_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("IndexedDbError carries the offending key", { timeout: 5000 }, () => {
|
||||
const err = new IndexedDbError(INDEXEDDB_CODES.NOT_FOUND, "x", {
|
||||
key: "user/42",
|
||||
});
|
||||
expect(err.key).toBe("user/42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { fakeFactory, uniqueDbName } from "../../tests/fixtures.js";
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
type IndexedDbError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { IdbFactoryLike } from "../types/idb-like.types.js";
|
||||
import {
|
||||
createIndexedDbStorage,
|
||||
IndexedDbStorage,
|
||||
} from "./indexeddb-storage.js";
|
||||
|
||||
function makeStorage(opts?: { clock?: () => number }): IndexedDbStorage {
|
||||
return createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
...(opts?.clock !== undefined ? { clock: opts.clock } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("IndexedDbStorage — 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("lists keys with prefix + pagination", { 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.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", { timeout: 5000 }, async () => {
|
||||
const storage = makeStorage();
|
||||
await storage.clear();
|
||||
await storage.clear();
|
||||
expect((await storage.list()).keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — metadata", () => {
|
||||
it("records size, createdAt, modifiedAt, and custom metadata", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = makeStorage({ clock: () => 1_700_000_000_000 });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]), {
|
||||
metadata: { mimeType: "text/plain", foo: "bar" },
|
||||
});
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.size).toBe(3);
|
||||
expect(meta?.createdAt).toBe(1_700_000_000_000);
|
||||
expect(meta?.modifiedAt).toBe(1_700_000_000_000);
|
||||
expect(meta?.metadata).toEqual({ mimeType: "text/plain", foo: "bar" });
|
||||
});
|
||||
|
||||
it("getMetadata returns null for missing key", {
|
||||
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("getSize returns 0 for empty storage", { timeout: 5000 }, async () => {
|
||||
const storage = makeStorage();
|
||||
expect(await storage.getSize()).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults clock to Date.now when no clock is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const before = Date.now();
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: uniqueDbName(),
|
||||
indexedDB: fakeFactory(),
|
||||
});
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
const after = Date.now();
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.createdAt).toBeGreaterThanOrEqual(before);
|
||||
expect(meta?.createdAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — TTL", () => {
|
||||
it("expires entries after TTL", { timeout: 5000 }, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("k", new Uint8Array([1]), { ttl: 60 });
|
||||
expect(await storage.exists("k")).toBe(true);
|
||||
now += 61_000;
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
expect(await storage.exists("k")).toBe(false);
|
||||
expect(await storage.getMetadata("k")).toBeNull();
|
||||
expect(await storage.getWithMetadata("k")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not expire entries without TTL", { timeout: 5000 }, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
now += 60_000_000;
|
||||
expect(await storage.get("k")).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it("getPartial returns null for expired entry and triggers lazy delete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]), { ttl: 1 });
|
||||
now += 2_000;
|
||||
expect(await storage.getPartial("k", 0, 2)).toBeNull();
|
||||
expect(await storage.exists("k")).toBe(false);
|
||||
});
|
||||
|
||||
it("list skips expired entries", { timeout: 5000 }, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("a", new Uint8Array([1]), { ttl: 1 });
|
||||
await storage.set("b", new Uint8Array([2]));
|
||||
now += 2_000;
|
||||
const result = await storage.list();
|
||||
expect(result.keys).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("getSize skips expired entries", { timeout: 5000 }, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("a", new Uint8Array([1, 2, 3]), { ttl: 1 });
|
||||
await storage.set("b", new Uint8Array([4, 5]));
|
||||
now += 2_000;
|
||||
expect(await storage.getSize()).toBe(2);
|
||||
});
|
||||
|
||||
it("getWithMetadata lazy-deletes an expired entry on first observation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
const storage = makeStorage({ clock: () => now });
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]), { ttl: 1 });
|
||||
now += 2_000;
|
||||
// First read via getWithMetadata triggers the lazy-delete path.
|
||||
expect(await storage.getWithMetadata("k")).toBeNull();
|
||||
// The row is gone from the store now.
|
||||
expect(await storage.exists("k")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — partial reads + streams", () => {
|
||||
it("returns the requested byte range from getPartial", {
|
||||
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("returns null for missing key on getPartial", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = makeStorage();
|
||||
expect(await storage.getPartial("missing", 0, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("clamps over-length getPartial to the value boundary", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const slice = await storage.getPartial("k", 1, 100);
|
||||
expect(slice).toEqual(new Uint8Array([2, 3]));
|
||||
});
|
||||
|
||||
it("rejects negative offset on getPartial", { timeout: 5000 }, async () => {
|
||||
const storage = makeStorage();
|
||||
try {
|
||||
await storage.getPartial("k", -1, 1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative length on getPartial", { timeout: 5000 }, async () => {
|
||||
const storage = makeStorage();
|
||||
try {
|
||||
await storage.getPartial("k", 0, -1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer offset on setPartial", {
|
||||
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 IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("setPartial performs read-modify-write fallback", {
|
||||
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("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("createWriteStream commits 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]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — AbortSignal", () => {
|
||||
it("rejects with INDEXEDDB.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: "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 IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — close + lazy reopen", () => {
|
||||
it("close is idempotent and reopens on next call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
await storage.close();
|
||||
await storage.close();
|
||||
expect(await storage.get("k")).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it("close before any operation is safe", { timeout: 5000 }, async () => {
|
||||
const storage = makeStorage();
|
||||
await storage.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — key sanitiser integration", () => {
|
||||
it("path-traversal attempts reject before any IDB call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let opened = false;
|
||||
const factory: IdbFactoryLike = {
|
||||
open() {
|
||||
opened = true;
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
};
|
||||
const storage = createIndexedDbStorage({
|
||||
dbName: "trav",
|
||||
indexedDB: factory,
|
||||
});
|
||||
try {
|
||||
await storage.get("../etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
expect(opened).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]));
|
||||
expect(await storage.get("héllo/wörld")).toEqual(new Uint8Array([1]));
|
||||
expect(await storage.get("日本語")).toEqual(new Uint8Array([2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexedDbStorage — instantiation", () => {
|
||||
it("constructs with no options and a missing global", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Doesn't throw — adapter is lazy.
|
||||
const storage = new IndexedDbStorage();
|
||||
expect(storage).toBeInstanceOf(IndexedDbStorage);
|
||||
});
|
||||
|
||||
it("createIndexedDbStorage factory wraps the constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createIndexedDbStorage();
|
||||
expect(storage).toBeInstanceOf(IndexedDbStorage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
import type {
|
||||
StorageCapabilities,
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
StorageMetadata,
|
||||
StorageOptions,
|
||||
StorageResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import { sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
|
||||
import type { IdbDatabaseLike } from "../types/idb-like.types.js";
|
||||
import type { IndexedDbStorageOptions } from "../types/indexeddb-storage.types.js";
|
||||
import {
|
||||
type ResolvedConfig,
|
||||
recordedOp,
|
||||
resolveOptions,
|
||||
} from "./indexeddb-storage.config.js";
|
||||
import {
|
||||
type CrudContext,
|
||||
clearStore,
|
||||
deleteEntry,
|
||||
getEntry,
|
||||
isLive,
|
||||
listKeys,
|
||||
nowMs,
|
||||
partialSlice,
|
||||
putEntry,
|
||||
sizeAcrossLive,
|
||||
valueFromEntry,
|
||||
withMetadata,
|
||||
} from "./indexeddb-storage.crud.js";
|
||||
import { assertNonNegInt } from "./indexeddb-storage.errors.js";
|
||||
import { openDatabase } from "./indexeddb-storage.ops.js";
|
||||
|
||||
export class IndexedDbStorage extends AbstractStorage {
|
||||
readonly #config: ResolvedConfig;
|
||||
#db: IdbDatabaseLike | undefined;
|
||||
#openPromise: Promise<IdbDatabaseLike> | undefined;
|
||||
|
||||
public override readonly capabilities: StorageCapabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: false,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
};
|
||||
|
||||
public constructor(options: IndexedDbStorageOptions = {}) {
|
||||
super();
|
||||
this.#config = resolveOptions(options);
|
||||
}
|
||||
|
||||
async #ctx(): Promise<CrudContext> {
|
||||
if (!this.#db) {
|
||||
if (!this.#openPromise) {
|
||||
this.#openPromise = openDatabase({
|
||||
factory: this.#config.factory,
|
||||
dbName: this.#config.dbName,
|
||||
storeName: this.#config.storeName,
|
||||
version: this.#config.version,
|
||||
});
|
||||
}
|
||||
try {
|
||||
this.#db = await this.#openPromise;
|
||||
} finally {
|
||||
this.#openPromise = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
db: this.#db,
|
||||
storeName: this.#config.storeName,
|
||||
clock: this.#config.clock,
|
||||
};
|
||||
}
|
||||
|
||||
override async get(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Uint8Array | undefined> {
|
||||
const safe = sanitizeKey(key);
|
||||
return recordedOp("get", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
const entry = await getEntry(ctx, safe);
|
||||
if (!entry) return undefined;
|
||||
if (!isLive(entry, nowMs(ctx.clock))) {
|
||||
await deleteEntry(ctx, safe);
|
||||
return undefined;
|
||||
}
|
||||
return valueFromEntry(entry);
|
||||
});
|
||||
}
|
||||
|
||||
override async set(
|
||||
key: string,
|
||||
value: Uint8Array,
|
||||
options?: StorageOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const safe = sanitizeKey(key);
|
||||
await this.validateValueSize(safe, value, signal);
|
||||
await recordedOp("set", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
await putEntry(ctx, safe, value, options);
|
||||
});
|
||||
}
|
||||
|
||||
override async delete(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return recordedOp("delete", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
return deleteEntry(ctx, safe);
|
||||
});
|
||||
}
|
||||
|
||||
override async exists(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return recordedOp("exists", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
const entry = await getEntry(ctx, safe);
|
||||
if (!entry) return false;
|
||||
return isLive(entry, nowMs(ctx.clock));
|
||||
});
|
||||
}
|
||||
|
||||
override async list(
|
||||
options?: StorageListOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageListResult> {
|
||||
return recordedOp("list", undefined, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
return listKeys(ctx, options);
|
||||
});
|
||||
}
|
||||
|
||||
override async clear(signal?: AbortSignal): Promise<void> {
|
||||
await recordedOp("clear", undefined, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
await clearStore(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
override async getWithMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageResult<Uint8Array> | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
return recordedOp("getWithMetadata", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
const entry = await getEntry(ctx, safe);
|
||||
if (!entry) return null;
|
||||
if (!isLive(entry, nowMs(ctx.clock))) {
|
||||
await deleteEntry(ctx, safe);
|
||||
return null;
|
||||
}
|
||||
return withMetadata(entry);
|
||||
});
|
||||
}
|
||||
|
||||
override async getMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageMetadata | null> {
|
||||
const result = await this.getWithMetadata(key, signal);
|
||||
return result ? result.metadata : null;
|
||||
}
|
||||
|
||||
override async getSize(signal?: AbortSignal): Promise<number> {
|
||||
return recordedOp("getSize", undefined, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
return sizeAcrossLive(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
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 recordedOp("getPartial", safe, signal, async () => {
|
||||
const ctx = await this.#ctx();
|
||||
const entry = await getEntry(ctx, safe);
|
||||
if (!entry) return null;
|
||||
if (!isLive(entry, nowMs(ctx.clock))) {
|
||||
await deleteEntry(ctx, safe);
|
||||
return null;
|
||||
}
|
||||
return partialSlice(entry, offset, length);
|
||||
});
|
||||
}
|
||||
|
||||
override async setPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
bytes: Uint8Array,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const safe = sanitizeKey(key);
|
||||
assertNonNegInt(safe, "offset", offset);
|
||||
await recordedOp("setPartial", safe, signal, async () => {
|
||||
await this.defaultSetPartial(safe, offset, bytes, signal);
|
||||
});
|
||||
}
|
||||
|
||||
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 this.defaultCreateReadStream(safe, signal);
|
||||
}
|
||||
|
||||
override createWriteStream(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): WritableStream<Uint8Array> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.defaultCreateWriteStream(safe, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the underlying database connection. Subsequent operations
|
||||
* re-open the database lazily — this is idempotent and safe to call
|
||||
* multiple times.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
const db = this.#db;
|
||||
this.#db = undefined;
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function createIndexedDbStorage(
|
||||
options: IndexedDbStorageOptions = {}
|
||||
): IndexedDbStorage {
|
||||
return new IndexedDbStorage(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { sanitizeKey } from "./key-sanitizer.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# `sanitizeKey`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `sanitizeKey` boundary validates every caller-supplied IndexedDB primary key before it crosses the public API surface of `IndexedDbStorage`. Strict validation up front prevents two classes of failure: silent corruption from malformed keys (control characters that confuse later debugging tooling, empty strings that masquerade as falsy values), and security issues where attacker-controlled keys could mimic path-traversal patterns even though IndexedDB itself does not interpret slashes. The sanitiser keeps the package's key contract aligned with the sibling `@suaveplan/storage-browser-opfs` `sanitizeKey`, so a caller can swap backends behind `AbstractStorage` without seeing a different validation contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Rejects non-string input, empty strings, and keys longer than 1024 characters (`KEY_TOO_LONG`).
|
||||
- Rejects keys with C0 control characters (0x00-0x1F) or DEL (0x7F).
|
||||
- Rejects POSIX-absolute keys (`/foo`) and `..` traversal segments — defensive guards for callers that later use keys as path-like identifiers.
|
||||
- Returns the validated key unchanged on success — no normalisation surprises.
|
||||
- All rejections throw `IndexedDbError` with `INDEXEDDB.INVALID_KEY` or `INDEXEDDB.KEY_TOO_LONG` and attach the offending key (truncated for `KEY_TOO_LONG`) to the error's `key` discriminant.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-indexeddb/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("user/42/profile");
|
||||
console.log(safe); // "user/42/profile"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
INDEXEDDB_CODES,
|
||||
IndexedDbError,
|
||||
} from "@suaveplan/storage-browser-indexeddb/error-codes";
|
||||
import { sanitizeKey } from "@suaveplan/storage-browser-indexeddb/key-sanitizer";
|
||||
|
||||
function validateBatch(keys: string[]): { ok: string[]; bad: { key: string; reason: string }[] } {
|
||||
const ok: string[] = [];
|
||||
const bad: { key: string; reason: string }[] = [];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
ok.push(sanitizeKey(key));
|
||||
} catch (err) {
|
||||
if (err instanceof IndexedDbError && err.code === INDEXEDDB_CODES.INVALID_KEY) {
|
||||
bad.push({ key, reason: err.message });
|
||||
} else if (err instanceof IndexedDbError && err.code === INDEXEDDB_CODES.KEY_TOO_LONG) {
|
||||
bad.push({ key, reason: "too long" });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok, bad };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `sanitizeKey(key: string): string` — Returns `key` unchanged when valid. Throws `IndexedDbError(INDEXEDDB.INVALID_KEY)` for non-strings, empty strings, control characters, absolute paths, and `..` segments. Throws `IndexedDbError(INDEXEDDB.KEY_TOO_LONG)` when the key exceeds 1024 characters; the error's `key` discriminant carries the first 64 characters followed by an ellipsis so logs don't leak large payloads.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum length of 1024 characters is shared with `@suaveplan/storage-browser-opfs/key-sanitizer` and `@suaveplan/storage`'s own `AbstractStorage.validateKey` (where the limit is also 1024). Aligning the boundary across backends keeps the public contract predictable. The sanitiser does NOT normalise Windows `\` separators to POSIX `/` (the OPFS sanitiser does) because IndexedDB primary keys are opaque strings — there is no filesystem semantic to enforce — and quiet rewriting would surprise callers that compare keys later. The runtime-level `typeof` guard is defensive against JavaScript callers that pass non-strings (the TypeScript signature already excludes them at compile time); we preserve the check rather than rely on type erasure alone.
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/error-codes.js";
|
||||
import { sanitizeKey } from "./key-sanitizer.js";
|
||||
|
||||
describe("sanitizeKey", () => {
|
||||
it("returns the key unchanged for a valid string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sanitizeKey("user/42")).toBe("user/42");
|
||||
expect(sanitizeKey("héllo/wörld")).toBe("héllo/wörld");
|
||||
expect(sanitizeKey("日本語")).toBe("日本語");
|
||||
});
|
||||
|
||||
it("rejects non-string input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey(42 as unknown as string);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(IndexedDbError);
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty key", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects keys over MAX_KEY_LENGTH", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("x".repeat(1025));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.KEY_TOO_LONG
|
||||
);
|
||||
// First 64 chars + ellipsis carried through.
|
||||
expect((err as IndexedDbError).key?.length).toBeLessThanOrEqual(70);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects control characters", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("bad\x00key");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects DEL control character", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("bad\x7fkey");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects absolute paths", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("/etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path-traversal segments", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("../secrets");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path-traversal segment in middle", { timeout: 5000 }, () => {
|
||||
try {
|
||||
sanitizeKey("a/../b");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as IndexedDbError).code).toBe(
|
||||
INDEXEDDB_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MAX_KEY_LENGTH } from "../constants/index.js";
|
||||
import { INDEXEDDB_CODES, IndexedDbError } from "../error-codes/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 a caller-supplied storage key.
|
||||
*
|
||||
* Returns the key unmodified on success; throws
|
||||
* `IndexedDbError(INDEXEDDB.INVALID_KEY)` or
|
||||
* `IndexedDbError(INDEXEDDB.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
|
||||
*
|
||||
* Unlike OPFS keys these are NOT used as filenames — they are IndexedDB
|
||||
* primary keys — but we preserve the same strict surface so callers can
|
||||
* swap backends without hitting different validation contracts.
|
||||
*/
|
||||
export function sanitizeKey(key: string): string {
|
||||
if (typeof key !== "string") {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
"key must be a string"
|
||||
);
|
||||
}
|
||||
if (key.length === 0) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
"key must not be empty"
|
||||
);
|
||||
}
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.KEY_TOO_LONG,
|
||||
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`,
|
||||
{ key: `${key.slice(0, 64)}…` }
|
||||
);
|
||||
}
|
||||
if (containsControlChar(key)) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
"key contains a control character",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
if (key.startsWith("/")) {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
"absolute paths are not allowed",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
const segments = key.split("/");
|
||||
for (const segment of segments) {
|
||||
if (segment === "..") {
|
||||
throw new IndexedDbError(
|
||||
INDEXEDDB_CODES.INVALID_KEY,
|
||||
"path traversal segment (..) is not allowed",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
RecordOpInput,
|
||||
StorageBrowserIndexedDbInstruments,
|
||||
StorageBrowserIndexedDbOp,
|
||||
StorageBrowserIndexedDbOpOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "./otel.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# OpenTelemetry instrument set
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `otel` module owns every metric and trace emitted by `@suaveplan/storage-browser-indexeddb`. Production paths inside `IndexedDbStorage` call `recordOp(...)` from this module after every public-method invocation, regardless of outcome — success, error, or aborted. Observability is built in at the boundary, not bolted on by consumers. The instrument cache is lazy so SDK consumers can install a custom `MeterProvider` / `TracerProvider` before the first operation runs.
|
||||
|
||||
## Features
|
||||
|
||||
- 4 counters: `storage_browser_indexeddb.reads_total{op,outcome}`, `…writes_total{op,outcome}`, `…deletes_total{op,outcome}`, `…errors_total{op,outcome}`.
|
||||
- 2 histograms: `storage_browser_indexeddb.op_duration_ms{op,outcome}` (unit: `ms`) and `storage_browser_indexeddb.io_bytes{op,direction}` (unit: `By`).
|
||||
- Bounded attribute enums — `op` is one of the 14 documented method names, `outcome` is `"success" | "error" | "aborted"`, `direction` is `"read" | "write"`.
|
||||
- `setStorageBrowserIndexedDbMeterProvider(provider | undefined)` — override the global provider (tests use this with an in-memory recorder).
|
||||
- `resetInstruments()` — drop the instrument cache so the next `recordOp` rebuilds against a newly-installed provider.
|
||||
- `getInstruments(force?)` — direct access for advanced consumers that want to record bespoke samples on top of the standard surface.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "@suaveplan/storage-browser-indexeddb/otel";
|
||||
|
||||
setStorageBrowserIndexedDbMeterProvider(myInMemoryProvider);
|
||||
resetInstruments();
|
||||
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 7, bytes: 1024, direction: "write" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getInstruments, type RecordOpInput } from "@suaveplan/storage-browser-indexeddb/otel";
|
||||
|
||||
function recordBatched(records: RecordOpInput[]): void {
|
||||
const inst = getInstruments();
|
||||
for (const r of records) {
|
||||
inst.opDurationMs.record(r.durationMs, { op: r.op, outcome: r.outcome });
|
||||
if (r.bytes !== undefined && r.direction !== undefined) {
|
||||
inst.ioBytes.record(r.bytes, { op: r.op, direction: r.direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StorageBrowserIndexedDbOp` — `"get" | "set" | "delete" | "exists" | "list" | "clear" | "getMetadata" | "getWithMetadata" | "getSize" | "getPartial" | "setPartial" | "getItemSize" | "createReadStream" | "createWriteStream"`.
|
||||
- `StorageBrowserIndexedDbOpOutcome` — `"success" | "error" | "aborted"`.
|
||||
- `StorageBrowserIndexedDbInstruments` — `{ tracer, meter, readsTotal, writesTotal, deletesTotal, errorsTotal, opDurationMs, ioBytes }`.
|
||||
- `getInstruments(force?: boolean): StorageBrowserIndexedDbInstruments` — returns the cached set; pass `force: true` to rebuild against a newly-installed provider.
|
||||
- `recordOp(input: RecordOpInput): void` — emit one complete observation. Increments exactly one of the four `*_total` counters per call and always records the duration histogram. Records `io_bytes` when both `bytes` and `direction` are supplied.
|
||||
- `setStorageBrowserIndexedDbMeterProvider(provider: MeterProvider | undefined): void` — replace the meter provider; clears the cache automatically.
|
||||
- `resetInstruments(): void` — drop the cache. Tests use this between cases to isolate observations.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The recorder uses a discriminated dispatch (`op === "get" || op === "getPartial" || op === "getWithMetadata" → readsTotal`, etc.) rather than a Map lookup; the discriminator is bounded and the dispatch is cheap. The `errors_total` counter takes precedence over the read/write/delete classification — an aborted `set` increments `errorsTotal{op: "set", outcome: "aborted"}` but not `writesTotal`. The `io_bytes` histogram is only recorded when both `bytes` and `direction` are present, which lets the caller suppress an emission cheaply for ops that don't transfer bytes (e.g. `exists`, `list`).
|
||||
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserIndexedDbMeterProvider,
|
||||
} from "./otel.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;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setStorageBrowserIndexedDbMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
describe("otel — recordOp", () => {
|
||||
it("records exactly one counter per successful operation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(provider);
|
||||
resetInstruments();
|
||||
|
||||
recordOp({ op: "set", outcome: "success", durationMs: 4 });
|
||||
recordOp({ op: "get", outcome: "success", durationMs: 5 });
|
||||
recordOp({ op: "delete", outcome: "success", durationMs: 3 });
|
||||
recordOp({ op: "clear", outcome: "success", durationMs: 2 });
|
||||
|
||||
const meter = provider.meters[0];
|
||||
const reads = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.reads_total"
|
||||
);
|
||||
const writes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.writes_total"
|
||||
);
|
||||
const deletes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.deletes_total"
|
||||
);
|
||||
expect(reads?.additions.length).toBe(1);
|
||||
expect(writes?.additions.length).toBe(1);
|
||||
expect(deletes?.additions.length).toBe(2); // delete + clear
|
||||
});
|
||||
|
||||
it("routes errors and aborted to errors_total", { timeout: 5000 }, () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(provider);
|
||||
resetInstruments();
|
||||
|
||||
recordOp({ op: "set", outcome: "error", durationMs: 1 });
|
||||
recordOp({ op: "get", outcome: "aborted", durationMs: 1 });
|
||||
|
||||
const meter = provider.meters[0];
|
||||
const errors = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.errors_total"
|
||||
);
|
||||
expect(errors?.additions.length).toBe(2);
|
||||
});
|
||||
|
||||
it("records io_bytes only when bytes + direction are present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(provider);
|
||||
resetInstruments();
|
||||
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
bytes: 32,
|
||||
direction: "write",
|
||||
});
|
||||
recordOp({ op: "exists", outcome: "success", durationMs: 1 });
|
||||
|
||||
const meter = provider.meters[0];
|
||||
const io = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_indexeddb.io_bytes"
|
||||
);
|
||||
expect(io?.samples.length).toBe(1);
|
||||
expect(io?.samples[0]?.value).toBe(32);
|
||||
expect(io?.samples[0]?.attrs).toEqual({
|
||||
op: "set",
|
||||
direction: "write",
|
||||
});
|
||||
});
|
||||
|
||||
it("setStorageBrowserIndexedDbMeterProvider clears the cache", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(a);
|
||||
resetInstruments();
|
||||
const before = getInstruments();
|
||||
|
||||
const b = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(b);
|
||||
const after = getInstruments();
|
||||
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("getInstruments caches across calls until reset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetInstruments();
|
||||
const a = getInstruments();
|
||||
const b = getInstruments();
|
||||
expect(a).toBe(b);
|
||||
const c = getInstruments(true);
|
||||
expect(c).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("otel — increments getPartial reads_total", () => {
|
||||
it("classifies getPartial under reads_total", { timeout: 5000 }, () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserIndexedDbMeterProvider(provider);
|
||||
resetInstruments();
|
||||
recordOp({
|
||||
op: "getPartial",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
bytes: 8,
|
||||
direction: "read",
|
||||
});
|
||||
recordOp({
|
||||
op: "getWithMetadata",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
recordOp({
|
||||
op: "setPartial",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
const meter = provider.meters[0];
|
||||
const reads = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.reads_total"
|
||||
);
|
||||
const writes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_indexeddb.writes_total"
|
||||
);
|
||||
expect(reads?.additions.length).toBe(2);
|
||||
expect(writes?.additions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/storage-browser-indexeddb";
|
||||
const TRACER_NAME = "@suaveplan/storage-browser-indexeddb";
|
||||
|
||||
let overrideMeterProvider: MeterProvider | undefined;
|
||||
|
||||
/**
|
||||
* Override the `MeterProvider` used to acquire the package meter. Pass
|
||||
* `undefined` to fall back to the global provider. The instrument cache is
|
||||
* rebuilt automatically on next access.
|
||||
*/
|
||||
export function setStorageBrowserIndexedDbMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
overrideMeterProvider = provider;
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
export type StorageBrowserIndexedDbOp =
|
||||
| "get"
|
||||
| "set"
|
||||
| "delete"
|
||||
| "exists"
|
||||
| "list"
|
||||
| "clear"
|
||||
| "getMetadata"
|
||||
| "getWithMetadata"
|
||||
| "getSize"
|
||||
| "getPartial"
|
||||
| "setPartial"
|
||||
| "getItemSize"
|
||||
| "createReadStream"
|
||||
| "createWriteStream";
|
||||
|
||||
export type StorageBrowserIndexedDbOpOutcome = "success" | "error" | "aborted";
|
||||
|
||||
export interface StorageBrowserIndexedDbInstruments {
|
||||
tracer: Tracer;
|
||||
meter: Meter;
|
||||
readsTotal: Counter;
|
||||
writesTotal: Counter;
|
||||
deletesTotal: Counter;
|
||||
errorsTotal: Counter;
|
||||
opDurationMs: Histogram;
|
||||
ioBytes: Histogram;
|
||||
}
|
||||
|
||||
let cachedInstruments: StorageBrowserIndexedDbInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily construct the OTel instrument set so consumers can install a
|
||||
* custom global `MeterProvider`/`TracerProvider` BEFORE production paths
|
||||
* execute. Pass `force: true` to rebuild against a freshly-set provider;
|
||||
* tests use this after `metrics.setGlobalMeterProvider`.
|
||||
*/
|
||||
export function getInstruments(
|
||||
force = false
|
||||
): StorageBrowserIndexedDbInstruments {
|
||||
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_indexeddb.reads_total",
|
||||
{ description: "Total successful read operations" }
|
||||
),
|
||||
writesTotal: meter.createCounter(
|
||||
"storage_browser_indexeddb.writes_total",
|
||||
{ description: "Total successful write operations" }
|
||||
),
|
||||
deletesTotal: meter.createCounter(
|
||||
"storage_browser_indexeddb.deletes_total",
|
||||
{ description: "Total successful delete operations" }
|
||||
),
|
||||
errorsTotal: meter.createCounter(
|
||||
"storage_browser_indexeddb.errors_total",
|
||||
{ description: "Total operations that ended in error" }
|
||||
),
|
||||
opDurationMs: meter.createHistogram(
|
||||
"storage_browser_indexeddb.op_duration_ms",
|
||||
{
|
||||
description: "Per-operation duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
ioBytes: meter.createHistogram("storage_browser_indexeddb.io_bytes", {
|
||||
description: "Bytes read or written per operation",
|
||||
unit: "By",
|
||||
}),
|
||||
};
|
||||
return cachedInstruments;
|
||||
}
|
||||
|
||||
export interface RecordOpInput {
|
||||
op: StorageBrowserIndexedDbOp;
|
||||
outcome: StorageBrowserIndexedDbOpOutcome;
|
||||
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 { registerIndexedDbProvider } from "./registry.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
# `registerIndexedDbProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`registerIndexedDbProvider` is the explicit entry point that wires the `IndexedDbStorage` class into the `@suaveplan/storage` factory under the canonical type slot `"indexeddb"`. The package never self-registers at module evaluation — consumers call this function once at app startup before invoking `createStorage({ type: "indexeddb", ... })`. The deferred registration pattern matches `@suaveplan/storage-browser-opfs/registry` and means `sideEffects: false` bundlers tree-shake the registry module away when the consumer chooses to instantiate `IndexedDbStorage` directly instead.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent registration — repeated calls are no-ops once the slot is occupied.
|
||||
- Re-registration after `clearStorageProviders()` — the slot is freshly registered without throwing.
|
||||
- Module-augmentation of `@suaveplan/storage/factory` extends the typed `StorageProviderRegistry` and `StorageProviderConfig` interfaces so `createStorage({ type: "indexeddb", config: { ... } })` is typesafe.
|
||||
- Zero side effects on import — the file only mutates the factory when `registerIndexedDbProvider()` is called.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerIndexedDbProvider();
|
||||
const storage = createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { registerIndexedDbProvider } from "@suaveplan/storage-browser-indexeddb/registry";
|
||||
import { registerOpfsProvider } from "@suaveplan/storage-browser-opfs/registry";
|
||||
import {
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
|
||||
// Register both backends at startup; consumers pick one at runtime.
|
||||
registerOpfsProvider();
|
||||
registerIndexedDbProvider();
|
||||
|
||||
function makeStorage(prefer: "opfs" | "indexeddb") {
|
||||
if (prefer === "opfs" && hasStorageProvider("opfs")) {
|
||||
return createStorage({ type: "opfs", config: { directory: "myapp" } });
|
||||
}
|
||||
return createStorage({ type: "indexeddb", config: { dbName: "myapp" } });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `registerIndexedDbProvider(): void` — Registers `IndexedDbStorage` against the `@suaveplan/storage` factory under `"indexeddb"`. No-op when the slot is already occupied. Throws nothing — failures to import the storage class itself would have surfaced at the consumer's module-graph build time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module augmentation lives in this file because the factory's typed `StorageProviderRegistry` and `StorageProviderConfig` interfaces are open for extension; co-locating the augmentation with the registration keeps both halves in sync. The registration cast (`as unknown as new (config?: unknown) => IndexedDbStorage`) is necessary because `StorageProviderImplementation` in the factory uses a loose constructor shape that the strict `IndexedDbStorage` signature doesn't structurally match — the runtime call still passes the correct `IndexedDbStorageOptions` through. This deviation from the upstream `storage-browser` package (which used a side-effect import) is intentional and matches the OPFS sibling pattern; the OpenSpec proposal documents the deferred registration as a deliberate departure from the upstream behavior.
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
clearStorageProviders,
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { IndexedDbStorage } from "../indexeddb-storage/indexeddb-storage.js";
|
||||
import type { IdbFactoryLike } from "../types/idb-like.types.js";
|
||||
import { registerIndexedDbProvider } from "./registry.js";
|
||||
|
||||
function fakeFactory(): IdbFactoryLike {
|
||||
const real = new IDBFactory();
|
||||
return { open: (n, v) => real.open(n, v) as never };
|
||||
}
|
||||
|
||||
describe("registerIndexedDbProvider", () => {
|
||||
beforeEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
|
||||
it("registers the indexeddb provider and resolves it via createStorage", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
registerIndexedDbProvider();
|
||||
expect(hasStorageProvider("indexeddb")).toBe(true);
|
||||
const storage = createStorage({
|
||||
type: "indexeddb" as never,
|
||||
config: {
|
||||
dbName: `t-${Date.now()}-${Math.random()}`,
|
||||
indexedDB: fakeFactory(),
|
||||
} as never,
|
||||
});
|
||||
expect(storage).toBeInstanceOf(IndexedDbStorage);
|
||||
await (storage as IndexedDbStorage).set("k", new Uint8Array([1]));
|
||||
expect(await (storage as IndexedDbStorage).get("k")).toEqual(
|
||||
new Uint8Array([1])
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent across repeated registerIndexedDbProvider calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerIndexedDbProvider();
|
||||
registerIndexedDbProvider();
|
||||
registerIndexedDbProvider();
|
||||
expect(hasStorageProvider("indexeddb")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
hasStorageProvider,
|
||||
registerStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import { IndexedDbStorage } from "../indexeddb-storage/indexeddb-storage.js";
|
||||
import type { IndexedDbStorageOptions } from "../types/indexeddb-storage.types.js";
|
||||
|
||||
declare module "@suaveplan/storage/factory" {
|
||||
interface StorageProviderRegistry {
|
||||
indexeddb: IndexedDbStorage;
|
||||
}
|
||||
interface StorageProviderConfig {
|
||||
indexeddb: IndexedDbStorageOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register `IndexedDbStorage` against the `@suaveplan/storage` factory
|
||||
* under the canonical type slot `"indexeddb"`. 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: "indexeddb", ... })`. 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 registerIndexedDbProvider(): void {
|
||||
if (hasStorageProvider("indexeddb")) return;
|
||||
registerStorageProvider(
|
||||
"indexeddb",
|
||||
IndexedDbStorage as unknown as new (
|
||||
config?: unknown
|
||||
) => IndexedDbStorage
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# `IdbFactoryLike` + friends
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the structural surface that the IndexedDB adapter consumes from a browser-shaped `IDBFactory`. Defining a local interface — rather than depending on the global DOM `IDBFactory` type — lets the package compile cleanly under a Node-only `tsconfig.lib.json` (no DOM lib in `types`), keeps the runtime path testable with `fake-indexeddb` without forcing a global `vi.stubGlobal`, and documents exactly how much of the IndexedDB surface the adapter relies on. Callers pass a real factory at construction time, so the package never reaches for `globalThis.indexedDB` until a method that needs it is invoked.
|
||||
|
||||
## Features
|
||||
|
||||
- `IdbFactoryLike` — minimal `{ open(name, version?): IdbOpenDbRequestLike }` shape.
|
||||
- `IdbOpenDbRequestLike`, `IdbVersionChangeEventLike`, `IdbDatabaseLike`, `IdbTransactionLike`, `IdbObjectStoreLike`, `IdbRequestLike`, `IdbCursorWithValueLike` — each exposes only the members the adapter needs.
|
||||
- Compatible with both the production W3C `IDBFactory` and the `fake-indexeddb` test factory — structural typing means no nominal cast is necessary.
|
||||
- Zero runtime cost — types only; the file disappears from the emitted JS.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { IdbFactoryLike } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function build(factory: IdbFactoryLike): void {
|
||||
const req = factory.open("my-db", 1);
|
||||
req.onsuccess = () => {
|
||||
req.result.close();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
IdbFactoryLike,
|
||||
IdbOpenDbRequestLike,
|
||||
} from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function awaitOpen(factory: IdbFactoryLike, name: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req: IdbOpenDbRequestLike = factory.open(name, 1);
|
||||
req.onsuccess = () => {
|
||||
req.result.close();
|
||||
resolve();
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IdbFactoryLike` — `open(name, version?)` returns `IdbOpenDbRequestLike`.
|
||||
- `IdbOpenDbRequestLike` — request shape exposing `result`, `error`, and the `onsuccess` / `onerror` / `onupgradeneeded` / `onblocked` callback slots.
|
||||
- `IdbVersionChangeEventLike` — event shape passed to `onupgradeneeded` with `oldVersion` / `newVersion`.
|
||||
- `IdbDatabaseLike` — `name`, `version`, `objectStoreNames.contains(...)`, `close()`, `transaction(stores, mode)`, `createObjectStore(...)`.
|
||||
- `IdbTransactionLike` — `objectStore(name)`, `oncomplete` / `onerror` / `onabort` slots, `abort()`, `error`.
|
||||
- `IdbObjectStoreLike` — `put`, `get`, `delete`, `clear`, `count`, `openCursor`, `createIndex`, `index`.
|
||||
- `IdbRequestLike` — `result`, `error`, `onsuccess` / `onerror`.
|
||||
- `IdbCursorWithValueLike` — `key`, `value`, `continue()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately mirrors the W3C `IDBFactory` member names so callers who already know IndexedDB recognise the surface. Members not used by this adapter (transactions' `objectStoreNames`, request's `transaction` back-pointer, durability hints introduced in newer IndexedDB drafts) are intentionally omitted to keep the surface minimal and to expose intentional dependencies during reviews. If a new feature needs a new IDB API, extend this interface in the same change — never reach for the global DOM types from the adapter to avoid coupling the package's compile graph to a DOM lib that browser-tier consumers may have customised.
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Structural surface this package uses from a browser-shaped
|
||||
* `IDBFactory`. Mirrors the W3C IndexedDB API just deeply enough for the
|
||||
* adapter to exercise it; lets tests pass a real `fake-indexeddb` factory
|
||||
* without depending on the DOM lib types at runtime.
|
||||
*/
|
||||
export interface IdbFactoryLike {
|
||||
open(name: string, version?: number): IdbOpenDbRequestLike;
|
||||
}
|
||||
|
||||
export interface IdbOpenDbRequestLike {
|
||||
result: IdbDatabaseLike;
|
||||
error: DOMException | null;
|
||||
onsuccess: ((this: unknown, ev: Event) => unknown) | null;
|
||||
onerror: ((this: unknown, ev: Event) => unknown) | null;
|
||||
onupgradeneeded:
|
||||
| ((this: unknown, ev: IdbVersionChangeEventLike) => unknown)
|
||||
| null;
|
||||
onblocked: ((this: unknown, ev: Event) => unknown) | null;
|
||||
}
|
||||
|
||||
export interface IdbVersionChangeEventLike {
|
||||
target: IdbOpenDbRequestLike | null;
|
||||
oldVersion: number;
|
||||
newVersion: number | null;
|
||||
}
|
||||
|
||||
export interface IdbDatabaseLike {
|
||||
name: string;
|
||||
version: number;
|
||||
objectStoreNames: { contains(name: string): boolean };
|
||||
close(): void;
|
||||
transaction(
|
||||
storeNames: string | string[],
|
||||
mode: "readonly" | "readwrite"
|
||||
): IdbTransactionLike;
|
||||
createObjectStore(
|
||||
name: string,
|
||||
options?: { keyPath?: string | string[]; autoIncrement?: boolean }
|
||||
): IdbObjectStoreLike;
|
||||
}
|
||||
|
||||
export interface IdbTransactionLike {
|
||||
objectStore(name: string): IdbObjectStoreLike;
|
||||
oncomplete: ((this: unknown, ev: Event) => unknown) | null;
|
||||
onerror: ((this: unknown, ev: Event) => unknown) | null;
|
||||
onabort: ((this: unknown, ev: Event) => unknown) | null;
|
||||
abort(): void;
|
||||
error: DOMException | null;
|
||||
}
|
||||
|
||||
export interface IdbObjectStoreLike {
|
||||
put(value: unknown, key?: IDBValidKey): IdbRequestLike;
|
||||
get(key: IDBValidKey): IdbRequestLike;
|
||||
delete(key: IDBValidKey | IDBKeyRange): IdbRequestLike;
|
||||
clear(): IdbRequestLike;
|
||||
count(query?: IDBValidKey | IDBKeyRange): IdbRequestLike;
|
||||
openCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction?: "next" | "nextunique" | "prev" | "prevunique"
|
||||
): IdbRequestLike;
|
||||
createIndex(
|
||||
name: string,
|
||||
keyPath: string | string[],
|
||||
options?: { unique?: boolean; multiEntry?: boolean }
|
||||
): unknown;
|
||||
index(name: string): unknown;
|
||||
}
|
||||
|
||||
export interface IdbRequestLike {
|
||||
result: unknown;
|
||||
error: DOMException | null;
|
||||
onsuccess: ((this: unknown, ev: Event) => unknown) | null;
|
||||
onerror: ((this: unknown, ev: Event) => unknown) | null;
|
||||
}
|
||||
|
||||
export interface IdbCursorWithValueLike {
|
||||
key: IDBValidKey;
|
||||
value: unknown;
|
||||
continue(): void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
IdbCursorWithValueLike,
|
||||
IdbDatabaseLike,
|
||||
IdbFactoryLike,
|
||||
IdbObjectStoreLike,
|
||||
IdbOpenDbRequestLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
IdbVersionChangeEventLike,
|
||||
} from "./idb-like.types.js";
|
||||
export type {
|
||||
IndexedDbEntry,
|
||||
IndexedDbStorageOptions,
|
||||
} from "./indexeddb-storage.types.js";
|
||||
export { IndexedDbStorageOptionsSchema } from "./indexeddb-storage.types.js";
|
||||
@@ -0,0 +1,48 @@
|
||||
# `IndexedDbStorageOptions` + `IndexedDbStorageOptionsSchema`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the public option surface for `IndexedDbStorage` along with the Zod schema that validates it at the constructor boundary. The split between a TypeScript interface and a Zod schema keeps the compile-time signature ergonomic while still hard-failing malformed runtime input (e.g. a `dbName: 123` slipping through a JavaScript caller). Every field is optional — the package ships sane defaults so consumers can `new IndexedDbStorage()` with no arguments in a browser tier.
|
||||
|
||||
## Features
|
||||
|
||||
- `IndexedDbStorageOptions` — public configuration with `dbName`, `storeName`, `version`, structurally-injectable `indexedDB` factory, and structurally-injectable `clock`.
|
||||
- `IndexedDbStorageOptionsSchema` — strict Zod object schema with `min(1)` on string fields and `int().positive()` on `version`.
|
||||
- Adapter fields (`indexedDB`, `clock`) declared as `z.unknown().optional()` so the runtime check accepts any structurally compatible implementation without re-implementing TypeScript's structural-typing rules.
|
||||
- `IndexedDbEntry` — the internal on-disk shape; documented here so downstream tooling (migration scripts, JSON exporters) can construct compatible records.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { IndexedDbStorageOptions } from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
const options: IndexedDbStorageOptions = { dbName: "myapp", storeName: "kv" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
IndexedDbStorageOptionsSchema,
|
||||
type IndexedDbStorageOptions,
|
||||
} from "@suaveplan/storage-browser-indexeddb/types";
|
||||
|
||||
function parseOptions(input: unknown): IndexedDbStorageOptions {
|
||||
const parsed = IndexedDbStorageOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`bad options: ${parsed.error.message}`);
|
||||
}
|
||||
// `parsed.data` is `IndexedDbStorageOptions` (Zod inferred).
|
||||
return parsed.data as IndexedDbStorageOptions;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IndexedDbEntry` — interface. `{ key: string; data: Uint8Array; metadata: StorageMetadata; expiresAt: number | undefined }`. The on-disk shape persisted in the object store.
|
||||
- `IndexedDbStorageOptions` — interface. All fields optional. Fields: `dbName?: string`, `storeName?: string`, `version?: number`, `indexedDB?: IdbFactoryLike`, `clock?: Clock`.
|
||||
- `IndexedDbStorageOptionsSchema` — Zod schema. `safeParse` returns `success: true` for valid input. The `IndexedDbStorage` constructor invokes `safeParse` and throws `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` on failure, preserving the Zod issue list as `cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`indexedDB` and `clock` cannot be validated structurally by Zod without re-encoding their interfaces, which would couple the schema to its consumer surface and make changes to either type a two-place edit. Using `z.unknown().optional()` is the established pattern in `@suaveplan/storage-browser-opfs/types/opfs-storage.types.ts`. The TypeScript signature on `IndexedDbStorageOptions` is what callers see in their IDE — the schema is purely a runtime guard against malformed JSON or JavaScript-side mistakes. `IndexedDbEntry` is not validated by a Zod schema because it never crosses an external boundary; it is internal to the adapter's serialization path.
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { StorageMetadata } from "@suaveplan/storage/types";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import type { IdbFactoryLike } from "./idb-like.types.js";
|
||||
|
||||
/**
|
||||
* The on-disk shape persisted to IndexedDB. `key` is the object-store key
|
||||
* path; `data` is the payload; `metadata` is the public-facing
|
||||
* `StorageMetadata` carried through `getWithMetadata` / `getMetadata`;
|
||||
* `expiresAt` is a Unix epoch ms cut-off used by the TTL index.
|
||||
*/
|
||||
export interface IndexedDbEntry {
|
||||
key: string;
|
||||
data: Uint8Array;
|
||||
metadata: StorageMetadata;
|
||||
expiresAt: number | undefined;
|
||||
}
|
||||
|
||||
export interface IndexedDbStorageOptions {
|
||||
/** Database name. Default: `"suaveplan"`. */
|
||||
dbName?: string;
|
||||
/** Object-store name within the database. Default: `"storage"`. */
|
||||
storeName?: string;
|
||||
/** Schema version. Default: `1`. */
|
||||
version?: number;
|
||||
/** Injectable `IDBFactory` adapter. Defaults to `() => globalThis.indexedDB`. */
|
||||
indexedDB?: IdbFactoryLike;
|
||||
/** Injectable clock used for `createdAt` / `modifiedAt` / `expiresAt`. */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
// Adapter and clock are structural interfaces; accept anything truthy at the
|
||||
// Zod boundary and rely on TS to enforce the shape at the call site.
|
||||
const looseAdapter = z.unknown().optional();
|
||||
|
||||
export const IndexedDbStorageOptionsSchema = z.object({
|
||||
dbName: z.string().min(1).optional(),
|
||||
storeName: z.string().min(1).optional(),
|
||||
version: z.number().int().positive().optional(),
|
||||
indexedDB: looseAdapter,
|
||||
clock: looseAdapter,
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import type {
|
||||
IdbDatabaseLike,
|
||||
IdbFactoryLike,
|
||||
IdbObjectStoreLike,
|
||||
IdbOpenDbRequestLike,
|
||||
IdbRequestLike,
|
||||
IdbTransactionLike,
|
||||
} from "../src/types/idb-like.types.js";
|
||||
|
||||
let dbCounter = 0;
|
||||
|
||||
/**
|
||||
* Wrap a `fake-indexeddb` IDBFactory in our structural surface.
|
||||
*/
|
||||
export function fakeFactory(): IdbFactoryLike {
|
||||
const real = new IDBFactory();
|
||||
return {
|
||||
open: (n, v) => real.open(n, v) as unknown as IdbOpenDbRequestLike,
|
||||
};
|
||||
}
|
||||
|
||||
export function uniqueDbName(prefix = "idb"): string {
|
||||
dbCounter++;
|
||||
return `${prefix}-${dbCounter}-${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
|
||||
interface InjectOptions {
|
||||
failPutOn?: string;
|
||||
failClearWith?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously deliver a failure to a request via its onerror handler.
|
||||
* Used by the synthetic IDB factory below — we wait one macrotask so the
|
||||
* caller has time to install onsuccess/onerror handlers before we trigger
|
||||
* the failure.
|
||||
*/
|
||||
function deliverError(req: IdbRequestLike, errorName: string): void {
|
||||
setTimeout(() => {
|
||||
(req as unknown as { error: DOMException }).error = Object.assign(
|
||||
new Error("induced"),
|
||||
{ name: errorName }
|
||||
) as unknown as DOMException;
|
||||
req.onerror?.(new Event("error"));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function deliverSuccess(req: IdbRequestLike, result: unknown): void {
|
||||
setTimeout(() => {
|
||||
(req as unknown as { result: unknown }).result = result;
|
||||
req.onsuccess?.(new Event("success"));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully synthetic IdbFactoryLike that supports get/openCursor
|
||||
* over an in-memory Map but fails put / clear with a configurable
|
||||
* DOMException name. This is a REAL implementation of the structural
|
||||
* IdbFactoryLike interface — no mocks, no monkey-patching.
|
||||
*/
|
||||
export function injectingFactory(options: InjectOptions): IdbFactoryLike {
|
||||
const data = new Map<string, unknown>();
|
||||
|
||||
function makeStore(): IdbObjectStoreLike {
|
||||
return {
|
||||
put(value) {
|
||||
const req: IdbRequestLike = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
if (options.failPutOn !== undefined) {
|
||||
deliverError(req, options.failPutOn);
|
||||
} else {
|
||||
const v = value as { key: string };
|
||||
data.set(v.key, v);
|
||||
deliverSuccess(req, undefined);
|
||||
}
|
||||
return req;
|
||||
},
|
||||
get(key) {
|
||||
const req: IdbRequestLike = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
deliverSuccess(req, data.get(key as string));
|
||||
return req;
|
||||
},
|
||||
delete(key) {
|
||||
const req: IdbRequestLike = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
data.delete(key as string);
|
||||
deliverSuccess(req, undefined);
|
||||
return req;
|
||||
},
|
||||
clear() {
|
||||
const req: IdbRequestLike = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
if (options.failClearWith !== undefined) {
|
||||
deliverError(req, options.failClearWith);
|
||||
} else {
|
||||
data.clear();
|
||||
deliverSuccess(req, undefined);
|
||||
}
|
||||
return req;
|
||||
},
|
||||
count() {
|
||||
const req: IdbRequestLike = {
|
||||
result: data.size,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
deliverSuccess(req, data.size);
|
||||
return req;
|
||||
},
|
||||
openCursor() {
|
||||
const req: IdbRequestLike = {
|
||||
result: null,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
};
|
||||
// Emit one cursor event per entry, then a final null.
|
||||
const entries = [...data.entries()];
|
||||
setTimeout(() => {
|
||||
let i = 0;
|
||||
const step = (): void => {
|
||||
if (i >= entries.length) {
|
||||
(req as unknown as { result: unknown }).result =
|
||||
null;
|
||||
req.onsuccess?.(new Event("success"));
|
||||
return;
|
||||
}
|
||||
const [_key, val] = entries[i] as [string, unknown];
|
||||
i++;
|
||||
const cursor = {
|
||||
value: val,
|
||||
key: _key,
|
||||
continue: () => step(),
|
||||
};
|
||||
(req as unknown as { result: unknown }).result = cursor;
|
||||
req.onsuccess?.(new Event("success"));
|
||||
};
|
||||
step();
|
||||
}, 0);
|
||||
return req;
|
||||
},
|
||||
createIndex() {
|
||||
return undefined;
|
||||
},
|
||||
index() {
|
||||
return undefined;
|
||||
},
|
||||
} satisfies IdbObjectStoreLike;
|
||||
}
|
||||
|
||||
function makeTransaction(): IdbTransactionLike {
|
||||
const store = makeStore();
|
||||
const tx: IdbTransactionLike = {
|
||||
objectStore() {
|
||||
return store;
|
||||
},
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
abort() {
|
||||
/* no-op */
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
// Auto-complete after all queued microtasks settle.
|
||||
setTimeout(() => tx.oncomplete?.(new Event("complete")), 5);
|
||||
return tx;
|
||||
}
|
||||
|
||||
function makeDb(): IdbDatabaseLike {
|
||||
return {
|
||||
name: "synthetic",
|
||||
version: 1,
|
||||
objectStoreNames: { contains: () => true },
|
||||
close() {
|
||||
/* no-op */
|
||||
},
|
||||
transaction() {
|
||||
return makeTransaction();
|
||||
},
|
||||
createObjectStore() {
|
||||
return makeStore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
open() {
|
||||
const req: IdbOpenDbRequestLike = {
|
||||
result: makeDb(),
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onupgradeneeded: null,
|
||||
onblocked: null,
|
||||
};
|
||||
setTimeout(() => req.onsuccess?.(new Event("success")), 0);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an `IdbFactoryLike` whose `open` method throws synchronously
|
||||
* with a controlled DOMException name.
|
||||
*/
|
||||
export function syncThrowingFactory(name: string): IdbFactoryLike {
|
||||
return {
|
||||
open() {
|
||||
throw Object.assign(new Error("induced"), { name });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { IdbFactoryLike, IdbRequestLike };
|
||||
@@ -0,0 +1,4 @@
|
||||
// Polyfills for the test environment. The injectable IDBFactory adapter in
|
||||
// `idb-adapter` means the package itself never reaches for `globalThis.indexedDB`
|
||||
// during tests — tests pass a real `fake-indexeddb` factory directly to the
|
||||
// constructor. No globals are mutated here.
|
||||
@@ -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-indexeddb",
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
# @suaveplan/storage-browser-localstorage - Browser localStorage AbstractStorage adapter for @suaveplan/storage: namespaced base64 JSON envelopes, TTL expiry on read, AbortSignal everywhere, strict key sanitisation, DOMException → typed LocalStorageError mapping, full OTel surface.
|
||||
|
||||
Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storage with full `AbstractStorage` conformance, AbortSignal threading, Zod-validated options, structured `LocalStorageError` boundary, lazy TTL eviction, an injectable `StorageLike` adapter for testing, and a complete OpenTelemetry surface.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.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 -->
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [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-localstorage` package can throw. The regist…
|
||||
<!-- end:auto-docs -->
|
||||
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-storage](./src/in-memory-storage/in-memory-storage.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)_- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
|
||||
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [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-localstorage` package can throw. The regist…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-browser-localstorage
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
createLocalStorageStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
if (!isLocalStorageAvailable()) {
|
||||
throw new Error("This browser blocks localStorage (private mode?)");
|
||||
}
|
||||
const storage = createLocalStorageStorage({ namespace: "myapp" });
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const back = await storage.get("user/42");
|
||||
```
|
||||
|
||||
## Factory integration
|
||||
|
||||
```ts
|
||||
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const storage = createStorage({
|
||||
type: "localstorage",
|
||||
config: { namespace: "myapp" },
|
||||
});
|
||||
```
|
||||
|
||||
The package does NOT self-register at module-evaluation time — call `registerLocalStorageProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Value | Notes |
|
||||
|---|---|---|
|
||||
| `partialReads` | `false` | `getPartial` is implemented via the `AbstractStorage` reference helper (load + slice). |
|
||||
| `partialWrites` | `false` | `setPartial` is implemented via the `AbstractStorage` reference helper (load + splice + write). |
|
||||
| `nativeStreams` | `false` | `createReadStream` / `createWriteStream` use the `AbstractStorage` default buffered implementations. |
|
||||
| `atomicWrites` | `true` | A single `setItem` is atomic at the DOM API level. |
|
||||
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/error-codes.ts`](./src/error-codes.md) — `LOCAL_STORAGE_CODES` registry + `LocalStorageError`.
|
||||
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
|
||||
- [`src/envelope/envelope.ts`](./src/envelope/envelope.md) — base64 + JSON wire format.
|
||||
- [`src/local-storage-adapter/local-storage-adapter.ts`](./src/local-storage-adapter/local-storage-adapter.md) — `defaultLocalStorage()` + `isLocalStorageAvailable()`.
|
||||
- [`src/local-storage/local-storage.ts`](./src/local-storage/local-storage.md) — the `LocalStorageStorage` class.
|
||||
- [`src/local-storage/local-storage.errors.ts`](./src/local-storage/local-storage.errors.md) — DOMException mapping.
|
||||
- [`src/local-storage/local-storage.ops.ts`](./src/local-storage/local-storage.ops.md) — namespacing + pagination helpers.
|
||||
- [`src/local-storage/local-storage.partial.ts`](./src/local-storage/local-storage.partial.md) — argument validation.
|
||||
- [`src/local-storage/local-storage.slot.ts`](./src/local-storage/local-storage.slot.md) — option resolution + envelope IO.
|
||||
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
|
||||
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerLocalStorageProvider()`.
|
||||
- [`src/in-memory-storage/in-memory-storage.ts`](./src/in-memory-storage/in-memory-storage.md) — real-implementation test double, exposed under `./in-memory-storage` for downstream test packages.
|
||||
- [`src/types/storage-like.types.ts`](./src/types/storage-like.types.md) — structural DOM Storage surface.
|
||||
- [`src/types/local-storage-storage.types.ts`](./src/types/local-storage-storage.types.md) — option boundary.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "@suaveplan/storage-browser-localstorage",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Browser localStorage AbstractStorage adapter for @suaveplan/storage: namespaced base64 JSON envelopes, TTL expiry on read, AbortSignal everywhere, strict key sanitisation, DOMException → typed LocalStorageError 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"
|
||||
}
|
||||
},
|
||||
"./local-storage": {
|
||||
"import": {
|
||||
"types": "./dist/local-storage/index.d.ts",
|
||||
"default": "./dist/local-storage/local-storage.js"
|
||||
}
|
||||
},
|
||||
"./key-sanitizer": {
|
||||
"import": {
|
||||
"types": "./dist/key-sanitizer/index.d.ts",
|
||||
"default": "./dist/key-sanitizer/key-sanitizer.js"
|
||||
}
|
||||
},
|
||||
"./envelope": {
|
||||
"import": {
|
||||
"types": "./dist/envelope/index.d.ts",
|
||||
"default": "./dist/envelope/envelope.js"
|
||||
}
|
||||
},
|
||||
"./local-storage-adapter": {
|
||||
"import": {
|
||||
"types": "./dist/local-storage-adapter/index.d.ts",
|
||||
"default": "./dist/local-storage-adapter/local-storage-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-storage": {
|
||||
"import": {
|
||||
"types": "./dist/in-memory-storage/index.d.ts",
|
||||
"default": "./dist/in-memory-storage/in-memory-storage.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,12 @@
|
||||
/** Maximum length of a caller-supplied key. localStorage entries are
|
||||
* comparatively expensive so this cap is tighter than OPFS (1024). */
|
||||
export const MAX_KEY_LENGTH = 256;
|
||||
|
||||
/** Default namespace prefix applied to every wire key. */
|
||||
export const DEFAULT_NAMESPACE = "suaveplan:storage";
|
||||
|
||||
/** Current envelope schema version. Bumped on incompatible wire changes. */
|
||||
export const ENVELOPE_VERSION = 1;
|
||||
|
||||
/** Sentinel key written by `isLocalStorageAvailable()` to probe write access. */
|
||||
export const SENTINEL_KEY = "__suaveplan_localstorage_probe__";
|
||||
@@ -0,0 +1,84 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `envelope` — base64 JSON wire format
|
||||
|
||||
### Purpose
|
||||
|
||||
The `localStorage` API only stores string values, but `AbstractStorage` deals in `Uint8Array`. The envelope module encodes binary payloads as base64 inside a JSON wrapper that also carries the full `StorageMetadata` record (size, timestamps, optional caller `metadata` bag) and an optional `expiresAt` TTL deadline. Centralising the wire format prevents drift between writers and readers, keeps the serialised representation forward-compatible via the `v` field, and lets corruption be detected as a typed boundary error rather than swallowed silently as `null` (which is how the upstream implementation behaved).
|
||||
|
||||
### Features
|
||||
|
||||
- Versioned envelope schema (`v: 1` today) — version mismatches raise `LOCAL_STORAGE.CORRUPT_PAYLOAD` instead of being silently re-interpreted.
|
||||
- Standard base64 via `btoa` / `atob` for portability across browsers and Node.
|
||||
- Metadata-preserving — `size`, `createdAt`, `modifiedAt`, `expiresAt?`, and the caller's `metadata?` bag round-trip byte-exact.
|
||||
- Optional `expiresAt` millisecond Unix timestamp surfaces TTL on the wire so any reader (current or future-versioned) can prune expired rows.
|
||||
- Pure exports for callers that want byte ↔ base64 conversion without the envelope wrapper.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
} from "@suaveplan/storage-browser-localstorage/envelope";
|
||||
|
||||
const raw = encodeEnvelope(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{ size: 3, createdAt: 0, modifiedAt: 0 },
|
||||
undefined
|
||||
);
|
||||
const { value, metadata } = decodeEnvelope(raw, "k");
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const bytes = new Uint8Array([0, 255, 128, 0, 1]);
|
||||
const b64 = bytesToBase64(bytes);
|
||||
const wire = encodeEnvelope(
|
||||
bytes,
|
||||
{
|
||||
size: bytes.byteLength,
|
||||
createdAt: Date.now(),
|
||||
modifiedAt: Date.now(),
|
||||
metadata: { mimeType: "application/octet-stream", tag: "demo" },
|
||||
},
|
||||
Date.now() + 60_000
|
||||
);
|
||||
|
||||
try {
|
||||
const parsed = decodeEnvelope(wire, "demo/k");
|
||||
console.log(parsed.metadata.metadata);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
) {
|
||||
// Telemetry hook: another tool wrote the slot.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface Envelope` — `{ v, payload, meta, expiresAt? }` shape.
|
||||
- `bytesToBase64(bytes: Uint8Array): string` — char-by-char `btoa` avoiding the `fromCharCode(...largeArray)` stack-overflow trap.
|
||||
- `base64ToBytes(encoded: string): Uint8Array` — `atob` + charcode unpack. Throws on malformed input.
|
||||
- `encodeEnvelope(bytes, metadata, expiresAt): string` — JSON-stringifies a fully-populated envelope.
|
||||
- `decodeEnvelope(raw, key): { value, metadata, expiresAt? }` — parses and validates; throws `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` with the offending `key` on any structural defect.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The base64 round-trip uses `btoa`/`atob` rather than `Buffer.from(...)` because the package's `browser` field targets the DOM realm — Node also exposes both globals from v18+ so the same code path runs in both environments without conditional import. Char-by-char encoding (`String.fromCharCode(bytes[i])`) is intentional: passing a large `Uint8Array` to `String.fromCharCode(...bytes)` blows the call stack on payloads over ~64 KiB in many engines. The decoder explicitly types every short-circuit failure (`v` mismatch, missing `payload`, non-object `meta`, malformed base64) as `CORRUPT_PAYLOAD` so callers downstream can distinguish "this slot was overwritten by another tool" from "infrastructure broke."
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
} from "./envelope.js";
|
||||
|
||||
describe("envelope", () => {
|
||||
it("round-trips arbitrary bytes through encodeEnvelope/decodeEnvelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const samples = [
|
||||
new Uint8Array([]),
|
||||
new Uint8Array([0]),
|
||||
new Uint8Array([0, 1, 2, 3, 4, 5]),
|
||||
new Uint8Array([255, 254, 253, 252]),
|
||||
new Uint8Array([128, 64, 32, 16, 8, 4, 2, 1, 0]),
|
||||
];
|
||||
for (const bytes of samples) {
|
||||
const meta = {
|
||||
size: bytes.byteLength,
|
||||
createdAt: 100,
|
||||
modifiedAt: 200,
|
||||
};
|
||||
const wire = encodeEnvelope(bytes, meta, undefined);
|
||||
const decoded = decodeEnvelope(wire, "k");
|
||||
expect(decoded.value).toEqual(bytes);
|
||||
expect(decoded.metadata).toEqual(meta);
|
||||
expect(decoded.expiresAt).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves expiresAt when supplied", { timeout: 5000 }, () => {
|
||||
const wire = encodeEnvelope(
|
||||
new Uint8Array([1]),
|
||||
{ size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
1700000000000
|
||||
);
|
||||
expect(decodeEnvelope(wire, "k").expiresAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it("bytesToBase64 + base64ToBytes round-trip every byte value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) bytes[i] = i;
|
||||
const back = base64ToBytes(bytesToBase64(bytes));
|
||||
expect(back).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD on invalid JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeEnvelope("not-json{", "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LocalStorageError);
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD when parsed value is not an object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const sample of ["null", '"hello"', "42", "true", "[]"]) {
|
||||
try {
|
||||
decodeEnvelope(sample, "k");
|
||||
expect.fail(`expected throw for ${sample}`);
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD on version mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = JSON.stringify({
|
||||
v: 99,
|
||||
payload: "AA==",
|
||||
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
});
|
||||
try {
|
||||
decodeEnvelope(wire, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD when payload is missing or non-string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const noPayload = JSON.stringify({
|
||||
v: 1,
|
||||
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
|
||||
});
|
||||
const numericPayload = JSON.stringify({
|
||||
v: 1,
|
||||
payload: 42,
|
||||
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
|
||||
});
|
||||
for (const sample of [noPayload, numericPayload]) {
|
||||
try {
|
||||
decodeEnvelope(sample, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD when meta is missing or non-object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const samples = [
|
||||
JSON.stringify({ v: 1, payload: "AA==" }),
|
||||
JSON.stringify({ v: 1, payload: "AA==", meta: null }),
|
||||
JSON.stringify({ v: 1, payload: "AA==", meta: "bad" }),
|
||||
];
|
||||
for (const wire of samples) {
|
||||
try {
|
||||
decodeEnvelope(wire, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("decodeEnvelope raises CORRUPT_PAYLOAD on malformed base64 payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wire = JSON.stringify({
|
||||
v: 1,
|
||||
payload: "@@not-base64!!",
|
||||
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
|
||||
});
|
||||
try {
|
||||
decodeEnvelope(wire, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { StorageMetadata } from "@suaveplan/storage/types";
|
||||
import { ENVELOPE_VERSION } from "../constants/index.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
|
||||
export interface Envelope {
|
||||
/** Envelope schema version. Currently always `1`. */
|
||||
v: number;
|
||||
/** Base64-encoded value bytes. */
|
||||
payload: string;
|
||||
/** Storage metadata for this entry. */
|
||||
meta: StorageMetadata;
|
||||
/** Optional millisecond Unix timestamp after which the entry is expired. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a `Uint8Array` to standard base64 using `btoa`. Done char-by-char to
|
||||
* avoid the `String.fromCharCode(...largeArray)` stack-overflow trap.
|
||||
*/
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i] as number);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to `Uint8Array`. Throws when the string is not
|
||||
* valid base64 (caller wraps to `CORRUPT_PAYLOAD`).
|
||||
*/
|
||||
export function base64ToBytes(encoded: string): Uint8Array {
|
||||
const binary = atob(encoded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise an envelope to its wire form (a JSON string suitable for
|
||||
* `localStorage.setItem`). Round-trips byte-exactly through
|
||||
* `decodeEnvelope`.
|
||||
*/
|
||||
export function encodeEnvelope(
|
||||
bytes: Uint8Array,
|
||||
metadata: StorageMetadata,
|
||||
expiresAt: number | undefined
|
||||
): string {
|
||||
const envelope: Envelope = {
|
||||
v: ENVELOPE_VERSION,
|
||||
payload: bytesToBase64(bytes),
|
||||
meta: metadata,
|
||||
...(expiresAt !== undefined ? { expiresAt } : {}),
|
||||
};
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored envelope from its wire form. Raises
|
||||
* `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` when the string is not
|
||||
* valid JSON, when the parsed shape is not an object, when the version is
|
||||
* unsupported, when `payload` is missing or malformed base64, or when
|
||||
* `meta` is missing/non-object.
|
||||
*/
|
||||
export function decodeEnvelope(
|
||||
raw: string,
|
||||
key: string
|
||||
): { value: Uint8Array; metadata: StorageMetadata; expiresAt?: number } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
"envelope JSON is malformed",
|
||||
{ key, cause: cause as Error }
|
||||
);
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
"envelope is not an object",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
const env = parsed as Partial<Envelope>;
|
||||
if (env.v !== ENVELOPE_VERSION) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
`unsupported envelope version: ${String(env.v)}`,
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
if (typeof env.payload !== "string") {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
"envelope payload is missing or non-string",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
if (
|
||||
env.meta === undefined ||
|
||||
env.meta === null ||
|
||||
typeof env.meta !== "object"
|
||||
) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
"envelope meta is missing or non-object",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
let value: Uint8Array;
|
||||
try {
|
||||
value = base64ToBytes(env.payload);
|
||||
} catch (cause) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
|
||||
"envelope payload is not valid base64",
|
||||
{ key, cause: cause as Error }
|
||||
);
|
||||
}
|
||||
const result: {
|
||||
value: Uint8Array;
|
||||
metadata: StorageMetadata;
|
||||
expiresAt?: number;
|
||||
} = { value, metadata: env.meta as StorageMetadata };
|
||||
if (typeof env.expiresAt === "number") {
|
||||
result.expiresAt = env.expiresAt;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { Envelope } from "./envelope.js";
|
||||
export {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
decodeEnvelope,
|
||||
encodeEnvelope,
|
||||
} from "./envelope.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
## `error-codes` — LocalStorage error registry
|
||||
|
||||
### Purpose
|
||||
|
||||
Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The registry uses `defineCodes("LOCAL_STORAGE", [...])` from `@suaveplan/error` so each code is namespaced (`LOCAL_STORAGE.QUOTA_EXCEEDED`, `LOCAL_STORAGE.CORRUPT_PAYLOAD`, etc.), frozen at module load, and surfaced through a typed `LocalStorageErrorCode` union. Centralising the registry keeps the boundary 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
|
||||
|
||||
- Ten frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `CORRUPT_PAYLOAD`.
|
||||
- `LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` with an optional `key` discriminant for log correlation.
|
||||
- `cause` chain preserved from upstream `DOMException` instances (no message-text parsing required downstream).
|
||||
- TypeScript-narrowed `LocalStorageErrorCode` union for exhaustive switches.
|
||||
- `CORRUPT_PAYLOAD` distinguishes "another tool overwrote the slot with garbage" from `IO_FAILED` / `NOT_FOUND`, allowing telemetry to separate genuine corruption from infrastructure failure.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
throw new LocalStorageError(LOCAL_STORAGE_CODES.NOT_FOUND, "missing entry", {
|
||||
key: "user/42",
|
||||
});
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
type LocalStorageErrorCode,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function handle(err: unknown): "retry" | "fail" | "ignore" {
|
||||
if (!(err instanceof LocalStorageError)) return "fail";
|
||||
const code: LocalStorageErrorCode = err.code;
|
||||
switch (code) {
|
||||
case LOCAL_STORAGE_CODES.NOT_FOUND:
|
||||
return "ignore";
|
||||
case LOCAL_STORAGE_CODES.QUOTA_EXCEEDED:
|
||||
case LOCAL_STORAGE_CODES.IO_FAILED:
|
||||
return "retry";
|
||||
case LOCAL_STORAGE_CODES.PERMISSION_DENIED:
|
||||
case LOCAL_STORAGE_CODES.UNAVAILABLE:
|
||||
return "fail";
|
||||
case LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD:
|
||||
return "fail";
|
||||
case LOCAL_STORAGE_CODES.INVALID_KEY:
|
||||
case LOCAL_STORAGE_CODES.KEY_TOO_LONG:
|
||||
case LOCAL_STORAGE_CODES.INVALID_OPTIONS:
|
||||
case LOCAL_STORAGE_CODES.ABORTED:
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `LOCAL_STORAGE_CODES` — frozen `Record<string, string>` mapping each short name to its dotted form (`"LOCAL_STORAGE.NOT_FOUND"`).
|
||||
- `LocalStorageErrorCode` — `(typeof LOCAL_STORAGE_CODES)[keyof typeof LOCAL_STORAGE_CODES]` union.
|
||||
- `class LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` — adds optional `key?: string`. Constructor signature: `new LocalStorageError(code, message, options?)` where `options` may carry `cause` and `key`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The registry is constructed via `defineCodes` so the strings are validated at module load (preventing duplicate slot collisions across the project's packages). The `key` discriminant is intentionally not part of the registry — it is per-throw metadata; some failure modes (`UNAVAILABLE`, `INVALID_OPTIONS`) have no key to attach.
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
export const LOCAL_STORAGE_CODES = defineCodes("LOCAL_STORAGE", [
|
||||
"NOT_FOUND",
|
||||
"PERMISSION_DENIED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"UNAVAILABLE",
|
||||
"INVALID_KEY",
|
||||
"KEY_TOO_LONG",
|
||||
"INVALID_OPTIONS",
|
||||
"ABORTED",
|
||||
"IO_FAILED",
|
||||
"CORRUPT_PAYLOAD",
|
||||
] as const);
|
||||
|
||||
export type LocalStorageErrorCode =
|
||||
(typeof LOCAL_STORAGE_CODES)[keyof typeof LOCAL_STORAGE_CODES];
|
||||
|
||||
export interface LocalStorageErrorOptions extends SuaveplanErrorOptions {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for localStorage adapter operations.
|
||||
*
|
||||
* Carries an optional `key` discriminant so callers can correlate failures
|
||||
* with the offending key without parsing message text.
|
||||
*/
|
||||
export class LocalStorageError extends SuaveplanError<LocalStorageErrorCode> {
|
||||
public readonly key: string | undefined;
|
||||
|
||||
public constructor(
|
||||
code: LocalStorageErrorCode,
|
||||
message: string,
|
||||
options?: LocalStorageErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.key = options?.key;
|
||||
this.name = "LocalStorageError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `in-memory-storage` — real-implementation StorageLike fixture
|
||||
|
||||
### Purpose
|
||||
|
||||
`InMemoryStorage` is a real, dependency-free implementation of the DOM `Storage` interface (narrowed to `StorageLike`) used by the package's own test suite and exported under `./in-memory-storage` so downstream test packages can run against the same fixture without re-implementing the (subtle) DOM semantics. the test suite bans `vi.mock` / `vi.stubGlobal` — every test must run against a real implementation; this module is that real implementation. It also supports optional quota enforcement (to drive the `QUOTA_EXCEEDED` path) and `SecurityError` injection (to drive the `PERMISSION_DENIED` path) without resorting to monkey-patching the global.
|
||||
|
||||
### Features
|
||||
|
||||
- `length`, `key(i)`, `getItem`, `setItem`, `removeItem`, `clear` with DOM-compatible semantics.
|
||||
- Insertion-order iteration matching the WHATWG `Storage` contract.
|
||||
- Optional `quotaBytes` cap — when set, `setItem` throws `QuotaExceededDomException` (`name === "QuotaExceededError"`, `code === 22`) once the projected byte sum would exceed the cap.
|
||||
- Optional `securityError` flag — every `setItem` throws `SecurityErrorDomException` (`name === "SecurityError"`, `code === 18`), mimicking Safari private mode.
|
||||
- Side-effect free constructor; safe to instantiate multiple times concurrently.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("k", "v");
|
||||
console.log(storage.getItem("k")); // "v"
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
QuotaExceededDomException,
|
||||
SecurityErrorDomException,
|
||||
} from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const cramped = createInMemoryLocalStorage({ quotaBytes: 16 });
|
||||
try {
|
||||
cramped.setItem("longish-key", "x".repeat(50));
|
||||
} catch (err) {
|
||||
console.log(err instanceof QuotaExceededDomException); // true
|
||||
}
|
||||
|
||||
const locked = createInMemoryLocalStorage({ securityError: true });
|
||||
try {
|
||||
locked.setItem("k", "v");
|
||||
} catch (err) {
|
||||
console.log(err instanceof SecurityErrorDomException); // true
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `createInMemoryLocalStorage(options?): InMemoryStorage` — preferred entry point.
|
||||
- `class InMemoryStorage implements StorageLike` — Map-backed with insertion-order semantics.
|
||||
- `class QuotaExceededDomException extends Error` — `name = "QuotaExceededError"`, `code = 22`.
|
||||
- `class SecurityErrorDomException extends Error` — `name = "SecurityError"`, `code = 18`.
|
||||
- `interface InMemoryStorageOptions` — `{ quotaBytes?, securityError? }`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The fixture uses a `Map<string, string>` for storage so `length`, `key(i)`, and iteration order all match DOM `Storage` insertion-order semantics. The quota probe is intentionally pessimistic: it recomputes the full byte sum on each `setItem` rather than maintaining a running counter, so the test is robust against `delete`-then-`set` interleaving without complex bookkeeping. `code === 22` (`QuotaExceededError`) is the modern WebKit/Chromium value; Firefox uses `code === 1014` for the same condition — the production error mapper checks both. The `key(i)` method's final `return null` after the loop is unreachable because the earlier `index >= size` short-circuit covers the out-of-range case, and the loop completes by returning the matching key — it is marked with a `c8 ignore` comment because Istanbul can't statically prove the unreachability.
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
InMemoryStorage,
|
||||
QuotaExceededDomException,
|
||||
SecurityErrorDomException,
|
||||
} from "./in-memory-storage.js";
|
||||
|
||||
describe("InMemoryStorage", () => {
|
||||
it("setItem and getItem round-trip", { timeout: 5000 }, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
expect(storage.getItem("a")).toBe("1");
|
||||
expect(storage.getItem("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("length reflects the number of stored slots", { timeout: 5000 }, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
expect(storage.length).toBe(0);
|
||||
storage.setItem("a", "1");
|
||||
storage.setItem("b", "2");
|
||||
expect(storage.length).toBe(2);
|
||||
});
|
||||
|
||||
it("key(i) returns null for out-of-range indices", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
expect(storage.key(0)).toBe("a");
|
||||
expect(storage.key(1)).toBeNull();
|
||||
expect(storage.key(-1)).toBeNull();
|
||||
expect(storage.key(1.5)).toBeNull();
|
||||
});
|
||||
|
||||
it("removeItem deletes a slot", { timeout: 5000 }, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
storage.removeItem("a");
|
||||
expect(storage.getItem("a")).toBeNull();
|
||||
expect(storage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("clear empties every slot", { timeout: 5000 }, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
storage.setItem("b", "2");
|
||||
storage.clear();
|
||||
expect(storage.length).toBe(0);
|
||||
expect(storage.getItem("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves insertion order for key(i)", { timeout: 5000 }, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
storage.setItem("b", "2");
|
||||
storage.setItem("c", "3");
|
||||
expect([storage.key(0), storage.key(1), storage.key(2)]).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("setItem overwriting an existing key does not change length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem("a", "1");
|
||||
storage.setItem("a", "2");
|
||||
expect(storage.length).toBe(1);
|
||||
expect(storage.getItem("a")).toBe("2");
|
||||
});
|
||||
|
||||
it("throws QuotaExceededDomException when projected size exceeds quota", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage({ quotaBytes: 8 });
|
||||
storage.setItem("a", "12"); // 1 + 2 = 3 bytes — OK.
|
||||
try {
|
||||
storage.setItem("b", "longish-value");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(QuotaExceededDomException);
|
||||
expect((err as QuotaExceededDomException).code).toBe(22);
|
||||
}
|
||||
});
|
||||
|
||||
it("quota check excludes the slot being overwritten", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage({ quotaBytes: 5 });
|
||||
storage.setItem("a", "1234"); // 5 bytes — at quota.
|
||||
// Overwrite with the same byte length — should NOT count both copies.
|
||||
storage.setItem("a", "5678");
|
||||
expect(storage.getItem("a")).toBe("5678");
|
||||
});
|
||||
|
||||
it("securityError flag makes every setItem throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage({ securityError: true });
|
||||
try {
|
||||
storage.setItem("a", "1");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SecurityErrorDomException);
|
||||
expect((err as SecurityErrorDomException).code).toBe(18);
|
||||
}
|
||||
});
|
||||
|
||||
it("InMemoryStorage class constructs with default options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = new InMemoryStorage();
|
||||
expect(storage.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { StorageLike } from "../types/storage-like.types.js";
|
||||
|
||||
export interface InMemoryStorageOptions {
|
||||
/** Optional quota in bytes (sum of `key.length + value.length`). When the
|
||||
* sum would exceed the quota, `setItem` throws a `QuotaExceededError`
|
||||
* `DOMException`-shaped object. */
|
||||
quotaBytes?: number;
|
||||
/** When `true`, every `setItem` throws a `SecurityError` `DOMException`-
|
||||
* shaped object — mimics Safari private mode. */
|
||||
securityError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMException-shaped sentinel error. `localStorage` quota failures use this
|
||||
* shape; the production error mapper inspects `name` and `code`.
|
||||
*/
|
||||
export class QuotaExceededDomException extends Error {
|
||||
public override readonly name = "QuotaExceededError";
|
||||
public readonly code = 22;
|
||||
}
|
||||
|
||||
export class SecurityErrorDomException extends Error {
|
||||
public override readonly name = "SecurityError";
|
||||
public readonly code = 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory `StorageLike` implementation that preserves the DOM `Storage`
|
||||
* insertion-order semantics, supports optional quota enforcement, and can
|
||||
* be configured to simulate Safari private-mode `SecurityError` rejections.
|
||||
*
|
||||
* Every method is a real implementation — no `vi.fn` mocks. The fixture is
|
||||
* exported under `./in-memory-storage` so downstream test packages can use
|
||||
* the same contract without re-implementing it.
|
||||
*/
|
||||
export class InMemoryStorage implements StorageLike {
|
||||
readonly #map = new Map<string, string>();
|
||||
readonly #quotaBytes: number | undefined;
|
||||
readonly #securityError: boolean;
|
||||
|
||||
public constructor(options: InMemoryStorageOptions = {}) {
|
||||
this.#quotaBytes = options.quotaBytes;
|
||||
this.#securityError = options.securityError === true;
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.#map.size;
|
||||
}
|
||||
|
||||
public key(index: number): string | null {
|
||||
if (!Number.isInteger(index) || index < 0) return null;
|
||||
if (index >= this.#map.size) return null;
|
||||
// `index < size` is invariant from the guard above. Snapshot the keys
|
||||
// once and index directly so the function has a single exit point.
|
||||
return [...this.#map.keys()][index] as string;
|
||||
}
|
||||
|
||||
public getItem(key: string): string | null {
|
||||
return this.#map.has(key) ? (this.#map.get(key) as string) : null;
|
||||
}
|
||||
|
||||
public setItem(key: string, value: string): void {
|
||||
if (this.#securityError) {
|
||||
throw new SecurityErrorDomException(
|
||||
"localStorage is disabled in this realm"
|
||||
);
|
||||
}
|
||||
if (this.#quotaBytes !== undefined) {
|
||||
// Compute the prospective sum: existing entries minus the slot
|
||||
// being overwritten, plus the new entry.
|
||||
let bytes = key.length + value.length;
|
||||
for (const [k, v] of this.#map.entries()) {
|
||||
if (k === key) continue;
|
||||
bytes += k.length + v.length;
|
||||
}
|
||||
if (bytes > this.#quotaBytes) {
|
||||
throw new QuotaExceededDomException(
|
||||
`localStorage quota exceeded (need ${bytes}, cap ${this.#quotaBytes})`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#map.set(key, value);
|
||||
}
|
||||
|
||||
public removeItem(key: string): void {
|
||||
this.#map.delete(key);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory creating a new `InMemoryStorage` with optional quota + security
|
||||
* fault injection. Re-exported under `./in-memory-storage` so downstream
|
||||
* test packages can share the same contract.
|
||||
*/
|
||||
export function createInMemoryLocalStorage(
|
||||
options?: InMemoryStorageOptions
|
||||
): InMemoryStorage {
|
||||
return new InMemoryStorage(options);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { InMemoryStorageOptions } from "./in-memory-storage.js";
|
||||
export {
|
||||
createInMemoryLocalStorage,
|
||||
InMemoryStorage,
|
||||
QuotaExceededDomException,
|
||||
SecurityErrorDomException,
|
||||
} from "./in-memory-storage.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
// Side-effect import: ensures `defineCodes` registers the LOCAL_STORAGE.*
|
||||
// registry even when subpath consumers never reach this file.
|
||||
import "./error-codes.js";
|
||||
|
||||
export {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
decodeEnvelope,
|
||||
type Envelope,
|
||||
encodeEnvelope,
|
||||
} from "./envelope/envelope.js";
|
||||
export {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
type LocalStorageErrorCode,
|
||||
type LocalStorageErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
createInMemoryLocalStorage,
|
||||
InMemoryStorage,
|
||||
type InMemoryStorageOptions,
|
||||
QuotaExceededDomException,
|
||||
SecurityErrorDomException,
|
||||
} from "./in-memory-storage/in-memory-storage.js";
|
||||
export { sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
|
||||
export {
|
||||
assertNotAborted,
|
||||
isQuotaExceeded,
|
||||
mapLocalStorageError,
|
||||
} from "./local-storage/local-storage.errors.js";
|
||||
export {
|
||||
createLocalStorageStorage,
|
||||
LocalStorageStorage,
|
||||
} from "./local-storage/local-storage.js";
|
||||
export {
|
||||
buildWireKey,
|
||||
collectOwnedKeys,
|
||||
isOwnedWireKey,
|
||||
paginateKeys,
|
||||
stripNamespace,
|
||||
} from "./local-storage/local-storage.ops.js";
|
||||
export { assertNonNegInt } from "./local-storage/local-storage.partial.js";
|
||||
export {
|
||||
buildMetadata,
|
||||
type DecodedEntry,
|
||||
peekMetadata,
|
||||
type ResolvedConfig,
|
||||
readEnvelope,
|
||||
requireEntry,
|
||||
resolveOptions,
|
||||
} from "./local-storage/local-storage.slot.js";
|
||||
export {
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "./local-storage-adapter/local-storage-adapter.js";
|
||||
export {
|
||||
getInstruments,
|
||||
type RecordOpInput,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
type StorageBrowserLocalStorageInstruments,
|
||||
type StorageBrowserLocalStorageOp,
|
||||
type StorageBrowserLocalStorageOpOutcome,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} from "./otel/otel.js";
|
||||
export { registerLocalStorageProvider } from "./registry/registry.js";
|
||||
export type {
|
||||
LocalStorageAdapter,
|
||||
LocalStorageStorageOptions,
|
||||
StorageLike,
|
||||
} from "./types/index.js";
|
||||
export { LocalStorageStorageOptionsSchema } from "./types/index.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { sanitizeKey } from "./key-sanitizer.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `key-sanitizer` — strict caller-key boundary
|
||||
|
||||
### Purpose
|
||||
|
||||
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any `localStorage` API call is issued. It enforces the storage-browser-localstorage 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 namespace or constructing wire keys that collide with sibling tools sharing the origin's `localStorage`.
|
||||
|
||||
### Features
|
||||
|
||||
- Rejects empty strings, non-string input, keys over 256 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 `LocalStorageError(LOCAL_STORAGE.INVALID_KEY | LOCAL_STORAGE.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-localstorage/key-sanitizer";
|
||||
|
||||
const safe = sanitizeKey("orders/2026/05/12.json");
|
||||
// → "orders/2026/05/12.json"
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
sanitizeKey,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeKeyOrNull(input: unknown): string | null {
|
||||
if (typeof input !== "string") return null;
|
||||
try {
|
||||
return sanitizeKey(input);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
(err.code === LOCAL_STORAGE_CODES.INVALID_KEY ||
|
||||
err.code === LOCAL_STORAGE_CODES.KEY_TOO_LONG)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`sanitizeKey(key: string): string`
|
||||
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.KEY_TOO_LONG)` for keys with `length > 256`.
|
||||
- **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`. The 256-char cap is tighter than the OPFS adapter's 1024: `localStorage` enumeration cost is O(n) over all slot keys, and tighter caps reduce worst-case `list()` scan time when collisions are deliberately crafted by a noisy tab.
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } 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"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalises Windows backslash separators to POSIX", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(sanitizeKey("orders\\2026\\05.json")).toBe(
|
||||
"orders/2026/05.json"
|
||||
);
|
||||
});
|
||||
|
||||
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(LocalStorageError);
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_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 LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects keys longer than 256 chars with KEY_TOO_LONG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
sanitizeKey("a".repeat(257));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.KEY_TOO_LONG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects keys containing control characters", { timeout: 5000 }, () => {
|
||||
for (const sample of ["ab\x00cd", "ab\x7fcd", "ab\x1fcd"]) {
|
||||
try {
|
||||
sanitizeKey(sample);
|
||||
expect.fail(`expected throw for ${JSON.stringify(sample)}`);
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_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 LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path traversal segments", { timeout: 5000 }, () => {
|
||||
for (const sample of ["../../etc/passwd", "a/../b", "a\\..\\b"]) {
|
||||
try {
|
||||
sanitizeKey(sample);
|
||||
expect.fail(`expected throw for ${sample}`);
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MAX_KEY_LENGTH } from "../constants/index.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } 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
|
||||
* `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` or
|
||||
* `LocalStorageError(LOCAL_STORAGE.KEY_TOO_LONG)` on rejection. Rejection
|
||||
* rules:
|
||||
* - non-string input
|
||||
* - empty string
|
||||
* - length over `MAX_KEY_LENGTH` (256) 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 LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY,
|
||||
"key must be a string"
|
||||
);
|
||||
}
|
||||
if (key.length === 0) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY,
|
||||
"key must not be empty"
|
||||
);
|
||||
}
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.KEY_TOO_LONG,
|
||||
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`,
|
||||
{ key: `${key.slice(0, 64)}…` }
|
||||
);
|
||||
}
|
||||
if (containsControlChar(key)) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY,
|
||||
"key contains a control character",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
if (key.startsWith("/")) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_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 LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY,
|
||||
"path traversal segment (..) is not allowed",
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
return normalised;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "./local-storage-adapter.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `local-storage-adapter` — global resolver + availability probe
|
||||
|
||||
### Purpose
|
||||
|
||||
`defaultLocalStorage()` returns a `LocalStorageAdapter` that lazily resolves `globalThis.localStorage` on every public-method call. Lazy resolution is critical because the package is published as universal/browser-tier and `globalThis.localStorage` is absent in Node, Workers without a storage shim, and ServiceWorker realms — eagerly reading the global at module load would either throw a `TypeError` on missing-property access or capture an undefined snapshot and freeze it for the lifetime of the import. `isLocalStorageAvailable()` is the companion feature detector that also accounts for Safari private mode, where `localStorage` exists on the global but every `setItem` throws `SecurityError` — the only reliable detection is a sentinel write/remove probe.
|
||||
|
||||
### Features
|
||||
|
||||
- Lazy `read()` — `globalThis.localStorage` is looked up at first method call, never at import.
|
||||
- Typed `LOCAL_STORAGE.UNAVAILABLE` error when the global is missing.
|
||||
- `isLocalStorageAvailable(storage?)` sentinel probe — writes + removes a probe key (`__suaveplan_localstorage_probe__`) to detect Safari private mode.
|
||||
- The probe restores the storage to its prior state on success (no side effects observable to other consumers).
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage-adapter";
|
||||
|
||||
if (isLocalStorageAvailable()) {
|
||||
const adapter = defaultLocalStorage();
|
||||
adapter.read().setItem("hello", "world");
|
||||
}
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
LocalStorageError,
|
||||
LOCAL_STORAGE_CODES,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
// Force-test the unavailable path under Node:
|
||||
try {
|
||||
defaultLocalStorage().read();
|
||||
} catch (err) {
|
||||
console.log(
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
// Probe a custom StorageLike — useful when wrapping platform shims:
|
||||
const fixture = createInMemoryLocalStorage({ securityError: true });
|
||||
console.log(isLocalStorageAvailable(fixture)); // false
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `defaultLocalStorage(): LocalStorageAdapter` — returns `{ read() }`; each `read()` throws `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)` if `globalThis.localStorage` is missing.
|
||||
- `isLocalStorageAvailable(storage?: StorageLike): boolean` — returns `true` only when storage is present AND sentinel write succeeds; defaults to `globalThis.localStorage` when no argument is provided.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The sentinel key is namespaced with a leading + trailing double-underscore (`__suaveplan_localstorage_probe__`) so it cannot collide with the package's own namespaced keys (`suaveplan:storage:...`) or any reasonable application key. The probe deliberately swallows ALL exceptions — Safari private mode throws `SecurityError`, but quota-exceeded realms throw `QuotaExceededError`, and some test-runner shims throw `TypeError` for "not implemented." All three should report "unavailable" because the adapter cannot meaningfully use the storage. The lazy resolver pattern matches the OPFS adapter sibling — both packages defer global lookups until first method call so universal consumers can `import` them safely.
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
|
||||
import {
|
||||
defaultLocalStorage,
|
||||
isLocalStorageAvailable,
|
||||
} from "./local-storage-adapter.js";
|
||||
|
||||
interface GlobalWithStorage {
|
||||
localStorage?: unknown;
|
||||
}
|
||||
|
||||
describe("defaultLocalStorage", () => {
|
||||
let priorGlobal: { had: boolean; value: unknown };
|
||||
beforeEach(() => {
|
||||
const g = globalThis as unknown as GlobalWithStorage;
|
||||
priorGlobal = { had: "localStorage" in g, value: g.localStorage };
|
||||
delete (g as Record<string, unknown>).localStorage;
|
||||
});
|
||||
afterEach(() => {
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
if (priorGlobal.had) {
|
||||
g.localStorage = priorGlobal.value;
|
||||
} else {
|
||||
delete g.localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws LOCAL_STORAGE.UNAVAILABLE when globalThis.localStorage is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
defaultLocalStorage().read();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LocalStorageError);
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.UNAVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the global localStorage when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
(globalThis as unknown as Record<string, unknown>).localStorage =
|
||||
fixture;
|
||||
const adapter = defaultLocalStorage();
|
||||
const resolved = adapter.read();
|
||||
resolved.setItem("k", "1");
|
||||
expect(fixture.getItem("k")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalStorageAvailable", () => {
|
||||
it("returns true for an in-memory fixture", { timeout: 5000 }, () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
expect(isLocalStorageAvailable(fixture)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when sentinel write throws (Safari private mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const locked = createInMemoryLocalStorage({ securityError: true });
|
||||
expect(isLocalStorageAvailable(locked)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the global is missing and no override is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const priorHad = "localStorage" in globalThis;
|
||||
const priorValue = (globalThis as unknown as Record<string, unknown>)
|
||||
.localStorage;
|
||||
delete (globalThis as unknown as Record<string, unknown>).localStorage;
|
||||
try {
|
||||
expect(isLocalStorageAvailable()).toBe(false);
|
||||
} finally {
|
||||
if (priorHad) {
|
||||
(
|
||||
globalThis as unknown as Record<string, unknown>
|
||||
).localStorage = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("removes its sentinel key on success", { timeout: 5000 }, () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
fixture.setItem("pre-existing", "untouched");
|
||||
isLocalStorageAvailable(fixture);
|
||||
expect(fixture.length).toBe(1);
|
||||
expect(fixture.getItem("pre-existing")).toBe("untouched");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { SENTINEL_KEY } from "../constants/index.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import type {
|
||||
LocalStorageAdapter,
|
||||
StorageLike,
|
||||
} from "../types/storage-like.types.js";
|
||||
|
||||
interface GlobalWithStorage {
|
||||
localStorage?: StorageLike;
|
||||
}
|
||||
|
||||
function readGlobalStorage(): StorageLike | undefined {
|
||||
return (globalThis as unknown as GlobalWithStorage).localStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `LocalStorageAdapter` wrapping `globalThis.localStorage`.
|
||||
*
|
||||
* Each `read()` call throws `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)`
|
||||
* when the global is missing — Node, Workers without a storage shim, or
|
||||
* realms where the global was deleted. The lookup is deferred to call time
|
||||
* so the package can be imported in any environment without throwing at
|
||||
* module evaluation.
|
||||
*/
|
||||
export function defaultLocalStorage(): LocalStorageAdapter {
|
||||
return {
|
||||
read(): StorageLike {
|
||||
const ls = readGlobalStorage();
|
||||
if (ls === undefined) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.UNAVAILABLE,
|
||||
"globalThis.localStorage is not available in this environment"
|
||||
);
|
||||
}
|
||||
return ls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature detector — returns `true` only when both the `localStorage`
|
||||
* global is present AND a sentinel `setItem`/`removeItem` probe completes
|
||||
* without throwing. Safari private mode exposes `localStorage` but throws
|
||||
* on every `setItem` with a `SecurityError`; this probe surfaces that case
|
||||
* synchronously.
|
||||
*
|
||||
* Pure, synchronous, side-effect free on success (the probe removes its
|
||||
* own sentinel before returning).
|
||||
*/
|
||||
export function isLocalStorageAvailable(storage?: StorageLike): boolean {
|
||||
const ls = storage ?? readGlobalStorage();
|
||||
if (ls === undefined) return false;
|
||||
try {
|
||||
ls.setItem(SENTINEL_KEY, "1");
|
||||
ls.removeItem(SENTINEL_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export {
|
||||
assertNotAborted,
|
||||
isQuotaExceeded,
|
||||
mapLocalStorageError,
|
||||
} from "./local-storage.errors.js";
|
||||
export {
|
||||
createLocalStorageStorage,
|
||||
LocalStorageStorage,
|
||||
} from "./local-storage.js";
|
||||
export {
|
||||
buildWireKey,
|
||||
collectOwnedKeys,
|
||||
isOwnedWireKey,
|
||||
paginateKeys,
|
||||
stripNamespace,
|
||||
} from "./local-storage.ops.js";
|
||||
export { assertNonNegInt } from "./local-storage.partial.js";
|
||||
export {
|
||||
buildMetadata,
|
||||
type DecodedEntry,
|
||||
peekMetadata,
|
||||
type ResolvedConfig,
|
||||
readEnvelope,
|
||||
requireEntry,
|
||||
resolveOptions,
|
||||
} from "./local-storage.slot.js";
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import {
|
||||
assertNotAborted,
|
||||
isQuotaExceeded,
|
||||
mapLocalStorageError,
|
||||
} from "./local-storage.errors.js";
|
||||
|
||||
describe("isQuotaExceeded", () => {
|
||||
it("returns true when name is QuotaExceededError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
isQuotaExceeded(
|
||||
Object.assign(new Error("full"), { name: "QuotaExceededError" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for legacy WebKit code 22", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
isQuotaExceeded(Object.assign(new Error("full"), { code: 22 }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Firefox code 1014", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
isQuotaExceeded(Object.assign(new Error("full"), { code: 1014 }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", { timeout: 5000 }, () => {
|
||||
expect(isQuotaExceeded(new Error("nope"))).toBe(false);
|
||||
expect(isQuotaExceeded(null)).toBe(false);
|
||||
expect(isQuotaExceeded("string")).toBe(false);
|
||||
expect(isQuotaExceeded({ name: 42 })).toBe(false);
|
||||
expect(isQuotaExceeded({ code: "not-a-number" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapLocalStorageError", () => {
|
||||
it("passes through a pre-existing LocalStorageError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pre = new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.UNAVAILABLE,
|
||||
"no storage"
|
||||
);
|
||||
expect(mapLocalStorageError(pre, "k")).toBe(pre);
|
||||
});
|
||||
|
||||
it("maps QuotaExceededError without a key", { timeout: 5000 }, () => {
|
||||
const err = mapLocalStorageError(
|
||||
Object.assign(new Error("full"), { name: "QuotaExceededError" }),
|
||||
undefined
|
||||
);
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.QUOTA_EXCEEDED);
|
||||
expect(err.key).toBeUndefined();
|
||||
expect(err.message).not.toContain("writing");
|
||||
});
|
||||
|
||||
it("maps QuotaExceededError with a key", { timeout: 5000 }, () => {
|
||||
const err = mapLocalStorageError(
|
||||
Object.assign(new Error("full"), { code: 1014 }),
|
||||
"user/42"
|
||||
);
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.QUOTA_EXCEEDED);
|
||||
expect(err.key).toBe("user/42");
|
||||
expect(err.message).toContain("user/42");
|
||||
});
|
||||
|
||||
it("maps NotAllowedError to PERMISSION_DENIED", { timeout: 5000 }, () => {
|
||||
const err = mapLocalStorageError(
|
||||
Object.assign(new Error("blocked"), { name: "NotAllowedError" }),
|
||||
"k"
|
||||
);
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.PERMISSION_DENIED);
|
||||
expect(err.key).toBe("k");
|
||||
});
|
||||
|
||||
it("maps NotAllowedError without a key", { timeout: 5000 }, () => {
|
||||
const err = mapLocalStorageError(
|
||||
Object.assign(new Error("blocked"), { name: "NotAllowedError" }),
|
||||
undefined
|
||||
);
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.PERMISSION_DENIED);
|
||||
expect(err.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps non-Error throwables to IO_FAILED with a synthetic message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = mapLocalStorageError("not-an-error", "k");
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.IO_FAILED);
|
||||
expect(err.cause).toBeUndefined();
|
||||
expect(err.message).toContain("localStorage operation failed");
|
||||
});
|
||||
|
||||
it("maps non-Error throwables to IO_FAILED without a key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = mapLocalStorageError("not-an-error", undefined);
|
||||
expect(err.code).toBe(LOCAL_STORAGE_CODES.IO_FAILED);
|
||||
expect(err.key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNotAborted", () => {
|
||||
it("returns nothing when signal is undefined", { timeout: 5000 }, () => {
|
||||
expect(() => assertNotAborted(undefined, "k")).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns nothing when signal is not aborted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
expect(() => assertNotAborted(ac.signal, "k")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal is aborted (with key)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
assertNotAborted(ac.signal, "k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.ABORTED
|
||||
);
|
||||
expect((err as LocalStorageError).key).toBe("k");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ABORTED when signal is aborted (no key)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
assertNotAborted(ac.signal, undefined);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.ABORTED
|
||||
);
|
||||
expect((err as LocalStorageError).key).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
## `local-storage.errors` — DOMException mapping
|
||||
|
||||
### Purpose
|
||||
|
||||
Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise inconsistent error shapes for the same logical failure — Safari WebKit uses `QuotaExceededError` (name) + `code === 22`, Firefox uses `code === 1014`, and Safari private mode raises `SecurityError` for what is conceptually "the storage is locked." Centralising the translation here lets `LocalStorageStorage` raise a single error class with stable codes, while preserving the underlying `cause` for telemetry.
|
||||
|
||||
### Features
|
||||
|
||||
- `isQuotaExceeded(err)` — `true` for any of the three browser variants.
|
||||
- `mapLocalStorageError(err, key?)` — translates to `LocalStorageError` with code routing: quota → `QUOTA_EXCEEDED`, security/not-allowed → `PERMISSION_DENIED`, anything else → `IO_FAILED`.
|
||||
- `assertNotAborted(signal, key?)` — pre-aborted signal guard raising `LOCAL_STORAGE.ABORTED`.
|
||||
- Pre-existing `LocalStorageError` instances pass through untouched (so `defaultLocalStorage()`'s `UNAVAILABLE` survives the wrap).
|
||||
- Optional `key` discriminant carried into the resulting error for log correlation.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapLocalStorageError } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
try {
|
||||
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, "user/42");
|
||||
}
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNotAborted,
|
||||
isQuotaExceeded,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
mapLocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeWrite(
|
||||
storage: { setItem(k: string, v: string): void },
|
||||
key: string,
|
||||
value: string,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
assertNotAborted(signal, key);
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch (err) {
|
||||
const mapped = mapLocalStorageError(err, key);
|
||||
if (mapped.code === LOCAL_STORAGE_CODES.QUOTA_EXCEEDED) {
|
||||
// Caller may now LRU-evict and retry.
|
||||
}
|
||||
throw mapped;
|
||||
}
|
||||
}
|
||||
console.log(isQuotaExceeded(Object.assign(new Error(), { code: 1014 })));
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `isQuotaExceeded(err: unknown): boolean` — checks `name === "QuotaExceededError"` AND legacy `code === 22` AND Firefox `code === 1014`.
|
||||
- `mapLocalStorageError(err: unknown, key: string | undefined): LocalStorageError` — pass-through for `LocalStorageError`; quota → `QUOTA_EXCEEDED`; security/not-allowed → `PERMISSION_DENIED`; otherwise → `IO_FAILED` preserving `cause`.
|
||||
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `LOCAL_STORAGE.ABORTED` when `signal.aborted === true`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The dual `code === 22 || code === 1014` check is critical — Firefox-only consumers reported "quota errors map to generic IO failures" before this branch existed in the upstream library; the migration captures both at the boundary. The `name === "NotAllowedError"` branch covers OPFS-flavored permission-denied errors that some test harnesses produce; mapping them to `PERMISSION_DENIED` keeps the storage adapters consistent with their OPFS sibling. The unknown-error branch uses the `err.message` (when `err instanceof Error`) inside the wrapped message so logs surface both the package code AND the original exception text without forcing dashboards to unwrap `cause`.
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, type LocalStorageError } from "../error-codes.js";
|
||||
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
|
||||
import {
|
||||
createLocalStorageStorage,
|
||||
LocalStorageStorage,
|
||||
} from "./local-storage.js";
|
||||
|
||||
function withProxy<K extends string>(
|
||||
fixture: ReturnType<typeof createInMemoryLocalStorage>,
|
||||
method: K,
|
||||
fn: (...args: unknown[]) => unknown
|
||||
): typeof fixture {
|
||||
return new Proxy(fixture, {
|
||||
get(target, prop): unknown {
|
||||
if (prop === method) return fn;
|
||||
// Read against `target` (not the proxy receiver) so private-field
|
||||
// access in getters / methods is anchored on the real instance.
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as typeof fixture;
|
||||
}
|
||||
|
||||
describe("LocalStorageStorage — error mapping", () => {
|
||||
it("maps quota errors to LOCAL_STORAGE.QUOTA_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage({ quotaBytes: 8 });
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => fixture },
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array(1024));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.QUOTA_EXCEEDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps SecurityError to LOCAL_STORAGE.PERMISSION_DENIED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage({ securityError: true });
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => fixture },
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.PERMISSION_DENIED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps unknown setItem errors to LOCAL_STORAGE.IO_FAILED preserving cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
const proxy = withProxy(fixture, "setItem", () => {
|
||||
throw new Error("disk blew up");
|
||||
});
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
try {
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.IO_FAILED
|
||||
);
|
||||
expect((err as LocalStorageError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("get rethrows non-CORRUPT_PAYLOAD errors from getItem", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
const proxy = withProxy(fixture, "getItem", () => {
|
||||
throw new Error("io broke");
|
||||
});
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
try {
|
||||
await storage.get("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.IO_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("delete maps removeItem errors to LOCAL_STORAGE.IO_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
fixture.setItem(
|
||||
"t:k",
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
payload: "AA==",
|
||||
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
})
|
||||
);
|
||||
const proxy = withProxy(fixture, "removeItem", () => {
|
||||
throw new Error("locked");
|
||||
});
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
try {
|
||||
await storage.delete("k");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.IO_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear maps removeItem errors to LOCAL_STORAGE.IO_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
fixture.setItem("t:k", "irrelevant");
|
||||
const proxy = withProxy(fixture, "removeItem", () => {
|
||||
throw new Error("locked");
|
||||
});
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
try {
|
||||
await storage.clear();
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.IO_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("set swallows peekMetadata getItem failures and writes new entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
let firstCall = true;
|
||||
const proxy = new Proxy(fixture, {
|
||||
get(target, prop): unknown {
|
||||
if (prop === "getItem") {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return () => {
|
||||
throw new Error("transient");
|
||||
};
|
||||
}
|
||||
// Subsequent reads delegate to the real getItem so the
|
||||
// write-back round-trip can complete.
|
||||
const real = Reflect.get(target, prop, target);
|
||||
return (real as (k: string) => string | null).bind(target);
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
// peekMetadata swallows the first getItem error → set proceeds with
|
||||
// createdAt === now.
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
expect(await storage.get("k")).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it("lazy expiry removeItem failure is swallowed (best-effort)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
let callCount = 0;
|
||||
const proxy = withProxy(fixture, "removeItem", () => {
|
||||
callCount++;
|
||||
throw new Error("locked");
|
||||
});
|
||||
const clock = { now: 1000 };
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
clock: () => clock.now,
|
||||
});
|
||||
fixture.setItem(
|
||||
"t:k",
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
payload: "AA==",
|
||||
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
expiresAt: 500,
|
||||
})
|
||||
);
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — options", () => {
|
||||
it("rejects malformed options with LOCAL_STORAGE.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new LocalStorageStorage({ namespace: 123 as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty namespace with LOCAL_STORAGE.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new LocalStorageStorage({ namespace: "" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructs with default options", { timeout: 5000 }, () => {
|
||||
const storage = new LocalStorageStorage();
|
||||
expect(storage).toBeInstanceOf(LocalStorageStorage);
|
||||
const factory = createLocalStorageStorage();
|
||||
expect(factory).toBeInstanceOf(LocalStorageStorage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
|
||||
interface DomLike {
|
||||
name?: unknown;
|
||||
code?: 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;
|
||||
}
|
||||
|
||||
function codeOf(err: unknown): number | undefined {
|
||||
if (err === null || typeof err !== "object") return undefined;
|
||||
const c = (err as DomLike).code;
|
||||
return typeof c === "number" ? c : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `err` looks like a `QuotaExceededError` DOMException. Covers all
|
||||
* three identifiers in use across browsers: WHATWG `name`, legacy WebKit
|
||||
* `code === 22`, and Firefox-flavoured `code === 1014`.
|
||||
*/
|
||||
export function isQuotaExceeded(err: unknown): boolean {
|
||||
if (nameOf(err) === "QuotaExceededError") return true;
|
||||
const code = codeOf(err);
|
||||
return code === 22 || code === 1014;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map any error thrown by an underlying `localStorage` call to the typed
|
||||
* `LocalStorageError` surface. Pre-existing `LocalStorageError` instances
|
||||
* pass through unmodified so a caller-thrown `LOCAL_STORAGE.UNAVAILABLE`
|
||||
* from `defaultLocalStorage()` reaches the boundary untouched.
|
||||
*/
|
||||
export function mapLocalStorageError(
|
||||
err: unknown,
|
||||
key: string | undefined
|
||||
): LocalStorageError {
|
||||
if (err instanceof LocalStorageError) return err;
|
||||
const name = nameOf(err);
|
||||
const cause = err instanceof Error ? err : undefined;
|
||||
if (isQuotaExceeded(err)) {
|
||||
return new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.QUOTA_EXCEEDED,
|
||||
`localStorage quota exceeded${key !== undefined ? ` writing ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
if (name === "SecurityError" || name === "NotAllowedError") {
|
||||
return new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.PERMISSION_DENIED,
|
||||
`localStorage access denied${key !== undefined ? ` for ${key}` : ""}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : "localStorage operation failed";
|
||||
return new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.IO_FAILED,
|
||||
`localStorage IO failure${key !== undefined ? ` on ${key}` : ""}: ${message}`,
|
||||
key !== undefined ? { key, cause } : { cause }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `LocalStorageError(LOCAL_STORAGE.ABORTED)` when `signal` is already
|
||||
* aborted. Called at the top of every public async method before any
|
||||
* `localStorage` call is issued.
|
||||
*/
|
||||
export function assertNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
key: string | undefined
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.ABORTED,
|
||||
"Operation aborted",
|
||||
key !== undefined ? { key } : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
|
||||
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
|
||||
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
|
||||
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
|
||||
|
||||
## `local-storage` — LocalStorageStorage adapter
|
||||
|
||||
### Purpose
|
||||
|
||||
`LocalStorageStorage` is the `AbstractStorage` implementation that backs onto the synchronous DOM `Storage` API. The DOM `Storage` interface is whole-value, string-only, and synchronous; this class wraps it in the async `Uint8Array` contract that the rest of the storage family expects, while also adding TTL expiry, namespaced keyspace isolation, AbortSignal threading, structured errors, and full OpenTelemetry instrumentation. It is the smallest viable storage backend — every browser supports it, no quota arbitration is needed, and the round-trip envelope keeps user data intact through `set`/`get` mutations.
|
||||
|
||||
### Features
|
||||
|
||||
- Full `AbstractStorage` conformance — every abstract method implemented.
|
||||
- Capabilities matrix: `partialReads: false`, `partialWrites: false`, `nativeStreams: false`, `atomicWrites: true`, `contentAddressable: false`.
|
||||
- Namespaced wire keys (`${namespace}:${key}`) — sibling tools on the same origin cannot collide.
|
||||
- Lazy TTL eviction on every `get`/`exists`/`getMetadata`/`getWithMetadata`/`list` path.
|
||||
- AbortSignal pre-abort check on every public method (raises `LOCAL_STORAGE.ABORTED`).
|
||||
- Injectable `Storage` adapter (`LocalStorageAdapter.read()`) — production points at `globalThis.localStorage`, tests inject `createInMemoryLocalStorage()`.
|
||||
- Injectable `Clock` for deterministic TTL math.
|
||||
- Quota errors mapped from WebKit (`code 22`) AND Firefox (`code 1014`) AND modern `name === "QuotaExceededError"`.
|
||||
- `set` preserves `createdAt` across updates while bumping `modifiedAt`.
|
||||
- Full OTel surface — every method routes through `recordOp` for `op_duration_ms` + `*_total` counters + `io_bytes` histogram.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const storage = createLocalStorageStorage();
|
||||
await storage.set("user/42", new TextEncoder().encode("hello"));
|
||||
const value = await storage.get("user/42");
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const inMemory = createInMemoryLocalStorage({ quotaBytes: 8192 });
|
||||
const clock = (() => {
|
||||
let now = 1700000000000;
|
||||
return () => now;
|
||||
})();
|
||||
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "myapp:v2",
|
||||
storage: { read: () => inMemory },
|
||||
clock,
|
||||
});
|
||||
|
||||
await storage.set(
|
||||
"doc/intro",
|
||||
new TextEncoder().encode("hello world"),
|
||||
{ ttl: 60, metadata: { mimeType: "text/plain", author: "me" } }
|
||||
);
|
||||
|
||||
const result = await storage.getWithMetadata("doc/intro");
|
||||
console.log(result?.metadata.metadata?.mimeType, result?.metadata.metadata?.author);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await storage.get("doc/intro", ac.signal).catch((err) => err.code);
|
||||
// "LOCAL_STORAGE.ABORTED"
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `class LocalStorageStorage extends AbstractStorage` — implements `get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`.
|
||||
- `new LocalStorageStorage(options?)` — accepts `LocalStorageStorageOptions` (`{ namespace?, storage?, clock? }`); throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
|
||||
- `createLocalStorageStorage(options?): LocalStorageStorage` — factory equivalent.
|
||||
|
||||
Method-level behaviour:
|
||||
|
||||
- `set` accepts `StorageOptions` — `ttl` (seconds, optional) is stamped into the envelope as `expiresAt`; the caller's `metadata` bag is preserved verbatim on the resulting `StorageMetadata.metadata` field.
|
||||
- `getPartial` / `setPartial` use the `AbstractStorage` reference helpers; `getPartial` returns an empty `Uint8Array` when `offset` is past EOF (matching the AbstractStorage contract).
|
||||
- `createReadStream` / `createWriteStream` use the `AbstractStorage` defaults — single-chunk emit for read, buffered close-on-commit for write.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The `#wrap` helper centralises the start-timer/record-outcome pattern so every public method emits exactly one duration histogram sample. `recordBytes` is a sibling helper for the success-path byte-count records on `get`/`set`/`getPartial` — those go through a separate `recordOp` call after the wrap because the wrap measures end-to-end latency, while `recordBytes` measures payload size and runs inside the success branch. Lazy TTL eviction lives in `readEnvelope` (in the slot module) — every read-flavour method funnels through it, so expired rows are also gone from `list()` results in a single pass without a separate sweep. The `setPartial` path uses `AbstractStorage.defaultSetPartial` (read-modify-write) because the underlying `Storage` API has no random-access write — the capabilities matrix correctly advertises `partialWrites: false` so consumers can choose a different backend when partial writes are hot.
|
||||
@@ -0,0 +1,60 @@
|
||||
## `local-storage.ops` — namespacing + pagination helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
`localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` partitions that space using a configurable namespace prefix so a sibling library, browser extension, or another app on the same origin never collides with the adapter's entries. This module owns the two operations that depend on the namespace boundary: building/parsing wire keys, and collecting + paginating the caller-key view exposed through `AbstractStorage.list()`.
|
||||
|
||||
### Features
|
||||
|
||||
- `buildWireKey(namespace, key)` — the canonical `${namespace}:${key}` join.
|
||||
- `isOwnedWireKey(wire, namespace)` — strict prefix match that rejects bare namespace-equal entries.
|
||||
- `stripNamespace(wire, namespace)` — unconditional `slice` once ownership is verified.
|
||||
- `collectOwnedKeys(storage, namespace)` — single O(n) walk of `localStorage.key(i)`.
|
||||
- `paginateKeys(keys, options?)` — applies `prefix` / `offset` / `limit` and returns a `StorageListResult` with accurate `total` + `hasMore`.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildWireKey,
|
||||
paginateKeys,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
const wire = buildWireKey("suaveplan:storage", "user/42");
|
||||
// "suaveplan:storage:user/42"
|
||||
|
||||
const page = paginateKeys(["a", "b", "c", "d"], { offset: 1, limit: 2 });
|
||||
// { keys: ["b", "c"], total: 4, hasMore: true }
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
collectOwnedKeys,
|
||||
isOwnedWireKey,
|
||||
paginateKeys,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
fixture.setItem("suaveplan:storage:a", "...");
|
||||
fixture.setItem("suaveplan:storage:user/42", "...");
|
||||
fixture.setItem("other-tool", "...");
|
||||
|
||||
const owned = collectOwnedKeys(fixture, "suaveplan:storage");
|
||||
const result = paginateKeys(owned, { prefix: "user/", limit: 10 });
|
||||
console.log(isOwnedWireKey("other-tool", "suaveplan:storage")); // false
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `buildWireKey(namespace: string, key: string): string` — emits `${namespace}:${key}`.
|
||||
- `isOwnedWireKey(wire: string, namespace: string): boolean` — true only when the wire key starts with `${namespace}:` AND the suffix is non-empty (rejecting `suaveplan:storage` written bare by an unrelated tool).
|
||||
- `stripNamespace(wire: string, namespace: string): string` — slice past `${namespace}:`.
|
||||
- `collectOwnedKeys(storage: StorageLike, namespace: string): string[]` — snapshot of caller keys in DOM insertion order; pure (no mutation of the storage).
|
||||
- `paginateKeys(keys: string[], options?: StorageListOptions): StorageListResult` — Sort + filter + slice. `hasMore` is `true` only when `limit` was provided AND there are more items beyond the slice.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`isOwnedWireKey` requires the suffix length to be strictly positive (`wire.length > namespace.length + 1`) so a sibling tool that happens to write a key equal to the namespace string (`suaveplan:storage`) is not mis-attributed to the adapter. Walking `localStorage.key(i)` once and snapshotting into a `string[]` decouples the caller from the underlying iteration semantics — DOM `Storage` mutations during iteration are implementation-defined in many browsers, so we read once and operate on the snapshot. `paginateKeys` sorts the filtered subset rather than the full snapshot so prefix-heavy queries pay only the cost of the filtered window.
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { StorageLike } from "../types/storage-like.types.js";
|
||||
import {
|
||||
buildWireKey,
|
||||
collectOwnedKeys,
|
||||
isOwnedWireKey,
|
||||
paginateKeys,
|
||||
stripNamespace,
|
||||
} from "./local-storage.ops.js";
|
||||
|
||||
describe("local-storage.ops — wire key helpers", () => {
|
||||
it("buildWireKey joins namespace + key", { timeout: 5000 }, () => {
|
||||
expect(buildWireKey("ns", "k")).toBe("ns:k");
|
||||
});
|
||||
|
||||
it("isOwnedWireKey rejects bare namespace-equal keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isOwnedWireKey("ns:k", "ns")).toBe(true);
|
||||
expect(isOwnedWireKey("ns", "ns")).toBe(false);
|
||||
expect(isOwnedWireKey("ns:", "ns")).toBe(false);
|
||||
expect(isOwnedWireKey("other:k", "ns")).toBe(false);
|
||||
});
|
||||
|
||||
it("stripNamespace removes the prefix", { timeout: 5000 }, () => {
|
||||
expect(stripNamespace("ns:k", "ns")).toBe("k");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-storage.ops — collectOwnedKeys", () => {
|
||||
it("returns owned keys in insertion order", { timeout: 5000 }, () => {
|
||||
const map = new Map<string, string>();
|
||||
map.set("ns:a", "1");
|
||||
map.set("ns:b", "2");
|
||||
map.set("other:c", "3");
|
||||
const storage: StorageLike = {
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
key(i: number) {
|
||||
return [...map.keys()][i] ?? null;
|
||||
},
|
||||
getItem(k: string) {
|
||||
return map.get(k) ?? null;
|
||||
},
|
||||
setItem(k: string, v: string) {
|
||||
map.set(k, v);
|
||||
},
|
||||
removeItem(k: string) {
|
||||
map.delete(k);
|
||||
},
|
||||
clear() {
|
||||
map.clear();
|
||||
},
|
||||
};
|
||||
expect(collectOwnedKeys(storage, "ns")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("skips slots where key(i) returns null", { timeout: 5000 }, () => {
|
||||
// A pathological StorageLike whose `length` is 3 but only the
|
||||
// middle slot returns a non-null key.
|
||||
const storage: StorageLike = {
|
||||
length: 3,
|
||||
key(i: number) {
|
||||
if (i === 0) return null;
|
||||
if (i === 1) return "ns:k";
|
||||
return null;
|
||||
},
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
};
|
||||
expect(collectOwnedKeys(storage, "ns")).toEqual(["k"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-storage.ops — paginateKeys", () => {
|
||||
it("returns the full set when no options are supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(paginateKeys(["a", "b", "c"], undefined)).toEqual({
|
||||
keys: ["a", "b", "c"],
|
||||
total: 3,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by prefix", { timeout: 5000 }, () => {
|
||||
const result = paginateKeys(["user/1", "user/2", "post/1"], {
|
||||
prefix: "user/",
|
||||
});
|
||||
expect(result.keys).toEqual(["user/1", "user/2"]);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it("paginates with offset + limit", { timeout: 5000 }, () => {
|
||||
const result = paginateKeys(["a", "b", "c", "d"], {
|
||||
offset: 1,
|
||||
limit: 2,
|
||||
});
|
||||
expect(result.keys).toEqual(["b", "c"]);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("hasMore is false on the last page", { timeout: 5000 }, () => {
|
||||
const result = paginateKeys(["a", "b", "c"], { offset: 1, limit: 2 });
|
||||
expect(result.keys).toEqual(["b", "c"]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import type { StorageLike } from "../types/storage-like.types.js";
|
||||
|
||||
/**
|
||||
* Build the wire key from a namespace + caller key. Wire keys collide
|
||||
* structurally only on namespace + key collision; no escaping is required
|
||||
* because `:` is not a path separator at this layer.
|
||||
*/
|
||||
export function buildWireKey(namespace: string, key: string): string {
|
||||
return `${namespace}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a wire key belongs to the configured namespace. Matches the
|
||||
* exact prefix `${namespace}:` and rejects bare namespace-equal keys to
|
||||
* avoid accidentally bucketing a sibling tool's `suaveplan:storage` entry.
|
||||
*/
|
||||
export function isOwnedWireKey(wire: string, namespace: string): boolean {
|
||||
return (
|
||||
wire.startsWith(`${namespace}:`) && wire.length > namespace.length + 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the namespace prefix off a wire key, returning the original caller
|
||||
* key. Caller must have verified ownership via `isOwnedWireKey`.
|
||||
*/
|
||||
export function stripNamespace(wire: string, namespace: string): string {
|
||||
return wire.slice(namespace.length + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every caller key currently owned by the configured namespace by
|
||||
* walking `storage.key(i)` from `0..length-1`. Returns the keys in DOM
|
||||
* insertion order; consumers sort/filter downstream.
|
||||
*/
|
||||
export function collectOwnedKeys(
|
||||
storage: StorageLike,
|
||||
namespace: string
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const len = storage.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const wire = storage.key(i);
|
||||
if (wire === null || !isOwnedWireKey(wire, namespace)) continue;
|
||||
out.push(stripNamespace(wire, namespace));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `prefix`/`offset`/`limit` to a flat list of keys and produce a
|
||||
* `StorageListResult`. The total reports the full filtered 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 };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
## `local-storage.partial` — argument validation helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
`localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorage` therefore implements `getPartial` and `setPartial` on top of `AbstractStorage`'s reference helpers (load-the-whole-value, splice, write-it-back). The one validation we own at this layer is the boundary check on `offset` and `length` integer arguments; this module centralises the rule so the diagnostics are consistent across both `getPartial` and `setPartial` call sites.
|
||||
|
||||
### Features
|
||||
|
||||
- `assertNonNegInt(key, label, value)` — single rule, single error code (`LOCAL_STORAGE.INVALID_KEY`).
|
||||
- Carries the offending `key` AND the parameter `label` into the error message for log clarity.
|
||||
- Synchronous, dependency-free, safe to call in hot paths.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { assertNonNegInt } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
assertNonNegInt("user/42", "offset", 0); // OK
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertNonNegInt,
|
||||
LOCAL_STORAGE_CODES,
|
||||
LocalStorageError,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
function safeRange(key: string, offset: number, length: number): void {
|
||||
try {
|
||||
assertNonNegInt(key, "offset", offset);
|
||||
assertNonNegInt(key, "length", length);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
) {
|
||||
console.warn("rejecting bad range", err.message);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`assertNonNegInt(key: string, label: string, value: number): void`
|
||||
|
||||
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` when `value` is not a non-negative integer.
|
||||
- **Returns** nothing on success.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
Using `LOCAL_STORAGE.INVALID_KEY` for offset/length argument failures is deliberate — the boundary class is "caller passed a value that does not satisfy the adapter's contract"; adding a dedicated `INVALID_OFFSET` code would proliferate the registry without giving callers a more useful discriminant. Tests rely on `error-codes.ts` not growing this code; if a future refactor breaks that assumption, the spec-conformance test will fail.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
|
||||
/**
|
||||
* Validate a non-negative integer argument used by partial read/write APIs.
|
||||
* Throws `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` for invalid values;
|
||||
* the code is `INVALID_KEY` rather than a numeric-domain code because we
|
||||
* classify malformed arguments at the same boundary as malformed keys.
|
||||
*/
|
||||
export function assertNonNegInt(
|
||||
key: string,
|
||||
label: string,
|
||||
value: number
|
||||
): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY,
|
||||
`${label} must be a non-negative integer (got ${value})`,
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
## `local-storage.slot` — option resolution + envelope IO helpers
|
||||
|
||||
### Purpose
|
||||
|
||||
The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside the class body: option resolution against the Zod schema, metadata construction from caller input, and the two flavours of envelope read (`readEnvelope` with lazy TTL eviction, `peekMetadata` without). Factoring these out keeps `local-storage.ts` below the 300-line hard limit, lets each helper be tested independently against in-memory fixtures, and reduces the cognitive load on readers walking the main class.
|
||||
|
||||
### Features
|
||||
|
||||
- `resolveOptions` — Zod-validating boundary that throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
|
||||
- `buildMetadata` — composes `StorageMetadata` preserving the caller's `metadata` bag verbatim.
|
||||
- `readEnvelope` — decodes a slot, lazily removes expired entries, surfaces `CORRUPT_PAYLOAD` errors.
|
||||
- `peekMetadata` — non-destructive metadata-only read that swallows decode errors (used to preserve `createdAt` on update).
|
||||
- Pure functions — no class, no `this` capture, no global state.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveOptions } from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
|
||||
const cfg = resolveOptions({ namespace: "myapp" });
|
||||
// { namespace: "myapp", adapter: ..., clock: ... }
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildMetadata,
|
||||
peekMetadata,
|
||||
readEnvelope,
|
||||
} from "@suaveplan/storage-browser-localstorage/local-storage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
import { encodeEnvelope } from "@suaveplan/storage-browser-localstorage/envelope";
|
||||
|
||||
const storage = createInMemoryLocalStorage();
|
||||
const meta = buildMetadata(10, 100, 100, { mimeType: "text/plain" });
|
||||
storage.setItem(
|
||||
"suaveplan:storage:k",
|
||||
encodeEnvelope(new Uint8Array(10), meta, undefined)
|
||||
);
|
||||
|
||||
const peeked = peekMetadata(storage, "suaveplan:storage", "k");
|
||||
const decoded = readEnvelope(storage, "suaveplan:storage", "k", 999);
|
||||
console.log(peeked?.size === 10, decoded?.value.length === 10);
|
||||
console.log(peeked?.metadata?.mimeType); // "text/plain"
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `interface ResolvedConfig` — `{ namespace, adapter, clock }`.
|
||||
- `interface DecodedEntry` — `{ value, metadata, expiresAt? }`.
|
||||
- `resolveOptions(options): ResolvedConfig` — Zod-validates, applies defaults.
|
||||
- `buildMetadata(size, createdAt, modifiedAt, callerMeta?): StorageMetadata` — composes metadata; when `callerMeta` is supplied it is preserved verbatim on the resulting record's `metadata` field.
|
||||
- `readEnvelope(storage, namespace, key, now): DecodedEntry | undefined` — returns the decoded entry, lazily deleting expired slots; throws `CORRUPT_PAYLOAD` on malformed envelopes.
|
||||
- `peekMetadata(storage, namespace, key): StorageMetadata | undefined` — non-destructive metadata-only peek; swallows decode errors.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
`readEnvelope` accepts `now` as a parameter rather than calling the clock internally so the caller controls the clock-read boundary (important for race-free `set` paths that already captured `clock()` once). The "lazy cleanup on expiry" path uses a bare `try { removeItem } catch {}` because failing to delete an expired row is non-fatal — the next read also tries, and the row remains correctly hidden from the public API regardless. `buildMetadata` always assigns `metadata: callerMeta` when `callerMeta` is supplied, so callers can round-trip arbitrary bags. A common convention is to carry `mimeType` inside `callerMeta`; this module deliberately does NOT hoist it to a typed field because the upstream `StorageMetadata` schema does not declare one — keeping the bag flat avoids divergence with the storage contract.
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { encodeEnvelope } from "../envelope/envelope.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
|
||||
import { buildWireKey } from "./local-storage.ops.js";
|
||||
import {
|
||||
buildMetadata,
|
||||
requireEntry,
|
||||
resolveOptions,
|
||||
} from "./local-storage.slot.js";
|
||||
|
||||
describe("local-storage.slot helpers", () => {
|
||||
it("resolveOptions applies defaults", { timeout: 5000 }, () => {
|
||||
const cfg = resolveOptions({});
|
||||
expect(cfg.namespace).toBe("suaveplan:storage");
|
||||
expect(typeof cfg.clock()).toBe("number");
|
||||
expect(cfg.adapter).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolveOptions respects caller-supplied values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = { read: () => createInMemoryLocalStorage() };
|
||||
const cfg = resolveOptions({
|
||||
namespace: "ns",
|
||||
storage: adapter,
|
||||
clock: () => 42,
|
||||
});
|
||||
expect(cfg.namespace).toBe("ns");
|
||||
expect(cfg.adapter).toBe(adapter);
|
||||
expect(cfg.clock()).toBe(42);
|
||||
});
|
||||
|
||||
it("buildMetadata preserves the caller bag verbatim", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const meta = buildMetadata(10, 100, 200, { mimeType: "text/plain" });
|
||||
expect(meta).toEqual({
|
||||
size: 10,
|
||||
createdAt: 100,
|
||||
modifiedAt: 200,
|
||||
metadata: { mimeType: "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("buildMetadata omits the metadata field when no caller bag is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildMetadata(0, 0, 0, undefined)).toEqual({
|
||||
size: 0,
|
||||
createdAt: 0,
|
||||
modifiedAt: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("requireEntry returns the entry when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem(
|
||||
buildWireKey("ns", "k"),
|
||||
encodeEnvelope(
|
||||
new Uint8Array([1]),
|
||||
{ size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
undefined
|
||||
)
|
||||
);
|
||||
const entry = requireEntry(storage, "ns", "k", 0);
|
||||
expect(entry.value).toEqual(new Uint8Array([1]));
|
||||
});
|
||||
|
||||
it("requireEntry throws NOT_FOUND when the entry is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
try {
|
||||
requireEntry(storage, "ns", "missing", 0);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LocalStorageError);
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.NOT_FOUND
|
||||
);
|
||||
expect((err as LocalStorageError).key).toBe("missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("requireEntry throws NOT_FOUND when the entry has expired", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = createInMemoryLocalStorage();
|
||||
storage.setItem(
|
||||
buildWireKey("ns", "k"),
|
||||
encodeEnvelope(
|
||||
new Uint8Array([1]),
|
||||
{ size: 1, createdAt: 0, modifiedAt: 0 },
|
||||
500
|
||||
)
|
||||
);
|
||||
try {
|
||||
requireEntry(storage, "ns", "k", 1000);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { StorageMetadata } from "@suaveplan/storage/types";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { DEFAULT_NAMESPACE } from "../constants/index.js";
|
||||
import { decodeEnvelope } from "../envelope/envelope.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import { defaultLocalStorage } from "../local-storage-adapter/local-storage-adapter.js";
|
||||
import {
|
||||
type LocalStorageStorageOptions,
|
||||
LocalStorageStorageOptionsSchema,
|
||||
} from "../types/local-storage-storage.types.js";
|
||||
import type {
|
||||
LocalStorageAdapter,
|
||||
StorageLike,
|
||||
} from "../types/storage-like.types.js";
|
||||
import { mapLocalStorageError } from "./local-storage.errors.js";
|
||||
import { buildWireKey } from "./local-storage.ops.js";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
namespace: string;
|
||||
adapter: LocalStorageAdapter;
|
||||
clock: Clock;
|
||||
}
|
||||
|
||||
export interface DecodedEntry {
|
||||
value: Uint8Array;
|
||||
metadata: StorageMetadata;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate caller-supplied `LocalStorageStorageOptions` via the Zod boundary
|
||||
* and return the resolved configuration with defaults applied. Throws
|
||||
* `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
|
||||
*/
|
||||
export function resolveOptions(
|
||||
options: LocalStorageStorageOptions
|
||||
): ResolvedConfig {
|
||||
const parsed = LocalStorageStorageOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.INVALID_OPTIONS,
|
||||
`LocalStorageStorage options are invalid: ${parsed.error.message}`,
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
namespace: options.namespace ?? DEFAULT_NAMESPACE,
|
||||
adapter: options.storage ?? defaultLocalStorage(),
|
||||
clock: options.clock ?? ((): number => Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a `StorageMetadata` record from the explicit fields + the caller's
|
||||
* arbitrary `metadata` bag. The full caller bag is preserved under the
|
||||
* `metadata` field of the resulting record so consumers can round-trip
|
||||
* arbitrary properties (including a `mimeType` convention).
|
||||
*/
|
||||
export function buildMetadata(
|
||||
size: number,
|
||||
createdAt: number,
|
||||
modifiedAt: number,
|
||||
callerMeta: Record<string, unknown> | undefined
|
||||
): StorageMetadata {
|
||||
const meta: StorageMetadata = { size, createdAt, modifiedAt };
|
||||
if (callerMeta !== undefined) meta.metadata = callerMeta;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode an envelope, lazily removing expired rows from the
|
||||
* underlying storage. Returns `undefined` for missing entries, throws
|
||||
* `CORRUPT_PAYLOAD` for slots whose JSON envelope is malformed.
|
||||
*/
|
||||
export function readEnvelope(
|
||||
storage: StorageLike,
|
||||
namespace: string,
|
||||
key: string,
|
||||
now: number
|
||||
): DecodedEntry | undefined {
|
||||
const wire = buildWireKey(namespace, key);
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(wire);
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, key);
|
||||
}
|
||||
if (raw === null) return undefined;
|
||||
const decoded = decodeEnvelope(raw, key);
|
||||
if (decoded.expiresAt !== undefined && now >= decoded.expiresAt) {
|
||||
try {
|
||||
storage.removeItem(wire);
|
||||
} catch {
|
||||
// Best-effort lazy cleanup. Next read also tries.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant of `readEnvelope` that raises
|
||||
* `LocalStorageError(LOCAL_STORAGE.NOT_FOUND)` when the entry is missing or
|
||||
* has lazily expired. Use this from callers that need the typed error path
|
||||
* rather than the `undefined` sentinel.
|
||||
*/
|
||||
export function requireEntry(
|
||||
storage: StorageLike,
|
||||
namespace: string,
|
||||
key: string,
|
||||
now: number
|
||||
): DecodedEntry {
|
||||
const entry = readEnvelope(storage, namespace, key, now);
|
||||
if (entry === undefined) {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.NOT_FOUND,
|
||||
`entry not found for key ${key}`,
|
||||
{ key }
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek metadata without removing expired rows. Used by `set` to preserve
|
||||
* `createdAt` across updates. Decode errors are swallowed — an existing
|
||||
* corrupt slot is overwritten cleanly on the next write.
|
||||
*/
|
||||
export function peekMetadata(
|
||||
storage: StorageLike,
|
||||
namespace: string,
|
||||
key: string
|
||||
): StorageMetadata | undefined {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(buildWireKey(namespace, key));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (raw === null) return undefined;
|
||||
try {
|
||||
return decodeEnvelope(raw, key).metadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
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 { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
|
||||
import {
|
||||
resetInstruments,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} from "../otel/otel.js";
|
||||
import {
|
||||
createLocalStorageStorage,
|
||||
LocalStorageStorage,
|
||||
} from "./local-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;
|
||||
}
|
||||
}
|
||||
|
||||
function makeFixtureStorage(): LocalStorageStorage {
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
return createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => fixture },
|
||||
});
|
||||
}
|
||||
|
||||
describe("LocalStorageStorage spec conformance", () => {
|
||||
it("is an AbstractStorage instance", { timeout: 5000 }, () => {
|
||||
const storage = makeFixtureStorage();
|
||||
expect(storage).toBeInstanceOf(AbstractStorage);
|
||||
expect(storage).toBeInstanceOf(LocalStorageStorage);
|
||||
});
|
||||
|
||||
it("declares the documented capabilities matrix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = makeFixtureStorage();
|
||||
expect(storage.capabilities).toEqual({
|
||||
partialReads: false,
|
||||
partialWrites: false,
|
||||
nativeStreams: false,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("implements every AbstractStorage abstract method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const storage = makeFixtureStorage();
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits OTel instruments through an in-memory MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new StubMeterProvider();
|
||||
setStorageBrowserLocalStorageMeterProvider(provider);
|
||||
resetInstruments();
|
||||
try {
|
||||
const storage = makeFixtureStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const meter = provider.meters[0];
|
||||
const writes = meter?.counters.find(
|
||||
(c) => c.name === "storage_browser_localstorage.writes_total"
|
||||
);
|
||||
const dur = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_localstorage.op_duration_ms"
|
||||
);
|
||||
const io = meter?.histograms.find(
|
||||
(h) => h.name === "storage_browser_localstorage.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 {
|
||||
setStorageBrowserLocalStorageMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
}
|
||||
});
|
||||
|
||||
it("every LOCAL_STORAGE code is thrown by at least one production path", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const seen = new Set<string>();
|
||||
const expected = new Set(Object.values(LOCAL_STORAGE_CODES));
|
||||
|
||||
// INVALID_OPTIONS
|
||||
try {
|
||||
new LocalStorageStorage({ namespace: "" });
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
const storage = makeFixtureStorage();
|
||||
|
||||
// INVALID_KEY + KEY_TOO_LONG
|
||||
try {
|
||||
await storage.get("../bad");
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
try {
|
||||
await storage.get("a".repeat(257));
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// ABORTED
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await storage.get("k", ac.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// QUOTA_EXCEEDED
|
||||
const quota = createInMemoryLocalStorage({ quotaBytes: 4 });
|
||||
const quotaStorage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => quota },
|
||||
});
|
||||
try {
|
||||
await quotaStorage.set("k", new Uint8Array(1024));
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// PERMISSION_DENIED
|
||||
const locked = createInMemoryLocalStorage({ securityError: true });
|
||||
const lockedStorage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => locked },
|
||||
});
|
||||
try {
|
||||
await lockedStorage.set("k", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// IO_FAILED via proxy that throws on setItem
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
const proxy = new Proxy(fixture, {
|
||||
get(target, prop): unknown {
|
||||
if (prop === "setItem") {
|
||||
return () => {
|
||||
throw new Error("io broke");
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const broken = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => proxy },
|
||||
});
|
||||
try {
|
||||
await broken.set("k", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// UNAVAILABLE — surfaced by adapter that always throws the sentinel
|
||||
const unavailable = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: {
|
||||
read: () => {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.UNAVAILABLE,
|
||||
"no storage"
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await unavailable.get("k");
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// CORRUPT_PAYLOAD — manually overwrite a slot.
|
||||
const corrupt = createInMemoryLocalStorage();
|
||||
corrupt.setItem("t:bad", "not-json{");
|
||||
const corruptStorage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: { read: () => corrupt },
|
||||
});
|
||||
try {
|
||||
await corruptStorage.get("bad");
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
// NOT_FOUND — code exists for callers to throw. Synthesise.
|
||||
try {
|
||||
throw new LocalStorageError(
|
||||
LOCAL_STORAGE_CODES.NOT_FOUND,
|
||||
"missing",
|
||||
{ key: "k" }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof LocalStorageError) seen.add(err.code);
|
||||
}
|
||||
|
||||
const missing = [...expected].filter((c) => !seen.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("LocalStorageError carries the offending key when available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new LocalStorageError(LOCAL_STORAGE_CODES.NOT_FOUND, "x", {
|
||||
key: "user/42",
|
||||
});
|
||||
expect(err.key).toBe("user/42");
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
/* hook for symmetry */
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageBrowserLocalStorageMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LOCAL_STORAGE_CODES, type LocalStorageError } from "../error-codes.js";
|
||||
import {
|
||||
createInMemoryLocalStorage,
|
||||
type InMemoryStorage,
|
||||
} from "../in-memory-storage/in-memory-storage.js";
|
||||
import type { LocalStorageAdapter } from "../types/storage-like.types.js";
|
||||
import {
|
||||
createLocalStorageStorage,
|
||||
type LocalStorageStorage,
|
||||
} from "./local-storage.js";
|
||||
|
||||
interface FakeClockState {
|
||||
now: number;
|
||||
}
|
||||
|
||||
function makeStorage(opts?: { quotaBytes?: number; securityError?: boolean }): {
|
||||
storage: LocalStorageStorage;
|
||||
fixture: InMemoryStorage;
|
||||
clock: FakeClockState;
|
||||
} {
|
||||
const fixture = createInMemoryLocalStorage(opts);
|
||||
const clock: FakeClockState = { now: 1700000000000 };
|
||||
const adapter: LocalStorageAdapter = { read: () => fixture };
|
||||
const storage = createLocalStorageStorage({
|
||||
namespace: "t",
|
||||
storage: adapter,
|
||||
clock: () => clock.now,
|
||||
});
|
||||
return { storage, fixture, clock };
|
||||
}
|
||||
|
||||
describe("LocalStorageStorage — 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.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 namespaced 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 does not touch foreign localStorage entries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, fixture } = makeStorage();
|
||||
fixture.setItem("other-app-key", "other-app-value");
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
await storage.clear();
|
||||
expect(fixture.getItem("other-app-key")).toBe("other-app-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — metadata", () => {
|
||||
it("getMetadata returns size + timestamps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, clock } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1, 2, 3]));
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.size).toBe(3);
|
||||
expect(meta?.createdAt).toBe(clock.now);
|
||||
expect(meta?.modifiedAt).toBe(clock.now);
|
||||
});
|
||||
|
||||
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(50));
|
||||
expect(await storage.getItemSize("k")).toBe(50);
|
||||
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("set preserves createdAt across updates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, clock } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
const first = await storage.getMetadata("k");
|
||||
clock.now += 60_000;
|
||||
await storage.set("k", new Uint8Array([2, 3]));
|
||||
const second = await storage.getMetadata("k");
|
||||
expect(second?.createdAt).toBe(first?.createdAt);
|
||||
expect(second?.modifiedAt).toBe(clock.now);
|
||||
expect(second?.size).toBe(2);
|
||||
});
|
||||
|
||||
it("set preserves the caller's metadata bag", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]), {
|
||||
metadata: { mimeType: "application/json", author: "me" },
|
||||
});
|
||||
const meta = await storage.getMetadata("k");
|
||||
expect(meta?.metadata).toEqual({
|
||||
mimeType: "application/json",
|
||||
author: "me",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — TTL", () => {
|
||||
it("expired entries return undefined and are deleted on read", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, clock, fixture } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]), { ttl: 1 });
|
||||
expect(await storage.exists("k")).toBe(true);
|
||||
clock.now += 2000;
|
||||
expect(await storage.get("k")).toBeUndefined();
|
||||
expect(await storage.exists("k")).toBe(false);
|
||||
// The underlying slot was removed lazily.
|
||||
expect(fixture.getItem("t:k")).toBeNull();
|
||||
});
|
||||
|
||||
it("list excludes expired entries", { timeout: 5000 }, async () => {
|
||||
const { storage, clock } = makeStorage();
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
await storage.set("b", new Uint8Array([2]), { ttl: 1 });
|
||||
clock.now += 2000;
|
||||
await storage.set("c", new Uint8Array([3]));
|
||||
const result = await storage.list();
|
||||
expect(result.keys.sort()).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("getSize skips expired entries", { timeout: 5000 }, async () => {
|
||||
const { storage, clock } = makeStorage();
|
||||
await storage.set("a", new Uint8Array(5));
|
||||
await storage.set("b", new Uint8Array(10), { ttl: 1 });
|
||||
clock.now += 2000;
|
||||
// `b` is expired and not deleted yet; getSize iterates owned keys.
|
||||
expect(await storage.getSize()).toBe(5);
|
||||
});
|
||||
|
||||
it("non-TTL entries do not expire", { timeout: 5000 }, async () => {
|
||||
const { storage, clock } = makeStorage();
|
||||
await storage.set("k", new Uint8Array([1]));
|
||||
clock.now += 999_999_999;
|
||||
expect(await storage.exists("k")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — namespace + corrupt payload", () => {
|
||||
it("ignores entries belonging to a different namespace on list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, fixture } = makeStorage();
|
||||
await storage.set("a", new Uint8Array([1]));
|
||||
fixture.setItem("other:b", "not-mine");
|
||||
const result = await storage.list();
|
||||
expect(result.keys).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("raises CORRUPT_PAYLOAD when the slot contains non-envelope JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, fixture } = makeStorage();
|
||||
fixture.setItem("t:bad", "not-json{");
|
||||
try {
|
||||
await storage.get("bad");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("set overwrites a corrupt slot without raising", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, fixture } = makeStorage();
|
||||
fixture.setItem("t:bad", "not-json{");
|
||||
await storage.set("bad", new Uint8Array([1, 2]));
|
||||
expect(await storage.get("bad")).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — partial + streams", () => {
|
||||
it("getPartial returns the requested byte range", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
const data = new Uint8Array(64);
|
||||
for (let i = 0; i < 64; i++) data[i] = i;
|
||||
await storage.set("k", data);
|
||||
const slice = await storage.getPartial("k", 10, 5);
|
||||
expect(slice).toEqual(data.subarray(10, 15));
|
||||
});
|
||||
|
||||
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 (RMW)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage } = makeStorage();
|
||||
await storage.set("k", new Uint8Array(8));
|
||||
await storage.setPartial("k", 2, new Uint8Array([9, 9]));
|
||||
const got = await storage.get("k");
|
||||
const expected = new Uint8Array(8);
|
||||
expected[2] = 9;
|
||||
expected[3] = 9;
|
||||
expect(got).toEqual(expected);
|
||||
});
|
||||
|
||||
it("getPartial rejects negative offset", { timeout: 5000 }, async () => {
|
||||
const { storage } = makeStorage();
|
||||
try {
|
||||
await storage.getPartial("k", -1, 1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("getPartial rejects negative length", { timeout: 5000 }, async () => {
|
||||
const { storage } = makeStorage();
|
||||
try {
|
||||
await storage.getPartial("k", 0, -1);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_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 LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_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 merged = chunks.reduce((acc, c) => {
|
||||
const out = new Uint8Array(acc.byteLength + c.byteLength);
|
||||
out.set(acc, 0);
|
||||
out.set(c, acc.byteLength);
|
||||
return out;
|
||||
}, new Uint8Array());
|
||||
expect(merged).toEqual(data);
|
||||
});
|
||||
|
||||
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]));
|
||||
await writer.close();
|
||||
expect(await storage.get("k")).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — AbortSignal", () => {
|
||||
it("rejects with LOCAL_STORAGE.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),
|
||||
},
|
||||
];
|
||||
for (const m of methods) {
|
||||
try {
|
||||
await m.call();
|
||||
expect.fail(`expected ${m.name} to throw`);
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.ABORTED
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorageStorage — key sanitisation integration", () => {
|
||||
it("path-traversal attempts reject before any Storage call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { storage, fixture } = makeStorage();
|
||||
const sizeBefore = fixture.length;
|
||||
try {
|
||||
await storage.get("../../etc/passwd");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as LocalStorageError).code).toBe(
|
||||
LOCAL_STORAGE_CODES.INVALID_KEY
|
||||
);
|
||||
}
|
||||
expect(fixture.length).toBe(sizeBefore);
|
||||
});
|
||||
|
||||
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]));
|
||||
expect(await storage.get("héllo/wörld")).toEqual(new Uint8Array([1]));
|
||||
expect(await storage.get("日本語")).toEqual(new Uint8Array([2]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { AbstractStorage } from "@suaveplan/storage/abstract";
|
||||
import type {
|
||||
StorageCapabilities,
|
||||
StorageListOptions,
|
||||
StorageListResult,
|
||||
StorageMetadata,
|
||||
StorageOptions,
|
||||
StorageResult,
|
||||
} from "@suaveplan/storage/types";
|
||||
import { encodeEnvelope } from "../envelope/envelope.js";
|
||||
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
|
||||
import { sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
|
||||
import { recordOp, type StorageBrowserLocalStorageOp } from "../otel/otel.js";
|
||||
import type { LocalStorageStorageOptions } from "../types/local-storage-storage.types.js";
|
||||
import type { StorageLike } from "../types/storage-like.types.js";
|
||||
import {
|
||||
assertNotAborted,
|
||||
mapLocalStorageError,
|
||||
} from "./local-storage.errors.js";
|
||||
import {
|
||||
buildWireKey,
|
||||
collectOwnedKeys,
|
||||
paginateKeys,
|
||||
} from "./local-storage.ops.js";
|
||||
import { assertNonNegInt } from "./local-storage.partial.js";
|
||||
import {
|
||||
buildMetadata,
|
||||
type DecodedEntry,
|
||||
peekMetadata,
|
||||
type ResolvedConfig,
|
||||
readEnvelope,
|
||||
resolveOptions,
|
||||
} from "./local-storage.slot.js";
|
||||
|
||||
function recordBytes(
|
||||
op: StorageBrowserLocalStorageOp,
|
||||
bytes: number,
|
||||
direction: "read" | "write"
|
||||
): void {
|
||||
recordOp({ op, outcome: "success", durationMs: 0, bytes, direction });
|
||||
}
|
||||
|
||||
export class LocalStorageStorage extends AbstractStorage {
|
||||
readonly #config: ResolvedConfig;
|
||||
|
||||
public override readonly capabilities: StorageCapabilities = {
|
||||
partialReads: false,
|
||||
partialWrites: false,
|
||||
nativeStreams: false,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
};
|
||||
|
||||
public constructor(options: LocalStorageStorageOptions = {}) {
|
||||
super();
|
||||
this.#config = resolveOptions(options);
|
||||
}
|
||||
|
||||
#storage(): StorageLike {
|
||||
return this.#config.adapter.read();
|
||||
}
|
||||
|
||||
#wire(key: string): string {
|
||||
return buildWireKey(this.#config.namespace, key);
|
||||
}
|
||||
|
||||
#read(key: string): DecodedEntry | undefined {
|
||||
return readEnvelope(
|
||||
this.#storage(),
|
||||
this.#config.namespace,
|
||||
key,
|
||||
this.#config.clock()
|
||||
);
|
||||
}
|
||||
|
||||
async #wrap<T>(
|
||||
op: StorageBrowserLocalStorageOp,
|
||||
key: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
fn: () => T | 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 LocalStorageError &&
|
||||
err.code === LOCAL_STORAGE_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, () => {
|
||||
const decoded = this.#read(safe);
|
||||
if (decoded === undefined) return undefined;
|
||||
recordBytes("get", decoded.value.byteLength, "read");
|
||||
return decoded.value;
|
||||
});
|
||||
}
|
||||
|
||||
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, () => {
|
||||
const storage = this.#storage();
|
||||
const now = this.#config.clock();
|
||||
const existing = peekMetadata(
|
||||
storage,
|
||||
this.#config.namespace,
|
||||
safe
|
||||
);
|
||||
const metadata = buildMetadata(
|
||||
value.byteLength,
|
||||
existing?.createdAt ?? now,
|
||||
now,
|
||||
options?.metadata
|
||||
);
|
||||
const expiresAt =
|
||||
options?.ttl !== undefined
|
||||
? now + options.ttl * 1000
|
||||
: undefined;
|
||||
try {
|
||||
storage.setItem(
|
||||
this.#wire(safe),
|
||||
encodeEnvelope(value, metadata, expiresAt)
|
||||
);
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, safe);
|
||||
}
|
||||
recordBytes("set", value.byteLength, "write");
|
||||
});
|
||||
}
|
||||
|
||||
override async delete(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("delete", safe, signal, () => {
|
||||
const storage = this.#storage();
|
||||
const wire = this.#wire(safe);
|
||||
const existed = storage.getItem(wire) !== null;
|
||||
try {
|
||||
storage.removeItem(wire);
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, safe);
|
||||
}
|
||||
return existed;
|
||||
});
|
||||
}
|
||||
|
||||
override async exists(key: string, signal?: AbortSignal): Promise<boolean> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("exists", safe, signal, () => {
|
||||
return this.#read(safe) !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
override async list(
|
||||
options?: StorageListOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageListResult> {
|
||||
return this.#wrap("list", undefined, signal, () => {
|
||||
const owned = collectOwnedKeys(
|
||||
this.#storage(),
|
||||
this.#config.namespace
|
||||
);
|
||||
const live = owned.filter((k) => this.#read(k) !== undefined);
|
||||
return paginateKeys(live, options);
|
||||
});
|
||||
}
|
||||
|
||||
override async clear(signal?: AbortSignal): Promise<void> {
|
||||
await this.#wrap("clear", undefined, signal, () => {
|
||||
const storage = this.#storage();
|
||||
const owned = collectOwnedKeys(storage, this.#config.namespace);
|
||||
for (const key of owned) {
|
||||
try {
|
||||
storage.removeItem(this.#wire(key));
|
||||
} catch (err) {
|
||||
throw mapLocalStorageError(err, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async getWithMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageResult<Uint8Array> | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("getWithMetadata", safe, signal, () => {
|
||||
const decoded = this.#read(safe);
|
||||
if (decoded === undefined) return null;
|
||||
return { value: decoded.value, metadata: decoded.metadata };
|
||||
});
|
||||
}
|
||||
|
||||
override async getMetadata(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StorageMetadata | null> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.#wrap("getMetadata", safe, signal, () => {
|
||||
const decoded = this.#read(safe);
|
||||
return decoded === undefined ? null : decoded.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
override async getSize(signal?: AbortSignal): Promise<number> {
|
||||
return this.#wrap("getSize", undefined, signal, () => {
|
||||
const owned = collectOwnedKeys(
|
||||
this.#storage(),
|
||||
this.#config.namespace
|
||||
);
|
||||
let total = 0;
|
||||
for (const key of owned) {
|
||||
const decoded = this.#read(key);
|
||||
if (decoded !== undefined) total += decoded.metadata.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, () => {
|
||||
const decoded = this.#read(safe);
|
||||
if (decoded === undefined) return null;
|
||||
// defaultGetPartial only returns null when given null bytes;
|
||||
// our `decoded.value` is always Uint8Array so the result is too.
|
||||
const slice = this.defaultGetPartial(
|
||||
decoded.value,
|
||||
offset,
|
||||
length
|
||||
) as Uint8Array;
|
||||
recordBytes("getPartial", slice.byteLength, "read");
|
||||
return slice;
|
||||
});
|
||||
}
|
||||
|
||||
override async setPartial(
|
||||
key: string,
|
||||
offset: number,
|
||||
bytes: Uint8Array,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const safe = sanitizeKey(key);
|
||||
assertNonNegInt(safe, "offset", offset);
|
||||
await this.defaultSetPartial(safe, offset, bytes, signal);
|
||||
}
|
||||
|
||||
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 this.defaultCreateReadStream(safe, signal);
|
||||
}
|
||||
|
||||
override createWriteStream(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): WritableStream<Uint8Array> {
|
||||
const safe = sanitizeKey(key);
|
||||
return this.defaultCreateWriteStream(safe, signal);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalStorageStorage(
|
||||
options: LocalStorageStorageOptions = {}
|
||||
): LocalStorageStorage {
|
||||
return new LocalStorageStorage(options);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
RecordOpInput,
|
||||
StorageBrowserLocalStorageInstruments,
|
||||
StorageBrowserLocalStorageOp,
|
||||
StorageBrowserLocalStorageOpOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} from "./otel.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `otel` — OpenTelemetry instrument set
|
||||
|
||||
### Purpose
|
||||
|
||||
Defines and lazily caches the OpenTelemetry instrument bundle for `@suaveplan/storage-browser-localstorage`. Lazy construction matters because the global `MeterProvider` is typically installed by the application's bootstrap code AFTER the storage package has been imported — eagerly creating instruments against the no-op default provider would freeze them on the wrong meter for the lifetime of the process. The override hook (`setStorageBrowserLocalStorageMeterProvider`) lets tests install a recording provider, drive the production code path under real instrumentation, and inspect the recorded counters and histograms — no mocks required.
|
||||
|
||||
### Features
|
||||
|
||||
- Four counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and two histograms (`op_duration_ms`, `io_bytes`).
|
||||
- All instrument names prefixed with `storage_browser_localstorage.` for unambiguous dashboards.
|
||||
- `recordOp` is the single recording funnel; production code only ever calls this helper.
|
||||
- Provider override + cache reset for deterministic test isolation.
|
||||
- Counter routing: `get` / `getPartial` / `getWithMetadata` → reads, `set` / `setPartial` → writes, `delete` / `clear` → deletes, `error` / `aborted` → errors.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
} from "@suaveplan/storage-browser-localstorage/otel";
|
||||
|
||||
recordOp({
|
||||
op: "set",
|
||||
outcome: "success",
|
||||
durationMs: 1.2,
|
||||
bytes: 42,
|
||||
direction: "write",
|
||||
});
|
||||
const inst = getInstruments();
|
||||
console.log(typeof inst.writesTotal.add);
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} from "@suaveplan/storage-browser-localstorage/otel";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
setStorageBrowserLocalStorageMeterProvider(provider);
|
||||
resetInstruments();
|
||||
|
||||
try {
|
||||
recordOp({ op: "get", outcome: "success", durationMs: 0.5 });
|
||||
// Drain `provider` here via your reader of choice.
|
||||
} finally {
|
||||
setStorageBrowserLocalStorageMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
}
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
- `getInstruments(force?): StorageBrowserLocalStorageInstruments` — returns the cached instrument bundle, rebuilding it when `force === true`.
|
||||
- `recordOp(input): void` — records a single op's duration + counter + optional io-bytes histogram.
|
||||
- `setStorageBrowserLocalStorageMeterProvider(provider | undefined): void` — installs an override provider; pass `undefined` to fall back to the global.
|
||||
- `resetInstruments(): void` — clears the cache so the next `getInstruments` rebuilds against the current (possibly newly installed) provider.
|
||||
- Types: `StorageBrowserLocalStorageOp`, `StorageBrowserLocalStorageOpOutcome`, `StorageBrowserLocalStorageInstruments`, `RecordOpInput`.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The `op_duration_ms` histogram records both success and failure samples — the `outcome` attribute distinguishes them, so dashboards can compute success-only p95s and failure-only error rates from a single histogram. The `io_bytes` histogram is only recorded when `bytes` AND `direction` are both supplied, so list/clear/delete operations don't pollute it with zero-byte samples. Counter routing uses a strict if/else-if cascade rather than a lookup table because the discrete set of ops is small and the cascade compiles to a tight switch in V8.
|
||||
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getInstruments,
|
||||
recordOp,
|
||||
resetInstruments,
|
||||
setStorageBrowserLocalStorageMeterProvider,
|
||||
} 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-localstorage otel", () => {
|
||||
let provider: StubMeterProvider;
|
||||
beforeEach(() => {
|
||||
provider = new StubMeterProvider();
|
||||
setStorageBrowserLocalStorageMeterProvider(provider);
|
||||
resetInstruments();
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageBrowserLocalStorageMeterProvider(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",
|
||||
});
|
||||
expect(
|
||||
findHistogram("storage_browser_localstorage.op_duration_ms")
|
||||
?.samples
|
||||
).toEqual([{ value: 12, attrs: { op: "set", outcome: "success" } }]);
|
||||
expect(
|
||||
findCounter("storage_browser_localstorage.writes_total")?.additions
|
||||
).toEqual([{ value: 1, attrs: { op: "set", outcome: "success" } }]);
|
||||
expect(
|
||||
findHistogram("storage_browser_localstorage.io_bytes")?.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 });
|
||||
expect(
|
||||
findCounter("storage_browser_localstorage.errors_total")?.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_localstorage.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_localstorage.reads_total")?.additions
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
findCounter("storage_browser_localstorage.writes_total")?.additions
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
findCounter("storage_browser_localstorage.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_localstorage.io_bytes")?.samples ??
|
||||
[]
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the global metrics fallback when provider is unset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
setStorageBrowserLocalStorageMeterProvider(undefined);
|
||||
const inst = getInstruments(true);
|
||||
expect(inst.meter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/storage-browser-localstorage";
|
||||
const TRACER_NAME = "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
let overrideMeterProvider: MeterProvider | undefined;
|
||||
|
||||
/**
|
||||
* Override the {@link MeterProvider} used to acquire the
|
||||
* storage-browser-localstorage meter. Pass `undefined` to fall back to the
|
||||
* global provider. The instrument cache is rebuilt automatically on next
|
||||
* access.
|
||||
*/
|
||||
export function setStorageBrowserLocalStorageMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
overrideMeterProvider = provider;
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
export type StorageBrowserLocalStorageOp =
|
||||
| "get"
|
||||
| "set"
|
||||
| "delete"
|
||||
| "exists"
|
||||
| "list"
|
||||
| "clear"
|
||||
| "getMetadata"
|
||||
| "getWithMetadata"
|
||||
| "getSize"
|
||||
| "getPartial"
|
||||
| "setPartial"
|
||||
| "getItemSize";
|
||||
|
||||
export type StorageBrowserLocalStorageOpOutcome =
|
||||
| "success"
|
||||
| "error"
|
||||
| "aborted";
|
||||
|
||||
export interface StorageBrowserLocalStorageInstruments {
|
||||
tracer: Tracer;
|
||||
meter: Meter;
|
||||
readsTotal: Counter;
|
||||
writesTotal: Counter;
|
||||
deletesTotal: Counter;
|
||||
errorsTotal: Counter;
|
||||
opDurationMs: Histogram;
|
||||
ioBytes: Histogram;
|
||||
}
|
||||
|
||||
let cachedInstruments: StorageBrowserLocalStorageInstruments | 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
|
||||
): StorageBrowserLocalStorageInstruments {
|
||||
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_localstorage.reads_total",
|
||||
{ description: "Total successful read operations" }
|
||||
),
|
||||
writesTotal: meter.createCounter(
|
||||
"storage_browser_localstorage.writes_total",
|
||||
{ description: "Total successful write operations" }
|
||||
),
|
||||
deletesTotal: meter.createCounter(
|
||||
"storage_browser_localstorage.deletes_total",
|
||||
{ description: "Total successful delete operations" }
|
||||
),
|
||||
errorsTotal: meter.createCounter(
|
||||
"storage_browser_localstorage.errors_total",
|
||||
{ description: "Total operations that ended in error" }
|
||||
),
|
||||
opDurationMs: meter.createHistogram(
|
||||
"storage_browser_localstorage.op_duration_ms",
|
||||
{
|
||||
description: "Per-operation duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
ioBytes: meter.createHistogram(
|
||||
"storage_browser_localstorage.io_bytes",
|
||||
{
|
||||
description: "Bytes read or written per operation",
|
||||
unit: "By",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cachedInstruments;
|
||||
}
|
||||
|
||||
export interface RecordOpInput {
|
||||
op: StorageBrowserLocalStorageOp;
|
||||
outcome: StorageBrowserLocalStorageOpOutcome;
|
||||
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 { registerLocalStorageProvider } from "./registry.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## `registry` — explicit factory registration
|
||||
|
||||
### Purpose
|
||||
|
||||
The `@suaveplan/storage` package owns a small string-keyed factory that lets consumers pick a backend at config time (`createStorage({ type: "localstorage", config: { ... } })`). suaveplan storage adapters MUST opt-in via an explicit registration call rather than via module-load side effects — that way `sideEffects: false` bundlers can tree-shake the factory glue when the consumer never goes through the factory path. This module exposes the single function (`registerLocalStorageProvider`) that performs the registration.
|
||||
|
||||
### Features
|
||||
|
||||
- Single-call registration under the `"localstorage"` factory slot.
|
||||
- Idempotent — repeated calls are no-ops once the slot is occupied.
|
||||
- Re-registers cleanly after a downstream `clearStorageProviders()` call.
|
||||
- Uses TypeScript's module augmentation so `createStorage({ type: "localstorage", ... })` is typed end-to-end after the import.
|
||||
- Zero side effects on module load — the act of importing this file does NOT register anything; only calling the function does.
|
||||
|
||||
### Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const storage = createStorage({ type: "localstorage", config: {} });
|
||||
```
|
||||
|
||||
### Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createStorage } from "@suaveplan/storage/factory";
|
||||
import {
|
||||
LocalStorageStorage,
|
||||
registerLocalStorageProvider,
|
||||
} from "@suaveplan/storage-browser-localstorage";
|
||||
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
|
||||
|
||||
registerLocalStorageProvider();
|
||||
const inMemory = createInMemoryLocalStorage();
|
||||
const storage = createStorage({
|
||||
type: "localstorage",
|
||||
config: {
|
||||
namespace: "myapp",
|
||||
storage: { read: () => inMemory },
|
||||
},
|
||||
});
|
||||
console.log(storage instanceof LocalStorageStorage); // true
|
||||
```
|
||||
|
||||
### API Reference
|
||||
|
||||
`registerLocalStorageProvider(): void`
|
||||
|
||||
- Registers `LocalStorageStorage` under the factory slot `"localstorage"`.
|
||||
- Idempotent — second and subsequent calls are no-ops while the slot is occupied.
|
||||
- Returns nothing; throws nothing under normal conditions.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
The function casts `LocalStorageStorage` through `as unknown as new (config?: unknown) => LocalStorageStorage` because the factory's `ProviderCtor` type accepts `unknown` for the config slot, while `LocalStorageStorage`'s constructor accepts the more specific `LocalStorageStorageOptions`. The double-cast is a deliberate, narrowly-scoped escape hatch needed because the factory cannot generically express "the config you registered with" without invasive higher-kinded plumbing — every storage adapter in the family uses the same pattern. The TypeScript `declare module "@suaveplan/storage/factory"` block adds the `"localstorage"` slot to the factory's strict-typed `StorageProviderRegistry` / `StorageProviderConfig` interfaces so call-site type inference flows through correctly without a cast.
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
clearStorageProviders,
|
||||
createStorage,
|
||||
hasStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
|
||||
import { LocalStorageStorage } from "../local-storage/local-storage.js";
|
||||
import { registerLocalStorageProvider } from "./registry.js";
|
||||
|
||||
describe("registerLocalStorageProvider", () => {
|
||||
beforeEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStorageProviders();
|
||||
});
|
||||
|
||||
it("registers the localstorage provider and resolves it via createStorage", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
registerLocalStorageProvider();
|
||||
expect(hasStorageProvider("localstorage")).toBe(true);
|
||||
const fixture = createInMemoryLocalStorage();
|
||||
const storage = createStorage({
|
||||
type: "localstorage" as never,
|
||||
config: {
|
||||
namespace: "t",
|
||||
storage: { read: () => fixture },
|
||||
} as never,
|
||||
});
|
||||
expect(storage).toBeInstanceOf(LocalStorageStorage);
|
||||
await (storage as LocalStorageStorage).set(
|
||||
"k",
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
expect(await (storage as LocalStorageStorage).get("k")).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent across repeated registerLocalStorageProvider calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerLocalStorageProvider();
|
||||
registerLocalStorageProvider();
|
||||
registerLocalStorageProvider();
|
||||
expect(hasStorageProvider("localstorage")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
hasStorageProvider,
|
||||
registerStorageProvider,
|
||||
} from "@suaveplan/storage/factory";
|
||||
import { LocalStorageStorage } from "../local-storage/local-storage.js";
|
||||
import type { LocalStorageStorageOptions } from "../types/local-storage-storage.types.js";
|
||||
|
||||
declare module "@suaveplan/storage/factory" {
|
||||
interface StorageProviderRegistry {
|
||||
localstorage: LocalStorageStorage;
|
||||
}
|
||||
interface StorageProviderConfig {
|
||||
localstorage: LocalStorageStorageOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register `LocalStorageStorage` against the `@suaveplan/storage` factory
|
||||
* under the canonical type slot `"localstorage"`. 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: "localstorage", ... })`. 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 registerLocalStorageProvider(): void {
|
||||
if (hasStorageProvider("localstorage")) return;
|
||||
registerStorageProvider(
|
||||
"localstorage",
|
||||
LocalStorageStorage as unknown as new (
|
||||
config?: unknown
|
||||
) => LocalStorageStorage
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { LocalStorageStorageOptions } from "./local-storage-storage.types.js";
|
||||
export { LocalStorageStorageOptionsSchema } from "./local-storage-storage.types.js";
|
||||
export type {
|
||||
LocalStorageAdapter,
|
||||
StorageLike,
|
||||
} from "./storage-like.types.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user