@optimystic/db-p2p-storage-web 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,108 +1,108 @@
1
- # @optimystic/db-p2p-storage-web
2
-
3
- IndexedDB-backed storage backend for Optimystic browser peers. Provides:
4
-
5
- - **`IndexedDBRawStorage`** — implements `IRawStorage` so a browser node persists
6
- block metadata, revisions, pending transactions, committed transactions, and
7
- materialized blocks across page reloads.
8
- - **`IndexedDBKVStore`** — implements `IKVStore` for the persistent transaction
9
- state used to recover crashed two-phase commits.
10
- - **`loadOrCreateBrowserPeerKey`** — generates an Ed25519 libp2p private key on
11
- first run and persists it in the same IndexedDB database, giving the browser
12
- peer a stable, reload-surviving identity.
13
-
14
- This package is browser-only — it imports no Node built-ins. Counterparts:
15
-
16
- - `@optimystic/db-p2p-storage-rn` — React Native (MMKV)
17
- - `@optimystic/db-p2p-storage-fs` — Node filesystem
18
-
19
- ## Install
20
-
21
- ```bash
22
- yarn add @optimystic/db-p2p-storage-web @optimystic/db-p2p @optimystic/db-core
23
- ```
24
-
25
- `@optimystic/db-p2p` is consumed via its `react-native` / `./rn` entry point so
26
- the Node-only filesystem code does not get bundled into the browser:
27
-
28
- ```ts
29
- import { createLibp2pNode } from '@optimystic/db-p2p/rn';
30
- ```
31
-
32
- ## Usage
33
-
34
- ```ts
35
- import { createLibp2pNode } from '@optimystic/db-p2p/rn';
36
- import {
37
- openOptimysticWebDb,
38
- IndexedDBRawStorage,
39
- IndexedDBKVStore,
40
- loadOrCreateBrowserPeerKey,
41
- } from '@optimystic/db-p2p-storage-web';
42
-
43
- const db = await openOptimysticWebDb(); // single shared handle
44
- const rawStorage = new IndexedDBRawStorage(db); // → IRawStorage
45
- const kvStore = new IndexedDBKVStore(db); // → IKVStore (txn recovery)
46
- const privateKey = await loadOrCreateBrowserPeerKey(db);
47
-
48
- const libp2p = await createLibp2pNode({
49
- bootstrapNodes: [/* … */],
50
- networkName: 'my-network',
51
- privateKey,
52
- });
53
- ```
54
-
55
- The same `IDBPDatabase` handle is shared by all three consumers — IndexedDB
56
- permits concurrent transactions across disjoint object stores, so the storage
57
- layer parallelises naturally.
58
-
59
- ## Persistence semantics
60
-
61
- The package opens a single IndexedDB database (`optimystic` by default) with
62
- six object stores. `IndexedDBRawStorage` is a thin shell over the shared
63
- `KvRawStorage` kernel driven by an `IndexedDBStoreDriver`: the kernel owns all
64
- JSON/UTF-8 serialization, so the five block-storage stores now hold opaque
65
- kernel-encoded `Uint8Array` bytes rather than live objects. The keys and the
66
- logical types the kernel decodes them back into are unchanged:
67
-
68
- | Store | Key | Stored value | Decoded (logical) type |
69
- |----------------|----------------------|--------------|------------------------|
70
- | `metadata` | `blockId` | `Uint8Array` | `BlockMetadata` |
71
- | `revisions` | `[blockId, rev]` | `Uint8Array` | `ActionId` |
72
- | `pending` | `[blockId, actionId]`| `Uint8Array` | `Transform` |
73
- | `transactions` | `[blockId, actionId]`| `Uint8Array` | `Transform` |
74
- | `materialized` | `[blockId, actionId]`| `Uint8Array` | `IBlock` |
75
- | `kv` | `key` | `string` or `Uint8Array` | — (not kernel-backed) |
76
-
77
- `listRevisions` and `listPendingTransactions` use real IndexedDB range cursors
78
- — never `getAllKeys()` + JS filter — so list latency stays bounded as the store
79
- grows. `promotePendingTransaction` runs as a single `readwrite` transaction
80
- spanning `pending` and `transactions`, so the move is atomic across crashes.
81
-
82
- `getApproximateBytesUsed()` returns `(await navigator.storage.estimate()).usage`,
83
- which is **per-origin**, not per-database. That is adequate for `StorageMonitor`
84
- — which uses the figure as an advisory ring-selection input — but is not a
85
- precise per-database accounting.
86
-
87
- ## Identity
88
-
89
- `loadOrCreateBrowserPeerKey(db, keyName?)` writes the libp2p private key as
90
- raw protobuf bytes (`Uint8Array`) under `keyName` in the `kv` store
91
- (default `peer-private-key`). IndexedDB stores typed arrays natively, so no
92
- base64 round-trip is needed. To rotate the identity, delete that one key:
93
-
94
- ```ts
95
- await db.delete('kv', 'peer-private-key');
96
- ```
97
-
98
- To wipe the entire backing store (debugging / dev reset):
99
-
100
- ```ts
101
- indexedDB.deleteDatabase('optimystic');
102
- ```
103
-
104
- ## Tests
105
-
106
- `yarn test` runs the spec suite under [`fake-indexeddb`](https://www.npmjs.com/package/fake-indexeddb)
107
- in Node so CI doesn't need a real browser. Production code only uses the
108
- IndexedDB W3C API, so fake-indexeddb is a faithful behavioural surrogate.
1
+ # @optimystic/db-p2p-storage-web
2
+
3
+ IndexedDB-backed storage backend for Optimystic browser peers. Provides:
4
+
5
+ - **`IndexedDBRawStorage`** — implements `IRawStorage` so a browser node persists
6
+ block metadata, revisions, pending transactions, committed transactions, and
7
+ materialized blocks across page reloads.
8
+ - **`IndexedDBKVStore`** — implements `IKVStore` for the persistent transaction
9
+ state used to recover crashed two-phase commits.
10
+ - **`loadOrCreateBrowserPeerKey`** — generates an Ed25519 libp2p private key on
11
+ first run and persists it in the same IndexedDB database, giving the browser
12
+ peer a stable, reload-surviving identity.
13
+
14
+ This package is browser-only — it imports no Node built-ins. Counterparts:
15
+
16
+ - `@optimystic/db-p2p-storage-rn` — React Native (MMKV)
17
+ - `@optimystic/db-p2p-storage-fs` — Node filesystem
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ yarn add @optimystic/db-p2p-storage-web @optimystic/db-p2p @optimystic/db-core
23
+ ```
24
+
25
+ `@optimystic/db-p2p` is consumed via its `react-native` / `./rn` entry point so
26
+ the Node-only filesystem code does not get bundled into the browser:
27
+
28
+ ```ts
29
+ import { createLibp2pNode } from '@optimystic/db-p2p/rn';
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { createLibp2pNode } from '@optimystic/db-p2p/rn';
36
+ import {
37
+ openOptimysticWebDb,
38
+ IndexedDBRawStorage,
39
+ IndexedDBKVStore,
40
+ loadOrCreateBrowserPeerKey,
41
+ } from '@optimystic/db-p2p-storage-web';
42
+
43
+ const db = await openOptimysticWebDb(); // single shared handle
44
+ const rawStorage = new IndexedDBRawStorage(db); // → IRawStorage
45
+ const kvStore = new IndexedDBKVStore(db); // → IKVStore (txn recovery)
46
+ const privateKey = await loadOrCreateBrowserPeerKey(db);
47
+
48
+ const libp2p = await createLibp2pNode({
49
+ bootstrapNodes: [/* … */],
50
+ networkName: 'my-network',
51
+ privateKey,
52
+ });
53
+ ```
54
+
55
+ The same `IDBPDatabase` handle is shared by all three consumers — IndexedDB
56
+ permits concurrent transactions across disjoint object stores, so the storage
57
+ layer parallelises naturally.
58
+
59
+ ## Persistence semantics
60
+
61
+ The package opens a single IndexedDB database (`optimystic` by default) with
62
+ six object stores. `IndexedDBRawStorage` is a thin shell over the shared
63
+ `KvRawStorage` kernel driven by an `IndexedDBStoreDriver`: the kernel owns all
64
+ JSON/UTF-8 serialization, so the five block-storage stores now hold opaque
65
+ kernel-encoded `Uint8Array` bytes rather than live objects. The keys and the
66
+ logical types the kernel decodes them back into are unchanged:
67
+
68
+ | Store | Key | Stored value | Decoded (logical) type |
69
+ |----------------|----------------------|--------------|------------------------|
70
+ | `metadata` | `blockId` | `Uint8Array` | `BlockMetadata` |
71
+ | `revisions` | `[blockId, rev]` | `Uint8Array` | `ActionId` |
72
+ | `pending` | `[blockId, actionId]`| `Uint8Array` | `Transform` |
73
+ | `transactions` | `[blockId, actionId]`| `Uint8Array` | `Transform` |
74
+ | `materialized` | `[blockId, actionId]`| `Uint8Array` | `IBlock` |
75
+ | `kv` | `key` | `string` or `Uint8Array` | — (not kernel-backed) |
76
+
77
+ `listRevisions` and `listPendingTransactions` use real IndexedDB range cursors
78
+ — never `getAllKeys()` + JS filter — so list latency stays bounded as the store
79
+ grows. `promotePendingTransaction` runs as a single `readwrite` transaction
80
+ spanning `pending` and `transactions`, so the move is atomic across crashes.
81
+
82
+ `getApproximateBytesUsed()` returns `(await navigator.storage.estimate()).usage`,
83
+ which is **per-origin**, not per-database. That is adequate for `StorageMonitor`
84
+ — which uses the figure as an advisory ring-selection input — but is not a
85
+ precise per-database accounting.
86
+
87
+ ## Identity
88
+
89
+ `loadOrCreateBrowserPeerKey(db, keyName?)` writes the libp2p private key as
90
+ raw protobuf bytes (`Uint8Array`) under `keyName` in the `kv` store
91
+ (default `peer-private-key`). IndexedDB stores typed arrays natively, so no
92
+ base64 round-trip is needed. To rotate the identity, delete that one key:
93
+
94
+ ```ts
95
+ await db.delete('kv', 'peer-private-key');
96
+ ```
97
+
98
+ To wipe the entire backing store (debugging / dev reset):
99
+
100
+ ```ts
101
+ indexedDB.deleteDatabase('optimystic');
102
+ ```
103
+
104
+ ## Tests
105
+
106
+ `yarn test` runs the spec suite under [`fake-indexeddb`](https://www.npmjs.com/package/fake-indexeddb)
107
+ in Node so CI doesn't need a real browser. Production code only uses the
108
+ IndexedDB W3C API, so fake-indexeddb is a faithful behavioural surrogate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimystic/db-p2p-storage-web",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "Browser IndexedDB storage backend for @optimystic/db-p2p",
6
6
  "main": "dist/src/index.js",
@@ -54,8 +54,8 @@
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "dependencies": {
57
- "@optimystic/db-core": "^0.22.0",
58
- "@optimystic/db-p2p": "^0.22.0",
57
+ "@optimystic/db-core": "^0.24.0",
58
+ "@optimystic/db-p2p": "^0.24.0",
59
59
  "debug": "^4.4.3",
60
60
  "idb": "^8.0.3"
61
61
  },
package/src/db.ts CHANGED
@@ -1,92 +1,92 @@
1
- import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
2
- import type { ActionId, BlockId } from '@optimystic/db-core';
3
-
4
- /**
5
- * IndexedDB schema for the Optimystic browser storage backend.
6
- *
7
- * The five block-storage stores keep their original object stores and compound
8
- * array keys, but their values are now opaque `Uint8Array` blobs: the shared
9
- * `KvRawStorage` kernel owns JSON (de)serialization, so `IndexedDBStoreDriver`
10
- * only ever hands IndexedDB kernel-encoded bytes. IndexedDB stores a
11
- * `Uint8Array` natively via structured clone and returns a `Uint8Array`, so the
12
- * bytes round-trip without a codec here.
13
- *
14
- * - `metadata`: per-block metadata bytes keyed by `blockId`.
15
- * - `revisions`: revision lookup keyed by `[blockId, rev]` mapping to actionId bytes.
16
- * Range scans use `IDBKeyRange.bound([blockId, startRev], [blockId, endRev])`.
17
- * - `pending`: pending transform bytes keyed by `[blockId, actionId]`. Listing pending
18
- * transactions for a block uses a key cursor over `[blockId, ...]`.
19
- * - `transactions`: committed transform bytes keyed by `[blockId, actionId]`.
20
- * - `materialized`: materialized block bytes keyed by `[blockId, actionId]`. Deleting
21
- * the row is a driver `delete`, not a stored `undefined`.
22
- * - `kv`: a generic string keyspace shared by `IndexedDBKVStore` and the
23
- * identity helper. Identity keys are stored as raw `Uint8Array` blobs (under
24
- * a separate logical store from string-only `IKVStore` data, but the same
25
- * IndexedDB object store — IndexedDB stores typed arrays natively).
26
- */
27
- export interface OptimysticWebDB extends DBSchema {
28
- metadata: {
29
- key: BlockId;
30
- value: Uint8Array;
31
- };
32
- revisions: {
33
- key: [BlockId, number];
34
- value: Uint8Array;
35
- };
36
- pending: {
37
- key: [BlockId, ActionId];
38
- value: Uint8Array;
39
- };
40
- transactions: {
41
- key: [BlockId, ActionId];
42
- value: Uint8Array;
43
- };
44
- materialized: {
45
- key: [BlockId, ActionId];
46
- value: Uint8Array;
47
- };
48
- kv: {
49
- key: string;
50
- value: string | Uint8Array;
51
- };
52
- }
53
-
54
- export const DEFAULT_DB_NAME = 'optimystic';
55
- export const DEFAULT_DB_VERSION = 1;
56
-
57
- export type OptimysticWebDBHandle = IDBPDatabase<OptimysticWebDB>;
58
-
59
- /**
60
- * Opens (and creates if necessary) the Optimystic IndexedDB database used by
61
- * `IndexedDBRawStorage`, `IndexedDBKVStore`, and `loadOrCreateBrowserPeerKey`.
62
- *
63
- * The same handle can be safely shared across all three — IndexedDB connections
64
- * support concurrent transactions across disjoint object stores.
65
- */
66
- export async function openOptimysticWebDb(
67
- name: string = DEFAULT_DB_NAME,
68
- version: number = DEFAULT_DB_VERSION,
69
- ): Promise<OptimysticWebDBHandle> {
70
- return openDB<OptimysticWebDB>(name, version, {
71
- upgrade(db) {
72
- if (!db.objectStoreNames.contains('metadata')) {
73
- db.createObjectStore('metadata');
74
- }
75
- if (!db.objectStoreNames.contains('revisions')) {
76
- db.createObjectStore('revisions');
77
- }
78
- if (!db.objectStoreNames.contains('pending')) {
79
- db.createObjectStore('pending');
80
- }
81
- if (!db.objectStoreNames.contains('transactions')) {
82
- db.createObjectStore('transactions');
83
- }
84
- if (!db.objectStoreNames.contains('materialized')) {
85
- db.createObjectStore('materialized');
86
- }
87
- if (!db.objectStoreNames.contains('kv')) {
88
- db.createObjectStore('kv');
89
- }
90
- },
91
- });
92
- }
1
+ import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
2
+ import type { ActionId, BlockId } from '@optimystic/db-core';
3
+
4
+ /**
5
+ * IndexedDB schema for the Optimystic browser storage backend.
6
+ *
7
+ * The five block-storage stores keep their original object stores and compound
8
+ * array keys, but their values are now opaque `Uint8Array` blobs: the shared
9
+ * `KvRawStorage` kernel owns JSON (de)serialization, so `IndexedDBStoreDriver`
10
+ * only ever hands IndexedDB kernel-encoded bytes. IndexedDB stores a
11
+ * `Uint8Array` natively via structured clone and returns a `Uint8Array`, so the
12
+ * bytes round-trip without a codec here.
13
+ *
14
+ * - `metadata`: per-block metadata bytes keyed by `blockId`.
15
+ * - `revisions`: revision lookup keyed by `[blockId, rev]` mapping to actionId bytes.
16
+ * Range scans use `IDBKeyRange.bound([blockId, startRev], [blockId, endRev])`.
17
+ * - `pending`: pending transform bytes keyed by `[blockId, actionId]`. Listing pending
18
+ * transactions for a block uses a key cursor over `[blockId, ...]`.
19
+ * - `transactions`: committed transform bytes keyed by `[blockId, actionId]`.
20
+ * - `materialized`: materialized block bytes keyed by `[blockId, actionId]`. Deleting
21
+ * the row is a driver `delete`, not a stored `undefined`.
22
+ * - `kv`: a generic string keyspace shared by `IndexedDBKVStore` and the
23
+ * identity helper. Identity keys are stored as raw `Uint8Array` blobs (under
24
+ * a separate logical store from string-only `IKVStore` data, but the same
25
+ * IndexedDB object store — IndexedDB stores typed arrays natively).
26
+ */
27
+ export interface OptimysticWebDB extends DBSchema {
28
+ metadata: {
29
+ key: BlockId;
30
+ value: Uint8Array;
31
+ };
32
+ revisions: {
33
+ key: [BlockId, number];
34
+ value: Uint8Array;
35
+ };
36
+ pending: {
37
+ key: [BlockId, ActionId];
38
+ value: Uint8Array;
39
+ };
40
+ transactions: {
41
+ key: [BlockId, ActionId];
42
+ value: Uint8Array;
43
+ };
44
+ materialized: {
45
+ key: [BlockId, ActionId];
46
+ value: Uint8Array;
47
+ };
48
+ kv: {
49
+ key: string;
50
+ value: string | Uint8Array;
51
+ };
52
+ }
53
+
54
+ export const DEFAULT_DB_NAME = 'optimystic';
55
+ export const DEFAULT_DB_VERSION = 1;
56
+
57
+ export type OptimysticWebDBHandle = IDBPDatabase<OptimysticWebDB>;
58
+
59
+ /**
60
+ * Opens (and creates if necessary) the Optimystic IndexedDB database used by
61
+ * `IndexedDBRawStorage`, `IndexedDBKVStore`, and `loadOrCreateBrowserPeerKey`.
62
+ *
63
+ * The same handle can be safely shared across all three — IndexedDB connections
64
+ * support concurrent transactions across disjoint object stores.
65
+ */
66
+ export async function openOptimysticWebDb(
67
+ name: string = DEFAULT_DB_NAME,
68
+ version: number = DEFAULT_DB_VERSION,
69
+ ): Promise<OptimysticWebDBHandle> {
70
+ return openDB<OptimysticWebDB>(name, version, {
71
+ upgrade(db) {
72
+ if (!db.objectStoreNames.contains('metadata')) {
73
+ db.createObjectStore('metadata');
74
+ }
75
+ if (!db.objectStoreNames.contains('revisions')) {
76
+ db.createObjectStore('revisions');
77
+ }
78
+ if (!db.objectStoreNames.contains('pending')) {
79
+ db.createObjectStore('pending');
80
+ }
81
+ if (!db.objectStoreNames.contains('transactions')) {
82
+ db.createObjectStore('transactions');
83
+ }
84
+ if (!db.objectStoreNames.contains('materialized')) {
85
+ db.createObjectStore('materialized');
86
+ }
87
+ if (!db.objectStoreNames.contains('kv')) {
88
+ db.createObjectStore('kv');
89
+ }
90
+ },
91
+ });
92
+ }
package/src/identity.ts CHANGED
@@ -1,29 +1,29 @@
1
- import { generateKeyPair, privateKeyFromProtobuf, privateKeyToProtobuf } from '@libp2p/crypto/keys';
2
- import type { PrivateKey } from '@libp2p/interface';
3
- import type { OptimysticWebDBHandle } from './db.js';
4
-
5
- export const DEFAULT_PEER_KEY_NAME = 'peer-private-key';
6
-
7
- /**
8
- * Loads the persisted browser peer's libp2p private key, or generates a fresh
9
- * Ed25519 key on first call and persists it.
10
- *
11
- * Stores the key as raw protobuf bytes (a `Uint8Array`) under `keyName` in the
12
- * `kv` object store. IndexedDB stores typed arrays natively — no base64 round-trip.
13
- *
14
- * The returned `PrivateKey` can be passed directly to `createLibp2pNode({ privateKey })`,
15
- * giving a browser peer a stable, reload-surviving identity.
16
- */
17
- export async function loadOrCreateBrowserPeerKey(
18
- db: OptimysticWebDBHandle,
19
- keyName: string = DEFAULT_PEER_KEY_NAME,
20
- ): Promise<PrivateKey> {
21
- const stored = await db.get('kv', keyName);
22
- if (stored instanceof Uint8Array) {
23
- return privateKeyFromProtobuf(stored);
24
- }
25
- const key = await generateKeyPair('Ed25519');
26
- const bytes = privateKeyToProtobuf(key);
27
- await db.put('kv', bytes, keyName);
28
- return key;
29
- }
1
+ import { generateKeyPair, privateKeyFromProtobuf, privateKeyToProtobuf } from '@libp2p/crypto/keys';
2
+ import type { PrivateKey } from '@libp2p/interface';
3
+ import type { OptimysticWebDBHandle } from './db.js';
4
+
5
+ export const DEFAULT_PEER_KEY_NAME = 'peer-private-key';
6
+
7
+ /**
8
+ * Loads the persisted browser peer's libp2p private key, or generates a fresh
9
+ * Ed25519 key on first call and persists it.
10
+ *
11
+ * Stores the key as raw protobuf bytes (a `Uint8Array`) under `keyName` in the
12
+ * `kv` object store. IndexedDB stores typed arrays natively — no base64 round-trip.
13
+ *
14
+ * The returned `PrivateKey` can be passed directly to `createLibp2pNode({ privateKey })`,
15
+ * giving a browser peer a stable, reload-surviving identity.
16
+ */
17
+ export async function loadOrCreateBrowserPeerKey(
18
+ db: OptimysticWebDBHandle,
19
+ keyName: string = DEFAULT_PEER_KEY_NAME,
20
+ ): Promise<PrivateKey> {
21
+ const stored = await db.get('kv', keyName);
22
+ if (stored instanceof Uint8Array) {
23
+ return privateKeyFromProtobuf(stored);
24
+ }
25
+ const key = await generateKeyPair('Ed25519');
26
+ const bytes = privateKeyToProtobuf(key);
27
+ await db.put('kv', bytes, keyName);
28
+ return key;
29
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export * from './db.js';
2
- export * from './indexeddb-storage.js';
3
- export * from './indexeddb-kv-store.js';
4
- export * from './identity.js';
1
+ export * from './db.js';
2
+ export * from './indexeddb-storage.js';
3
+ export * from './indexeddb-kv-store.js';
4
+ export * from './identity.js';
@@ -1,49 +1,49 @@
1
- import type { IKVStore } from '@optimystic/db-p2p';
2
- import type { OptimysticWebDBHandle } from './db.js';
3
-
4
- /**
5
- * IndexedDB-backed `IKVStore` adapter for browser peers.
6
- *
7
- * Stored keys are namespaced with `prefix` so the `kv` object store can be
8
- * shared with the identity helper without collisions. `list(prefix)` uses a
9
- * range-bounded key cursor — never `getAllKeys()` — so a large `kv` store
10
- * does not pay an O(n) JS-side filter cost.
11
- */
12
- export class IndexedDBKVStore implements IKVStore {
13
- constructor(
14
- private readonly db: OptimysticWebDBHandle,
15
- private readonly prefix: string = 'optimystic:txn:',
16
- ) {}
17
-
18
- async get(key: string): Promise<string | undefined> {
19
- const value = await this.db.get('kv', this.prefix + key);
20
- return typeof value === 'string' ? value : undefined;
21
- }
22
-
23
- async set(key: string, value: string): Promise<void> {
24
- await this.db.put('kv', value, this.prefix + key);
25
- }
26
-
27
- async delete(key: string): Promise<void> {
28
- await this.db.delete('kv', this.prefix + key);
29
- }
30
-
31
- async list(prefix: string): Promise<string[]> {
32
- const fullPrefix = this.prefix + prefix;
33
- // '￿' is the highest BMP code unit; appending it produces a string
34
- // that is >= every string starting with `fullPrefix`. IndexedDB compares
35
- // strings by UTF-16 code units, so this bound is exact.
36
- const range = IDBKeyRange.bound(fullPrefix, fullPrefix + '￿');
37
- const tx = this.db.transaction('kv', 'readonly');
38
- const store = tx.objectStore('kv');
39
- const keys: string[] = [];
40
- let cursor = await store.openKeyCursor(range);
41
- while (cursor) {
42
- const fullKey = cursor.key as string;
43
- keys.push(fullKey.slice(this.prefix.length));
44
- cursor = await cursor.continue();
45
- }
46
- await tx.done;
47
- return keys;
48
- }
49
- }
1
+ import type { IKVStore } from '@optimystic/db-p2p';
2
+ import type { OptimysticWebDBHandle } from './db.js';
3
+
4
+ /**
5
+ * IndexedDB-backed `IKVStore` adapter for browser peers.
6
+ *
7
+ * Stored keys are namespaced with `prefix` so the `kv` object store can be
8
+ * shared with the identity helper without collisions. `list(prefix)` uses a
9
+ * range-bounded key cursor — never `getAllKeys()` — so a large `kv` store
10
+ * does not pay an O(n) JS-side filter cost.
11
+ */
12
+ export class IndexedDBKVStore implements IKVStore {
13
+ constructor(
14
+ private readonly db: OptimysticWebDBHandle,
15
+ private readonly prefix: string = 'optimystic:txn:',
16
+ ) {}
17
+
18
+ async get(key: string): Promise<string | undefined> {
19
+ const value = await this.db.get('kv', this.prefix + key);
20
+ return typeof value === 'string' ? value : undefined;
21
+ }
22
+
23
+ async set(key: string, value: string): Promise<void> {
24
+ await this.db.put('kv', value, this.prefix + key);
25
+ }
26
+
27
+ async delete(key: string): Promise<void> {
28
+ await this.db.delete('kv', this.prefix + key);
29
+ }
30
+
31
+ async list(prefix: string): Promise<string[]> {
32
+ const fullPrefix = this.prefix + prefix;
33
+ // '￿' is the highest BMP code unit; appending it produces a string
34
+ // that is >= every string starting with `fullPrefix`. IndexedDB compares
35
+ // strings by UTF-16 code units, so this bound is exact.
36
+ const range = IDBKeyRange.bound(fullPrefix, fullPrefix + '￿');
37
+ const tx = this.db.transaction('kv', 'readonly');
38
+ const store = tx.objectStore('kv');
39
+ const keys: string[] = [];
40
+ let cursor = await store.openKeyCursor(range);
41
+ while (cursor) {
42
+ const fullKey = cursor.key as string;
43
+ keys.push(fullKey.slice(this.prefix.length));
44
+ cursor = await cursor.continue();
45
+ }
46
+ await tx.done;
47
+ return keys;
48
+ }
49
+ }
@@ -1,188 +1,188 @@
1
- import type { ActionId, BlockId } from '@optimystic/db-core';
2
- import { KvRawStorage, type RawStoreDriver } from '@optimystic/db-p2p';
3
- import type { OptimysticWebDBHandle } from './db.js';
4
- import { createLogger } from './logger.js';
5
-
6
- const log = createLogger('storage:indexeddb');
7
-
8
- /**
9
- * IndexedDB {@link RawStoreDriver}: the five logical block-storage stores mapped
10
- * to the five IndexedDB object stores (`metadata`, `revisions`, `pending`,
11
- * `transactions`, `materialized`) with their original compound array keys. This
12
- * is a code refactor, not a storage-format change at the key level.
13
- *
14
- * `KvRawStorage` now owns all JSON serialization, so this driver only ever
15
- * reads/writes `Uint8Array` values — IndexedDB stores a typed array natively via
16
- * structured clone and returns a `Uint8Array`, so no codec lives here. Everything
17
- * IndexedDB-specific stays: the range/key cursors (drained snapshot-first before
18
- * yielding), and the single `readwrite` transaction that makes `promote` atomic.
19
- */
20
- export class IndexedDBStoreDriver implements RawStoreDriver {
21
- constructor(private readonly db: OptimysticWebDBHandle) {}
22
-
23
- // --- metadata ---
24
-
25
- async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
26
- return this.db.get('metadata', blockId);
27
- }
28
-
29
- async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
30
- await this.db.put('metadata', value, blockId);
31
- }
32
-
33
- // --- revisions ---
34
-
35
- async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
36
- return this.db.get('revisions', [blockId, rev]);
37
- }
38
-
39
- async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
40
- await this.db.put('revisions', value, [blockId, rev]);
41
- }
42
-
43
- async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
44
- const range = IDBKeyRange.bound([blockId, lo], [blockId, hi]);
45
- const tx = this.db.transaction('revisions', 'readonly');
46
- const store = tx.objectStore('revisions');
47
- // Snapshot first so we don't hold the transaction open across yields —
48
- // IndexedDB auto-commits idle transactions, which would invalidate the
49
- // cursor between the consumer's awaits (the kernel's drain-before-yield
50
- // contract). Do NOT switch to lazy yielding mid-transaction.
51
- const results: [number, Uint8Array][] = [];
52
- let cursor = await store.openCursor(range, reverse ? 'prev' : 'next');
53
- while (cursor) {
54
- results.push([(cursor.key as [BlockId, number])[1], cursor.value]);
55
- cursor = await cursor.continue();
56
- }
57
- await tx.done;
58
- for (const result of results) {
59
- yield result;
60
- }
61
- }
62
-
63
- // --- pending ---
64
-
65
- async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
66
- return this.db.get('pending', [blockId, actionId]);
67
- }
68
-
69
- async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
70
- await this.db.put('pending', value, [blockId, actionId]);
71
- }
72
-
73
- async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
74
- await this.db.delete('pending', [blockId, actionId]);
75
- }
76
-
77
- async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
78
- // IndexedDB key ordering: array keys compare element-by-element, and a
79
- // shorter prefix-equal array is less than a longer one; arrays sort
80
- // above all primitive types. So `[blockId]` < `[blockId, anyActionId]`
81
- // < `[blockId, []]`, which captures exactly every key for this block.
82
- const range = IDBKeyRange.bound([blockId] as IDBValidKey, [blockId, []] as IDBValidKey);
83
- const tx = this.db.transaction('pending', 'readonly');
84
- const store = tx.objectStore('pending');
85
- // Snapshot-first, same rationale as rangeRevisions.
86
- const results: ActionId[] = [];
87
- let cursor = await store.openKeyCursor(range);
88
- while (cursor) {
89
- results.push((cursor.key as [BlockId, ActionId])[1]);
90
- cursor = await cursor.continue();
91
- }
92
- await tx.done;
93
- for (const actionId of results) {
94
- yield actionId;
95
- }
96
- }
97
-
98
- // --- transactions ---
99
-
100
- async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
101
- return this.db.get('transactions', [blockId, actionId]);
102
- }
103
-
104
- async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
105
- await this.db.put('transactions', value, [blockId, actionId]);
106
- }
107
-
108
- // --- materialized ---
109
-
110
- async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
111
- return this.db.get('materialized', [blockId, actionId]);
112
- }
113
-
114
- async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
115
- await this.db.put('materialized', value, [blockId, actionId]);
116
- }
117
-
118
- // The kernel owns the put-or-delete branch of `saveMaterializedBlock`, so the
119
- // driver exposes delete as a separate op.
120
- async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
121
- await this.db.delete('materialized', [blockId, actionId]);
122
- }
123
-
124
- // --- promote (the only cross-key atomic op) ---
125
-
126
- async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
127
- const key: [BlockId, ActionId] = [blockId, actionId];
128
- // Single readwrite transaction over both stores IS the atomic move: a crash
129
- // leaves either the pending or the committed entry, never both/neither.
130
- const tx = this.db.transaction(['pending', 'transactions'], 'readwrite');
131
- const pendingStore = tx.objectStore('pending');
132
- const transactionsStore = tx.objectStore('transactions');
133
- const value = await pendingStore.get(key);
134
- if (!value) {
135
- // Settle the transaction before throwing so the failed promote does not
136
- // leak an open transaction — keep this ordering.
137
- await tx.done.catch(() => undefined);
138
- throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
139
- }
140
- await transactionsStore.put(value, key);
141
- await pendingStore.delete(key);
142
- await tx.done;
143
- }
144
-
145
- // --- optional passthroughs ---
146
-
147
- async *listBlockIds(): AsyncIterable<BlockId> {
148
- // The `metadata` store is keyed by blockId directly, so its keys ARE the
149
- // distinct block ids. getAllKeys reads them under an implicit readonly
150
- // transaction and returns an already-materialized array — no cursor held
151
- // across yields (same rationale as the cursor scans' snapshot-first pattern).
152
- const keys = await this.db.getAllKeys('metadata');
153
- for (const key of keys) {
154
- yield key as BlockId;
155
- }
156
- }
157
-
158
- async approximateBytesUsed(): Promise<number> {
159
- try {
160
- const estimate = await navigator.storage?.estimate?.();
161
- return estimate?.usage ?? 0;
162
- } catch (err) {
163
- log('navigator.storage.estimate() failed: %o', err);
164
- return 0;
165
- }
166
- }
167
- }
168
-
169
- /**
170
- * IndexedDB-backed {@link IRawStorage} for browser peers, now a thin shell over
171
- * the shared {@link KvRawStorage} kernel driven by an {@link IndexedDBStoreDriver}.
172
- * The public name/constructor (`new IndexedDBRawStorage(handle)`) is unchanged so
173
- * existing imports keep resolving; the kernel supplies the `IRawStorage` surface
174
- * and the driver supplies IndexedDB behavior.
175
- *
176
- * `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
177
- * (the IndexedDB driver always implements them, so the kernel constructor always
178
- * wires them) — the base declares them optional, but every web consumer relies
179
- * on them.
180
- */
181
- export class IndexedDBRawStorage extends KvRawStorage {
182
- declare listBlockIds: () => AsyncIterable<BlockId>;
183
- declare getApproximateBytesUsed: () => Promise<number>;
184
-
185
- constructor(db: OptimysticWebDBHandle) {
186
- super(new IndexedDBStoreDriver(db));
187
- }
188
- }
1
+ import type { ActionId, BlockId } from '@optimystic/db-core';
2
+ import { KvRawStorage, type RawStoreDriver } from '@optimystic/db-p2p';
3
+ import type { OptimysticWebDBHandle } from './db.js';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('storage:indexeddb');
7
+
8
+ /**
9
+ * IndexedDB {@link RawStoreDriver}: the five logical block-storage stores mapped
10
+ * to the five IndexedDB object stores (`metadata`, `revisions`, `pending`,
11
+ * `transactions`, `materialized`) with their original compound array keys. This
12
+ * is a code refactor, not a storage-format change at the key level.
13
+ *
14
+ * `KvRawStorage` now owns all JSON serialization, so this driver only ever
15
+ * reads/writes `Uint8Array` values — IndexedDB stores a typed array natively via
16
+ * structured clone and returns a `Uint8Array`, so no codec lives here. Everything
17
+ * IndexedDB-specific stays: the range/key cursors (drained snapshot-first before
18
+ * yielding), and the single `readwrite` transaction that makes `promote` atomic.
19
+ */
20
+ export class IndexedDBStoreDriver implements RawStoreDriver {
21
+ constructor(private readonly db: OptimysticWebDBHandle) {}
22
+
23
+ // --- metadata ---
24
+
25
+ async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
26
+ return this.db.get('metadata', blockId);
27
+ }
28
+
29
+ async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
30
+ await this.db.put('metadata', value, blockId);
31
+ }
32
+
33
+ // --- revisions ---
34
+
35
+ async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
36
+ return this.db.get('revisions', [blockId, rev]);
37
+ }
38
+
39
+ async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
40
+ await this.db.put('revisions', value, [blockId, rev]);
41
+ }
42
+
43
+ async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
44
+ const range = IDBKeyRange.bound([blockId, lo], [blockId, hi]);
45
+ const tx = this.db.transaction('revisions', 'readonly');
46
+ const store = tx.objectStore('revisions');
47
+ // Snapshot first so we don't hold the transaction open across yields —
48
+ // IndexedDB auto-commits idle transactions, which would invalidate the
49
+ // cursor between the consumer's awaits (the kernel's drain-before-yield
50
+ // contract). Do NOT switch to lazy yielding mid-transaction.
51
+ const results: [number, Uint8Array][] = [];
52
+ let cursor = await store.openCursor(range, reverse ? 'prev' : 'next');
53
+ while (cursor) {
54
+ results.push([(cursor.key as [BlockId, number])[1], cursor.value]);
55
+ cursor = await cursor.continue();
56
+ }
57
+ await tx.done;
58
+ for (const result of results) {
59
+ yield result;
60
+ }
61
+ }
62
+
63
+ // --- pending ---
64
+
65
+ async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
66
+ return this.db.get('pending', [blockId, actionId]);
67
+ }
68
+
69
+ async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
70
+ await this.db.put('pending', value, [blockId, actionId]);
71
+ }
72
+
73
+ async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
74
+ await this.db.delete('pending', [blockId, actionId]);
75
+ }
76
+
77
+ async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
78
+ // IndexedDB key ordering: array keys compare element-by-element, and a
79
+ // shorter prefix-equal array is less than a longer one; arrays sort
80
+ // above all primitive types. So `[blockId]` < `[blockId, anyActionId]`
81
+ // < `[blockId, []]`, which captures exactly every key for this block.
82
+ const range = IDBKeyRange.bound([blockId] as IDBValidKey, [blockId, []] as IDBValidKey);
83
+ const tx = this.db.transaction('pending', 'readonly');
84
+ const store = tx.objectStore('pending');
85
+ // Snapshot-first, same rationale as rangeRevisions.
86
+ const results: ActionId[] = [];
87
+ let cursor = await store.openKeyCursor(range);
88
+ while (cursor) {
89
+ results.push((cursor.key as [BlockId, ActionId])[1]);
90
+ cursor = await cursor.continue();
91
+ }
92
+ await tx.done;
93
+ for (const actionId of results) {
94
+ yield actionId;
95
+ }
96
+ }
97
+
98
+ // --- transactions ---
99
+
100
+ async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
101
+ return this.db.get('transactions', [blockId, actionId]);
102
+ }
103
+
104
+ async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
105
+ await this.db.put('transactions', value, [blockId, actionId]);
106
+ }
107
+
108
+ // --- materialized ---
109
+
110
+ async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
111
+ return this.db.get('materialized', [blockId, actionId]);
112
+ }
113
+
114
+ async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
115
+ await this.db.put('materialized', value, [blockId, actionId]);
116
+ }
117
+
118
+ // The kernel owns the put-or-delete branch of `saveMaterializedBlock`, so the
119
+ // driver exposes delete as a separate op.
120
+ async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
121
+ await this.db.delete('materialized', [blockId, actionId]);
122
+ }
123
+
124
+ // --- promote (the only cross-key atomic op) ---
125
+
126
+ async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
127
+ const key: [BlockId, ActionId] = [blockId, actionId];
128
+ // Single readwrite transaction over both stores IS the atomic move: a crash
129
+ // leaves either the pending or the committed entry, never both/neither.
130
+ const tx = this.db.transaction(['pending', 'transactions'], 'readwrite');
131
+ const pendingStore = tx.objectStore('pending');
132
+ const transactionsStore = tx.objectStore('transactions');
133
+ const value = await pendingStore.get(key);
134
+ if (!value) {
135
+ // Settle the transaction before throwing so the failed promote does not
136
+ // leak an open transaction — keep this ordering.
137
+ await tx.done.catch(() => undefined);
138
+ throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
139
+ }
140
+ await transactionsStore.put(value, key);
141
+ await pendingStore.delete(key);
142
+ await tx.done;
143
+ }
144
+
145
+ // --- optional passthroughs ---
146
+
147
+ async *listBlockIds(): AsyncIterable<BlockId> {
148
+ // The `metadata` store is keyed by blockId directly, so its keys ARE the
149
+ // distinct block ids. getAllKeys reads them under an implicit readonly
150
+ // transaction and returns an already-materialized array — no cursor held
151
+ // across yields (same rationale as the cursor scans' snapshot-first pattern).
152
+ const keys = await this.db.getAllKeys('metadata');
153
+ for (const key of keys) {
154
+ yield key as BlockId;
155
+ }
156
+ }
157
+
158
+ async approximateBytesUsed(): Promise<number> {
159
+ try {
160
+ const estimate = await navigator.storage?.estimate?.();
161
+ return estimate?.usage ?? 0;
162
+ } catch (err) {
163
+ log('navigator.storage.estimate() failed: %o', err);
164
+ return 0;
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * IndexedDB-backed {@link IRawStorage} for browser peers, now a thin shell over
171
+ * the shared {@link KvRawStorage} kernel driven by an {@link IndexedDBStoreDriver}.
172
+ * The public name/constructor (`new IndexedDBRawStorage(handle)`) is unchanged so
173
+ * existing imports keep resolving; the kernel supplies the `IRawStorage` surface
174
+ * and the driver supplies IndexedDB behavior.
175
+ *
176
+ * `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
177
+ * (the IndexedDB driver always implements them, so the kernel constructor always
178
+ * wires them) — the base declares them optional, but every web consumer relies
179
+ * on them.
180
+ */
181
+ export class IndexedDBRawStorage extends KvRawStorage {
182
+ declare listBlockIds: () => AsyncIterable<BlockId>;
183
+ declare getApproximateBytesUsed: () => Promise<number>;
184
+
185
+ constructor(db: OptimysticWebDBHandle) {
186
+ super(new IndexedDBStoreDriver(db));
187
+ }
188
+ }
package/src/logger.ts CHANGED
@@ -1,7 +1,7 @@
1
- import debug from 'debug';
2
-
3
- const BASE_NAMESPACE = 'optimystic:db-p2p-storage-web';
4
-
5
- export function createLogger(subNamespace: string): debug.Debugger {
6
- return debug(`${BASE_NAMESPACE}:${subNamespace}`);
7
- }
1
+ import debug from 'debug';
2
+
3
+ const BASE_NAMESPACE = 'optimystic:db-p2p-storage-web';
4
+
5
+ export function createLogger(subNamespace: string): debug.Debugger {
6
+ return debug(`${BASE_NAMESPACE}:${subNamespace}`);
7
+ }