@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,4 @@
1
+ import { EVENT_PLATFORM_UNLOADED } from "../../events/coreEvents.js";
1
2
  import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
2
3
  import {
3
4
  EVENT_ADVERTISEMENT_RECEIVED,
@@ -6,83 +7,117 @@ import {
6
7
  EVENT_DEVICE_LEAVED,
7
8
  EVENT_P2P_REPLICATOR_PROGRESS
8
9
  } from "./TrysteroReplicatorP2PServer.js";
10
+ function initialProgress(peerId, peerName) {
11
+ return {
12
+ peerId,
13
+ peerName,
14
+ fetching: { current: 0, max: 0, isActive: false },
15
+ sending: { current: 0, max: 0, isActive: false }
16
+ };
17
+ }
18
+ function applyReport(previous, report) {
19
+ if ("fetching" in report) {
20
+ if (report.fetching.isActive || report.fetching.statistics) {
21
+ previous.fetching = report.fetching;
22
+ } else {
23
+ previous.fetching.isActive = false;
24
+ }
25
+ }
26
+ if ("sending" in report) {
27
+ if (report.sending.isActive || report.sending.statistics) {
28
+ previous.sending = report.sending;
29
+ } else {
30
+ previous.sending.isActive = false;
31
+ }
32
+ }
33
+ }
34
+ function snapshotProgress(progress) {
35
+ const snapshotDirection = (direction) => Object.freeze({
36
+ ...direction,
37
+ ...direction.statistics ? { statistics: Object.freeze({ ...direction.statistics }) } : {}
38
+ });
39
+ return Object.freeze({
40
+ ...progress,
41
+ fetching: snapshotDirection(progress.fetching),
42
+ sending: snapshotDirection(progress.sending)
43
+ });
44
+ }
45
+ function renderProgressLine(progresses) {
46
+ const sortedProgresses = [...progresses].sort((a, b) => a.peerId.localeCompare(b.peerId));
47
+ const renderCount = (current, max) => {
48
+ if (current == max) {
49
+ return `${current}`;
50
+ }
51
+ return `${current} (${max})`;
52
+ };
53
+ return sortedProgresses.map(
54
+ (peer) => `${peer.fetching.isActive || peer.sending.isActive ? "\u26A1" : "\u{1F4A4}"} ${peer.peerName} \u2191 ${renderCount(peer.sending.current, peer.sending.max)} \u2193 ${renderCount(peer.fetching.current, peer.fetching.max)} `
55
+ ).join("\n");
56
+ }
9
57
  class P2PLogCollector {
58
+ eventController = new AbortController();
59
+ statisticsSource = reactiveSource(Object.freeze([]));
60
+ p2pReplicationStats = this.statisticsSource;
10
61
  constructor(events) {
11
- events.onEvent(EVENT_ADVERTISEMENT_RECEIVED, (data) => {
12
- this.p2pReplicationResult.set(data.peerId, {
13
- peerId: data.peerId,
14
- peerName: data.name,
15
- fetching: {
16
- current: 0,
17
- max: 0,
18
- isActive: false
19
- },
20
- sending: {
21
- current: 0,
22
- max: 0,
23
- isActive: false
24
- }
25
- });
26
- this.updateP2PReplicationLine();
27
- });
28
- events.onEvent(EVENT_P2P_CONNECTED, () => {
29
- this.p2pReplicationResult.clear();
30
- this.updateP2PReplicationLine();
31
- });
32
- events.onEvent(EVENT_P2P_DISCONNECTED, () => {
33
- this.p2pReplicationResult.clear();
34
- this.updateP2PReplicationLine();
35
- });
36
- events.onEvent(EVENT_DEVICE_LEAVED, (peerId) => {
37
- this.p2pReplicationResult.delete(peerId);
38
- this.updateP2PReplicationLine();
39
- });
40
- events.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (data) => {
41
- const prev = this.p2pReplicationResult.get(data.peerId) || {
42
- peerId: data.peerId,
43
- peerName: data.peerName,
44
- fetching: {
45
- current: 0,
46
- max: 0,
47
- isActive: false
48
- },
49
- sending: {
50
- current: 0,
51
- max: 0,
52
- isActive: false
53
- }
54
- };
55
- if ("fetching" in data) {
56
- if (data.fetching.isActive) {
57
- prev.fetching = data.fetching;
58
- } else {
59
- prev.fetching.isActive = false;
60
- }
61
- }
62
- if ("sending" in data) {
63
- if (data.sending.isActive) {
64
- prev.sending = data.sending;
65
- } else {
66
- prev.sending.isActive = false;
67
- }
68
- }
69
- this.p2pReplicationResult.set(data.peerId, prev);
70
- this.updateP2PReplicationLine();
71
- });
62
+ const options = { signal: this.eventController.signal };
63
+ events.onEvent(EVENT_ADVERTISEMENT_RECEIVED, (data) => this.handleAdvertisementReceived(data), options);
64
+ events.onEvent(EVENT_P2P_CONNECTED, () => this.resetPeerState(), options);
65
+ events.onEvent(EVENT_P2P_DISCONNECTED, () => this.resetPeerState(), options);
66
+ events.onEvent(EVENT_DEVICE_LEAVED, (peerId) => this.handleDeviceLeaved(peerId), options);
67
+ events.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (data) => this.handleReplicationProgress(data), options);
68
+ events.onEvent(EVENT_PLATFORM_UNLOADED, () => this.dispose(), options);
69
+ }
70
+ handleAdvertisementReceived(data) {
71
+ this.p2pReplicationResult.set(data.peerId, initialProgress(data.peerId, data.name));
72
+ this.updateP2PReplicationLine();
73
+ const statistics = this.statisticsFor(data.peerId, data.name);
74
+ statistics.peerName = data.name;
75
+ this.publishStatistics(statistics);
76
+ }
77
+ resetPeerState() {
78
+ this.p2pReplicationResult.clear();
79
+ this.updateP2PReplicationLine();
80
+ this.statisticsSource.value = Object.freeze([]);
81
+ }
82
+ handleDeviceLeaved(peerId) {
83
+ this.p2pReplicationResult.delete(peerId);
84
+ this.updateP2PReplicationLine();
85
+ this.statisticsSource.value = Object.freeze(
86
+ this.statisticsSource.value.filter((peer) => peer.peerId !== peerId)
87
+ );
88
+ }
89
+ handleReplicationProgress(data) {
90
+ const previous = this.p2pReplicationResult.get(data.peerId) ?? initialProgress(data.peerId, data.peerName);
91
+ applyReport(previous, data);
92
+ this.p2pReplicationResult.set(data.peerId, previous);
93
+ this.updateP2PReplicationLine();
94
+ const statistics = this.statisticsFor(data.peerId, data.peerName);
95
+ applyReport(statistics, data);
96
+ this.publishStatistics(statistics);
97
+ }
98
+ statisticsFor(peerId, peerName) {
99
+ const previous = this.statisticsSource.value.find((peer) => peer.peerId === peerId);
100
+ return previous ? {
101
+ ...previous,
102
+ fetching: { ...previous.fetching },
103
+ sending: { ...previous.sending }
104
+ } : initialProgress(peerId, peerName);
105
+ }
106
+ publishStatistics(progress) {
107
+ const snapshot = snapshotProgress(progress);
108
+ this.statisticsSource.value = Object.freeze(
109
+ [...this.statisticsSource.value.filter((peer) => peer.peerId !== progress.peerId), snapshot].sort(
110
+ (a, b) => a.peerId.localeCompare(b.peerId)
111
+ )
112
+ );
113
+ }
114
+ /** Release this collector's event registrations without affecting another consumer. */
115
+ dispose() {
116
+ this.eventController.abort();
72
117
  }
