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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dignity.js",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "REST-like object API over peer-to-peer replication",
5
5
  "resources": {
6
6
  "collections/{collection}/{id}": {
@@ -12,8 +12,16 @@
12
12
  "method": "read(collection, id)"
13
13
  },
14
14
  "update": {
15
- "method": "update(collection, id, patch)",
16
- "authorization": "owner-only"
15
+ "method": "update(collection, id, patch, options)",
16
+ "authorization": "owner-only",
17
+ "options": {
18
+ "expectedVersion": "optional number; throws VERSION_CONFLICT when mismatched",
19
+ "broadcastScope": "optional scoped broadcast password namespace"
20
+ }
21
+ },
22
+ "updateWithRetry": {
23
+ "method": "updateWithRetry(collection, id, patchFn, options)",
24
+ "description": "read-modify-write helper with automatic retry on version conflicts"
17
25
  },
18
26
  "delete": {
19
27
  "method": "remove(collection, id)",
@@ -26,6 +34,31 @@
26
34
  }
27
35
  }
28
36
  },
37
+ "events": {
38
+ "change": "object create/update/delete applied",
39
+ "conflict": "local or remote version mismatch",
40
+ "peerdiscovered": "peer joined discovery scope",
41
+ "peerleft": "peer left or timed out",
42
+ "message": "custom decrypted message received"
43
+ },
44
+ "persistence": {
45
+ "IndexedDBPersistence": {
46
+ "method": "attach(node)",
47
+ "options": [
48
+ "dbName",
49
+ "storeName",
50
+ "collections"
51
+ ]
52
+ }
53
+ },
54
+ "react": {
55
+ "entrypoint": "dignity.js/react",
56
+ "hooks": [
57
+ "useDignity",
58
+ "useCollection",
59
+ "usePeers"
60
+ ]
61
+ },
29
62
  "signaling": {
30
63
  "defaults": {
31
64
  "cloudflare": "enabled by default",
@@ -45,13 +78,18 @@
45
78
  "signingEnabled": true,
46
79
  "encryptionEnabled": true,
47
80
  "powEnabled": true,
48
- "powTargetMs": 1000
81
+ "powSteps": 22,
82
+ "powTargetMs": 1000,
83
+ "kdfIterations": 100000,
84
+ "banDurationMs": 172800000
49
85
  },
50
86
  "broadcast": {
51
- "encryption": "aes-256-gcm with appPassword-derived key"
87
+ "encryption": "AES secretbox with PBKDF2-SHA256 derived key",
88
+ "scopePasswords": "broadcastPasswords map keyed by broadcastScope",
89
+ "legacyKdf": "single-hash fallback accepted for older peers"
52
90
  },
53
91
  "direct": {
54
- "encryption": "aes-256-gcm payload + rsa-oaep wrapped session key"
92
+ "encryption": "NaCl box (X25519 + XSalsa20-Poly1305) to recipient public key"
55
93
  },
56
94
  "pow": {
57
95
  "algorithm": "Sloth VDF",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dignity.js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "P2P object API for decentralized JavaScript applications",
5
5
  "main": "dist/dignity.cjs.js",
6
6
  "module": "dist/dignity.esm.js",
@@ -12,6 +12,11 @@
12
12
  "require": "./dist/dignity.cjs.js",
13
13
  "import": "./dist/dignity.esm.js",
14
14
  "default": "./dist/dignity.esm.js"
15
+ },
16
+ "./react": {
17
+ "require": "./src/react/index.js",
18
+ "import": "./src/react/index.js",
19
+ "default": "./src/react/index.js"
15
20
  }
16
21
  },
17
22
  "files": [
@@ -27,7 +32,7 @@
27
32
  "test:pow-calibrate": "jest tests/unit/sloth-vdf-timing.test.js --runInBand",
28
33
  "build": "node scripts/build.js",
29
34
  "docs:serve": "npx http-server docs -p 4173 -o",
30
- "docs:check": "node -e \"require('fs').accessSync('docs/index.html')\"",
35
+ "docs:check": "node -e \"const fs=require('fs');['docs/index.html','docs/assets/highlight/highlight.min.js','docs/assets/highlight/github.min.css','docs/assets/highlight/github-dark.min.css'].forEach(p=>fs.accessSync(p));\"",
31
36
  "example:tictactoe": "node examples/decentralized-tictactoe.js",
32
37
  "example:chess": "node examples/decentralized-chess-lite.js",
33
38
  "prepublishOnly": "npm test && npm run build"
@@ -40,9 +45,22 @@
40
45
  "objects"
41
46
  ],
42
47
  "license": "Apache 2.0",
