@optimystic/db-p2p-storage-web 0.21.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 +108 -108
- package/package.json +3 -3
- package/src/db.ts +92 -92
- 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 +188 -188
- package/src/logger.ts +7 -7
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.
|
|
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.
|
|
58
|
-
"@optimystic/db-p2p": "^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
|
+
}
|
package/src/indexeddb-storage.ts
CHANGED
|
@@ -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
|
+
}
|