73
118
  p2pReplicationResult = /* @__PURE__ */ new Map();
74
119
  updateP2PReplicationLine() {
75
- const p2pReplicationResultX = [...this.p2pReplicationResult.values()].sort(
76
- (a, b) => a.peerId.localeCompare(b.peerId)
77
- );
78
- const renderProgress = (current, max) => {
79
- if (current == max) return `${current}`;
80
- return `${current} (${max})`;
81
- };
82
- const line = p2pReplicationResultX.map(
83
- (e) => `${e.fetching.isActive || e.sending.isActive ? "\u26A1" : "\u{1F4A4}"} ${e.peerName} \u2191 ${renderProgress(e.sending.current, e.sending.max)} \u2193 ${renderProgress(e.fetching.current, e.fetching.max)} `
84
- ).join("\n");
85
- this.p2pReplicationLine.value = line;
120
+ this.p2pReplicationLine.value = renderProgressLine(this.p2pReplicationResult.values());
86
121
  }
87
122
  p2pReplicationLine = reactiveSource("");
88
123
  }
@@ -1,3 +1,4 @@
1
+ import { type P2PDiagnosticReporter } from "../../p2p/p2pDiagnostics.js";
1
2
  import { type P2PSyncSetting } from "../../common/types.js";
2
3
  import type { LiveSyncReplicatorEnv } from "../LiveSyncAbstractReplicator.js";
