@vrtmrz/livesync-commonlib 0.3.2 → 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.
Files changed (31) hide show
  1. package/README.md +17 -0
  2. package/dist/managers/EntryManager/EntryManagerImpls.js +1 -1
  3. package/dist/p2p/P2PService.d.ts +10 -1
  4. package/dist/p2p/P2PService.js +25 -2
  5. package/dist/p2p/index.d.ts +4 -1
  6. package/dist/p2p/index.js +2 -0
  7. package/dist/p2p/p2pDiagnostics.d.ts +35 -0
  8. package/dist/p2p/p2pDiagnostics.js +76 -0
  9. package/dist/pouchdb/ReplicatorShim.batch.d.ts +30 -0
  10. package/dist/pouchdb/ReplicatorShim.batch.js +62 -0
  11. package/dist/pouchdb/ReplicatorShim.d.ts +14 -1
  12. package/dist/pouchdb/ReplicatorShim.js +64 -80
  13. package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
  14. package/dist/replication/trystero/P2PLogCollector.js +107 -72
  15. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
  16. package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
  17. package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
  18. package/dist/replication/trystero/TrysteroReplicator.js +191 -69
  19. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
  20. package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
  21. package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
  22. package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
  23. package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
  24. package/dist/replication/trystero/types.d.ts +3 -0
  25. package/dist/services/base/APIService.d.ts +10 -0
  26. package/dist/services/base/APIService.js +13 -0
  27. package/dist/services/base/IService.d.ts +2 -0
  28. package/docs/journal-receive-recovery.md +7 -4
  29. package/docs/p2p-transport-lifecycle.md +117 -10
  30. package/docs/releasing.md +6 -0
  31. package/package.json +2 -2
@@ -1,3 +1,15 @@
1
+ import { reportP2PDiagnostic } from "../../p2p/p2pDiagnostics.js";
2
+ import {
3
+ authenticationResultDiagnostic,
4
+ authenticationFailureDiagnostic,
5
+ pullResultDiagnostic,
6
+ pullFailureDiagnostic,
7
+ notificationFailureDiagnostic
8
+ } from "./p2pReplicationDiagnostics.js";
9
+ import {
10
+ projectDirectionProgress,
11
+ projectAcknowledgedAttempt
12
+ } from "./p2pReplicationProgress.js";
1
13
  import { parsePeerPatterns } from "./peerPatterns.js";
