@vrtmrz/livesync-commonlib 0.3.1 → 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 (51) hide show
  1. package/README.md +29 -2
  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/SyncParamsHandler.d.ts +1 -0
  14. package/dist/replication/SyncParamsHandler.js +5 -1
  15. package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
  16. package/dist/replication/journal/JournalReceiveFailure.js +16 -0
  17. package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
  18. package/dist/replication/journal/JournalSyncCore.js +288 -166
  19. package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
  20. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
  21. package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
  22. package/dist/replication/journal/journalCompatibility.d.ts +9 -0
  23. package/dist/replication/journal/journalCompatibility.js +58 -0
  24. package/dist/replication/journal/journalReceive.d.ts +23 -0
  25. package/dist/replication/journal/journalReceive.js +44 -0
  26. package/dist/replication/journal/journalRecords.d.ts +10 -0
  27. package/dist/replication/journal/journalRecords.js +96 -0
  28. package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
  29. package/dist/replication/trystero/P2PLogCollector.js +107 -72
  30. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
  31. package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
  32. package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
  33. package/dist/replication/trystero/TrysteroReplicator.js +191 -69
  34. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
  35. package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
  36. package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
  37. package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
  38. package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
  39. package/dist/replication/trystero/types.d.ts +3 -0
  40. package/dist/services/base/APIService.d.ts +10 -0
  41. package/dist/services/base/APIService.js +13 -0
  42. package/dist/services/base/IService.d.ts +2 -0
  43. package/docs/adr/2026_11_shared_storage_path_resolution.md +4 -2
  44. package/docs/design_docs/storage_path_resolution.md +8 -7
  45. package/docs/development.md +2 -2
  46. package/docs/journal-receive-recovery.md +69 -0
  47. package/docs/p2p-transport-lifecycle.md +117 -10
  48. package/docs/platform-storage.md +2 -2
  49. package/docs/releasing.md +6 -0
  50. package/docs/unicode-path-identity.md +9 -8
  51. package/package.json +2 -2
@@ -12,7 +12,7 @@ import { MinioStorageAdapter } from "./objectstore/MinioStorageAdapter.js";
12
12
  import { LiveSyncAbstractReplicator } from "../LiveSyncAbstractReplicator.js";
13
13
  import { ensureRemoteIsCompatible } from "../../pouchdb/LiveSyncDBFunctions.js";
14
14
  import { getEffectiveTweakValues } from "../../pouchdb/LiveSyncDBFunctions.js";
15
- import { assertSupportedPathNormalizationVersion, getPathNormalizationVersion } from "../../common/pathNormalization.js";
15
+ import { assertSupportedPathNormalizationVersion } from "../../common/pathNormalization.js";
16
16
  import { JournalStorageReadStatuses } from "./objectstore/JournalStorageAdapter.js";
17
17
  import { computeKeyedId, configuredIdKey } from "../../common/idDerivation.js";
18
18
  import { decryptString, encryptString } from "../../encryption/stringEncryption.js";
@@ -31,6 +31,12 @@ import {
31
31
  forgetJoinedDevice as forgetJoinedDeviceFromStore,
32
32
  projectJoinedDevices
33
33
  } from "../joinedDevices.js";
34
+ import {
35
+ journalVersionRange,
36
+ hasJournalNodeDeclarations,
37
+ matchesJournalPathIdentity,
38
+ inspectJournalPathIdentity
39
+ } from "./journalCompatibility.js";
34
40
  const MILSTONE_DOCID = "_00000000-milestone.json";