3
4
  import { P2PRoomSession } from "./P2PRoomSession.js";
@@ -8,6 +9,7 @@ type P2PRoomSessionFactory = (env: ReplicatorHostEnv) => P2PRoomSession;
8
9
  export type PrepareP2PSettings = (settings: Readonly<P2PSyncSetting>, signal: AbortSignal) => Promise<P2PSyncSetting>;
9
10
  export interface P2PRoomSessionOwnerOptions {
10
11
  readonly prepareP2PSettings?: PrepareP2PSettings;
12
+ readonly onDiagnosticEvent?: P2PDiagnosticReporter;
11
13
  }
12
14
  export declare const P2P_SETTINGS_PREPARATION_TIMEOUT_MS = 30000;
13
15
  export declare const P2P_ICE_SERVER_EXPIRY_MARGIN_MS = 30000;
@@ -39,6 +41,7 @@ export declare class P2PRoomSessionOwner implements P2PRoomSessionAccess {
39
41
  private activeBinding?;
40
42
  private pendingPreparation?;
41
43
  private runtimeGeneration;
44
+ private diagnosticGeneration;
42
45
  constructor(env: LiveSyncReplicatorEnv, createSession?: P2PRoomSessionFactory, options?: P2PRoomSessionOwnerOptions);
43
46
  get currentSession(): P2PRoomSession | undefined;
44
47
  get isConnected(): boolean;
@@ -62,6 +65,7 @@ export declare class P2PRoomSessionOwner implements P2PRoomSessionAccess {
62
65
  private hasRoomDemand;
63
66
  private enqueueLifecycleOperation;
64
67
  private closeTransport;
68
+ private reportDiagnostic;
65
69
  private bindingsMatch;
66
70
  private connectionSettingsRemainUsable;
67
71
  private fencePreparationForCurrentSettings;
@@ -1,3 +1,4 @@
1
+ import { reportP2PDiagnostic } from "../../p2p/p2pDiagnostics.js";
1
2
  import { Logger } from "../../common/logger.js";
2
3
  import {
3
4
  LOG_LEVEL_NOTICE,
@@ -39,6 +40,7 @@ class P2PRoomSessionOwner {
39
40
  activeBinding;
40
41
  pendingPreparation;
41
42
  runtimeGeneration = 0;
43
+ diagnosticGeneration = 0;
42
44
  get currentSession() {
43
45
  return this.current;
44
46
  }
@@ -105,7 +107,9 @@ class P2PRoomSessionOwner {
105
107
  /** Arbitrate a complete signalling probe on the room lifecycle queue. */
106
108
  runConnectionProbe(trialSettings, runOwnedTrial) {
107
109
  try {
108
- assertSupportedPathNormalizationVersion(this.env.services.setting.currentSettings().pathNormalizationVersion);
110
+ assertSupportedPathNormalizationVersion(
111
+ this.env.services.setting.currentSettings().pathNormalizationVersion
112
+ );
109
113
  } catch (error) {
110
114
  return Promise.reject(error);
111
115
  }
@@ -154,6 +158,7 @@ class P2PRoomSessionOwner {
154
158
  }
155
159
  let candidate;
156
160
  const operationGeneration = this.runtimeGeneration;
161
+ this.reportDiagnostic({ category: "relay", code: "room-opening", reason: "relay-details-unobserved" });
157
162
  try {
158
163
  const connectionSettings = await this.prepareConnectionSettings(binding, operationGeneration);
159
164
  await this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
@@ -169,13 +174,21 @@ class P2PRoomSessionOwner {
169
174
  }
170
175
  const currentBinding = await this.getEffectiveBinding();
171
176
  if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding) || operationGeneration !== this.runtimeGeneration || !this.connectionSettingsRemainUsable(connectionSettings)) {
177
+ this.diagnosticGeneration += 1;
172
178
  await candidate.retire();
179
+ this.reportDiagnostic({
180
+ category: "relay",
181
+ code: "room-open-cancelled",
182
+ reason: "operation-cancelled"
183
+ });
173
184
  return;
174
185
  }
175
186
  this.current = candidate;
176
187
  this.activeBinding = connectionBinding;
177
188
  this.reconcileCurrentSessionPolicy(candidate);
189
+ this.reportDiagnostic({ category: "relay", code: "room-opened", reason: "relay-details-unobserved" });
178
190
  } catch (error) {
191
+ this.diagnosticGeneration += 1;
179
192
  await candidate?.retire(error).catch((retirementError) => {
180
193
  if (hasManagedP2PTurnConfiguration(binding.settings)) {
181
194
  Logger("The managed P2P room could not be retired cleanly.", LOG_LEVEL_VERBOSE);
@@ -186,8 +199,14 @@ class P2PRoomSessionOwner {
186
199
  this.current = void 0;
187
200
  this.activeBinding = void 0;
188
201
  if (error instanceof StaleRoomReconciliationError || operationGeneration !== this.runtimeGeneration) {
202
+ this.reportDiagnostic({
203
+ category: "relay",
204
+ code: "room-open-cancelled",
205
+ reason: "operation-cancelled"
206
+ });
189
207
  return;
190
208
  }
209
+ this.reportDiagnostic({ category: "relay", code: "room-open-failed", reason: "transport-error" });
191
210
  if (hasManagedP2PTurnConfiguration(binding.settings)) {
192
211
  Logger("The managed P2P room could not be prepared or opened.", LOG_LEVEL_NOTICE);
193
212
  Logger("Managed P2P room failure details were omitted.", LOG_LEVEL_VERBOSE);
@@ -222,9 +241,19 @@ class P2PRoomSessionOwner {
222
241
  this.current = void 0;
223
242
  this.activeBinding = void 0;
224
243
  if (session) {
225
- await session.retire();
244
+ this.diagnosticGeneration += 1;
245
+ try {
246
+ await session.retire();
247
+ this.reportDiagnostic({ category: "relay", code: "room-closed", reason: "relay-details-unobserved" });
248
+ } catch (error) {
249
+ this.reportDiagnostic({ category: "relay", code: "room-close-failed", reason: "transport-error" });
250
+ throw error;
251
+ }
226
252
  }
227
253
  }
254
+ reportDiagnostic(input) {
255
+ reportP2PDiagnostic(this.options.onDiagnosticEvent, input);
256
+ }
228
257
  bindingsMatch(current, candidate) {
229
258
  return current?.database === candidate.database && current.signature === candidate.signature;
230
259
  }
@@ -413,7 +442,15 @@ class P2PRoomSessionOwner {
413
442
  }
414
443
  buildSessionEnv(binding) {
415
444
  const services = this.env.services;
445
+ const diagnosticGeneration = ++this.diagnosticGeneration;
416
446
  return {
447
+ reportDiagnostic: (event) => {
448
+ if (diagnosticGeneration !== this.diagnosticGeneration) return;
449
+ try {
450
+ this.options.onDiagnosticEvent?.(event);
451
+ } catch {
452
+ }
453
+ },
417
454
  events: services.context.events,
418
455
  translate: services.context.translate,
419
456
  settings: binding.settings,
@@ -1,6 +1,7 @@
1
+ import { type ProgressAcknowledgement } from "./p2pReplicationProgress.js";
1
2
  import { type EntryDoc, type ObsidianLiveSyncSettings, type P2PSyncSetting } from "../../common/types.js";
2
3
  import { type LOG_LEVEL } from "octagonal-wheels/common/logger";
3
- import { type ProgressInfo } from "../../pouchdb/ReplicatorShim.js";
4
+ import { type ProgressInfo, type ReplicationStatistics } from "../../pouchdb/ReplicatorShim.js";
4
5
  import type { Confirm } from "../../interfaces/Confirm.js";
5
6
  import { type Advertisement, type ReplicatorHostEnv } from "./types.js";
6
7
  import { EVENT_P2P_REPLICATOR_PROGRESS, EVENT_P2P_REPLICATOR_STATUS, P2PHost } from "./TrysteroReplicatorP2PServer.js";
@@ -12,35 +13,26 @@ export type P2PReplicatorStatus = {
12
13
  replicatingFrom: string[];
13
14
  watchingPeers: string[];
14
15
  };
16
+ /** Sequence progress retained for compatibility, with independently observed work counts. */
17
+ export type P2PReplicationDirectionProgress = {
18
+ max: number;
19
+ current: number;
20
+ isActive: boolean;
21
+ statistics?: ReplicationStatistics;
22
+ };
15
23
  export type P2PReplicationProgress = {
16
24
  peerId: string;
17
25
  peerName: string;
18
- fetching: {
19
- max: number;
20
- current: number;
21
- isActive: boolean;
22
- };
23
- sending: {
24
- max: number;
25
- current: number;
26
- isActive: boolean;
27
- };
26
+ fetching: P2PReplicationDirectionProgress;
27
+ sending: P2PReplicationDirectionProgress;
28
28
  };
29
29
  export type P2PReplicationReport = {
30
30
  peerId: string;
31
31
  peerName: string;
32
32
  } & ({
33
- fetching: {
34
- max: number;
35
- current: number;
36
- isActive: boolean;
37
- };
33
+ fetching: P2PReplicationDirectionProgress;
38
34
  } | {
39
- sending: {
40
- max: number;
41
- current: number;
42
- isActive: boolean;
43
- };
35
+ sending: P2PReplicationDirectionProgress;
44
36
  });
45
37
  /**
46
38
  * In-process outcome of one P2P replication request.
@@ -112,9 +104,11 @@ export declare class TrysteroReplicator {
112
104
  _env: ReplicatorHostEnv;
113
105
  private readonly finiteOperationOwner?;
114
106
  private disposed;
107
+ private readonly acknowledgedAttempts;
115
108
  private readonly fallbackAutomationCoordinator;
116
109
  server?: P2PHost;
117
110
  replicationStatus(): {};
111
+ private reportDiagnostic;
118
112
  get settings(): P2PSyncSetting;
119
113
  /** Latest room-local policy settings supplied by the owning host. */
120
114
  get currentSettings(): P2PSyncSetting;
@@ -136,6 +130,7 @@ export declare class TrysteroReplicator {
136
130
  get autoSyncPeers(): RegExp[];
137
131
  get autoWatchPeers(): RegExp[];
138
132
  onNewPeer(peer: Advertisement): Promise<void>;
133
+ private watchBroadcastingPeer;
139
134
  onPeerLeaved(peerId: string): void;
140
135
  _onSetup: boolean;
141
136
  setOnSetup(): void;
@@ -524,7 +519,7 @@ export declare class TrysteroReplicator {
524
519
  getAllConfig: (fromPeerId: string) => Promise<string | {
525
520
  error: import("../../rpc/types.js").RpcErrorShape;
526
521
  }>;
527
- onProgressAcknowledged: (fromPeerId: string, info: ProgressInfo) => Promise<void>;
522
+ onProgressAcknowledged: (fromPeerId: string, info?: ProgressInfo | null, acknowledgement?: ProgressAcknowledgement) => Promise<void>;
528
523
  getIsBroadcasting: () => Promise<boolean>;
529
524
  requestBroadcasting: (peerId: string) => Promise<true | {
530
525
  error: import("../../rpc/types.js").RpcErrorShape;
@@ -551,13 +546,15 @@ export declare class TrysteroReplicator {
551
546
  _replicateFromPeers: Set<string>;
552
547
  dispatchReplicationProgress(peerId: string, info?: ProgressInfo): void;
553
548
  onReplicationProgress(peerId: string, info?: ProgressInfo): boolean;
554
- onProgressAcknowledged(peerId: string, info?: ProgressInfo): boolean;
555
- acknowledgeProgress(remotePeerId: string, info?: ProgressInfo): void;
549
+ onProgressAcknowledged(peerId: string, info?: ProgressInfo | null, acknowledgement?: ProgressAcknowledgement): boolean;
550
+ acknowledgeProgress(remotePeerId: string, info?: ProgressInfo, acknowledgement?: ProgressAcknowledgement): void;
556
551
  replicateFrom(remotePeer: string, showNotice?: boolean, fromStart?: boolean, skipOrdinaryReplicationPolicy?: boolean, callerSignal?: AbortSignal): Promise<P2PReplicationResult>;
557
552
  private replicateFromWithinSession;
553
+ private pullFromWithinSession;
558
554
  private checkPeerForReplication;
559
555
  private transferFromPeer;
560
556
  notifyProgress(excludePeerId?: string): Promise<void>;
557
+ private notifyPeerProgress;
561
558
  requestBroadcastChanges(peerId: string): Promise<unknown>;
562
559
  getRemoteIsBroadcasting(peerId: string): Promise<unknown>;
563
560
  _watchingPeers: Set<string>;