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.
Files changed (43) hide show
  1. package/README.md +142 -4
  2. package/dist/dignity.cjs.js +768 -20
  3. package/dist/dignity.cjs.js.map +4 -4
  4. package/dist/dignity.esm.js +768 -20
  5. package/dist/dignity.esm.js.map +3 -3
  6. package/dist/dignity.min.js +18 -18
  7. package/docs/assets/dignity.esm.js +11205 -0
  8. package/docs/assets/docs.js +47 -0
  9. package/docs/assets/favicon.svg +8 -0
  10. package/docs/assets/highlight/github-dark.min.css +10 -0
  11. package/docs/assets/highlight/github.min.css +10 -0
  12. package/docs/assets/highlight/highlight.min.js +1244 -0
  13. package/docs/assets/styles.css +449 -38
  14. package/docs/chess/assets/chess-app.js +58022 -0
  15. package/docs/chess/assets/chess-app.js.map +7 -0
  16. package/docs/chess/assets/chess.css +584 -0
  17. package/docs/chess/favicon.ico +0 -0
  18. package/docs/chess/index.html +16 -0
  19. package/docs/chess/src/App.jsx +128 -0
  20. package/docs/chess/src/components/Board3D.jsx +364 -0
  21. package/docs/chess/src/components/GameView.jsx +847 -0
  22. package/docs/chess/src/components/JoinGate.jsx +68 -0
  23. package/docs/chess/src/components/LinkPanel.jsx +132 -0
  24. package/docs/chess/src/components/Lobby.jsx +154 -0
  25. package/docs/chess/src/components/MovePanel.jsx +123 -0
  26. package/docs/chess/src/lib/audio.js +50 -0
  27. package/docs/chess/src/lib/dignitySetup.js +42 -0
  28. package/docs/chess/src/lib/links.js +124 -0
  29. package/docs/chess/src/lib/localGames.js +160 -0
  30. package/docs/chess/src/lib/p2pDebug.js +192 -0
  31. package/docs/chess/src/main.jsx +5 -0
  32. package/docs/favicon.ico +0 -0
  33. package/docs/index.html +605 -81
  34. package/docs/openapi-like.json +74 -7
  35. package/examples/decentralized-chess-lite.js +52 -30
  36. package/package.json +30 -4
  37. package/src/core/dignity-p2p.js +466 -15
  38. package/src/index.js +8 -0
  39. package/src/network/peerjs-network.js +234 -0
  40. package/src/persistence/indexeddb-persistence.js +184 -0
  41. package/src/react/index.js +256 -0
  42. package/src/signaling/parse-peerjs-url.js +24 -0
  43. package/src/signaling/peerjs-signaling-provider.js +2 -8
@@ -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.ownerId !== this.nodeId) {
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 = options.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.actorId !== current.ownerId) {
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
- this.appliedOperations.add(operation.opId);
586
- this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
587
- return true;
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: 'delete', collection: operation.collectionName, id: operation.id });
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,