35
41
  async function readRemoteMilestone(client) {
36
42
  const result = await client.downloadJsonWithResult(MILSTONE_DOCID);
@@ -38,23 +44,6 @@ async function readRemoteMilestone(client) {
38
44
  if (result.status === JournalStorageReadStatuses.NOT_FOUND) return void 0;
39
45
  throw result.error;
40
46
  }
41
- const currentVersionRange = {
42
- min: 0,
43
- max: 2,
44
- current: 2
45
- };
46
- function journalVersionRange(setting) {
47
- if (getPathNormalizationVersion(setting) === 1) return { min: 4, max: 4, current: 4 };
48
- return configuredIdKey(setting) && setting.encrypt && setting.usePathObfuscation ? { min: 3, max: 3, current: 3 } : currentVersionRange;
49
- }
50
- function pathIdentityFromJournalRange(range) {
51
- if (range?.min === 4 && range?.max === 4) return 1;
52
- if (!range) return 0;
53
- if (Number.isSafeInteger(range.min) && Number.isSafeInteger(range.max) && range.min >= 0 && range.max <= 3 && range.min <= range.max) {
54
- return 0;
55
- }
56
- throw new Error("The Journal declares an unsupported path identity range.");
57
- }
58
47
  class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
59
48
  get client() {
60
49
  return this.setupJournalSyncClient();
@@ -89,17 +78,8 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
89
78
  return this._client;
90
79
  }
91
80
  async isBucketPathIdentityCompatible(setting, milestone, client) {
92
- const pathVersion = getPathNormalizationVersion(setting);
93
- const ranges = milestone?.node_chunk_info ?? {};
94
- const nodes = /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(ranges)]);
95
- for (const node of nodes) {
96
- const range = ranges[node];
97
- const usesNFC = range?.min === 4 && range?.max === 4;
98
- if (usesNFC !== (pathVersion === 1)) return false;
99
- }
100
- const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
101
- if (preferred && getPathNormalizationVersion(preferred) !== pathVersion) return false;
102
- if (nodes.size === 0 && await client.hasRemoteJournals()) {
81
+ if (!matchesJournalPathIdentity(setting, milestone)) return false;
82
+ if (!hasJournalNodeDeclarations(milestone) && await client.hasRemoteJournals()) {
103
83
  throw new Error(
104
84
  "The Journal compatibility milestone is missing. Rebuild from the authoritative Vault before synchronising."
105
85
  );
@@ -144,22 +124,13 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
144
124
  async inspectRemotePathIdentity(setting) {
145
125
  const client = this.setupJournalSyncClient(setting);
146
126
  const milestone = await readRemoteMilestone(client);
147
- const versions = /* @__PURE__ */ new Set();
148
- const ranges = milestone?.node_chunk_info ?? {};
149
- const nodes = /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(ranges)]);
150
- for (const node of nodes) {
151
- versions.add(pathIdentityFromJournalRange(ranges[node]));
152
- }
153
- const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
154
- if (preferred) versions.add(getPathNormalizationVersion(preferred));
155
- if (versions.size > 1) throw new Error("The Journal has conflicting path identity declarations.");
156
- if (nodes.size === 0 && await client.hasRemoteJournals()) {
127
+ const identity = inspectJournalPathIdentity(milestone);
128
+ if (!hasJournalNodeDeclarations(milestone) && await client.hasRemoteJournals()) {
157
129
  throw new Error(
158
130
  "The Journal path identity declaration is missing. Use matching settings or Rebuild from the authoritative Vault."
159
131
  );
160
132
  }
161
- const version = [...versions][0];
162
- return version === void 0 ? { status: "empty" } : { status: "configured", pathNormalizationVersion: version };
133
+ return identity;
163
134
  }
164
135
  /** Lock existing journals for replacement without declaring the replacement's identity. */
165
136
  async markRemoteLockedForRebuild(setting) {
@@ -178,7 +149,7 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
178
149
  throw new Error("Could not upload remote milestone");
179
150
  }
180
151
  }
