dignity.js 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -4
- package/dist/dignity.cjs.js +768 -20
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +768 -20
- package/dist/dignity.esm.js.map +3 -3
- package/dist/dignity.min.js +18 -18
- package/docs/assets/dignity.esm.js +11205 -0
- package/docs/assets/docs.js +47 -0
- package/docs/assets/favicon.svg +8 -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/chess/assets/chess-app.js +58022 -0
- package/docs/chess/assets/chess-app.js.map +7 -0
- package/docs/chess/assets/chess.css +584 -0
- package/docs/chess/favicon.ico +0 -0
- package/docs/chess/index.html +16 -0
- package/docs/chess/src/App.jsx +128 -0
- package/docs/chess/src/components/Board3D.jsx +364 -0
- package/docs/chess/src/components/GameView.jsx +847 -0
- package/docs/chess/src/components/JoinGate.jsx +68 -0
- package/docs/chess/src/components/LinkPanel.jsx +132 -0
- package/docs/chess/src/components/Lobby.jsx +154 -0
- package/docs/chess/src/components/MovePanel.jsx +123 -0
- package/docs/chess/src/lib/audio.js +50 -0
- package/docs/chess/src/lib/dignitySetup.js +42 -0
- package/docs/chess/src/lib/links.js +124 -0
- package/docs/chess/src/lib/localGames.js +160 -0
- package/docs/chess/src/lib/p2pDebug.js +192 -0
- package/docs/chess/src/main.jsx +5 -0
- package/docs/favicon.ico +0 -0
- package/docs/index.html +605 -81
- package/docs/openapi-like.json +74 -7
- package/examples/decentralized-chess-lite.js +52 -30
- package/package.json +30 -4
- package/src/core/dignity-p2p.js +466 -15
- package/src/index.js +8 -0
- package/src/network/peerjs-network.js +234 -0
- package/src/persistence/indexeddb-persistence.js +184 -0
- package/src/react/index.js +256 -0
- package/src/signaling/parse-peerjs-url.js +24 -0
- package/src/signaling/peerjs-signaling-provider.js +2 -8
package/src/core/dignity-p2p.js
CHANGED
|
@@ -11,9 +11,16 @@ const { MessageSecurityService } = require('../security/message-security-service
|
|
|
11
11
|
* - update(collection, id, patch)
|
|
12
12
|
* - remove(collection, id)
|
|
13
13
|
*
|
|
14
|
+
* PeerJS / mesh replication helpers (see README "PeerJS mesh bootstrap"):
|
|
15
|
+
* - connectToPeer(peerId), getConnectionStats(), ensureConnectedToPeers(peerIds)
|
|
16
|
+
* - joinDiscovery(scope, { bootstrapPeerIds })
|
|
17
|
+
* - broadcastMessage(type, payload, { connectToPeers, broadcastScope })
|
|
18
|
+
* - pushRecordSnapshot(collection, id, options) — full record sync for late joiners
|
|
19
|
+
* - getRecordPeerIds(collection, id) — owner + collaborators for connectToPeers
|
|
20
|
+
*
|
|
14
21
|
* Authorization model:
|
|
15
22
|
* - object creator is the owner
|
|
16
|
-
* - only owner can update or delete
|
|
23
|
+
* - only owner can update or delete (collaborators may update when listed)
|
|
17
24
|
*/
|
|
18
25
|
class DignityP2P extends EventEmitter {
|
|
19
26
|
constructor({ nodeId, networkAdapter, idGenerator, now, security } = {}) {
|
|
@@ -97,6 +104,7 @@ class DignityP2P extends EventEmitter {
|
|
|
97
104
|
return {
|
|
98
105
|
id: record.id,
|
|
99
106
|
ownerId: record.ownerId,
|
|
107
|
+
collaboratorIds: Array.isArray(record.collaboratorIds) ? [...record.collaboratorIds] : [],
|
|
100
108
|
createdAt: record.createdAt,
|
|
101
109
|
updatedAt: record.updatedAt,
|
|
102
110
|
version: record.version,
|
|
@@ -104,6 +112,79 @@ class DignityP2P extends EventEmitter {
|
|
|
104
112
|
};
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
canUpdateRecord(record, actorId) {
|
|
116
|
+
if (!record || !actorId) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (record.ownerId === actorId) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Array.isArray(record.collaboratorIds) && record.collaboratorIds.includes(actorId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
normalizeCollaboratorIds(collaborators) {
|
|
128
|
+
if (!Array.isArray(collaborators)) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return [...new Set(collaborators.filter(Boolean))];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getRecordPeerIds(collectionName, id, options = {}) {
|
|
136
|
+
const record = options.fromRecord || this.getCollection(collectionName).get(id);
|
|
137
|
+
if (!record) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const includeSelf = options.includeSelf === true;
|
|
142
|
+
const peerIds = [record.ownerId, ...(record.collaboratorIds || [])];
|
|
143
|
+
|
|
144
|
+
return [...new Set(peerIds.filter(Boolean).filter((peerId) => includeSelf || peerId !== this.nodeId))];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resolveReplicationPeers(collectionName, id, options = {}, hints = {}) {
|
|
148
|
+
if (options.connectToPeers === false) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (Array.isArray(options.connectToPeers)) {
|
|
153
|
+
return options.connectToPeers;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const peerIds = new Set();
|
|
157
|
+
|
|
158
|
+
if (hints.fromRecord) {
|
|
159
|
+
for (const peerId of this.getRecordPeerIds(collectionName, id, {
|
|
160
|
+
fromRecord: hints.fromRecord,
|
|
161
|
+
includeSelf: true
|
|
162
|
+
})) {
|
|
163
|
+
peerIds.add(peerId);
|
|
164
|
+
}
|
|
165
|
+
} else if (id) {
|
|
166
|
+
for (const peerId of this.getRecordPeerIds(collectionName, id, { includeSelf: true })) {
|
|
167
|
+
peerIds.add(peerId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (Array.isArray(options.collaborators)) {
|
|
172
|
+
for (const peerId of this.normalizeCollaboratorIds(options.collaborators)) {
|
|
173
|
+
peerIds.add(peerId);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(hints.extraPeerIds)) {
|
|
178
|
+
for (const peerId of hints.extraPeerIds) {
|
|
179
|
+
if (peerId) {
|
|
180
|
+
peerIds.add(peerId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return [...peerIds].filter((peerId) => peerId && peerId !== this.nodeId);
|
|
186
|
+
}
|
|
187
|
+
|
|
107
188
|
async create(collectionName, data, options = {}) {
|
|
108
189
|
const collection = this.getCollection(collectionName);
|
|
109
190
|
const id = options.id || this.idGenerator();
|
|
@@ -113,6 +194,7 @@ class DignityP2P extends EventEmitter {
|
|
|
113
194
|
}
|
|
114
195
|
|
|
115
196
|
const timestamp = this.now();
|
|
197
|
+
const collaboratorIds = this.normalizeCollaboratorIds(options.collaborators);
|
|
116
198
|
const operation = {
|
|
117
199
|
opId: this.idGenerator(),
|
|
118
200
|
kind: 'create',
|
|
@@ -120,6 +202,7 @@ class DignityP2P extends EventEmitter {
|
|
|
120
202
|
id,
|
|
121
203
|
actorId: this.nodeId,
|
|
122
204
|
ownerId: this.nodeId,
|
|
205
|
+
collaboratorIds,
|
|
123
206
|
timestamp,
|
|
124
207
|
payload: { ...data }
|
|
125
208
|
};
|
|
@@ -130,6 +213,9 @@ class DignityP2P extends EventEmitter {
|
|
|
130
213
|
messageType: 'operation',
|
|
131
214
|
operation,
|
|
132
215
|
collectionName
|
|
216
|
+
}),
|
|
217
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, null, options, {
|
|
218
|
+
extraPeerIds: options.collaborators
|
|
133
219
|
})
|
|
134
220
|
});
|
|
135
221
|
|
|
@@ -174,8 +260,29 @@ class DignityP2P extends EventEmitter {
|
|
|
174
260
|
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
175
261
|
}
|
|
176
262
|
|
|
177
|
-
if (existing
|
|
178
|
-
throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
|
|
263
|
+
if (!this.canUpdateRecord(existing, this.nodeId)) {
|
|
264
|
+
throw new Error(`Only owner ${existing.ownerId} or collaborators can update object ${id}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (options.collaborators !== undefined && existing.ownerId !== this.nodeId) {
|
|
268
|
+
throw new Error(`Only owner ${existing.ownerId} can change collaborators on object ${id}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof options.expectedVersion === 'number' && existing.version !== options.expectedVersion) {
|
|
272
|
+
this.emitConflict({
|
|
273
|
+
kind: 'update',
|
|
274
|
+
collection: collectionName,
|
|
275
|
+
id,
|
|
276
|
+
expectedVersion: options.expectedVersion,
|
|
277
|
+
currentVersion: existing.version,
|
|
278
|
+
phase: 'local'
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const error = new Error(
|
|
282
|
+
`Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
|
|
283
|
+
);
|
|
284
|
+
error.code = 'VERSION_CONFLICT';
|
|
285
|
+
throw error;
|
|
179
286
|
}
|
|
180
287
|
|
|
181
288
|
const operation = {
|
|
@@ -189,12 +296,85 @@ class DignityP2P extends EventEmitter {
|
|
|
189
296
|
payload: { ...partialData }
|
|
190
297
|
};
|
|
191
298
|
|
|
299
|
+
if (options.collaborators !== undefined) {
|
|
300
|
+
operation.collaboratorIds = this.normalizeCollaboratorIds(options.collaborators);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.applyOperation(operation);
|
|
304
|
+
await this.broadcastMessage('operation', operation, {
|
|
305
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
306
|
+
messageType: 'operation',
|
|
307
|
+
operation,
|
|
308
|
+
collectionName
|
|
309
|
+
}),
|
|
310
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, { fromRecord: existing })
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return this.read(collectionName, id);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async updateWithRetry(collectionName, id, patchFn, options = {}) {
|
|
317
|
+
const maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 5;
|
|
318
|
+
|
|
319
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
320
|
+
const current = this.read(collectionName, id);
|
|
321
|
+
if (!current) {
|
|
322
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const patch = await patchFn(current);
|
|
326
|
+
try {
|
|
327
|
+
return await this.update(collectionName, id, patch, {
|
|
328
|
+
...options,
|
|
329
|
+
expectedVersion: current.version
|
|
330
|
+
});
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code !== 'VERSION_CONFLICT' || attempt === maxAttempts - 1) {
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async transferOwnership(collectionName, id, newOwnerId, options = {}) {
|
|
342
|
+
if (!newOwnerId) {
|
|
343
|
+
throw new Error('newOwnerId is required');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const existing = this.getCollection(collectionName).get(id);
|
|
347
|
+
|
|
348
|
+
if (!existing || existing.deletedAt) {
|
|
349
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (existing.ownerId !== this.nodeId) {
|
|
353
|
+
throw new Error(`Only owner ${existing.ownerId} can transfer object ${id}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const operation = {
|
|
357
|
+
opId: this.idGenerator(),
|
|
358
|
+
kind: 'transfer-ownership',
|
|
359
|
+
collectionName,
|
|
360
|
+
id,
|
|
361
|
+
actorId: this.nodeId,
|
|
362
|
+
timestamp: this.now(),
|
|
363
|
+
baseVersion: existing.version,
|
|
364
|
+
newOwnerId,
|
|
365
|
+
keepPreviousOwnerAsCollaborator: options.keepAsCollaborator !== false
|
|
366
|
+
};
|
|
367
|
+
|
|
192
368
|
this.applyOperation(operation);
|
|
193
369
|
await this.broadcastMessage('operation', operation, {
|
|
194
370
|
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
195
371
|
messageType: 'operation',
|
|
196
372
|
operation,
|
|
197
373
|
collectionName
|
|
374
|
+
}),
|
|
375
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, {
|
|
376
|
+
fromRecord: existing,
|
|
377
|
+
extraPeerIds: [newOwnerId]
|
|
198
378
|
})
|
|
199
379
|
});
|
|
200
380
|
|
|
@@ -228,7 +408,8 @@ class DignityP2P extends EventEmitter {
|
|
|
228
408
|
messageType: 'operation',
|
|
229
409
|
operation,
|
|
230
410
|
collectionName
|
|
231
|
-
})
|
|
411
|
+
}),
|
|
412
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, { fromRecord: existing })
|
|
232
413
|
});
|
|
233
414
|
}
|
|
234
415
|
|
|
@@ -236,11 +417,88 @@ class DignityP2P extends EventEmitter {
|
|
|
236
417
|
this.securityService.registerPeerPublicKey(peerId, publicKey);
|
|
237
418
|
}
|
|
238
419
|
|
|
420
|
+
trustPeerPublicKey(peerId, publicKey) {
|
|
421
|
+
if (!peerId || !publicKey) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
this.registerPeerPublicKey(peerId, publicKey);
|
|
427
|
+
return true;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
this.emit('warning', { type: 'peer-key-trust-failed', peerId, error });
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
trustPeerFromMetadata(peerId, metadata) {
|
|
435
|
+
if (!metadata || !metadata.publicKey) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return this.trustPeerPublicKey(peerId, metadata.publicKey);
|
|
440
|
+
}
|
|
441
|
+
|
|
239
442
|
getPublicKey() {
|
|
240
443
|
return this.securityService.getPublicKey();
|
|
241
444
|
}
|
|
242
445
|
|
|
446
|
+
async connectToPeer(peerId) {
|
|
447
|
+
if (!peerId || peerId === this.nodeId) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (typeof this.networkAdapter.connectToPeer !== 'function') {
|
|
452
|
+
throw new Error('Network adapter does not support connectToPeer');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return this.networkAdapter.connectToPeer(peerId);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
getConnectionStats() {
|
|
459
|
+
const adapter = this.networkAdapter;
|
|
460
|
+
if (!adapter) {
|
|
461
|
+
return { openCount: 0, peerIds: [] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const peerIds = typeof adapter.listOpenPeerIds === 'function'
|
|
465
|
+
? adapter.listOpenPeerIds()
|
|
466
|
+
: [];
|
|
467
|
+
|
|
468
|
+
const openCount = typeof adapter.getOpenConnectionCount === 'function'
|
|
469
|
+
? adapter.getOpenConnectionCount()
|
|
470
|
+
: peerIds.length;
|
|
471
|
+
|
|
472
|
+
return { openCount, peerIds };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async ensureConnectedToPeers(peerIds = []) {
|
|
476
|
+
const normalized = [...new Set((peerIds || []).filter(Boolean))];
|
|
477
|
+
const results = [];
|
|
478
|
+
|
|
479
|
+
for (const peerId of normalized) {
|
|
480
|
+
if (peerId === this.nodeId) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
await this.connectToPeer(peerId);
|
|
486
|
+
results.push({ peerId, ok: true });
|
|
487
|
+
} catch (error) {
|
|
488
|
+
this.emit('warning', { type: 'peer-connect-failed', peerId, error });
|
|
489
|
+
results.push({ peerId, ok: false, error });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return results;
|
|
494
|
+
}
|
|
495
|
+
|
|
243
496
|
async broadcastMessage(messageType, payload, securityContext = {}) {
|
|
497
|
+
const connectToPeers = securityContext.connectToPeers;
|
|
498
|
+
if (Array.isArray(connectToPeers) && connectToPeers.length > 0) {
|
|
499
|
+
await this.ensureConnectedToPeers(connectToPeers);
|
|
500
|
+
}
|
|
501
|
+
|
|
244
502
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
245
503
|
messageType,
|
|
246
504
|
payload,
|
|
@@ -251,6 +509,14 @@ class DignityP2P extends EventEmitter {
|
|
|
251
509
|
}
|
|
252
510
|
|
|
253
511
|
async sendDirectMessage(targetId, messageType, payload) {
|
|
512
|
+
if (targetId) {
|
|
513
|
+
try {
|
|
514
|
+
await this.connectToPeer(targetId);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.emit('warning', { type: 'direct-message-connect-failed', targetId, error });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
254
520
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
255
521
|
messageType,
|
|
256
522
|
payload,
|
|
@@ -279,6 +545,8 @@ class DignityP2P extends EventEmitter {
|
|
|
279
545
|
};
|
|
280
546
|
map.set(peerId, next);
|
|
281
547
|
|
|
548
|
+
this.trustPeerFromMetadata(peerId, next.metadata);
|
|
549
|
+
|
|
282
550
|
if (!existing) {
|
|
283
551
|
this.emit('peerdiscovered', { scope, peerId, metadata: next.metadata });
|
|
284
552
|
}
|
|
@@ -305,13 +573,23 @@ class DignityP2P extends EventEmitter {
|
|
|
305
573
|
const normalizedScope = scope || 'main';
|
|
306
574
|
const heartbeatIntervalMs = options.heartbeatIntervalMs || this.defaultDiscoveryHeartbeatMs;
|
|
307
575
|
const ttlMs = options.ttlMs || this.defaultPresenceTtlMs;
|
|
308
|
-
const metadata =
|
|
576
|
+
const metadata = {
|
|
577
|
+
publicKey: this.getPublicKey(),
|
|
578
|
+
...(options.metadata || {})
|
|
579
|
+
};
|
|
580
|
+
const bootstrapPeerIds = Array.isArray(options.bootstrapPeerIds)
|
|
581
|
+
? [...new Set(options.bootstrapPeerIds.filter(Boolean))]
|
|
582
|
+
: [];
|
|
309
583
|
|
|
310
584
|
const existing = this.discoveryRooms.get(normalizedScope);
|
|
311
585
|
if (existing && existing.timer) {
|
|
312
586
|
clearInterval(existing.timer);
|
|
313
587
|
}
|
|
314
588
|
|
|
589
|
+
if (bootstrapPeerIds.length > 0) {
|
|
590
|
+
await this.ensureConnectedToPeers(bootstrapPeerIds);
|
|
591
|
+
}
|
|
592
|
+
|
|
315
593
|
const timer = setInterval(() => {
|
|
316
594
|
this.announcePresence(normalizedScope).catch((error) => {
|
|
317
595
|
this.emit('warning', { type: 'presence-heartbeat-failed', scope: normalizedScope, error });
|
|
@@ -320,6 +598,7 @@ class DignityP2P extends EventEmitter {
|
|
|
320
598
|
|
|
321
599
|
this.discoveryRooms.set(normalizedScope, {
|
|
322
600
|
metadata,
|
|
601
|
+
bootstrapPeerIds,
|
|
323
602
|
heartbeatIntervalMs,
|
|
324
603
|
ttlMs,
|
|
325
604
|
timer
|
|
@@ -417,6 +696,10 @@ class DignityP2P extends EventEmitter {
|
|
|
417
696
|
return;
|
|
418
697
|
}
|
|
419
698
|
|
|
699
|
+
if (message && message.senderId && message.senderPublicKey) {
|
|
700
|
+
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
701
|
+
}
|
|
702
|
+
|
|
420
703
|
let decrypted;
|
|
421
704
|
try {
|
|
422
705
|
decrypted = await this.securityService.decryptIncomingMessage(message);
|
|
@@ -442,6 +725,23 @@ class DignityP2P extends EventEmitter {
|
|
|
442
725
|
return;
|
|
443
726
|
}
|
|
444
727
|
|
|
728
|
+
if (decrypted.messageType === 'record:snapshot') {
|
|
729
|
+
const payload = decrypted.payload || {};
|
|
730
|
+
const { collectionName, record } = payload;
|
|
731
|
+
|
|
732
|
+
if (collectionName && record) {
|
|
733
|
+
const applied = this.restoreRecord(collectionName, record);
|
|
734
|
+
if (applied) {
|
|
735
|
+
this.emit('change', {
|
|
736
|
+
kind: 'snapshot',
|
|
737
|
+
collection: collectionName,
|
|
738
|
+
id: record.id
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
445
745
|
if (decrypted.messageType === 'presence:announce') {
|
|
446
746
|
const payload = decrypted.payload || {};
|
|
447
747
|
const scope = payload.scope || 'main';
|
|
@@ -464,6 +764,12 @@ class DignityP2P extends EventEmitter {
|
|
|
464
764
|
// Discovery handshake: when a new peer appears in a joined scope,
|
|
465
765
|
// send our current presence so late joiners quickly converge.
|
|
466
766
|
if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
|
|
767
|
+
if (typeof this.networkAdapter.connectToPeer === 'function') {
|
|
768
|
+
Promise.resolve(this.connectToPeer(peerId)).catch((error) => {
|
|
769
|
+
this.emit('warning', { type: 'peer-connect-failed', scope, peerId, error });
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
467
773
|
this.announcePresence(scope).catch((error) => {
|
|
468
774
|
this.emit('warning', { type: 'presence-handshake-failed', scope, error });
|
|
469
775
|
});
|
|
@@ -534,6 +840,66 @@ class DignityP2P extends EventEmitter {
|
|
|
534
840
|
return this.getBanInfo(peerId) !== null;
|
|
535
841
|
}
|
|
536
842
|
|
|
843
|
+
emitConflict(details) {
|
|
844
|
+
this.emit('conflict', details);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
restoreRecord(collectionName, record) {
|
|
848
|
+
if (!record || !record.id) {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const collection = this.getCollection(collectionName);
|
|
853
|
+
const current = collection.get(record.id);
|
|
854
|
+
if (current && current.version >= record.version) {
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
collection.set(record.id, {
|
|
859
|
+
id: record.id,
|
|
860
|
+
ownerId: record.ownerId,
|
|
861
|
+
collaboratorIds: this.normalizeCollaboratorIds(record.collaboratorIds),
|
|
862
|
+
data: { ...(record.data || {}) },
|
|
863
|
+
createdAt: record.createdAt,
|
|
864
|
+
updatedAt: record.updatedAt,
|
|
865
|
+
deletedAt: record.deletedAt || null,
|
|
866
|
+
version: record.version
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async pushRecordSnapshot(collectionName, id, options = {}) {
|
|
873
|
+
const collection = this.getCollection(collectionName);
|
|
874
|
+
const raw = collection.get(id);
|
|
875
|
+
|
|
876
|
+
if (!raw || raw.deletedAt) {
|
|
877
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const record = {
|
|
881
|
+
id: raw.id,
|
|
882
|
+
ownerId: raw.ownerId,
|
|
883
|
+
collaboratorIds: Array.isArray(raw.collaboratorIds) ? [...raw.collaboratorIds] : [],
|
|
884
|
+
data: { ...raw.data },
|
|
885
|
+
createdAt: raw.createdAt,
|
|
886
|
+
updatedAt: raw.updatedAt,
|
|
887
|
+
deletedAt: raw.deletedAt || null,
|
|
888
|
+
version: raw.version
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
await this.broadcastMessage('record:snapshot', { collectionName, record }, {
|
|
892
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
893
|
+
messageType: 'record:snapshot',
|
|
894
|
+
collectionName,
|
|
895
|
+
id
|
|
896
|
+
}),
|
|
897
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, { fromRecord: raw })
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
return record;
|
|
901
|
+
}
|
|
902
|
+
|
|
537
903
|
applyOperation(operation) {
|
|
538
904
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
539
905
|
return false;
|
|
@@ -550,6 +916,7 @@ class DignityP2P extends EventEmitter {
|
|
|
550
916
|
collection.set(operation.id, {
|
|
551
917
|
id: operation.id,
|
|
552
918
|
ownerId: operation.ownerId,
|
|
919
|
+
collaboratorIds: this.normalizeCollaboratorIds(operation.collaboratorIds),
|
|
553
920
|
data: { ...operation.payload },
|
|
554
921
|
createdAt: operation.timestamp,
|
|
555
922
|
updatedAt: operation.timestamp,
|
|
@@ -563,14 +930,103 @@ class DignityP2P extends EventEmitter {
|
|
|
563
930
|
}
|
|
564
931
|
|
|
565
932
|
if (!current || current.deletedAt) {
|
|
933
|
+
if (operation.kind !== 'create') {
|
|
934
|
+
this.emit('warning', {
|
|
935
|
+
type: 'orphan-operation',
|
|
936
|
+
kind: operation.kind,
|
|
937
|
+
collection: operation.collectionName,
|
|
938
|
+
id: operation.id,
|
|
939
|
+
actorId: operation.actorId,
|
|
940
|
+
hint: 'Peer is missing the record; pushRecordSnapshot from the owner to catch up.'
|
|
941
|
+
});
|
|
942
|
+
}
|
|
566
943
|
return false;
|
|
567
944
|
}
|
|
568
945
|
|
|
569
|
-
if (operation.
|
|
946
|
+
if (operation.kind === 'transfer-ownership') {
|
|
947
|
+
if (operation.actorId !== current.ownerId) {
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
|
|
952
|
+
this.emitConflict({
|
|
953
|
+
kind: operation.kind,
|
|
954
|
+
collection: operation.collectionName,
|
|
955
|
+
id: operation.id,
|
|
956
|
+
expectedVersion: operation.baseVersion,
|
|
957
|
+
currentVersion: current.version,
|
|
958
|
+
phase: 'remote',
|
|
959
|
+
operation
|
|
960
|
+
});
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const previousOwnerId = current.ownerId;
|
|
965
|
+
current.ownerId = operation.newOwnerId;
|
|
966
|
+
|
|
967
|
+
if (operation.keepPreviousOwnerAsCollaborator !== false) {
|
|
968
|
+
const collaborators = this.normalizeCollaboratorIds(current.collaboratorIds);
|
|
969
|
+
if (!collaborators.includes(previousOwnerId)) {
|
|
970
|
+
collaborators.push(previousOwnerId);
|
|
971
|
+
}
|
|
972
|
+
current.collaboratorIds = collaborators.filter((peerId) => peerId !== operation.newOwnerId);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
current.updatedAt = operation.timestamp;
|
|
976
|
+
current.version += 1;
|
|
977
|
+
|
|
978
|
+
this.appliedOperations.add(operation.opId);
|
|
979
|
+
this.emit('change', {
|
|
980
|
+
kind: 'transfer-ownership',
|
|
981
|
+
collection: operation.collectionName,
|
|
982
|
+
id: operation.id,
|
|
983
|
+
previousOwnerId,
|
|
984
|
+
newOwnerId: operation.newOwnerId
|
|
985
|
+
});
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (operation.kind === 'delete') {
|
|
990
|
+
if (operation.actorId !== current.ownerId) {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
|
|
995
|
+
this.emitConflict({
|
|
996
|
+
kind: operation.kind,
|
|
997
|
+
collection: operation.collectionName,
|
|
998
|
+
id: operation.id,
|
|
999
|
+
expectedVersion: operation.baseVersion,
|
|
1000
|
+
currentVersion: current.version,
|
|
1001
|
+
phase: 'remote',
|
|
1002
|
+
operation
|
|
1003
|
+
});
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
current.deletedAt = operation.timestamp;
|
|
1008
|
+
current.updatedAt = operation.timestamp;
|
|
1009
|
+
current.version += 1;
|
|
1010
|
+
|
|
1011
|
+
this.appliedOperations.add(operation.opId);
|
|
1012
|
+
this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (!this.canUpdateRecord(current, operation.actorId)) {
|
|
570
1017
|
return false;
|
|
571
1018
|
}
|
|
572
1019
|
|
|
573
1020
|
if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
|
|
1021
|
+
this.emitConflict({
|
|
1022
|
+
kind: operation.kind,
|
|
1023
|
+
collection: operation.collectionName,
|
|
1024
|
+
id: operation.id,
|
|
1025
|
+
expectedVersion: operation.baseVersion,
|
|
1026
|
+
currentVersion: current.version,
|
|
1027
|
+
phase: 'remote',
|
|
1028
|
+
operation
|
|
1029
|
+
});
|
|
574
1030
|
return false;
|
|
575
1031
|
}
|
|
576
1032
|
|
|
@@ -579,21 +1035,16 @@ class DignityP2P extends EventEmitter {
|
|
|
579
1035
|
...current.data,
|
|
580
1036
|
...operation.payload
|
|
581
1037
|
};
|
|
582
|
-
current.updatedAt = operation.timestamp;
|
|
583
|
-
current.version += 1;
|
|
584
1038
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
1039
|
+
if (Array.isArray(operation.collaboratorIds) && operation.actorId === current.ownerId) {
|
|
1040
|
+
current.collaboratorIds = this.normalizeCollaboratorIds(operation.collaboratorIds);
|
|
1041
|
+
}
|
|
589
1042
|
|
|
590
|
-
if (operation.kind === 'delete') {
|
|
591
|
-
current.deletedAt = operation.timestamp;
|
|
592
1043
|
current.updatedAt = operation.timestamp;
|
|
593
1044
|
current.version += 1;
|
|
594
1045
|
|
|
595
1046
|
this.appliedOperations.add(operation.opId);
|
|
596
|
-
this.emit('change', { kind: '
|
|
1047
|
+
this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
|
|
597
1048
|
return true;
|
|
598
1049
|
}
|
|
599
1050
|
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,11 @@ const {
|
|
|
15
15
|
InMemoryNetworkHub,
|
|
16
16
|
InMemoryNetworkAdapter
|
|
17
17
|
} = require('./network/in-memory-network');
|
|
18
|
+
const {
|
|
19
|
+
PeerJSNetworkAdapter,
|
|
20
|
+
createPeerJSNetworkAdapter
|
|
21
|
+
} = require('./network/peerjs-network');
|
|
22
|
+
const IndexedDBPersistence = require('./persistence/indexeddb-persistence');
|
|
18
23
|
const {
|
|
19
24
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
20
25
|
DEFAULT_SIGNALING_FALLBACK_URLS
|
|
@@ -34,6 +39,9 @@ module.exports = {
|
|
|
34
39
|
PeerJSSignalingProvider,
|
|
35
40
|
InMemoryNetworkHub,
|
|
36
41
|
InMemoryNetworkAdapter,
|
|
42
|
+
PeerJSNetworkAdapter,
|
|
43
|
+
createPeerJSNetworkAdapter,
|
|
44
|
+
IndexedDBPersistence,
|
|
37
45
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
38
46
|
DEFAULT_SIGNALING_FALLBACK_URLS,
|
|
39
47
|
VDF,
|