2
14
  import {
3
15
  TweakValuesShouldMatchedTemplate,
@@ -42,6 +54,7 @@ async function encrypt(data, passphrase) {
42
54
  async function decrypt(encryptedData, passphrase) {
43
55
  return await decryptWithEphemeralSalt(encryptedData, passphrase);
44
56
  }
57
+ let nextProgressAttempt = 0;
45
58
  const P2P_REPLICATION_COMPLETED = Object.freeze({ status: "completed", ok: true });
46
59
  const P2P_REPLICATION_CANCELLED = Object.freeze({ status: "cancelled" });
47
60
  function p2pReplicationFailed(error) {
@@ -59,11 +72,17 @@ class TrysteroReplicator {
59
72
  _env;
60
73
  finiteOperationOwner;
61
74
  disposed = false;
75
+ acknowledgedAttempts = /* @__PURE__ */ new Map();
62
76
  fallbackAutomationCoordinator = new P2PAutomationCoordinator();
63
77
  server;
64
78
  replicationStatus() {
65
79
  return {};
66
80
  }
81
+ reportDiagnostic(input) {
82
+ if (!this.disposed) {
83
+ reportP2PDiagnostic(this._env.reportDiagnostic, input);
84
+ }
85
+ }
67
86
  get settings() {
68
87
  return this._env.settings;
69
88
  }
@@ -137,6 +156,7 @@ class TrysteroReplicator {
137
156
  async close() {
138
157
  this.requestStatus();
139
158
  await this.server?.shutdown();
159
+ this.acknowledgedAttempts.clear();
140
160
  this._replicateFromPeers.clear();
141
161
  this._replicateToPeers.clear();
142
162
  this._watchingPeers.clear();
@@ -184,20 +204,46 @@ class TrysteroReplicator {
184
204
  const peerName = peer.name;
185
205
  const shouldAutoSync = this.autoSyncPeers.some((e) => e.test(peerName));
186
206
  const shouldAutoWatch = this.autoWatchPeers.some((e) => e.test(peerName));
187
- if (!shouldAutoSync && !shouldAutoWatch) return;
207
+ if (!shouldAutoSync && !shouldAutoWatch) {
208
+ return;
209
+ }
188
210
  const acceptance = await this.server?.evaluatePeerAcceptance(peer.peerId);
189
- if (acceptance !== "accepted") return;
211
+ if (acceptance !== "accepted") {
212
+ if (shouldAutoWatch) {
213
+ this.reportDiagnostic({
214
+ category: "sync",
215
+ code: "watch-skipped",
216
+ peerId: peer.peerId,
217
+ reason: "not-accepted"
218
+ });
219
+ }
220
+ return;
221
+ }
190
222
  if (shouldAutoSync) {
191
223
  await this.automationCoordinator.runBaseline(
192
224
  peerName,
193
225
  () => this.runFiniteReplicationActivity(() => this.sync(peer.peerId))
194
226
  );
195
227
  }
196
- if (shouldAutoWatch && await this.getRemoteIsBroadcasting(peer.peerId) === true) {
197
- this.watchPeer(peer.peerId);
228
+ if (shouldAutoWatch) {
229
+ await this.watchBroadcastingPeer(peer.peerId);
198
230
  }
199
231
  }
232
+ async watchBroadcastingPeer(peerId) {
233
+ const broadcasting = await this.getRemoteIsBroadcasting(peerId);
234
+ if (broadcasting === true) {
235
+ this.watchPeer(peerId);
236
+ return;
237
+ }
238
+ this.reportDiagnostic({
239
+ category: "sync",
240
+ code: "watch-skipped",
241
+ peerId,
242
+ reason: broadcasting === false ? "peer-not-broadcasting" : "peer-broadcast-unobserved"
243
+ });
244
+ }
200
245
  onPeerLeaved(peerId) {
246
+ this.acknowledgedAttempts.delete(peerId);
201
247
  void this.unwatchPeer(peerId);
202
248
  }
203
249
  _onSetup = false;
@@ -257,6 +303,12 @@ class TrysteroReplicator {
257
303
  },
258
304
  onProgress: async (fromPeerId) => {
259
305
  if (this._onSetup) {
306
+ this.reportDiagnostic({
307
+ category: "sync",
308
+ code: "change-notification-ignored",
309
+ peerId: fromPeerId,
310
+ reason: "setup-in-progress"
311
+ });
260
312
  return { error: toP2PWireError(new Error("The setup is in progress")) };
261
313
  }
262
314
  await this.onUpdateDatabase(fromPeerId);
@@ -300,9 +352,9 @@ class TrysteroReplicator {
300
352
  }
301
353
  return encrypt(JSON.stringify(setting), passphrase.trim());
302
354
  },
303
- onProgressAcknowledged: async (fromPeerId, info) => {
355
+ onProgressAcknowledged: async (fromPeerId, info, acknowledgement) => {
304
356
  try {
305
- await Promise.resolve(this.onProgressAcknowledged(fromPeerId, info));
357
+ await Promise.resolve(this.onProgressAcknowledged(fromPeerId, info, acknowledgement));
306
358
  } catch (e) {
307
359
  Logger("Error while acknowledging the progress", LOG_LEVEL_VERBOSE);
308
360
  Logger(e, LOG_LEVEL_VERBOSE);
@@ -341,8 +393,10 @@ class TrysteroReplicator {
341
393
  2e4,
342
394
  signal
343
395
  );
396
+ this.reportDiagnostic(authenticationResultDiagnostic(peerId, r));
344
397
  return r;
345
398
  } catch (e) {
399
+ this.reportDiagnostic(authenticationFailureDiagnostic(peerId, e, signal));
346
400
  Logger("Error while requesting authentication", LOG_LEVEL_VERBOSE);
347
401
  Logger(e, LOG_LEVEL_VERBOSE);
348
402
  return false;
@@ -402,9 +456,12 @@ class TrysteroReplicator {
402
456
  changes;
403
457
  _isBroadcasting = false;
404
458
  disableBroadcastChanges() {
459
+ const wasBroadcasting = this._isBroadcasting;
405
460
  this.changes?.cancel();
461
+ const needsDiagnostic = wasBroadcasting && this._isBroadcasting;
406
462
  this._isBroadcasting = false;
407
463
  this.dispatchStatus();
464
+ if (needsDiagnostic) this.reportDiagnostic({ category: "sync", code: "broadcast-disabled" });
408
465
  }
409
466
  enableBroadcastChanges() {
410
467
  if (this._isBroadcasting) return;
@@ -429,6 +486,7 @@ class TrysteroReplicator {
429
486
  await this.notifyProgress();
430
487
  });
431
488
  const closeChanges = (reason) => {
489
+ const wasBroadcasting = this._isBroadcasting;
432
490
  if (reason) {
433
491
  if (reason instanceof Error) {
434
492
  Logger(`Error while broadcasting the changes`, LOG_LEVEL_INFO);
@@ -443,9 +501,16 @@ class TrysteroReplicator {
443
501
  this.changes = void 0;
444
502
  this._isBroadcasting = false;
445
503
  this.dispatchStatus();
504
+ if (wasBroadcasting)
505
+ this.reportDiagnostic({
506
+ category: "sync",
507
+ code: "broadcast-disabled",
508
+ ...reason instanceof Error ? { reason: "replication-error" } : {}
509
+ });
446
510
  };
447
511
  void this.changes.on("error", closeChanges);
448
512
  void this.changes.on("complete", closeChanges);
513
+ this.reportDiagnostic({ category: "sync", code: "broadcast-enabled" });
449
514
  fireAndForget(async () => await this.notifyProgress());
450
515
  }
451
516
  /** Apply automatic broadcast policy without restarting this room. */
@@ -494,55 +559,51 @@ class TrysteroReplicator {
494
559
  this.onReplicationProgress(peerId, info);
495
560
  }
496
561
  onReplicationProgress(peerId, info) {
497
- const name = this.server?._knownAdvertisements.get(peerId)?.name || peerId;
498
- const stat = {
562
+ if (this.disposed) {
563
+ return false;
564
+ }
565
+ const name = this.server?._knownAdvertisements?.get(peerId)?.name || peerId;
566
+ this._env.events.emitEvent(EVENT_P2P_REPLICATOR_PROGRESS, {
499
567
  peerId,
500
568
  peerName: name,
501
- fetching: {
502
- max: 0,
503
- current: 0,
504
- isActive: false
505
- }
506
- };
507
- if (info) {
508
- stat.fetching = {
509
- max: info.maxSeqInBatch,
510
- current: info.lastSeq,
511
- isActive: true
512
- };
513
- }
514
- this._env.events.emitEvent(EVENT_P2P_REPLICATOR_PROGRESS, stat);
569
+ fetching: projectDirectionProgress(info)
570
+ });
515
571
  return true;
516
572
  }
517
- onProgressAcknowledged(peerId, info) {
518
- const name = this.server?._knownAdvertisements.get(peerId)?.name || peerId;
519
- const ack = {
520
- peerId,
521
- peerName: name,
522
- sending: {
523
- max: 0,
524
- current: 0,
525
- isActive: false
573
+ onProgressAcknowledged(peerId, info, acknowledgement) {
574
+ if (this.disposed) {
575
+ return false;
576
+ }
577
+ if (acknowledgement) {
578
+ const next = projectAcknowledgedAttempt(this.acknowledgedAttempts.get(peerId), info, acknowledgement);
579
+ if (!next) {
580
+ return false;
526
581
  }
527
- };
528
- if (info) {
529
- ack.sending = {
530
- max: info.maxSeqInBatch,
531
- current: info.lastSeq,
532
- isActive: true
533
- };
582
+ this.acknowledgedAttempts.set(peerId, next);
534
583
  }
535
- this._env.events.emitEvent(EVENT_P2P_REPLICATOR_PROGRESS, ack);
584
+ const name = this.server?._knownAdvertisements?.get(peerId)?.name || peerId;
585
+ const sending = projectDirectionProgress(info ?? acknowledgement?.finalProgress);
586
+ sending.isActive = info != null;
587
+ this._env.events.emitEvent(EVENT_P2P_REPLICATOR_PROGRESS, {
588
+ peerId,
589
+ peerName: name,
590
+ sending
591
+ });
536
592
  return true;
537
593
  }
538
- // Sending the progress to the remote peer
539
- acknowledgeProgress(remotePeerId, info) {
540
- if (!this.server) return;
541
- const connection = this.server.getConnection(remotePeerId);
542
- void connection.invokeRemoteFunction("onProgressAcknowledged", [this.server.serverPeerId, info], 500).catch((ex) => {
594
+ // Sending the progress to the remote peer.
595
+ acknowledgeProgress(remotePeerId, info, acknowledgement) {
596
+ if (!this.server || this.disposed) return;
597
+ try {
598
+ const connection = this.server.getConnection(remotePeerId);
599
+ void connection.invokeRemoteFunction("onProgressAcknowledged", [this.server.serverPeerId, info, acknowledgement], 500).catch((error) => {
600
+ Logger("Error while acknowledging the progress", LOG_LEVEL_VERBOSE);
601
+ Logger(error, LOG_LEVEL_VERBOSE);
602
+ });
603
+ } catch (error) {
543
604
  Logger("Error while acknowledging the progress", LOG_LEVEL_VERBOSE);
544
- Logger(ex, LOG_LEVEL_VERBOSE);
545
- });
605
+ Logger(error, LOG_LEVEL_VERBOSE);
606
+ }
546
607
  }
547
608
  async replicateFrom(remotePeer, showNotice = false, fromStart = false, skipOrdinaryReplicationPolicy = false, callerSignal) {
548
609
  return await this.runSessionFiniteOperation(
@@ -557,6 +618,23 @@ class TrysteroReplicator {
557
618
  );
558
619
  }
559
620
  async replicateFromWithinSession(remotePeer, showNotice, fromStart, skipOrdinaryReplicationPolicy, signal) {
621
+ this.reportDiagnostic({ category: "sync", code: "pull-started", peerId: remotePeer });
622
+ try {
623
+ const result = await this.pullFromWithinSession(
624
+ remotePeer,
625
+ showNotice,
626
+ fromStart,
627
+ skipOrdinaryReplicationPolicy,
628
+ signal
629
+ );
630
+ this.reportDiagnostic(pullResultDiagnostic(remotePeer, result.status));
631
+ return result;
632
+ } catch (error) {
633
+ this.reportDiagnostic(pullFailureDiagnostic(remotePeer, signal?.aborted ?? false));
634
+ throw error;
635
+ }
636
+ }
637
+ async pullFromWithinSession(remotePeer, showNotice, fromStart, skipOrdinaryReplicationPolicy, signal) {
560
638
  if (!skipOrdinaryReplicationPolicy && !await this.canStartOrdinaryReplication(showNotice)) {
561
639
  return p2pReplicationFailed(new Error("Replication is not ready"));
562
640
  }
@@ -571,14 +649,28 @@ class TrysteroReplicator {
571
649
  }
572
650
  this._replicateFromPeers.add(remotePeer);
573
651
  this.dispatchStatus();
652
+ const acknowledgement = { attempt: ++nextProgressAttempt };
653
+ let latestProgress = {
654
+ lastSeq: 0,
655
+ maxSeqInBatch: 0,
656
+ statistics: { documents: 0, checkedChanges: 0 }
657
+ };
658
+ this.dispatchReplicationProgress(remotePeer, latestProgress);
659
+ this.acknowledgeProgress(remotePeer, latestProgress, acknowledgement);
574
660
  try {
575
- return await this.transferFromPeer(remotePeer, fromStart, logLevel, signal);
661
+ return await this.transferFromPeer(remotePeer, fromStart, logLevel, signal, (info) => {
662
+ latestProgress = info;
663
+ this.dispatchReplicationProgress(remotePeer, info);
664
+ this.acknowledgeProgress(remotePeer, info, acknowledgement);
665
+ });
576
666
  } catch (e) {
577
667
  if (signal?.aborted) return P2P_REPLICATION_CANCELLED;
578
668
  Logger("Error while P2P replicating", logLevel, "p2p-replicator");
579
669
  Logger(e, LOG_LEVEL_VERBOSE);
580
670
  return p2pReplicationFailed(e);
581
671
  } finally {
672
+ this.dispatchReplicationProgress(remotePeer);
673
+ this.acknowledgeProgress(remotePeer, void 0, { ...acknowledgement, finalProgress: latestProgress });
582
674
  this._replicateFromPeers.delete(remotePeer);
583
675
  this.dispatchStatus();
584
676
  }
@@ -605,7 +697,7 @@ class TrysteroReplicator {
605
697
  }
606
698
  return void 0;
607
699
  }
608
- async transferFromPeer(remotePeer, fromStart, logLevel, signal) {
700
+ async transferFromPeer(remotePeer, fromStart, logLevel, signal, onStatistics) {
609
701
  if (!this.server) {
610
702
  throw new Error("Server is not available");
611
703
  }
@@ -640,8 +732,6 @@ class TrysteroReplicator {
640
732
  remoteDB,
641
733
  async (docs, info) => {
642
734
  await this._env.processReplicatedDocs(docs);
643
- void this.dispatchReplicationProgress(remotePeer, info);
644
- void this.acknowledgeProgress(remotePeer, info);
645
735
  void this.notifyProgress(remotePeer);
646
736
  Logger(
647
737
  `P2P Replication from ${remotePeer}
@@ -650,10 +740,14 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
650
740
  "p2p-replicator"
651
741
  );
652
742
  },
653
- { live: false, rewind: fromStart, signal }
743
+ {
744
+ live: false,
745
+ rewind: fromStart,
746
+ signal,
747
+ onStatistics
748
+ }
654
749
  );
655
750
  Logger(`P2P replicateFrom replicateShim returned for ${remotePeer}`, LOG_LEVEL_VERBOSE, "p2p-replicator");
656
- void this.acknowledgeProgress(remotePeer, void 0);
657
751
  if (outcome.status === "cancelled") {
658
752
  Logger(`P2P Replication from ${remotePeer} has been cancelled`, logLevel, "p2p-replicator");
659
753
  return P2P_REPLICATION_CANCELLED;
@@ -662,28 +756,43 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
662
756
  return P2P_REPLICATION_COMPLETED;
663
757
  }
664
758
  notifyProgress(excludePeerId) {
665
- if (!this._isBroadcasting) return;
666
- if (!this.server) return;
759
+ if (!this._isBroadcasting) {
760
+ return;
761
+ }
762
+ if (!this.server) {
763
+ return;
764
+ }
667
765
  for (const peer of this.server.knownAdvertisements) {
668
766
  const peerId = peer.peerId;
669
- if (peerId === excludePeerId) continue;
670
- void serialized(`notifyProgress-${peerId}`, async () => {
671
- const isAcceptable = await this.server?.isAcceptablePeer(peerId);
672
- if (isAcceptable) {
673
- try {
674
- const ret = await this.server?.getConnection(peerId).invokeRemoteFunction("onProgress", [this.server?.serverPeerId], 0);
675
- return ret;
676
- } catch (e) {
677
- Logger(`Error while notifying progress to ${peerId}`, LOG_LEVEL_VERBOSE);
678
- Logger(e, LOG_LEVEL_VERBOSE);
679
- }
680
- } else {
681
- Logger(`Peer ${peerId} is not acceptable to notify progress`, LOG_LEVEL_VERBOSE);
682
- }
683
- });
767
+ if (peerId === excludePeerId) {
768
+ continue;
769
+ }
770
+ void serialized(`notifyProgress-${peerId}`, () => this.notifyPeerProgress(peerId));
684
771
  }
685
772
  return Promise.resolve();
686
773
  }
774
+ async notifyPeerProgress(peerId) {
775
+ const isAcceptable = await this.server?.isAcceptablePeer(peerId);
776
+ if (!isAcceptable) {
777
+ this.reportDiagnostic({
778
+ category: "sync",
779
+ code: "change-notification-ignored",
780
+ peerId,
781
+ reason: "not-accepted"
782
+ });
783
+ Logger(`Peer ${peerId} is not acceptable to notify progress`, LOG_LEVEL_VERBOSE);
784
+ return;
785
+ }
786
+ try {
787
+ const result = await this.server?.getConnection(peerId).invokeRemoteFunction("onProgress", [this.server?.serverPeerId], 0);
788
+ this.reportDiagnostic({ category: "sync", code: "change-notification-sent", peerId });
789
+ return result;
790
+ } catch (error) {
791
+ this.reportDiagnostic(notificationFailureDiagnostic(peerId, error));
792
+ Logger(`Error while notifying progress to ${peerId}`, LOG_LEVEL_VERBOSE);
793
+ Logger(error, LOG_LEVEL_VERBOSE);
794
+ }
795
+ }
687
796
  async requestBroadcastChanges(peerId) {
688
797
  return await this.server?.getConnection(peerId).invokeRemoteFunction("requestBroadcasting", [this.server.serverPeerId], 0);
689
798
  }
@@ -697,20 +806,33 @@ ${info.lastSeq} / ${info.maxSeqInBatch})`,
697
806
  }
698
807
  _watchingPeers = /* @__PURE__ */ new Set();
699
808
  watchPeer(peerId) {
809
+ if (!this._watchingPeers.has(peerId)) {
810
+ this.reportDiagnostic({ category: "sync", code: "watch-enabled", peerId });
811
+ }
700
812
  this._watchingPeers.add(peerId);
701
813
  this.dispatchStatus();
702
814
  }
703
815
  unwatchPeer(peerId) {
816
+ if (this._watchingPeers.has(peerId)) {
817
+ this.reportDiagnostic({ category: "sync", code: "watch-disabled", peerId });
818
+ }
704
819
  this._watchingPeers.delete(peerId);
705
820
  this.dispatchStatus();
706
821
  }
707
822
  async onUpdateDatabase(fromPeerId) {
708
823
  if (this._watchingPeers.has(fromPeerId)) {
824
+ this.reportDiagnostic({ category: "sync", code: "change-notification-received", peerId: fromPeerId });
709
825
  Logger(`Progress notification from ${fromPeerId}`, LOG_LEVEL_VERBOSE);
710
826
  return await serialized(`onProgress-${fromPeerId}`, async () => {
711
827
  return await this.runFiniteReplicationActivity(() => this.replicateFrom(fromPeerId));
712
828
  });
713
829
  }
830
+ this.reportDiagnostic({
831
+ category: "sync",
832
+ code: "change-notification-ignored",
833
+ peerId: fromPeerId,
834
+ reason: "not-watching"
835
+ });
714
836
  return false;
715
837
  }
716
838
  async getRemoteConfig(peerId) {
@@ -1,3 +1,4 @@
1
+ import { reportP2PDiagnostic } from "../../p2p/p2pDiagnostics.js";
1
2
  import { parsePeerPatterns } from "./peerPatterns.js";
2
3
  import { selfId, joinRoom } from "@trystero-p2p/nostr";
3
4
  import { AutoAccepting, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE } from "../../common/types.js";
@@ -225,7 +226,10 @@ class TrysteroReplicatorP2PServer {
225
226
  if (peerId === this.serverPeerId) return;
226
227
  if (data.peerId === this.serverPeerId) return;
227
228
  if (data.peerId !== peerId) return;
229
+ const isNewPeer = !this._knownAdvertisements.has(peerId);
228
230
  this._knownAdvertisements.set(peerId, data);
231
+ if (isNewPeer)
232
+ reportP2PDiagnostic(this._env.reportDiagnostic, { category: "peer", code: "peer-advertised", peerId });
229
233
  void this.dispatchConnectionStatus();
230
234
  void Promise.resolve().then(() => this._sessionPeerHandlers?.onAdvertisement(data)).catch((error) => Logger(error, LOG_LEVEL_VERBOSE));
231
235
  void this._env.events.emitEvent(EVENT_ADVERTISEMENT_RECEIVED, data);
@@ -368,6 +372,7 @@ You can chose as follows:
368
372
  _onPeerLeave(peerId) {
369
373
  if (this._hostLifetimeEnded) return;
370
374
  Logger(`Peer left: ${peerId}`, LOG_LEVEL_VERBOSE);
375
+ reportP2PDiagnostic(this._env.reportDiagnostic, { category: "peer", code: "peer-left", peerId });
371
376
  this._knownAdvertisements.delete(peerId);
372
377
  void Promise.resolve().then(() => this._sessionPeerHandlers?.onPeerLeft(peerId)).catch((error) => Logger(error, LOG_LEVEL_VERBOSE));
373
378
  void this._env.events.emitEvent(EVENT_DEVICE_LEAVED, peerId);
@@ -459,6 +464,12 @@ You can chose as follows:
459
464
  const room = joinRoom(options, roomId, {
460
465
  handshakeTimeoutMs: 3e4,
461
466
  onJoinError: (error) => {
467
+ if (!this._hostLifetimeEnded)
468
+ reportP2PDiagnostic(this._env.reportDiagnostic, {
469
+ category: "relay",
470
+ code: "room-join-error",
471
+ reason: "transport-error"
472
+ });
462
473
  Logger("Some peer Failed to join Trystero room");
463
474
  this.logTransportError(error);
464
475
  }
@@ -0,0 +1,9 @@
1
+ import { type P2PDiagnosticInput } from "../../p2p/p2pDiagnostics.js";
2
+ import type { P2PReplicationResult } from "./TrysteroReplicator.js";
3
+ /** Describe an observed response without coercing an unavailable response to refusal. */
4
+ export declare function authenticationResultDiagnostic(peerId: string, response: unknown): P2PDiagnosticInput;
5
+ export declare function authenticationFailureDiagnostic(peerId: string, error: unknown, signal?: AbortSignal): P2PDiagnosticInput;
6
+ /** Classify the result without changing the operation's result object or error. */
7
+ export declare function pullResultDiagnostic(peerId: string, status: P2PReplicationResult["status"]): P2PDiagnosticInput;
8
+ export declare function pullFailureDiagnostic(peerId: string, cancelled: boolean): P2PDiagnosticInput;
9
+ export declare function notificationFailureDiagnostic(peerId: string, error: unknown): P2PDiagnosticInput;
@@ -0,0 +1,51 @@
1
+ import { classifyP2PRpcFailure } from "../../p2p/p2pDiagnostics.js";
2
+ const authenticationFailureCodes = {
3
+ "rpc-timeout": "auth-timeout",
4
+ "rpc-cancelled": "auth-cancelled",
5
+ "rpc-error": "auth-error"
6
+ };
7
+ const notificationFailureCodes = {
8
+ "rpc-timeout": "change-notification-timeout",
9
+ "rpc-cancelled": "change-notification-cancelled",
10
+ "rpc-error": "change-notification-error"
11
+ };
12
+ function authenticationResultDiagnostic(peerId, response) {
13
+ if (response === true) {
14
+ return { category: "peer", code: "auth-accepted", peerId };
15
+ }
16
+ if (response === false) {
17
+ return { category: "peer", code: "auth-refused", reason: "authentication-refused", peerId };
18
+ }
19
+ return { category: "peer", code: "auth-error", reason: "authentication-unobserved", peerId };
20
+ }
21
+ function authenticationFailureDiagnostic(peerId, error, signal) {
22
+ const reason = classifyP2PRpcFailure(error, signal);
23
+ return { category: "peer", code: authenticationFailureCodes[reason], reason, peerId };
24
+ }
25
+ function pullResultDiagnostic(peerId, status) {
26
+ switch (status) {
27
+ case "completed":
28
+ return { category: "sync", code: "pull-completed", peerId };
29
+ case "cancelled":
30
+ return { category: "sync", code: "pull-cancelled", reason: "operation-cancelled", peerId };
31
+ default:
32
+ return { category: "sync", code: "pull-failed", reason: "replication-error", peerId };
33
+ }
34
+ }
35
+ function pullFailureDiagnostic(peerId, cancelled) {
36
+ if (cancelled) {
37
+ return pullResultDiagnostic(peerId, "cancelled");
38
+ }
39
+ return pullResultDiagnostic(peerId, "failed");
40
+ }
41
+ function notificationFailureDiagnostic(peerId, error) {
42
+ const reason = classifyP2PRpcFailure(error);
43
+ return { category: "sync", code: notificationFailureCodes[reason], reason, peerId };
44
+ }
45
+ export {
46
+ authenticationFailureDiagnostic,
47
+ authenticationResultDiagnostic,
48
+ notificationFailureDiagnostic,
49
+ pullFailureDiagnostic,
50
+ pullResultDiagnostic
51
+ };
@@ -0,0 +1,15 @@
1
+ import type { ProgressInfo } from "../../pouchdb/ReplicatorShim.js";
2
+ import type { P2PReplicationDirectionProgress } from "./TrysteroReplicator.js";
3
+ /** Transient attempt correlation; no replication checkpoint is stored here. */
4
+ export type ProgressAcknowledgement = {
5
+ attempt: number;
6
+ finalProgress?: ProgressInfo;
7
+ };
8
+ export type AcknowledgedProgressAttempt = {
9
+ attempt: number;
10
+ isActive: boolean;
11
+ checkedChanges: number;
12
+ };
13
+ export declare function projectDirectionProgress(info?: ProgressInfo | null): P2PReplicationDirectionProgress;
14
+ /** Return the next attempt snapshot, or reject stale, reordered, or already-ended progress. */
15
+ export declare function projectAcknowledgedAttempt(previous: AcknowledgedProgressAttempt | undefined, info: ProgressInfo | null | undefined, acknowledgement: ProgressAcknowledgement): AcknowledgedProgressAttempt | undefined;
@@ -0,0 +1,37 @@
1
+ function projectDirectionProgress(info) {
2
+ return {
3
+ max: info?.maxSeqInBatch ?? 0,
4
+ current: info?.lastSeq ?? 0,
5
+ isActive: info != null,
6
+ ...info?.statistics ? { statistics: { ...info.statistics } } : {}
7
+ };
8
+ }
9
+ function projectAcknowledgedAttempt(previous, info, acknowledgement) {
10
+ if (!previous) {
11
+ return {
12
+ attempt: acknowledgement.attempt,
13
+ isActive: info != null,
14
+ checkedChanges: info?.statistics?.checkedChanges ?? 0
15
+ };
16
+ }
17
+ if (acknowledgement.attempt < previous.attempt) {
18
+ return void 0;
19
+ }
20
+ if (acknowledgement.attempt === previous.attempt) {
21
+ if (!previous.isActive) {
22
+ return void 0;
23
+ }
24
+ if ((info?.statistics?.checkedChanges ?? previous.checkedChanges) < previous.checkedChanges) {
25
+ return void 0;
26
+ }
27
+ }
28
+ return {
29
+ attempt: acknowledgement.attempt,
30
+ isActive: info != null,
31
+ checkedChanges: info?.statistics?.checkedChanges ?? previous.checkedChanges
32
+ };
33
+ }
34
+ export {
35
+ projectAcknowledgedAttempt,
36
+ projectDirectionProgress
37
+ };
@@ -5,6 +5,7 @@ import type { Confirm } from "../../interfaces/Confirm.js";
5
5
  import type { AsyncActivityRunner } from "../../interfaces/AsyncActivityRunner.js";
6
6
  import type { LiveSyncEventHub } from "../../hub/hub.js";
7
7
  import type { MessageTranslator } from "../../services/base/MessageTranslator.js";
8
+ import type { P2PDiagnosticReporter } from "../../p2p/p2pDiagnostics.js";
8
9
  import type { P2PAutomationCoordinator } from "./P2PAutomationCoordinator.js";
9
10
  export declare const DIRECTION_REQUEST = "request";
10
11
  export type DIRECTION_REQUEST = typeof DIRECTION_REQUEST;
@@ -78,6 +79,8 @@ export interface ReplicatorHost {
78
79
  confirm: Confirm;
79
80
  }
80
81
  export interface ReplicatorHostEnv extends ReplicatorHost {
82
+ /** Observation port fenced by the owning room session; it cannot control transport work. */
83
+ reportDiagnostic?: P2PDiagnosticReporter;
81
84
  /** Event channel owned by the containing Commonlib service composition. */
82
85
  events: LiveSyncEventHub;
83
86
  /** Message translation owned by the containing Commonlib service composition. */
@@ -72,6 +72,16 @@ export declare abstract class APIService<T extends ServiceContext = ServiceConte
72
72
  * @param handler The handler function for the protocol.
73
73
  */
74
74
  abstract registerProtocolHandler(action: string, handler: (params: Record<string, string>) => unknown): void;
75
+ /**
76
+ * Register synchronous cleanup for this host's normal terminal unload.
77
+ * The default uses the context-owned platform-unloaded event once per registration,
78
+ * after lifecycle handlers and local database closure. An unload interrupted before
79
+ * that event does not execute these callbacks.
80
+ * Native hosts may override this to retain their own unload timing. Callbacks are
81
+ * not awaited; resource retirement which must finish before database closure
82
+ * belongs to AppLifecycleService.onUnload instead.
83
+ */
84
+ registerCleanup(callback: () => void): void;
75
85
  /**
76
86
  * Get the basic UI component for showing a confirmation dialog to the user.
77
87
  */
@@ -1,3 +1,4 @@
1
+ import { EVENT_PLATFORM_UNLOADED } from "../../events/coreEvents.js";
1
2
  import { ServiceBase } from "./ServiceBase.js";
2
3
  import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
3
4
  import { _fetch, compatGlobal } from "../../common/coreEnvFunctions.js";
@@ -9,6 +10,18 @@ class APIService extends ServiceBase {
9
10
  showWindowOnRight(type) {
10
11
  return this.showWindow(type);
11
12
  }
13
+ /**
14
+ * Register synchronous cleanup for this host's normal terminal unload.
15
+ * The default uses the context-owned platform-unloaded event once per registration,
16
+ * after lifecycle handlers and local database closure. An unload interrupted before
17
+ * that event does not execute these callbacks.
18
+ * Native hosts may override this to retain their own unload timing. Callbacks are
19
+ * not awaited; resource retirement which must finish before database closure
20
+ * belongs to AppLifecycleService.onUnload instead.
21
+ */
22
+ registerCleanup(callback) {
23
+ this.context.events.onceEvent(EVENT_PLATFORM_UNLOADED, callback);
24
+ }
12
25
  requestCount = reactiveSource(0);
13
26
  responseCount = reactiveSource(0);
14
27
  get isOnline() {
@@ -49,6 +49,8 @@ export interface IAPIService {
49
49
  registerWindow<T>(type: string, factory: (leaf: T) => unknown): void;
50
50
  addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => unknown): HTMLElement;
51
51
  registerProtocolHandler(action: string, handler: (params: Record<string, string>) => unknown): void;
52
+ /** Registers synchronous cleanup at host terminal unload; callbacks are not awaited. */
53
+ registerCleanup(callback: () => void): void;
52
54
  confirm: Confirm;
53
55
  responseCount: ReactiveSource<number>;
54
56
  requestCount: ReactiveSource<number>;