dignity.js 0.4.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 +83 -2
- package/dist/dignity.cjs.js +542 -21
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +542 -21
- 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/favicon.svg +8 -0
- 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 +7 -3
- package/docs/openapi-like.json +35 -6
- package/examples/decentralized-chess-lite.js +52 -30
- package/package.json +12 -4
- package/src/core/dignity-p2p.js +388 -16
- package/src/index.js +6 -0
- package/src/network/peerjs-network.js +234 -0
- package/src/persistence/indexeddb-persistence.js +2 -0
- package/src/react/index.js +143 -1
- 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,12 @@ 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}`);
|
|
179
269
|
}
|
|
180
270
|
|
|
181
271
|
if (typeof options.expectedVersion === 'number' && existing.version !== options.expectedVersion) {
|
|
@@ -206,13 +296,18 @@ class DignityP2P extends EventEmitter {
|
|
|
206
296
|
payload: { ...partialData }
|
|
207
297
|
};
|
|
208
298
|
|
|
299
|
+
if (options.collaborators !== undefined) {
|
|
300
|
+
operation.collaboratorIds = this.normalizeCollaboratorIds(options.collaborators);
|
|
301
|
+
}
|
|
302
|
+
|
|
209
303
|
this.applyOperation(operation);
|
|
210
304
|
await this.broadcastMessage('operation', operation, {
|
|
211
305
|
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
212
306
|
messageType: 'operation',
|
|
213
307
|
operation,
|
|
214
308
|
collectionName
|
|
215
|
-
})
|
|
309
|
+
}),
|
|
310
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, { fromRecord: existing })
|
|
216
311
|
});
|
|
217
312
|
|
|
218
313
|
return this.read(collectionName, id);
|
|
@@ -243,6 +338,49 @@ class DignityP2P extends EventEmitter {
|
|
|
243
338
|
throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
|
|
244
339
|
}
|
|
245
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
|
+
|
|
368
|
+
this.applyOperation(operation);
|
|
369
|
+
await this.broadcastMessage('operation', operation, {
|
|
370
|
+
broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
|
|
371
|
+
messageType: 'operation',
|
|
372
|
+
operation,
|
|
373
|
+
collectionName
|
|
374
|
+
}),
|
|
375
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, {
|
|
376
|
+
fromRecord: existing,
|
|
377
|
+
extraPeerIds: [newOwnerId]
|
|
378
|
+
})
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return this.read(collectionName, id);
|
|
382
|
+
}
|
|
383
|
+
|
|
246
384
|
async remove(collectionName, id, options = {}) {
|
|
247
385
|
const existing = this.getCollection(collectionName).get(id);
|
|
248
386
|
|
|
@@ -270,7 +408,8 @@ class DignityP2P extends EventEmitter {
|
|
|
270
408
|
messageType: 'operation',
|
|
271
409
|
operation,
|
|
272
410
|
collectionName
|
|
273
|
-
})
|
|
411
|
+
}),
|
|
412
|
+
connectToPeers: this.resolveReplicationPeers(collectionName, id, options, { fromRecord: existing })
|
|
274
413
|
});
|
|
275
414
|
}
|
|
276
415
|
|
|
@@ -278,11 +417,88 @@ class DignityP2P extends EventEmitter {
|
|
|
278
417
|
this.securityService.registerPeerPublicKey(peerId, publicKey);
|
|
279
418
|
}
|
|
280
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
|
+
|
|
281
442
|
getPublicKey() {
|
|
282
443
|
return this.securityService.getPublicKey();
|
|
283
444
|
}
|
|
284
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
|
+
|
|
285
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
|
+
|
|
286
502
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
287
503
|
messageType,
|
|
288
504
|
payload,
|
|
@@ -293,6 +509,14 @@ class DignityP2P extends EventEmitter {
|
|
|
293
509
|
}
|
|
294
510
|
|
|
295
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
|
+
|
|
296
520
|
const envelope = await this.securityService.secureOutgoingMessage({
|
|
297
521
|
messageType,
|
|
298
522
|
payload,
|
|
@@ -321,6 +545,8 @@ class DignityP2P extends EventEmitter {
|
|
|
321
545
|
};
|
|
322
546
|
map.set(peerId, next);
|
|
323
547
|
|
|
548
|
+
this.trustPeerFromMetadata(peerId, next.metadata);
|
|
549
|
+
|
|
324
550
|
if (!existing) {
|
|
325
551
|
this.emit('peerdiscovered', { scope, peerId, metadata: next.metadata });
|
|
326
552
|
}
|
|
@@ -347,13 +573,23 @@ class DignityP2P extends EventEmitter {
|
|
|
347
573
|
const normalizedScope = scope || 'main';
|
|
348
574
|
const heartbeatIntervalMs = options.heartbeatIntervalMs || this.defaultDiscoveryHeartbeatMs;
|
|
349
575
|
const ttlMs = options.ttlMs || this.defaultPresenceTtlMs;
|
|
350
|
-
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
|
+
: [];
|
|
351
583
|
|
|
352
584
|
const existing = this.discoveryRooms.get(normalizedScope);
|
|
353
585
|
if (existing && existing.timer) {
|
|
354
586
|
clearInterval(existing.timer);
|
|
355
587
|
}
|
|
356
588
|
|
|
589
|
+
if (bootstrapPeerIds.length > 0) {
|
|
590
|
+
await this.ensureConnectedToPeers(bootstrapPeerIds);
|
|
591
|
+
}
|
|
592
|
+
|
|
357
593
|
const timer = setInterval(() => {
|
|
358
594
|
this.announcePresence(normalizedScope).catch((error) => {
|
|
359
595
|
this.emit('warning', { type: 'presence-heartbeat-failed', scope: normalizedScope, error });
|
|
@@ -362,6 +598,7 @@ class DignityP2P extends EventEmitter {
|
|
|
362
598
|
|
|
363
599
|
this.discoveryRooms.set(normalizedScope, {
|
|
364
600
|
metadata,
|
|
601
|
+
bootstrapPeerIds,
|
|
365
602
|
heartbeatIntervalMs,
|
|
366
603
|
ttlMs,
|
|
367
604
|
timer
|
|
@@ -459,6 +696,10 @@ class DignityP2P extends EventEmitter {
|
|
|
459
696
|
return;
|
|
460
697
|
}
|
|
461
698
|
|
|
699
|
+
if (message && message.senderId && message.senderPublicKey) {
|
|
700
|
+
this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
|
|
701
|
+
}
|
|
702
|
+
|
|
462
703
|
let decrypted;
|
|
463
704
|
try {
|
|
464
705
|
decrypted = await this.securityService.decryptIncomingMessage(message);
|
|
@@ -484,6 +725,23 @@ class DignityP2P extends EventEmitter {
|
|
|
484
725
|
return;
|
|
485
726
|
}
|
|
486
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
|
+
|
|
487
745
|
if (decrypted.messageType === 'presence:announce') {
|
|
488
746
|
const payload = decrypted.payload || {};
|
|
489
747
|
const scope = payload.scope || 'main';
|
|
@@ -506,6 +764,12 @@ class DignityP2P extends EventEmitter {
|
|
|
506
764
|
// Discovery handshake: when a new peer appears in a joined scope,
|
|
507
765
|
// send our current presence so late joiners quickly converge.
|
|
508
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
|
+
|
|
509
773
|
this.announcePresence(scope).catch((error) => {
|
|
510
774
|
this.emit('warning', { type: 'presence-handshake-failed', scope, error });
|
|
511
775
|
});
|
|
@@ -594,6 +858,7 @@ class DignityP2P extends EventEmitter {
|
|
|
594
858
|
collection.set(record.id, {
|
|
595
859
|
id: record.id,
|
|
596
860
|
ownerId: record.ownerId,
|
|
861
|
+
collaboratorIds: this.normalizeCollaboratorIds(record.collaboratorIds),
|
|
597
862
|
data: { ...(record.data || {}) },
|
|
598
863
|
createdAt: record.createdAt,
|
|
599
864
|
updatedAt: record.updatedAt,
|
|
@@ -604,6 +869,37 @@ class DignityP2P extends EventEmitter {
|
|
|
604
869
|
return true;
|
|
605
870
|
}
|
|
606
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
|
+
|
|
607
903
|
applyOperation(operation) {
|
|
608
904
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
609
905
|
return false;
|
|
@@ -620,6 +916,7 @@ class DignityP2P extends EventEmitter {
|
|
|
620
916
|
collection.set(operation.id, {
|
|
621
917
|
id: operation.id,
|
|
622
918
|
ownerId: operation.ownerId,
|
|
919
|
+
collaboratorIds: this.normalizeCollaboratorIds(operation.collaboratorIds),
|
|
623
920
|
data: { ...operation.payload },
|
|
624
921
|
createdAt: operation.timestamp,
|
|
625
922
|
updatedAt: operation.timestamp,
|
|
@@ -633,10 +930,90 @@ class DignityP2P extends EventEmitter {
|
|
|
633
930
|
}
|
|
634
931
|
|
|
635
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
|
+
}
|
|
636
943
|
return false;
|
|
637
944
|
}
|
|
638
945
|
|
|
639
|
-
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)) {
|
|
640
1017
|
return false;
|
|
641
1018
|
}
|
|
642
1019
|
|
|
@@ -658,21 +1035,16 @@ class DignityP2P extends EventEmitter {
|
|
|
658
1035
|
...current.data,
|
|
659
1036
|
...operation.payload
|
|
660
1037
|
};
|
|
661
|
-
current.updatedAt = operation.timestamp;
|
|
662
|
-
current.version += 1;
|
|
663
1038
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
1039
|
+
if (Array.isArray(operation.collaboratorIds) && operation.actorId === current.ownerId) {
|
|
1040
|
+
current.collaboratorIds = this.normalizeCollaboratorIds(operation.collaboratorIds);
|
|
1041
|
+
}
|
|
668
1042
|
|
|
669
|
-
if (operation.kind === 'delete') {
|
|
670
|
-
current.deletedAt = operation.timestamp;
|
|
671
1043
|
current.updatedAt = operation.timestamp;
|
|
672
1044
|
current.version += 1;
|
|
673
1045
|
|
|
674
1046
|
this.appliedOperations.add(operation.opId);
|
|
675
|
-
this.emit('change', { kind: '
|
|
1047
|
+
this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
|
|
676
1048
|
return true;
|
|
677
1049
|
}
|
|
678
1050
|
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,10 @@ 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');
|
|
18
22
|
const IndexedDBPersistence = require('./persistence/indexeddb-persistence');
|
|
19
23
|
const {
|
|
20
24
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
@@ -35,6 +39,8 @@ module.exports = {
|
|
|
35
39
|
PeerJSSignalingProvider,
|
|
36
40
|
InMemoryNetworkHub,
|
|
37
41
|
InMemoryNetworkAdapter,
|
|
42
|
+
PeerJSNetworkAdapter,
|
|
43
|
+
createPeerJSNetworkAdapter,
|
|
38
44
|
IndexedDBPersistence,
|
|
39
45
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
40
46
|
DEFAULT_SIGNALING_FALLBACK_URLS,
|