181
- async ensureBucketIsCompatible(deviceNodeID, currentVersionRange2, setting = this.currentSettings, client = this.setupJournalSyncClient(setting), recordTweakAssessment) {
152
+ async ensureBucketIsCompatible(deviceNodeID, currentVersionRange, setting = this.currentSettings, client = this.setupJournalSyncClient(setting), recordTweakAssessment) {
182
153
  const downloadedMilestone = await readRemoteMilestone(client) ?? false;
183
154
  if (!await this.isBucketPathIdentityCompatible(setting, downloadedMilestone || void 0, client)) {
184
155
  return "INCOMPATIBLE";
@@ -193,7 +164,7 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
193
164
  downloadedMilestone,
194
165
  setting,
195
166
  deviceNodeID,
196
- currentVersionRange2,
167
+ currentVersionRange,
197
168
  {
198
169
  app_version: this.env.services.API.getAppVersion(),
199
170
  plugin_version: this.env.services.API.getPluginVersion(),
@@ -356,6 +327,18 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
356
327
  (client) => client.sendLocalJournal(showingNotice)
357
328
  );
358
329
  }
330
+ /** Return the stopped Journal from the current transfer client without creating another client. */
331
+ getStoppedJournal() {
332
+ return this._client?.getStoppedJournal();
333
+ }
334
+ /** Recheck connectivity and compatibility, skip the confirmed Journal, and resume receipt. */
335
+ async skipStoppedJournalAndResume(setting, stopped, showingNotice = true) {
336
+ return await this.runDirectionalReplication(
337
+ setting,
338
+ showingNotice,
339
+ (client) => client.skipStoppedJournalAndResume(stopped, showingNotice)
340
+ );
341
+ }
359
342
  async replicateAllFromServer(setting, showingNotice) {
360
343
  return await this.runDirectionalReplication(
361
344
  setting,
@@ -721,6 +704,10 @@ ${ex.message}`, LOG_LEVEL_NOTICE);
721
704
  return Promise.resolve(false);
722
705
  }
723
706
  }
707
+ function isLiveSyncJournalReplicator(value) {
708
+ return value instanceof LiveSyncJournalReplicator;
709
+ }
724
710
  export {
725
- LiveSyncJournalReplicator
711
+ LiveSyncJournalReplicator,
712
+ isLiveSyncJournalReplicator
726
713
  };
@@ -0,0 +1,9 @@
1
+ import { type ChunkVersionRange, type EntryMilestoneInfo, type RemoteDBSettings } from "../../common/types.js";
2
+ import type { PathIdentityInspection } from "../../pouchdb/pathIdentityCompatibility.js";
3
+ export declare function journalVersionRange(setting: RemoteDBSettings): ChunkVersionRange;
4
+ export declare function pathIdentityFromJournalRange(range: ChunkVersionRange | undefined): 0 | 1;
5
+ /** Without a node declaration, the caller must inspect storage before treating the bucket as empty. */
6
+ export declare function hasJournalNodeDeclarations(milestone: EntryMilestoneInfo | undefined): boolean;
7
+ export declare function matchesJournalPathIdentity(setting: RemoteDBSettings, milestone: EntryMilestoneInfo | undefined): boolean;
8
+ /** Project declarations only; an empty result does not establish that remote storage is empty. */
9
+ export declare function inspectJournalPathIdentity(milestone: EntryMilestoneInfo | undefined): PathIdentityInspection;
@@ -0,0 +1,58 @@
1
+ import {
2
+ DEVICE_ID_PREFERRED
3
+ } from "../../common/types.js";
4
+ import { configuredIdKey } from "../../common/idDerivation.js";
5
+ import { getPathNormalizationVersion } from "../../common/pathNormalization.js";
6
+ const currentVersionRange = {
7
+ min: 0,
8
+ max: 2,
9
+ current: 2
10
+ };
11
+ function journalVersionRange(setting) {
12
+ if (getPathNormalizationVersion(setting) === 1) return { min: 4, max: 4, current: 4 };
13
+ return configuredIdKey(setting) && setting.encrypt && setting.usePathObfuscation ? { min: 3, max: 3, current: 3 } : currentVersionRange;
14
+ }
15
+ function pathIdentityFromJournalRange(range) {
16
+ if (range?.min === 4 && range?.max === 4) return 1;
17
+ if (!range) return 0;
18
+ if (Number.isSafeInteger(range.min) && Number.isSafeInteger(range.max) && range.min >= 0 && range.max <= 3 && range.min <= range.max) {
19
+ return 0;
20
+ }
21
+ throw new Error("The Journal declares an unsupported path identity range.");
22
+ }
23
+ function journalPathIdentityNodes(milestone) {
24
+ return /* @__PURE__ */ new Set([...milestone?.accepted_nodes ?? [], ...Object.keys(milestone?.node_chunk_info ?? {})]);
25
+ }
26
+ function hasJournalNodeDeclarations(milestone) {
27
+ return journalPathIdentityNodes(milestone).size > 0;
28
+ }
29
+ function matchesJournalPathIdentity(setting, milestone) {
30
+ const pathVersion = getPathNormalizationVersion(setting);
31
+ const ranges = milestone?.node_chunk_info ?? {};
32
+ for (const node of journalPathIdentityNodes(milestone)) {
33
+ const range = ranges[node];
34
+ const usesNFC = range?.min === 4 && range?.max === 4;
35
+ if (usesNFC !== (pathVersion === 1)) return false;
36
+ }
37
+ const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
38
+ return !preferred || getPathNormalizationVersion(preferred) === pathVersion;
39
+ }
40
+ function inspectJournalPathIdentity(milestone) {
41
+ const versions = /* @__PURE__ */ new Set();
42
+ const ranges = milestone?.node_chunk_info ?? {};
43
+ for (const node of journalPathIdentityNodes(milestone)) {
44
+ versions.add(pathIdentityFromJournalRange(ranges[node]));
45
+ }
46
+ const preferred = milestone?.tweak_values?.[DEVICE_ID_PREFERRED];
47
+ if (preferred) versions.add(getPathNormalizationVersion(preferred));
48
+ if (versions.size > 1) throw new Error("The Journal has conflicting path identity declarations.");
49
+ const version = [...versions][0];
50
+ return version === void 0 ? { status: "empty" } : { status: "configured", pathNormalizationVersion: version };
51
+ }
52
+ export {
53
+ hasJournalNodeDeclarations,
54
+ inspectJournalPathIdentity,
55
+ journalVersionRange,
56
+ matchesJournalPathIdentity,
57
+ pathIdentityFromJournalRange
58
+ };
@@ -0,0 +1,23 @@
1
+ import type { EntryDoc } from "../../common/types.js";
2
+ import type { ProcessingEntry } from "./journalRecords.js";
3
+ type ChunkReadRow = {
4
+ id?: string;
5
+ error?: string;
6
+ };
7
+ type WriteResult = PouchDB.Core.Error | PouchDB.Core.Response;
8
+ export declare function partitionJournalDocuments(entries: readonly ProcessingEntry[]): {
9
+ chunks: ProcessingEntry[];
10
+ docs: ProcessingEntry[];
11
+ };
12
+ /** Chunks have stable content, so only absent identifiers need a new local revision. */
13
+ export declare function selectChunksToSave(chunks: readonly ProcessingEntry[], rows: readonly ChunkReadRow[]): PouchDB.Core.PutDocument<EntryDoc>[];
14
+ export declare function hasJournalWriteErrors(results: readonly WriteResult[]): boolean;
15
+ /** Report persisted Chunks even when another row failed, but require every requested Chunk for completion. */
16
+ export declare function assessChunkWrites<T extends PouchDB.Core.PutDocument<EntryDoc>>(chunks: readonly T[], results: readonly WriteResult[]): {
17
+ persisted: T[];
18
+ failed: boolean;
19
+ };
20
+ export declare function journalMetadataRevisions(docs: readonly ProcessingEntry[]): PouchDB.Core.RevisionDiffOptions;
21
+ /** Retain complete transferred Metadata, including revision ancestry, for each missing revision. */
22
+ export declare function selectMetadataToSave(docs: readonly ProcessingEntry[], diff: PouchDB.Core.RevisionDiffResponse): ProcessingEntry[];
23
+ export {};
@@ -0,0 +1,44 @@
1
+ function partitionJournalDocuments(entries) {
2
+ const chunks = [];
3
+ const docs = [];
4
+ for (const entry of entries) {
5
+ (entry._id.startsWith("h:") ? chunks : docs).push(entry);
6
+ }
7
+ return { chunks, docs };
8
+ }
9
+ function selectChunksToSave(chunks, rows) {
10
+ if (rows.some((row) => "error" in row && row.error !== "not_found")) {
11
+ throw new Error("Could not inspect existing Chunks");
12
+ }
13
+ const existingIDs = new Set(rows.map((row) => row.id).filter((id) => id !== void 0));
14
+ return chunks.filter((entry) => !existingIDs.has(entry._id)).map((entry) => ({ ...entry, _rev: void 0 }));
15
+ }
16
+ function hasJournalWriteErrors(results) {
17
+ return results.some((row) => "error" in row);
18
+ }
19
+ function assessChunkWrites(chunks, results) {
20
+ const persistedIDs = new Set(results.filter((row) => !("error" in row)).map((row) => row.id));
21
+ return {
22
+ persisted: chunks.filter((entry) => persistedIDs.has(entry._id)),
23
+ failed: hasJournalWriteErrors(results) || chunks.some((entry) => !persistedIDs.has(entry._id))
24
+ };
25
+ }
26
+ function journalMetadataRevisions(docs) {
27
+ return docs.reduce(
28
+ (revisions, entry) => ({ ...revisions, [entry._id]: [...revisions[entry._id] ?? [], entry._rev] }),
29
+ {}
30
+ );
31
+ }
32
+ function selectMetadataToSave(docs, diff) {
33
+ return docs.filter(
34
+ (entry) => entry._id in diff && "missing" in diff[entry._id] && (diff[entry._id].missing?.indexOf(entry._rev) ?? 0) !== -1
35
+ );
36
+ }
37
+ export {
38
+ assessChunkWrites,
39
+ hasJournalWriteErrors,
40
+ journalMetadataRevisions,
41
+ partitionJournalDocuments,
42
+ selectChunksToSave,
43
+ selectMetadataToSave
44
+ };
@@ -0,0 +1,10 @@
1
+ import { type EntryDoc } from "../../common/types.js";
2
+ export type ProcessingEntry = PouchDB.Core.PutDocument<EntryDoc> & PouchDB.Core.GetMeta;
3
+ export declare function serializeDoc(doc: EntryDoc): Uint8Array;
4
+ export declare function decodeJournalRecords(data: Uint8Array): {
5
+ ok: true;
6
+ docs: ProcessingEntry[];
7
+ } | {
8
+ ok: false;
9
+ stage: "decode" | "parse";
10
+ };
@@ -0,0 +1,96 @@
1
+ import {} from "../../common/types.js";
2
+ import { escapeNewLineFromString, unescapeNewLineFromString } from "../../common/utils.js";
3
+ const RECORD_SPLIT = `
4
+ `;
5
+ const UNIT_SPLIT = ``;
6
+ const te = new TextEncoder();
7
+ function parseJournalRecord(value) {
8
+ const parsed = JSON.parse(value);
9
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
10
+ throw new TypeError("Invalid Journal record");
11
+ }
12
+ const record = parsed;
13
+ if (typeof record._id !== "string" || record._id.length === 0) {
14
+ throw new TypeError("Invalid Journal document identifier");
15
+ }
16
+ if (record._id.startsWith("h:")) {
17
+ if (record._id.length <= 2 || typeof record._rev !== "string" || typeof record.data !== "string") {
18
+ throw new TypeError("Invalid Journal Chunk record");
19
+ }
20
+ } else if (typeof record._rev !== "string" || record._rev.length === 0) {
21
+ throw new TypeError("Invalid Journal Metadata revision");
22
+ }
23
+ return parsed;
24
+ }
25
+ function decodeCompactJournalRecord(value) {
26
+ const separatorIndex = value.indexOf(UNIT_SPLIT);
27
+ if (separatorIndex === -1) {
28
+ return { ok: false, stage: "parse" };
29
+ }
30
+ const idPart = value.substring(1, separatorIndex);
31
+ if (!idPart.startsWith("h:") || idPart.length <= 2) {
32
+ return { ok: false, stage: "parse" };
33
+ }
34
+ const dataPart = value.substring(separatorIndex + 1);
35
+ return {
36
+ ok: true,
37
+ doc: {
38
+ _id: idPart,
39
+ data: unescapeNewLineFromString(dataPart),
40
+ type: "leaf",
41
+ _rev: ""
42
+ }
43
+ };
44
+ }
45
+ function decodeJournalRecordFrame(frame, decoder) {
46
+ let value;
47
+ try {
48
+ value = decoder.decode(frame);
49
+ } catch {
50
+ return { ok: false, stage: "decode" };
51
+ }
52
+ if (value.startsWith("~")) {
53
+ return decodeCompactJournalRecord(value);
54
+ }
55
+ try {
56
+ return { ok: true, doc: parseJournalRecord(value) };
57
+ } catch {
58
+ return { ok: false, stage: "parse" };
59
+ }
60
+ }
61
+ function serializeDoc(doc) {
62
+ if (doc._id.startsWith("h:")) {
63
+ const data = doc.data;
64
+ const writeData = escapeNewLineFromString(data);
65
+ return te.encode(`~${doc._id}${UNIT_SPLIT}${writeData}${RECORD_SPLIT}`);
66
+ }
67
+ return te.encode(JSON.stringify(doc) + RECORD_SPLIT);
68
+ }
69
+ function decodeJournalRecords(data) {
70
+ if (data.length === 0) {
71
+ return { ok: true, docs: [] };
72
+ }
73
+ if (data[data.length - 1] !== 10) {
74
+ return { ok: false, stage: "parse" };
75
+ }
76
+ const decoder = new TextDecoder("utf-8", { fatal: true });
77
+ let idxFrom = 0;
78
+ const result = [];
79
+ while (idxFrom < data.length) {
80
+ const idxTo = data.indexOf(10, idxFrom);
81
+ if (idxTo === -1 || idxTo === idxFrom) {
82
+ return { ok: false, stage: "parse" };
83
+ }
84
+ const record = decodeJournalRecordFrame(data.slice(idxFrom, idxTo), decoder);
85
+ if (record.ok === false) {
86
+ return record;
87
+ }
88
+ result.push(record.doc);
89
+ idxFrom = idxTo + 1;
90
+ }
91
+ return { ok: true, docs: result };
92
+ }
93
+ export {
94
+ decodeJournalRecords,
95
+ serializeDoc
96
+ };
@@ -1,7 +1,19 @@
1
1
  import type { LiveSyncEventHub } from "../../hub/hub.js";
2
+ import { type ReactiveValue } from "octagonal-wheels/dataobject/reactive_v2";
2
3
  import type { P2PReplicationProgress } from "./TrysteroReplicator.js";
3
4
  export declare class P2PLogCollector {
5
+ private readonly eventController;
6
+ private readonly statisticsSource;
7
+ readonly p2pReplicationStats: ReactiveValue<readonly P2PReplicationProgress[]>;
4
8
  constructor(events: LiveSyncEventHub);
9
+ private handleAdvertisementReceived;
10
+ private resetPeerState;
11
+ private handleDeviceLeaved;
12
+ private handleReplicationProgress;
13
+ private statisticsFor;
14
+ private publishStatistics;
15
+ /** Release this collector's event registrations without affecting another consumer. */
16
+ dispose(): void;
5
17
  p2pReplicationResult: Map<string, P2PReplicationProgress>;
6
18
  updateP2PReplicationLine(): void;
7
19
  p2pReplicationLine: import("octagonal-wheels/dataobject/reactive_v2").ReactiveSource<string>;
@@ -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;