dignity.js 0.3.0 → 0.4.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 +60 -3
- package/dist/dignity.cjs.js +227 -0
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +227 -0
- package/dist/dignity.esm.js.map +3 -3
- package/dist/dignity.min.js +5 -5
- package/docs/assets/docs.js +47 -0
- package/docs/assets/highlight/github-dark.min.css +10 -0
- package/docs/assets/highlight/github.min.css +10 -0
- package/docs/assets/highlight/highlight.min.js +1244 -0
- package/docs/assets/styles.css +449 -38
- package/docs/index.html +601 -81
- package/docs/openapi-like.json +44 -6
- package/package.json +21 -3
- package/src/core/dignity-p2p.js +79 -0
- package/src/index.js +2 -0
- package/src/persistence/indexeddb-persistence.js +182 -0
- package/src/react/index.js +114 -0
package/README.md
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
+
<a href="https://jose-compu.github.io/dignity.js/"><img src="https://img.shields.io/badge/docs-online-5B7FFF" alt="documentation"></a>
|
|
14
15
|
<a href="https://www.npmjs.com/package/dignity.js"><img src="https://img.shields.io/npm/v/dignity.js?color=cb3837&label=npm" alt="npm version"></a>
|
|
15
16
|
<a href="https://www.npmjs.com/package/dignity.js"><img src="https://img.shields.io/npm/dm/dignity.js?color=blue" alt="npm downloads"></a>
|
|
16
|
-
<img src="https://
|
|
17
|
-
<img src="https://img.shields.io/badge/
|
|
17
|
+
<a href="https://github.com/jose-compu/dignity.js/actions/workflows/ci.yml"><img src="https://github.com/jose-compu/dignity.js/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
18
|
+
<img src="https://img.shields.io/badge/tests-150%2B%20passing-brightgreen" alt="tests passing">
|
|
19
|
+
<img src="https://img.shields.io/badge/coverage-99%25-brightgreen" alt="coverage">
|
|
18
20
|
<img src="https://img.shields.io/badge/license-Apache%202.0-black" alt="license">
|
|
19
21
|
</p>
|
|
20
22
|
|
|
@@ -34,6 +36,9 @@ REST-like P2P object API for decentralized JavaScript applications.
|
|
|
34
36
|
- default `powSteps: 22` (calibrated on this machine to about 1000ms)
|
|
35
37
|
- automatic peer ban on invalid signature/PoW (`48h` default)
|
|
36
38
|
- Team/subapp scoped broadcast passwords (`broadcastScope` + `broadcastPasswords`)
|
|
39
|
+
- Optimistic concurrency helpers (`expectedVersion`, `updateWithRetry`, `conflict` events)
|
|
40
|
+
- Optional IndexedDB persistence for browser reload survival
|
|
41
|
+
- Optional React hooks via `dignity.js/react`
|
|
37
42
|
- Browser-first: published npm package includes IIFE, ESM, and CJS builds
|
|
38
43
|
|
|
39
44
|
## Install
|
|
@@ -145,6 +150,57 @@ bob.registerPeerPublicKey('alice', alice.getPublicKey());
|
|
|
145
150
|
await alice.sendDirectMessage('bob', 'dm', { text: 'private payload' });
|
|
146
151
|
```
|
|
147
152
|
|
|
153
|
+
## Optimistic Concurrency
|
|
154
|
+
|
|
155
|
+
Updates carry a monotonic `version`. Remote peers reject stale operations when `baseVersion` does not match.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
node.on('conflict', (event) => {
|
|
159
|
+
console.log('conflict', event.phase, event.expectedVersion, event.currentVersion);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await node.update('games', 'g1', { score: 10 }, { expectedVersion: 3 });
|
|
163
|
+
|
|
164
|
+
await node.updateWithRetry('games', 'g1', (current) => ({
|
|
165
|
+
score: current.data.score + 1
|
|
166
|
+
}));
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Use `expectedVersion` for fail-fast local writes. Use `updateWithRetry` for read-modify-write loops in fast multiplayer state.
|
|
170
|
+
|
|
171
|
+
## IndexedDB Persistence
|
|
172
|
+
|
|
173
|
+
Persist replicated collections across page reloads:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
const { DignityP2P, IndexedDBPersistence } = require('dignity.js');
|
|
177
|
+
|
|
178
|
+
const node = new DignityP2P({ nodeId, networkAdapter, security });
|
|
179
|
+
const persistence = new IndexedDBPersistence({
|
|
180
|
+
dbName: 'my-app',
|
|
181
|
+
collections: ['games', 'matches']
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await node.start();
|
|
185
|
+
await persistence.attach(node);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## React Hooks
|
|
189
|
+
|
|
190
|
+
Optional React integration (`react >= 18` peer dependency):
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
import { useDignity, useCollection, usePeers } from 'dignity.js/react';
|
|
194
|
+
|
|
195
|
+
function Room() {
|
|
196
|
+
const { node, status } = useDignity(config);
|
|
197
|
+
const games = useCollection(node, 'games');
|
|
198
|
+
const peers = usePeers(node, 'room:chess', { includeSelf: false });
|
|
199
|
+
|
|
200
|
+
return <pre>{JSON.stringify({ status, games, peers }, null, 2)}</pre>;
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
148
204
|
## Browser Usage
|
|
149
205
|
|
|
150
206
|
The published npm package includes pre-built bundles (IIFE, ESM, CJS) generated at publish time. The `dist/` folder is not checked into the repository.
|
|
@@ -195,7 +251,8 @@ npm run test:pow-calibrate
|
|
|
195
251
|
|
|
196
252
|
## Docs and Examples
|
|
197
253
|
|
|
198
|
-
-
|
|
254
|
+
- **Documentation:** [jose-compu.github.io/dignity.js](https://jose-compu.github.io/dignity.js/)
|
|
255
|
+
- Docs site source: `docs/index.html` (serve locally with `npm run docs:serve`)
|
|
199
256
|
- API metadata: `docs/openapi-like.json`
|
|
200
257
|
- Minimal demos:
|
|
201
258
|
- `examples/decentralized-tictactoe.js`
|
package/dist/dignity.cjs.js
CHANGED
|
@@ -2980,6 +2980,21 @@ var require_dignity_p2p = __commonJS({
|
|
|
2980
2980
|
if (existing.ownerId !== this.nodeId) {
|
|
2981
2981
|
throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
|
|
2982
2982
|
}
|
|
2983
|
+
if (typeof options.expectedVersion === "number" && existing.version !== options.expectedVersion) {
|
|
2984
|
+
this.emitConflict({
|
|
2985
|
+
kind: "update",
|
|
2986
|
+
collection: collectionName,
|
|
2987
|
+
id,
|
|
2988
|
+
expectedVersion: options.expectedVersion,
|
|
2989
|
+
currentVersion: existing.version,
|
|
2990
|
+
phase: "local"
|
|
2991
|
+
});
|
|
2992
|
+
const error = new Error(
|
|
2993
|
+
`Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
|
|
2994
|
+
);
|
|
2995
|
+
error.code = "VERSION_CONFLICT";
|
|
2996
|
+
throw error;
|
|
2997
|
+
}
|
|
2983
2998
|
const operation = {
|
|
2984
2999
|
opId: this.idGenerator(),
|
|
2985
3000
|
kind: "update",
|
|
@@ -3000,6 +3015,27 @@ var require_dignity_p2p = __commonJS({
|
|
|
3000
3015
|
});
|
|
3001
3016
|
return this.read(collectionName, id);
|
|
3002
3017
|
}
|
|
3018
|
+
async updateWithRetry(collectionName, id, patchFn, options = {}) {
|
|
3019
|
+
const maxAttempts = typeof options.maxAttempts === "number" ? options.maxAttempts : 5;
|
|
3020
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
3021
|
+
const current = this.read(collectionName, id);
|
|
3022
|
+
if (!current) {
|
|
3023
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
3024
|
+
}
|
|
3025
|
+
const patch = await patchFn(current);
|
|
3026
|
+
try {
|
|
3027
|
+
return await this.update(collectionName, id, patch, {
|
|
3028
|
+
...options,
|
|
3029
|
+
expectedVersion: current.version
|
|
3030
|
+
});
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
if (error.code !== "VERSION_CONFLICT" || attempt === maxAttempts - 1) {
|
|
3033
|
+
throw error;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
|
|
3038
|
+
}
|
|
3003
3039
|
async remove(collectionName, id, options = {}) {
|
|
3004
3040
|
const existing = this.getCollection(collectionName).get(id);
|
|
3005
3041
|
if (!existing || existing.deletedAt) {
|
|
@@ -3277,6 +3313,29 @@ var require_dignity_p2p = __commonJS({
|
|
|
3277
3313
|
isPeerBanned(peerId) {
|
|
3278
3314
|
return this.getBanInfo(peerId) !== null;
|
|
3279
3315
|
}
|
|
3316
|
+
emitConflict(details) {
|
|
3317
|
+
this.emit("conflict", details);
|
|
3318
|
+
}
|
|
3319
|
+
restoreRecord(collectionName, record) {
|
|
3320
|
+
if (!record || !record.id) {
|
|
3321
|
+
return false;
|
|
3322
|
+
}
|
|
3323
|
+
const collection = this.getCollection(collectionName);
|
|
3324
|
+
const current = collection.get(record.id);
|
|
3325
|
+
if (current && current.version >= record.version) {
|
|
3326
|
+
return false;
|
|
3327
|
+
}
|
|
3328
|
+
collection.set(record.id, {
|
|
3329
|
+
id: record.id,
|
|
3330
|
+
ownerId: record.ownerId,
|
|
3331
|
+
data: { ...record.data || {} },
|
|
3332
|
+
createdAt: record.createdAt,
|
|
3333
|
+
updatedAt: record.updatedAt,
|
|
3334
|
+
deletedAt: record.deletedAt || null,
|
|
3335
|
+
version: record.version
|
|
3336
|
+
});
|
|
3337
|
+
return true;
|
|
3338
|
+
}
|
|
3280
3339
|
applyOperation(operation) {
|
|
3281
3340
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
3282
3341
|
return false;
|
|
@@ -3307,6 +3366,15 @@ var require_dignity_p2p = __commonJS({
|
|
|
3307
3366
|
return false;
|
|
3308
3367
|
}
|
|
3309
3368
|
if (typeof operation.baseVersion === "number" && operation.baseVersion !== current.version) {
|
|
3369
|
+
this.emitConflict({
|
|
3370
|
+
kind: operation.kind,
|
|
3371
|
+
collection: operation.collectionName,
|
|
3372
|
+
id: operation.id,
|
|
3373
|
+
expectedVersion: operation.baseVersion,
|
|
3374
|
+
currentVersion: current.version,
|
|
3375
|
+
phase: "remote",
|
|
3376
|
+
operation
|
|
3377
|
+
});
|
|
3310
3378
|
return false;
|
|
3311
3379
|
}
|
|
3312
3380
|
if (operation.kind === "update") {
|
|
@@ -10384,6 +10452,163 @@ var require_in_memory_network = __commonJS({
|
|
|
10384
10452
|
}
|
|
10385
10453
|
});
|
|
10386
10454
|
|
|
10455
|
+
// src/persistence/indexeddb-persistence.js
|
|
10456
|
+
var require_indexeddb_persistence = __commonJS({
|
|
10457
|
+
"src/persistence/indexeddb-persistence.js"(exports2, module2) {
|
|
10458
|
+
var IndexedDBPersistence2 = class {
|
|
10459
|
+
constructor({
|
|
10460
|
+
dbName = "dignity",
|
|
10461
|
+
storeName = "records",
|
|
10462
|
+
collections = null,
|
|
10463
|
+
indexedDB = typeof globalThis !== "undefined" ? globalThis.indexedDB : null
|
|
10464
|
+
} = {}) {
|
|
10465
|
+
this.dbName = dbName;
|
|
10466
|
+
this.storeName = storeName;
|
|
10467
|
+
this.collections = collections;
|
|
10468
|
+
this.indexedDB = indexedDB;
|
|
10469
|
+
this.node = null;
|
|
10470
|
+
this.changeHandler = null;
|
|
10471
|
+
}
|
|
10472
|
+
recordKey(collection, id) {
|
|
10473
|
+
return `${collection}:${id}`;
|
|
10474
|
+
}
|
|
10475
|
+
shouldPersist(collection) {
|
|
10476
|
+
if (!this.collections) {
|
|
10477
|
+
return true;
|
|
10478
|
+
}
|
|
10479
|
+
return this.collections.includes(collection);
|
|
10480
|
+
}
|
|
10481
|
+
openDb() {
|
|
10482
|
+
if (!this.indexedDB) {
|
|
10483
|
+
return Promise.reject(new Error("IndexedDB is not available"));
|
|
10484
|
+
}
|
|
10485
|
+
return new Promise((resolve, reject) => {
|
|
10486
|
+
const request = this.indexedDB.open(this.dbName, 1);
|
|
10487
|
+
request.onupgradeneeded = () => {
|
|
10488
|
+
const db = request.result;
|
|
10489
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
10490
|
+
db.createObjectStore(this.storeName, { keyPath: "key" });
|
|
10491
|
+
}
|
|
10492
|
+
};
|
|
10493
|
+
request.onsuccess = () => resolve(request.result);
|
|
10494
|
+
request.onerror = () => reject(request.error || new Error("Unable to open IndexedDB"));
|
|
10495
|
+
});
|
|
10496
|
+
}
|
|
10497
|
+
runTransaction(mode, handler) {
|
|
10498
|
+
return this.openDb().then((db) => new Promise((resolve, reject) => {
|
|
10499
|
+
const transaction = db.transaction(this.storeName, mode);
|
|
10500
|
+
const store = transaction.objectStore(this.storeName);
|
|
10501
|
+
Promise.resolve(handler(store)).then(resolve).catch(reject);
|
|
10502
|
+
transaction.oncomplete = () => db.close();
|
|
10503
|
+
transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
|
|
10504
|
+
transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
|
|
10505
|
+
}));
|
|
10506
|
+
}
|
|
10507
|
+
serializeRecord(collection, id) {
|
|
10508
|
+
const record = this.node.getCollection(collection).get(id);
|
|
10509
|
+
if (!record) {
|
|
10510
|
+
return null;
|
|
10511
|
+
}
|
|
10512
|
+
return {
|
|
10513
|
+
key: this.recordKey(collection, id),
|
|
10514
|
+
collection,
|
|
10515
|
+
id,
|
|
10516
|
+
ownerId: record.ownerId,
|
|
10517
|
+
data: { ...record.data },
|
|
10518
|
+
createdAt: record.createdAt,
|
|
10519
|
+
updatedAt: record.updatedAt,
|
|
10520
|
+
deletedAt: record.deletedAt,
|
|
10521
|
+
version: record.version
|
|
10522
|
+
};
|
|
10523
|
+
}
|
|
10524
|
+
async persistRecord(collection, id) {
|
|
10525
|
+
if (!this.node || !this.shouldPersist(collection)) {
|
|
10526
|
+
return;
|
|
10527
|
+
}
|
|
10528
|
+
const serialized = this.serializeRecord(collection, id);
|
|
10529
|
+
const key = this.recordKey(collection, id);
|
|
10530
|
+
if (!serialized) {
|
|
10531
|
+
await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
|
|
10532
|
+
const request = store.delete(key);
|
|
10533
|
+
request.onsuccess = () => resolve();
|
|
10534
|
+
request.onerror = () => reject(request.error);
|
|
10535
|
+
}));
|
|
10536
|
+
return;
|
|
10537
|
+
}
|
|
10538
|
+
await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
|
|
10539
|
+
const request = store.put(serialized);
|
|
10540
|
+
request.onsuccess = () => resolve();
|
|
10541
|
+
request.onerror = () => reject(request.error);
|
|
10542
|
+
}));
|
|
10543
|
+
}
|
|
10544
|
+
persistChange(event) {
|
|
10545
|
+
if (!event || !event.collection || !event.id) {
|
|
10546
|
+
return;
|
|
10547
|
+
}
|
|
10548
|
+
this.persistRecord(event.collection, event.id).catch((error) => {
|
|
10549
|
+
this.node.emit("warning", {
|
|
10550
|
+
type: "persistence-failed",
|
|
10551
|
+
collection: event.collection,
|
|
10552
|
+
id: event.id,
|
|
10553
|
+
error
|
|
10554
|
+
});
|
|
10555
|
+
});
|
|
10556
|
+
}
|
|
10557
|
+
async loadAllRecords() {
|
|
10558
|
+
return this.runTransaction("readonly", (store) => new Promise((resolve, reject) => {
|
|
10559
|
+
const request = store.getAll();
|
|
10560
|
+
request.onsuccess = () => resolve(request.result || []);
|
|
10561
|
+
request.onerror = () => reject(request.error);
|
|
10562
|
+
}));
|
|
10563
|
+
}
|
|
10564
|
+
async hydrate() {
|
|
10565
|
+
if (!this.node) {
|
|
10566
|
+
throw new Error("IndexedDBPersistence requires an attached node before hydrate");
|
|
10567
|
+
}
|
|
10568
|
+
const storedRecords = await this.loadAllRecords();
|
|
10569
|
+
for (const stored of storedRecords) {
|
|
10570
|
+
if (!this.shouldPersist(stored.collection)) {
|
|
10571
|
+
continue;
|
|
10572
|
+
}
|
|
10573
|
+
this.node.restoreRecord(stored.collection, {
|
|
10574
|
+
id: stored.id,
|
|
10575
|
+
ownerId: stored.ownerId,
|
|
10576
|
+
data: stored.data,
|
|
10577
|
+
createdAt: stored.createdAt,
|
|
10578
|
+
updatedAt: stored.updatedAt,
|
|
10579
|
+
deletedAt: stored.deletedAt,
|
|
10580
|
+
version: stored.version
|
|
10581
|
+
});
|
|
10582
|
+
}
|
|
10583
|
+
}
|
|
10584
|
+
async attach(node) {
|
|
10585
|
+
if (!node) {
|
|
10586
|
+
throw new Error("IndexedDBPersistence.attach requires a DignityP2P node");
|
|
10587
|
+
}
|
|
10588
|
+
this.node = node;
|
|
10589
|
+
await this.hydrate();
|
|
10590
|
+
this.changeHandler = (event) => this.persistChange(event);
|
|
10591
|
+
node.on("change", this.changeHandler);
|
|
10592
|
+
}
|
|
10593
|
+
async detach() {
|
|
10594
|
+
if (this.node && this.changeHandler) {
|
|
10595
|
+
this.node.off("change", this.changeHandler);
|
|
10596
|
+
}
|
|
10597
|
+
this.changeHandler = null;
|
|
10598
|
+
this.node = null;
|
|
10599
|
+
}
|
|
10600
|
+
async clear() {
|
|
10601
|
+
await this.runTransaction("readwrite", (store) => new Promise((resolve, reject) => {
|
|
10602
|
+
const request = store.clear();
|
|
10603
|
+
request.onsuccess = () => resolve();
|
|
10604
|
+
request.onerror = () => reject(request.error);
|
|
10605
|
+
}));
|
|
10606
|
+
}
|
|
10607
|
+
};
|
|
10608
|
+
module2.exports = IndexedDBPersistence2;
|
|
10609
|
+
}
|
|
10610
|
+
});
|
|
10611
|
+
|
|
10387
10612
|
// src/index.js
|
|
10388
10613
|
var DignityP2P = require_dignity_p2p();
|
|
10389
10614
|
var createDefaultSignalingPool = require_create_default_signaling_pool();
|
|
@@ -10394,6 +10619,7 @@ var {
|
|
|
10394
10619
|
InMemoryNetworkHub,
|
|
10395
10620
|
InMemoryNetworkAdapter
|
|
10396
10621
|
} = require_in_memory_network();
|
|
10622
|
+
var IndexedDBPersistence = require_indexeddb_persistence();
|
|
10397
10623
|
var {
|
|
10398
10624
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
10399
10625
|
DEFAULT_SIGNALING_FALLBACK_URLS
|
|
@@ -10412,6 +10638,7 @@ module.exports = {
|
|
|
10412
10638
|
PeerJSSignalingProvider,
|
|
10413
10639
|
InMemoryNetworkHub,
|
|
10414
10640
|
InMemoryNetworkAdapter,
|
|
10641
|
+
IndexedDBPersistence,
|
|
10415
10642
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
10416
10643
|
DEFAULT_SIGNALING_FALLBACK_URLS,
|
|
10417
10644
|
VDF,
|