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 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://img.shields.io/badge/tests-122%20passing-brightgreen" alt="tests passing">
17
- <img src="https://img.shields.io/badge/coverage-97%25-brightgreen" alt="coverage">
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
- - Docs site source: `docs/index.html`
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`
@@ -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,