@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.
- package/README.md +29 -2
- package/dist/managers/EntryManager/EntryManagerImpls.js +1 -1
- package/dist/p2p/P2PService.d.ts +10 -1
- package/dist/p2p/P2PService.js +25 -2
- package/dist/p2p/index.d.ts +4 -1
- package/dist/p2p/index.js +2 -0
- package/dist/p2p/p2pDiagnostics.d.ts +35 -0
- package/dist/p2p/p2pDiagnostics.js +76 -0
- package/dist/pouchdb/ReplicatorShim.batch.d.ts +30 -0
- package/dist/pouchdb/ReplicatorShim.batch.js +62 -0
- package/dist/pouchdb/ReplicatorShim.d.ts +14 -1
- package/dist/pouchdb/ReplicatorShim.js +64 -80
- package/dist/replication/SyncParamsHandler.d.ts +1 -0
- package/dist/replication/SyncParamsHandler.js +5 -1
- package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
- package/dist/replication/journal/JournalReceiveFailure.js +16 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
- package/dist/replication/journal/JournalSyncCore.js +288 -166
- package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
- package/dist/replication/journal/journalCompatibility.d.ts +9 -0
- package/dist/replication/journal/journalCompatibility.js +58 -0
- package/dist/replication/journal/journalReceive.d.ts +23 -0
- package/dist/replication/journal/journalReceive.js +44 -0
- package/dist/replication/journal/journalRecords.d.ts +10 -0
- package/dist/replication/journal/journalRecords.js +96 -0
- package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
- package/dist/replication/trystero/P2PLogCollector.js +107 -72
- package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
- package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
- package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
- package/dist/replication/trystero/TrysteroReplicator.js +191 -69
- package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
- package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
- package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
- package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
- package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
- package/dist/replication/trystero/types.d.ts +3 -0
- package/dist/services/base/APIService.d.ts +10 -0
- package/dist/services/base/APIService.js +13 -0
- package/dist/services/base/IService.d.ts +2 -0
- package/docs/adr/2026_11_shared_storage_path_resolution.md +4 -2
- package/docs/design_docs/storage_path_resolution.md +8 -7
- package/docs/development.md +2 -2
- package/docs/journal-receive-recovery.md +69 -0
- package/docs/p2p-transport-lifecycle.md +117 -10
- package/docs/platform-storage.md +2 -2
- package/docs/releasing.md +6 -0
- package/docs/unicode-path-identity.md +9 -8
- 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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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;
|