Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View File

@@ -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"
]
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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";
}
}

View File

@@ -0,0 +1,8 @@
export type {
IndexedDbErrorCode,
IndexedDbErrorOptions,
} from "./error-codes.js";
export {
INDEXEDDB_CODES,
IndexedDbError,
} from "./error-codes.js";

View File

@@ -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.

View File

@@ -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 }]);
});
});

View File

@@ -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"
);
}

View File

@@ -0,0 +1,4 @@
export {
defaultIndexedDB,
isIndexedDbAvailable,
} from "./idb-adapter.js";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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 }
);
}
}

View File

@@ -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,
};
}

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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.

View File

@@ -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();
};
});
}

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1 @@
export { sanitizeKey } from "./key-sanitizer.js";

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
export type {
RecordOpInput,
StorageBrowserIndexedDbInstruments,
StorageBrowserIndexedDbOp,
StorageBrowserIndexedDbOpOutcome,
} from "./otel.js";
export {
getInstruments,
recordOp,
resetInstruments,
setStorageBrowserIndexedDbMeterProvider,
} from "./otel.js";

View File

@@ -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`).

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { registerIndexedDbProvider } from "./registry.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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
);
}

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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,
});

View File

@@ -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 };

View File

@@ -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.

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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",
],
},
},
});

View File

@@ -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,
},
});