first commit
This commit is contained in:
93
docs/persistence/storage/storage-browser-indexeddb/README.md
Normal file
93
docs/persistence/storage/storage-browser-indexeddb/README.md
Normal file
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user