@optimystic/db-p2p-storage-web 1.4.0 → 1.5.1
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/package.json +3 -3
- package/src/identity.ts +29 -29
- package/src/index.ts +4 -4
- package/src/indexeddb-kv-store.ts +49 -49
- package/src/indexeddb-storage.ts +210 -210
- package/src/logger.ts +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optimystic/db-p2p-storage-web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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": "^1.
|
|
58
|
-
"@optimystic/db-p2p": "^1.
|
|
57
|
+
"@optimystic/db-core": "^1.5.1",
|
|
58
|
+
"@optimystic/db-p2p": "^1.5.1",
|
|
59
59
|
"debug": "^4.4.3",
|
|
60
60
|
"idb": "^8.0.3"
|
|
61
61
|
},
|
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
|
+
}
|
package/src/indexeddb-storage.ts
CHANGED
|
@@ -1,210 +1,210 @@
|
|
|
1
|
-
import type { ActionId, BlockId } from '@optimystic/db-core';
|
|
2
|
-
import { KvRawStorage, identityForHandle, type RawStoreDriver, type StoreIdentity } 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 six logical block-storage stores mapped
|
|
10
|
-
* to the six IndexedDB object stores (`metadata`, `revisions`, `pending`,
|
|
11
|
-
* `transactions`, `proofs`, `materialized`) with their compound array keys.
|
|
12
|
-
*
|
|
13
|
-
* `KvRawStorage` now owns all JSON serialization, so this driver only ever
|
|
14
|
-
* reads/writes `Uint8Array` values — IndexedDB stores a typed array natively via
|
|
15
|
-
* structured clone and returns a `Uint8Array`, so no codec lives here. Everything
|
|
16
|
-
* IndexedDB-specific stays: the range/key cursors (drained snapshot-first before
|
|
17
|
-
* yielding), and the single `readwrite` transaction that makes `promote` atomic.
|
|
18
|
-
*/
|
|
19
|
-
export class IndexedDBStoreDriver implements RawStoreDriver {
|
|
20
|
-
// NOTE: identity is the HANDLE object, so two handles opened over the same IndexedDB database
|
|
21
|
-
// NAME read as two identities. Resolving that would mean naming the underlying database,
|
|
22
|
-
// which `OptimysticWebDBHandle` does not expose. The package opener (`db.ts`) hands out one
|
|
23
|
-
// handle per database name in practice, so this is the reachable case, not the complete one.
|
|
24
|
-
private readonly identity: StoreIdentity;
|
|
25
|
-
|
|
26
|
-
constructor(private readonly db: OptimysticWebDBHandle) {
|
|
27
|
-
this.identity = identityForHandle('idb-handle', db);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
storeIdentity(): StoreIdentity {
|
|
31
|
-
return this.identity;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// --- metadata ---
|
|
35
|
-
|
|
36
|
-
async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
|
|
37
|
-
return this.db.get('metadata', blockId);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
|
|
41
|
-
await this.db.put('metadata', value, blockId);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// --- revisions ---
|
|
45
|
-
|
|
46
|
-
async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
|
|
47
|
-
return this.db.get('revisions', [blockId, rev]);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
|
|
51
|
-
await this.db.put('revisions', value, [blockId, rev]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
|
|
55
|
-
const range = IDBKeyRange.bound([blockId, lo], [blockId, hi]);
|
|
56
|
-
const tx = this.db.transaction('revisions', 'readonly');
|
|
57
|
-
const store = tx.objectStore('revisions');
|
|
58
|
-
// Snapshot first so we don't hold the transaction open across yields —
|
|
59
|
-
// IndexedDB auto-commits idle transactions, which would invalidate the
|
|
60
|
-
// cursor between the consumer's awaits (the kernel's drain-before-yield
|
|
61
|
-
// contract). Do NOT switch to lazy yielding mid-transaction.
|
|
62
|
-
const results: [number, Uint8Array][] = [];
|
|
63
|
-
let cursor = await store.openCursor(range, reverse ? 'prev' : 'next');
|
|
64
|
-
while (cursor) {
|
|
65
|
-
results.push([(cursor.key as [BlockId, number])[1], cursor.value]);
|
|
66
|
-
cursor = await cursor.continue();
|
|
67
|
-
}
|
|
68
|
-
await tx.done;
|
|
69
|
-
for (const result of results) {
|
|
70
|
-
yield result;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// --- pending ---
|
|
75
|
-
|
|
76
|
-
async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
77
|
-
return this.db.get('pending', [blockId, actionId]);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
81
|
-
await this.db.put('pending', value, [blockId, actionId]);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
85
|
-
await this.db.delete('pending', [blockId, actionId]);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
|
|
89
|
-
// IndexedDB key ordering: array keys compare element-by-element, and a
|
|
90
|
-
// shorter prefix-equal array is less than a longer one; arrays sort
|
|
91
|
-
// above all primitive types. So `[blockId]` < `[blockId, anyActionId]`
|
|
92
|
-
// < `[blockId, []]`, which captures exactly every key for this block.
|
|
93
|
-
const range = IDBKeyRange.bound([blockId] as IDBValidKey, [blockId, []] as IDBValidKey);
|
|
94
|
-
const tx = this.db.transaction('pending', 'readonly');
|
|
95
|
-
const store = tx.objectStore('pending');
|
|
96
|
-
// Snapshot-first, same rationale as rangeRevisions.
|
|
97
|
-
const results: ActionId[] = [];
|
|
98
|
-
let cursor = await store.openKeyCursor(range);
|
|
99
|
-
while (cursor) {
|
|
100
|
-
results.push((cursor.key as [BlockId, ActionId])[1]);
|
|
101
|
-
cursor = await cursor.continue();
|
|
102
|
-
}
|
|
103
|
-
await tx.done;
|
|
104
|
-
for (const actionId of results) {
|
|
105
|
-
yield actionId;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// --- transactions ---
|
|
110
|
-
|
|
111
|
-
async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
112
|
-
return this.db.get('transactions', [blockId, actionId]);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
116
|
-
await this.db.put('transactions', value, [blockId, actionId]);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// --- proofs (keyed [blockId, rev] like revisions; no delete — see RawStoreDriver.getProof) ---
|
|
120
|
-
|
|
121
|
-
async getProof(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
|
|
122
|
-
return this.db.get('proofs', [blockId, rev]);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
|
|
126
|
-
await this.db.put('proofs', value, [blockId, rev]);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// --- materialized ---
|
|
130
|
-
|
|
131
|
-
async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
132
|
-
return this.db.get('materialized', [blockId, actionId]);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
136
|
-
await this.db.put('materialized', value, [blockId, actionId]);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// The kernel owns the put-or-delete branch of `saveMaterializedBlock`, so the
|
|
140
|
-
// driver exposes delete as a separate op.
|
|
141
|
-
async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
142
|
-
await this.db.delete('materialized', [blockId, actionId]);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// --- promote (the only cross-key atomic op) ---
|
|
146
|
-
|
|
147
|
-
async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
148
|
-
const key: [BlockId, ActionId] = [blockId, actionId];
|
|
149
|
-
// Single readwrite transaction over both stores IS the atomic move: a crash
|
|
150
|
-
// leaves either the pending or the committed entry, never both/neither.
|
|
151
|
-
const tx = this.db.transaction(['pending', 'transactions'], 'readwrite');
|
|
152
|
-
const pendingStore = tx.objectStore('pending');
|
|
153
|
-
const transactionsStore = tx.objectStore('transactions');
|
|
154
|
-
const value = await pendingStore.get(key);
|
|
155
|
-
if (!value) {
|
|
156
|
-
// Settle the transaction before throwing so the failed promote does not
|
|
157
|
-
// leak an open transaction — keep this ordering.
|
|
158
|
-
await tx.done.catch(() => undefined);
|
|
159
|
-
throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
|
|
160
|
-
}
|
|
161
|
-
await transactionsStore.put(value, key);
|
|
162
|
-
await pendingStore.delete(key);
|
|
163
|
-
await tx.done;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// --- optional passthroughs ---
|
|
167
|
-
|
|
168
|
-
async *listBlockIds(): AsyncIterable<BlockId> {
|
|
169
|
-
// The `metadata` store is keyed by blockId directly, so its keys ARE the
|
|
170
|
-
// distinct block ids. getAllKeys reads them under an implicit readonly
|
|
171
|
-
// transaction and returns an already-materialized array — no cursor held
|
|
172
|
-
// across yields (same rationale as the cursor scans' snapshot-first pattern).
|
|
173
|
-
const keys = await this.db.getAllKeys('metadata');
|
|
174
|
-
for (const key of keys) {
|
|
175
|
-
yield key as BlockId;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async approximateBytesUsed(): Promise<number> {
|
|
180
|
-
try {
|
|
181
|
-
const estimate = await navigator.storage?.estimate?.();
|
|
182
|
-
return estimate?.usage ?? 0;
|
|
183
|
-
} catch (err) {
|
|
184
|
-
log('navigator.storage.estimate() failed: %o', err);
|
|
185
|
-
return 0;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* IndexedDB-backed {@link IRawStorage} for browser peers, now a thin shell over
|
|
192
|
-
* the shared {@link KvRawStorage} kernel driven by an {@link IndexedDBStoreDriver}.
|
|
193
|
-
* The public name/constructor (`new IndexedDBRawStorage(handle)`) is unchanged so
|
|
194
|
-
* existing imports keep resolving; the kernel supplies the `IRawStorage` surface
|
|
195
|
-
* and the driver supplies IndexedDB behavior.
|
|
196
|
-
*
|
|
197
|
-
* `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
|
|
198
|
-
* (the IndexedDB driver always implements them, so the kernel constructor always
|
|
199
|
-
* wires them) — the base declares them optional, but every web consumer relies
|
|
200
|
-
* on them.
|
|
201
|
-
*/
|
|
202
|
-
export class IndexedDBRawStorage extends KvRawStorage {
|
|
203
|
-
declare listBlockIds: () => AsyncIterable<BlockId>;
|
|
204
|
-
declare getApproximateBytesUsed: () => Promise<number>;
|
|
205
|
-
declare getStoreIdentity: () => StoreIdentity;
|
|
206
|
-
|
|
207
|
-
constructor(db: OptimysticWebDBHandle) {
|
|
208
|
-
super(new IndexedDBStoreDriver(db));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
1
|
+
import type { ActionId, BlockId } from '@optimystic/db-core';
|
|
2
|
+
import { KvRawStorage, identityForHandle, type RawStoreDriver, type StoreIdentity } 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 six logical block-storage stores mapped
|
|
10
|
+
* to the six IndexedDB object stores (`metadata`, `revisions`, `pending`,
|
|
11
|
+
* `transactions`, `proofs`, `materialized`) with their compound array keys.
|
|
12
|
+
*
|
|
13
|
+
* `KvRawStorage` now owns all JSON serialization, so this driver only ever
|
|
14
|
+
* reads/writes `Uint8Array` values — IndexedDB stores a typed array natively via
|
|
15
|
+
* structured clone and returns a `Uint8Array`, so no codec lives here. Everything
|
|
16
|
+
* IndexedDB-specific stays: the range/key cursors (drained snapshot-first before
|
|
17
|
+
* yielding), and the single `readwrite` transaction that makes `promote` atomic.
|
|
18
|
+
*/
|
|
19
|
+
export class IndexedDBStoreDriver implements RawStoreDriver {
|
|
20
|
+
// NOTE: identity is the HANDLE object, so two handles opened over the same IndexedDB database
|
|
21
|
+
// NAME read as two identities. Resolving that would mean naming the underlying database,
|
|
22
|
+
// which `OptimysticWebDBHandle` does not expose. The package opener (`db.ts`) hands out one
|
|
23
|
+
// handle per database name in practice, so this is the reachable case, not the complete one.
|
|
24
|
+
private readonly identity: StoreIdentity;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly db: OptimysticWebDBHandle) {
|
|
27
|
+
this.identity = identityForHandle('idb-handle', db);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
storeIdentity(): StoreIdentity {
|
|
31
|
+
return this.identity;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- metadata ---
|
|
35
|
+
|
|
36
|
+
async getMetadata(blockId: BlockId): Promise<Uint8Array | undefined> {
|
|
37
|
+
return this.db.get('metadata', blockId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async putMetadata(blockId: BlockId, value: Uint8Array): Promise<void> {
|
|
41
|
+
await this.db.put('metadata', value, blockId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- revisions ---
|
|
45
|
+
|
|
46
|
+
async getRevision(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
|
|
47
|
+
return this.db.get('revisions', [blockId, rev]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
|
|
51
|
+
await this.db.put('revisions', value, [blockId, rev]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async *rangeRevisions(blockId: BlockId, lo: number, hi: number, reverse: boolean): AsyncIterable<[number, Uint8Array]> {
|
|
55
|
+
const range = IDBKeyRange.bound([blockId, lo], [blockId, hi]);
|
|
56
|
+
const tx = this.db.transaction('revisions', 'readonly');
|
|
57
|
+
const store = tx.objectStore('revisions');
|
|
58
|
+
// Snapshot first so we don't hold the transaction open across yields —
|
|
59
|
+
// IndexedDB auto-commits idle transactions, which would invalidate the
|
|
60
|
+
// cursor between the consumer's awaits (the kernel's drain-before-yield
|
|
61
|
+
// contract). Do NOT switch to lazy yielding mid-transaction.
|
|
62
|
+
const results: [number, Uint8Array][] = [];
|
|
63
|
+
let cursor = await store.openCursor(range, reverse ? 'prev' : 'next');
|
|
64
|
+
while (cursor) {
|
|
65
|
+
results.push([(cursor.key as [BlockId, number])[1], cursor.value]);
|
|
66
|
+
cursor = await cursor.continue();
|
|
67
|
+
}
|
|
68
|
+
await tx.done;
|
|
69
|
+
for (const result of results) {
|
|
70
|
+
yield result;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- pending ---
|
|
75
|
+
|
|
76
|
+
async getPending(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
77
|
+
return this.db.get('pending', [blockId, actionId]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async putPending(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
81
|
+
await this.db.put('pending', value, [blockId, actionId]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
85
|
+
await this.db.delete('pending', [blockId, actionId]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
|
|
89
|
+
// IndexedDB key ordering: array keys compare element-by-element, and a
|
|
90
|
+
// shorter prefix-equal array is less than a longer one; arrays sort
|
|
91
|
+
// above all primitive types. So `[blockId]` < `[blockId, anyActionId]`
|
|
92
|
+
// < `[blockId, []]`, which captures exactly every key for this block.
|
|
93
|
+
const range = IDBKeyRange.bound([blockId] as IDBValidKey, [blockId, []] as IDBValidKey);
|
|
94
|
+
const tx = this.db.transaction('pending', 'readonly');
|
|
95
|
+
const store = tx.objectStore('pending');
|
|
96
|
+
// Snapshot-first, same rationale as rangeRevisions.
|
|
97
|
+
const results: ActionId[] = [];
|
|
98
|
+
let cursor = await store.openKeyCursor(range);
|
|
99
|
+
while (cursor) {
|
|
100
|
+
results.push((cursor.key as [BlockId, ActionId])[1]);
|
|
101
|
+
cursor = await cursor.continue();
|
|
102
|
+
}
|
|
103
|
+
await tx.done;
|
|
104
|
+
for (const actionId of results) {
|
|
105
|
+
yield actionId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- transactions ---
|
|
110
|
+
|
|
111
|
+
async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
112
|
+
return this.db.get('transactions', [blockId, actionId]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async putTransaction(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
116
|
+
await this.db.put('transactions', value, [blockId, actionId]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- proofs (keyed [blockId, rev] like revisions; no delete — see RawStoreDriver.getProof) ---
|
|
120
|
+
|
|
121
|
+
async getProof(blockId: BlockId, rev: number): Promise<Uint8Array | undefined> {
|
|
122
|
+
return this.db.get('proofs', [blockId, rev]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async putProof(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
|
|
126
|
+
await this.db.put('proofs', value, [blockId, rev]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- materialized ---
|
|
130
|
+
|
|
131
|
+
async getMaterialized(blockId: BlockId, actionId: ActionId): Promise<Uint8Array | undefined> {
|
|
132
|
+
return this.db.get('materialized', [blockId, actionId]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async putMaterialized(blockId: BlockId, actionId: ActionId, value: Uint8Array): Promise<void> {
|
|
136
|
+
await this.db.put('materialized', value, [blockId, actionId]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The kernel owns the put-or-delete branch of `saveMaterializedBlock`, so the
|
|
140
|
+
// driver exposes delete as a separate op.
|
|
141
|
+
async deleteMaterialized(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
142
|
+
await this.db.delete('materialized', [blockId, actionId]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- promote (the only cross-key atomic op) ---
|
|
146
|
+
|
|
147
|
+
async promote(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
148
|
+
const key: [BlockId, ActionId] = [blockId, actionId];
|
|
149
|
+
// Single readwrite transaction over both stores IS the atomic move: a crash
|
|
150
|
+
// leaves either the pending or the committed entry, never both/neither.
|
|
151
|
+
const tx = this.db.transaction(['pending', 'transactions'], 'readwrite');
|
|
152
|
+
const pendingStore = tx.objectStore('pending');
|
|
153
|
+
const transactionsStore = tx.objectStore('transactions');
|
|
154
|
+
const value = await pendingStore.get(key);
|
|
155
|
+
if (!value) {
|
|
156
|
+
// Settle the transaction before throwing so the failed promote does not
|
|
157
|
+
// leak an open transaction — keep this ordering.
|
|
158
|
+
await tx.done.catch(() => undefined);
|
|
159
|
+
throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
|
|
160
|
+
}
|
|
161
|
+
await transactionsStore.put(value, key);
|
|
162
|
+
await pendingStore.delete(key);
|
|
163
|
+
await tx.done;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- optional passthroughs ---
|
|
167
|
+
|
|
168
|
+
async *listBlockIds(): AsyncIterable<BlockId> {
|
|
169
|
+
// The `metadata` store is keyed by blockId directly, so its keys ARE the
|
|
170
|
+
// distinct block ids. getAllKeys reads them under an implicit readonly
|
|
171
|
+
// transaction and returns an already-materialized array — no cursor held
|
|
172
|
+
// across yields (same rationale as the cursor scans' snapshot-first pattern).
|
|
173
|
+
const keys = await this.db.getAllKeys('metadata');
|
|
174
|
+
for (const key of keys) {
|
|
175
|
+
yield key as BlockId;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async approximateBytesUsed(): Promise<number> {
|
|
180
|
+
try {
|
|
181
|
+
const estimate = await navigator.storage?.estimate?.();
|
|
182
|
+
return estimate?.usage ?? 0;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
log('navigator.storage.estimate() failed: %o', err);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* IndexedDB-backed {@link IRawStorage} for browser peers, now a thin shell over
|
|
192
|
+
* the shared {@link KvRawStorage} kernel driven by an {@link IndexedDBStoreDriver}.
|
|
193
|
+
* The public name/constructor (`new IndexedDBRawStorage(handle)`) is unchanged so
|
|
194
|
+
* existing imports keep resolving; the kernel supplies the `IRawStorage` surface
|
|
195
|
+
* and the driver supplies IndexedDB behavior.
|
|
196
|
+
*
|
|
197
|
+
* `listBlockIds`/`getApproximateBytesUsed` are re-declared here as always-present
|
|
198
|
+
* (the IndexedDB driver always implements them, so the kernel constructor always
|
|
199
|
+
* wires them) — the base declares them optional, but every web consumer relies
|
|
200
|
+
* on them.
|
|
201
|
+
*/
|
|
202
|
+
export class IndexedDBRawStorage extends KvRawStorage {
|
|
203
|
+
declare listBlockIds: () => AsyncIterable<BlockId>;
|
|
204
|
+
declare getApproximateBytesUsed: () => Promise<number>;
|
|
205
|
+
declare getStoreIdentity: () => StoreIdentity;
|
|
206
|
+
|
|
207
|
+
constructor(db: OptimysticWebDBHandle) {
|
|
208
|
+
super(new IndexedDBStoreDriver(db));
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
import { registerDebugModule } from '@optimystic/db-core';
|
|
3
|
-
|
|
4
|
-
const BASE_NAMESPACE = 'optimystic:db-p2p-storage-web';
|
|
5
|
-
|
|
6
|
-
// So `enableOptimysticLogging` reaches this package's copy of `debug`, which may be no one else's.
|
|
7
|
-
registerDebugModule('db-p2p-storage-web', debug);
|
|
8
|
-
|
|
9
|
-
export function createLogger(subNamespace: string): debug.Debugger {
|
|
10
|
-
return debug(`${BASE_NAMESPACE}:${subNamespace}`);
|
|
11
|
-
}
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { registerDebugModule } from '@optimystic/db-core';
|
|
3
|
+
|
|
4
|
+
const BASE_NAMESPACE = 'optimystic:db-p2p-storage-web';
|
|
5
|
+
|
|
6
|
+
// So `enableOptimysticLogging` reaches this package's copy of `debug`, which may be no one else's.
|
|
7
|
+
registerDebugModule('db-p2p-storage-web', debug);
|
|
8
|
+
|
|
9
|
+
export function createLogger(subNamespace: string): debug.Debugger {
|
|
10
|
+
return debug(`${BASE_NAMESPACE}:${subNamespace}`);
|
|
11
|
+
}
|