48
+ "peerDependencies": {
49
+ "react": ">=18"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "react": {
53
+ "optional": true
54
+ }
55
+ },
43
56
  "devDependencies": {
57
+ "@testing-library/react": "^16.3.0",
44
58
  "esbuild": "^0.28.0",
45
- "jest": "^29.7.0"
59
+ "fake-indexeddb": "^6.0.0",
60
+ "jest": "^29.7.0",
61
+ "jest-environment-jsdom": "^29.7.0",
62
+ "react": "^19.1.0",
63
+ "react-dom": "^19.1.0"
46
64
  },
47
65
  "dependencies": {
48
66
  "peerjs": "^1.5.5",
@@ -178,6 +178,23 @@ class DignityP2P extends EventEmitter {
178
178
  throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
179
179
  }
180
180
 
181
+ if (typeof options.expectedVersion === 'number' && existing.version !== options.expectedVersion) {
182
+ this.emitConflict({
183
+ kind: 'update',
184
+ collection: collectionName,
185
+ id,
186
+ expectedVersion: options.expectedVersion,
187
+ currentVersion: existing.version,
188
+ phase: 'local'
189
+ });
190
+
191
+ const error = new Error(
192
+ `Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
193
+ );
194
+ error.code = 'VERSION_CONFLICT';
195
+ throw error;
196
+ }
197
+
181
198
  const operation = {
182
199
  opId: this.idGenerator(),
183
200
  kind: 'update',
@@ -201,6 +218,31 @@ class DignityP2P extends EventEmitter {
201
218
  return this.read(collectionName, id);
202
219
  }
203
220
 
221
+ async updateWithRetry(collectionName, id, patchFn, options = {}) {
222
+ const maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 5;
223
+
224
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
225
+ const current = this.read(collectionName, id);
226
+ if (!current) {
227
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
228
+ }
229
+
230
+ const patch = await patchFn(current);
231
+ try {
232
+ return await this.update(collectionName, id, patch, {
233
+ ...options,
234
+ expectedVersion: current.version
235
+ });
236
+ } catch (error) {
237
+ if (error.code !== 'VERSION_CONFLICT' || attempt === maxAttempts - 1) {
238
+ throw error;
239
+ }
240
+ }
241
+ }
242
+
243
+ throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
244
+ }
245
+
204
246
  async remove(collectionName, id, options = {}) {
205
247
  const existing = this.getCollection(collectionName).get(id);
206
248
 
@@ -534,6 +576,34 @@ class DignityP2P extends EventEmitter {
534
576
  return this.getBanInfo(peerId) !== null;
535
577
  }
536
578
 
579
+ emitConflict(details) {
580
+ this.emit('conflict', details);
581
+ }
582
+
583
+ restoreRecord(collectionName, record) {
584
+ if (!record || !record.id) {
585
+ return false;
586
+ }
587
+
588
+ const collection = this.getCollection(collectionName);
589
+ const current = collection.get(record.id);
590
+ if (current && current.version >= record.version) {
591
+ return false;
592
+ }
593
+
594
+ collection.set(record.id, {
595
+ id: record.id,
596
+ ownerId: record.ownerId,
597
+ data: { ...(record.data || {}) },
598
+ createdAt: record.createdAt,
599
+ updatedAt: record.updatedAt,
600
+ deletedAt: record.deletedAt || null,
601
+ version: record.version
602
+ });
603
+
604
+ return true;
605
+ }
606
+
537
607
  applyOperation(operation) {
538
608
  if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
539
609
  return false;
@@ -571,6 +641,15 @@ class DignityP2P extends EventEmitter {
571
641
  }
572
642
 
573
643
  if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
644
+ this.emitConflict({
645
+ kind: operation.kind,
646
+ collection: operation.collectionName,
647
+ id: operation.id,
648
+ expectedVersion: operation.baseVersion,
649
+ currentVersion: current.version,
650
+ phase: 'remote',
651
+ operation
652
+ });
574
653
  return false;
575
654
  }
576
655
 
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  InMemoryNetworkHub,
16
16
  InMemoryNetworkAdapter
17
17
  } = require('./network/in-memory-network');
18
+ const IndexedDBPersistence = require('./persistence/indexeddb-persistence');
18
19
  const {
19
20
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
20
21
  DEFAULT_SIGNALING_FALLBACK_URLS
@@ -34,6 +35,7 @@ module.exports = {
34
35
  PeerJSSignalingProvider,
35
36
  InMemoryNetworkHub,
36
37
  InMemoryNetworkAdapter,
38
+ IndexedDBPersistence,
37
39
  DEFAULT_CLOUDFLARE_SIGNALING_URLS,
38
40
  DEFAULT_SIGNALING_FALLBACK_URLS,
39
41
  VDF,
@@ -0,0 +1,182 @@
1
+ class IndexedDBPersistence {
2
+ constructor({
3
+ dbName = 'dignity',
4
+ storeName = 'records',
5
+ collections = null,
6
+ indexedDB = typeof globalThis !== 'undefined' ? globalThis.indexedDB : null
7
+ } = {}) {
8
+ this.dbName = dbName;
9
+ this.storeName = storeName;
10
+ this.collections = collections;
11
+ this.indexedDB = indexedDB;
12
+ this.node = null;
13
+ this.changeHandler = null;
14
+ }
15
+
16
+ recordKey(collection, id) {
17
+ return `${collection}:${id}`;
18
+ }
19
+
20
+ shouldPersist(collection) {
21
+ if (!this.collections) {
22
+ return true;
23
+ }
24
+
25
+ return this.collections.includes(collection);
26
+ }
27
+
28
+ openDb() {
29
+ if (!this.indexedDB) {
30
+ return Promise.reject(new Error('IndexedDB is not available'));
31
+ }
32
+
33
+ return new Promise((resolve, reject) => {
34
+ const request = this.indexedDB.open(this.dbName, 1);
35
+
36
+ request.onupgradeneeded = () => {
37
+ const db = request.result;
38
+ if (!db.objectStoreNames.contains(this.storeName)) {
39
+ db.createObjectStore(this.storeName, { keyPath: 'key' });
40
+ }
41
+ };
42
+
43
+ request.onsuccess = () => resolve(request.result);
44
+ request.onerror = () => reject(request.error || new Error('Unable to open IndexedDB'));
45
+ });
46
+ }
47
+
48
+ runTransaction(mode, handler) {
49
+ return this.openDb().then((db) => new Promise((resolve, reject) => {
50
+ const transaction = db.transaction(this.storeName, mode);
51
+ const store = transaction.objectStore(this.storeName);
52
+
53
+ Promise.resolve(handler(store))
54
+ .then(resolve)
55
+ .catch(reject);
56
+
57
+ transaction.oncomplete = () => db.close();
58
+ transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed'));
59
+ transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted'));
60
+ }));
61
+ }
62
+
63
+ serializeRecord(collection, id) {
64
+ const record = this.node.getCollection(collection).get(id);
65
+ if (!record) {
66
+ return null;
67
+ }
68
+
69
+ return {
70
+ key: this.recordKey(collection, id),
71
+ collection,
72
+ id,
73
+ ownerId: record.ownerId,
74
+ data: { ...record.data },
75
+ createdAt: record.createdAt,
76
+ updatedAt: record.updatedAt,
77
+ deletedAt: record.deletedAt,
78
+ version: record.version
79
+ };
80
+ }
81
+
82
+ async persistRecord(collection, id) {
83
+ if (!this.node || !this.shouldPersist(collection)) {
84
+ return;
85
+ }
86
+
87
+ const serialized = this.serializeRecord(collection, id);
88
+ const key = this.recordKey(collection, id);
89
+
90
+ if (!serialized) {
91
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
92
+ const request = store.delete(key);
93
+ request.onsuccess = () => resolve();
94
+ request.onerror = () => reject(request.error);
95
+ }));
96
+ return;
97
+ }
98
+
99
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
100
+ const request = store.put(serialized);
101
+ request.onsuccess = () => resolve();
102
+ request.onerror = () => reject(request.error);
103
+ }));
104
+ }
105
+
106
+ persistChange(event) {
107
+ if (!event || !event.collection || !event.id) {
108
+ return;
109
+ }
110
+
111
+ this.persistRecord(event.collection, event.id).catch((error) => {
112
+ this.node.emit('warning', {
113
+ type: 'persistence-failed',
114
+ collection: event.collection,
115
+ id: event.id,
116
+ error
117
+ });
118
+ });
119
+ }
120
+
121
+ async loadAllRecords() {
122
+ return this.runTransaction('readonly', (store) => new Promise((resolve, reject) => {
123
+ const request = store.getAll();
124
+ request.onsuccess = () => resolve(request.result || []);
125
+ request.onerror = () => reject(request.error);
126
+ }));
127
+ }
128
+
129
+ async hydrate() {
130
+ if (!this.node) {
131
+ throw new Error('IndexedDBPersistence requires an attached node before hydrate');
132
+ }
133
+
134
+ const storedRecords = await this.loadAllRecords();
135
+ for (const stored of storedRecords) {
136
+ if (!this.shouldPersist(stored.collection)) {
137
+ continue;
138
+ }
139
+
140
+ this.node.restoreRecord(stored.collection, {
141
+ id: stored.id,
142
+ ownerId: stored.ownerId,
143
+ data: stored.data,
144
+ createdAt: stored.createdAt,
145
+ updatedAt: stored.updatedAt,
146
+ deletedAt: stored.deletedAt,
147
+ version: stored.version
148
+ });
149
+ }
150
+ }
151
+
152
+ async attach(node) {
153
+ if (!node) {
154
+ throw new Error('IndexedDBPersistence.attach requires a DignityP2P node');
155
+ }
156
+
157
+ this.node = node;
158
+ await this.hydrate();
159
+
160
+ this.changeHandler = (event) => this.persistChange(event);
161
+ node.on('change', this.changeHandler);
162
+ }
163
+
164
+ async detach() {
165
+ if (this.node && this.changeHandler) {
166
+ this.node.off('change', this.changeHandler);
167
+ }
168
+
169
+ this.changeHandler = null;
170
+ this.node = null;
171
+ }
172
+
173
+ async clear() {
174
+ await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
175
+ const request = store.clear();
176
+ request.onsuccess = () => resolve();
177
+ request.onerror = () => reject(request.error);
178
+ }));
179
+ }
180
+ }
181
+
182
+ module.exports = IndexedDBPersistence;
@@ -0,0 +1,114 @@
1
+ const { useCallback, useEffect, useState } = require('react');
2
+ const DignityP2P = require('../core/dignity-p2p');
3
+
4
+ function useDignity(config) {
5
+ const [node, setNode] = useState(null);
6
+ const [status, setStatus] = useState('idle');
7
+ const [error, setError] = useState(null);
8
+
9
+ useEffect(() => {
10
+ if (!config) {
11
+ return undefined;
12
+ }
13
+
14
+ let cancelled = false;
15
+ const instance = new DignityP2P(config);
16
+ setNode(instance);
17
+ setStatus('starting');
18
+ setError(null);
19
+
20
+ instance.start()
21
+ .then(() => {
22
+ if (!cancelled) {
23
+ setStatus('running');
24
+ }
25
+ })
26
+ .catch((startError) => {
27
+ if (!cancelled) {
28
+ setError(startError);
29
+ setStatus('error');
30
+ }
31
+ });
32
+
33
+ return () => {
34
+ cancelled = true;
35
+ instance.stop()
36
+ .catch(() => undefined)
37
+ .finally(() => {
38
+ setStatus('stopped');
39
+ setNode(null);
40
+ });
41
+ };
42
+ }, [config]);
43
+
44
+ return {
45
+ node,
46
+ status,
47
+ error
48
+ };
49
+ }
50
+
51
+ function useCollection(node, collectionName) {
52
+ const [records, setRecords] = useState([]);
53
+
54
+ const refresh = useCallback(() => {
55
+ if (!node || !collectionName) {
56
+ setRecords([]);
57
+ return;
58
+ }
59
+
60
+ setRecords(node.list(collectionName));
61
+ }, [node, collectionName]);
62
+
63
+ useEffect(() => {
64
+ refresh();
65
+
66
+ if (!node) {
67
+ return undefined;
68
+ }
69
+
70
+ node.on('change', refresh);
71
+ return () => node.off('change', refresh);
72
+ }, [node, refresh]);
73
+
74
+ return records;
75
+ }
76
+
77
+ function usePeers(node, scope = 'main', options = {}) {
78
+ const includeSelf = options.includeSelf !== false;
79
+ const [peers, setPeers] = useState([]);
80
+
81
+ const refresh = useCallback(() => {
82
+ if (!node) {
83
+ setPeers([]);
84
+ return;
85
+ }
86
+
87
+ setPeers(node.listPeers(scope, { includeSelf }));
88
+ }, [node, scope, includeSelf]);
89
+
90
+ useEffect(() => {
91
+ refresh();
92
+
93
+ if (!node) {
94
+ return undefined;
95
+ }
96
+
97
+ const handlePresenceChange = () => refresh();
98
+ node.on('peerdiscovered', handlePresenceChange);
99
+ node.on('peerleft', handlePresenceChange);
100
+
101
+ return () => {
102
+ node.off('peerdiscovered', handlePresenceChange);
103
+ node.off('peerleft', handlePresenceChange);
104
+ };
105
+ }, [node, refresh]);
106
+
107
+ return peers;
108
+ }
109
+
110
+ module.exports = {
111
+ useDignity,
112
+ useCollection,
113
+ usePeers
114
+ };