@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.
- package/README.md +17 -0
- 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/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/journal-receive-recovery.md +7 -4
- package/docs/p2p-transport-lifecycle.md +117 -10
- package/docs/releasing.md +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -149,6 +149,23 @@ After the Self-hosted LiveSync 1.0 work, the planned high-level composition is a
|
|
|
149
149
|
|
|
150
150
|
The factory is not implemented yet. Until it is available, service implementations exposed under `compat/*` retain their owning client's lifecycle rather than becoming a general public composition API.
|
|
151
151
|
|
|
152
|
+
### Host cleanup registration
|
|
153
|
+
|
|
154
|
+
Available from 0.4.0, `services.API.registerCleanup(callback)` registers synchronous
|
|
155
|
+
cleanup for the owning host's normal terminal unload. The default `APIService` implementation
|
|
156
|
+
uses the context-owned `EVENT_PLATFORM_UNLOADED` event once per registration.
|
|
157
|
+
CLI and browser hosts already deliver that event through `ControlService.onUnload()`
|
|
158
|
+
after their asynchronous lifecycle work and local database closure. An unload
|
|
159
|
+
interrupted before that event does not invoke these callbacks.
|
|
160
|
+
|
|
161
|
+
The Obsidian adapter delegates to native `Component.register()` and retains that
|
|
162
|
+
host's unload timing. This API therefore does not promise one shared shutdown
|
|
163
|
+
phase across hosts. Register callbacks before terminal unload, and use it for
|
|
164
|
+
presentation resources and subscriptions. Returned promises are not awaited;
|
|
165
|
+
resources which must retire before database closure belong to
|
|
166
|
+
`appLifecycle.onUnload` instead. The registration does not add process signals,
|
|
167
|
+
browser navigation handlers, or a new shutdown/recovery path.
|
|
168
|
+
|
|
152
169
|
## UI commands
|
|
153
170
|
|
|
154
171
|
Import `declareUICommand` and `UICommandRegistry` from `@vrtmrz/livesync-commonlib/ui-commands`. A declaration binds a stable command ID to its request and result types. Each `UIService` owns its own `commands` registry; declarations do not register global handlers.
|
package/dist/p2p/P2PService.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { type P2PDiagnosticEvent, type P2PDiagnosticSnapshot } from "./p2pDiagnostics.js";
|
|
1
2
|
import type { ObsidianLiveSyncSettings } from "../common/types.js";
|
|
2
3
|
import type { ReplicatorInstance } from "../replication/ReplicatorInstance.js";
|
|
3
4
|
import type { DiagRTCPeerConnectionMetrics } from "../rpc/transports/DiagRTCPeerConnections.types.js";
|
|
4
5
|
import { LiveSyncTrysteroReplicator, type LiveSyncTrysteroReplicatorEnv } from "../replication/trystero/LiveSyncTrysteroReplicator.js";
|
|
5
|
-
import type { P2PReplicationResult } from "../replication/trystero/TrysteroReplicator.js";
|
|
6
|
+
import type { P2PReplicationProgress, P2PReplicationResult } from "../replication/trystero/TrysteroReplicator.js";
|
|
6
7
|
import type { AcceptanceDecision, RevokeAcceptanceDecision } from "../replication/trystero/TrysteroReplicatorP2PServer.js";
|
|
7
8
|
import type { Advertisement } from "../replication/trystero/types.js";
|
|
8
9
|
import { type PrepareP2PSettings } from "../replication/trystero/P2PRoomSessionOwner.js";
|
|
@@ -79,6 +80,14 @@ export interface P2PConfigurationExchange {
|
|
|
79
80
|
/** Supplies transport diagnostics without exposing a room or peer connection. */
|
|
80
81
|
export interface P2PDiagnostics {
|
|
81
82
|
requestStatus(): void;
|
|
83
|
+
/** Latest owned observation in each category; absent categories remain unobserved. */
|
|
84
|
+
getDiagnosticSnapshot?(): P2PDiagnosticSnapshot;
|
|
85
|
+
/** Observe accepted diagnostic events; the returned function removes this subscription. */
|
|
86
|
+
onDiagnosticEvent?(listener: (event: P2PDiagnosticEvent) => void): () => void;
|
|
87
|
+
/** Current per-peer sequence progress and optional cumulative transfer statistics. */
|
|
88
|
+
getReplicationProgress?(): readonly P2PReplicationProgress[];
|
|
89
|
+
/** Observe subsequent snapshots; the returned function removes this subscription. */
|
|
90
|
+
onReplicationProgress?(listener: (snapshot: readonly P2PReplicationProgress[]) => void): () => void;
|
|
82
91
|
getPeerConnectionMetrics(peerId: string): Promise<P2PPeerConnectionMetrics | undefined>;
|
|
83
92
|
}
|
|
84
93
|
/** Focused contract views backed by one private P2P service context. */
|
package/dist/p2p/P2PService.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createP2PDiagnosticStore } from "./p2pDiagnostics.js";
|
|
1
2
|
import { getPeerConnectionStats } from "../rpc/transports/DiagRTCPeerConnections.utils.js";
|
|
2
3
|
import {
|
|
3
4
|
LiveSyncTrysteroReplicator
|
|
4
5
|
} from "../replication/trystero/LiveSyncTrysteroReplicator.js";
|
|
6
|
+
import { P2PLogCollector } from "../replication/trystero/P2PLogCollector.js";
|
|
5
7
|
import {
|
|
6
8
|
P2PRoomSessionOwner
|
|
7
9
|
} from "../replication/trystero/P2PRoomSessionOwner.js";
|
|
@@ -207,6 +209,15 @@ function createServiceViews(context) {
|
|
|
207
209
|
getRemoteConfiguration: (peerId) => context.compatibilityReplicator.getRemoteConfig(peerId)
|
|
208
210
|
};
|
|
209
211
|
const diagnostics = {
|
|
212
|
+
getDiagnosticSnapshot: context.diagnosticStore.getSnapshot,
|
|
213
|
+
onDiagnosticEvent: context.diagnosticStore.subscribe,
|
|
214
|
+
getReplicationProgress: () => context.progressCollector.p2pReplicationStats.value,
|
|
215
|
+
onReplicationProgress: (listener) => {
|
|
216
|
+
const progress = context.progressCollector.p2pReplicationStats;
|
|
217
|
+
const onChanged = () => listener(progress.value);
|
|
218
|
+
progress.onChanged(onChanged);
|
|
219
|
+
return () => progress.offChanged(onChanged);
|
|
220
|
+
},
|
|
210
221
|
requestStatus: () => context.compatibilityReplicator.requestStatus(),
|
|
211
222
|
getPeerConnectionMetrics: (peerId) => getPeerConnectionMetrics(context, peerId)
|
|
212
223
|
};
|
|
@@ -231,7 +242,11 @@ function createServiceLifecycle(context) {
|
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
function createP2PService(env, options = {}) {
|
|
234
|
-
const
|
|
245
|
+
const diagnosticStore = createP2PDiagnosticStore(env.services.context.events);
|
|
246
|
+
const roomSessionOwner = new P2PRoomSessionOwner(env, void 0, {
|
|
247
|
+
...options,
|
|
248
|
+
onDiagnosticEvent: diagnosticStore.record
|
|
249
|
+
});
|
|
235
250
|
const state = {
|
|
236
251
|
explicitDisconnectVeto: false,
|
|
237
252
|
lifecycleClosed: false,
|
|
@@ -242,7 +257,15 @@ function createP2PService(env, options = {}) {
|
|
|
242
257
|
env,
|
|
243
258
|
createCompatibilitySessionAccess(roomSessionOwner, state)
|
|
244
259
|
);
|
|
245
|
-
const
|
|
260
|
+
const progressCollector = new P2PLogCollector(env.services.context.events);
|
|
261
|
+
const context = {
|
|
262
|
+
env,
|
|
263
|
+
roomSessionOwner,
|
|
264
|
+
compatibilityReplicator,
|
|
265
|
+
state,
|
|
266
|
+
progressCollector,
|
|
267
|
+
diagnosticStore
|
|
268
|
+
};
|
|
246
269
|
return {
|
|
247
270
|
compatibilityReplicator,
|
|
248
271
|
views: createServiceViews(context),
|
package/dist/p2p/index.d.ts
CHANGED
|
@@ -4,7 +4,10 @@ export { useP2PReplicatorCommands } from "../replication/trystero/useP2PReplicat
|
|
|
4
4
|
export { useP2PReplicatorFeature } from "../replication/trystero/useP2PReplicatorFeature.js";
|
|
5
5
|
export type { OpenRebuildUIFactory, OpenReplicationUIFactory, } from "../replication/trystero/useP2PReplicatorFeature.js";
|
|
6
6
|
export type { UseP2PReplicatorResult } from "../replication/trystero/UseP2PReplicatorResult.js";
|
|
7
|
-
export type {
|
|
7
|
+
export type { ReplicationStatistics } from "../pouchdb/ReplicatorShim.js";
|
|
8
|
+
export type { P2PReplicationProgress, P2PReplicationDirectionProgress, P2PReplicationResult, } from "../replication/trystero/TrysteroReplicator.js";
|
|
8
9
|
export type { Advertisement } from "../replication/trystero/types.js";
|
|
9
10
|
export { P2P_ICE_SERVER_EXPIRY_MARGIN_MS, P2P_SETTINGS_PREPARATION_TIMEOUT_MS, } from "../replication/trystero/P2PRoomSessionOwner.js";
|
|
10
11
|
export type { P2PReplicatorFeatureOptions } from "../replication/trystero/useP2PReplicatorFeature.js";
|
|
12
|
+
export { EVENT_P2P_DIAGNOSTIC } from "./p2pDiagnostics.js";
|
|
13
|
+
export type { P2PDiagnosticCategory, P2PDiagnosticCode, P2PDiagnosticReason, P2PDiagnosticEvent, P2PDiagnosticSnapshot, } from "./p2pDiagnostics.js";
|
package/dist/p2p/index.js
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
P2P_ICE_SERVER_EXPIRY_MARGIN_MS,
|
|
6
6
|
P2P_SETTINGS_PREPARATION_TIMEOUT_MS
|
|
7
7
|
} from "../replication/trystero/P2PRoomSessionOwner.js";
|
|
8
|
+
import { EVENT_P2P_DIAGNOSTIC } from "./p2pDiagnostics.js";
|
|
8
9
|
export {
|
|
9
10
|
ACTIVE_P2P_RELAY_BINDING_CONFLICT,
|
|
11
|
+
EVENT_P2P_DIAGNOSTIC,
|
|
10
12
|
P2P_ICE_SERVER_EXPIRY_MARGIN_MS,
|
|
11
13
|
P2P_SETTINGS_PREPARATION_TIMEOUT_MS,
|
|
12
14
|
useP2PReplicatorCommands,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { LiveSyncEventHub } from "../hub/hub.js";
|
|
2
|
+
/** Lightweight, context-owned observations without transport or configuration secrets. */
|
|
3
|
+
export type P2PDiagnosticCategory = "relay" | "peer" | "sync";
|
|
4
|
+
export type P2PDiagnosticCode = "room-opening" | "room-opened" | "room-closed" | "room-open-failed" | "room-close-failed" | "room-open-cancelled" | "room-join-error" | "peer-advertised" | "peer-left" | "rtc-failed" | "auth-accepted" | "auth-refused" | "auth-timeout" | "auth-cancelled" | "auth-error" | "pull-started" | "pull-completed" | "pull-failed" | "pull-cancelled" | "watch-enabled" | "watch-disabled" | "watch-skipped" | "broadcast-enabled" | "broadcast-disabled" | "change-notification-received" | "change-notification-ignored" | "change-notification-sent" | "change-notification-timeout" | "change-notification-cancelled" | "change-notification-error";
|
|
5
|
+
export type P2PDiagnosticReason = "relay-details-unobserved" | "transport-error" | "operation-cancelled" | "rpc-timeout" | "rpc-cancelled" | "rpc-error" | "authentication-refused" | "authentication-unobserved" | "replication-error" | "replication-not-ready" | "already-running" | "not-watching" | "not-accepted" | "setup-in-progress" | "peer-not-broadcasting" | "peer-broadcast-unobserved" | "ICE_GATHERING_NOT_COMPLETED" | "ICE_CONNECTIVITY_FAILED" | "STUN_REQUEST_TIMEOUT" | "SIGNALING_NOT_STABLE" | "CONNECTION_DROPPED_AFTER_ESTABLISHED" | "NETWORK_INTERRUPTED" | "UNKNOWN";
|
|
6
|
+
export type P2PDiagnosticEvent = {
|
|
7
|
+
readonly category: P2PDiagnosticCategory;
|
|
8
|
+
readonly code: P2PDiagnosticCode;
|
|
9
|
+
/** Milliseconds since the Unix epoch, captured at the observed source boundary. */
|
|
10
|
+
readonly timestamp: number;
|
|
11
|
+
readonly peerId?: string;
|
|
12
|
+
readonly reason?: P2PDiagnosticReason;
|
|
13
|
+
};
|
|
14
|
+
/** Missing categories have not been observed; no zero or successful result is implied. */
|
|
15
|
+
export type P2PDiagnosticSnapshot = Readonly<Partial<Record<P2PDiagnosticCategory, P2PDiagnosticEvent>>>;
|
|
16
|
+
export type P2PDiagnosticInput = Omit<P2PDiagnosticEvent, "timestamp">;
|
|
17
|
+
export type P2PDiagnosticReporter = (event: P2PDiagnosticEvent) => void;
|
|
18
|
+
/** Accepted, fenced events from the diagnostic store of this Commonlib context. */
|
|
19
|
+
export declare const EVENT_P2P_DIAGNOSTIC = "p2p-diagnostic";
|
|
20
|
+
declare global {
|
|
21
|
+
interface LSEvents {
|
|
22
|
+
[EVENT_P2P_DIAGNOSTIC]: P2PDiagnosticEvent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Publish a fixed observation without allowing diagnostic callbacks to affect the operation. */
|
|
26
|
+
export declare function reportP2PDiagnostic(reporter: P2PDiagnosticReporter | undefined, input: P2PDiagnosticInput): void;
|
|
27
|
+
/** Classify only stable RPC error identifiers; never expose the error text or details. */
|
|
28
|
+
export declare function classifyP2PRpcFailure(error: unknown, signal?: AbortSignal): "rpc-timeout" | "rpc-cancelled" | "rpc-error";
|
|
29
|
+
/** Retain only the latest observation of each category for one service context. */
|
|
30
|
+
export declare function createP2PDiagnosticStore(events: LiveSyncEventHub): {
|
|
31
|
+
getSnapshot: () => P2PDiagnosticSnapshot;
|
|
32
|
+
subscribe: (listener: P2PDiagnosticReporter) => (() => void);
|
|
33
|
+
record: (input: P2PDiagnosticEvent) => void;
|
|
34
|
+
dispose: () => void;
|
|
35
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EVENT_PLATFORM_UNLOADED } from "../events/coreEvents.js";
|
|
2
|
+
const EVENT_P2P_DIAGNOSTIC = "p2p-diagnostic";
|
|
3
|
+
function reportP2PDiagnostic(reporter, input) {
|
|
4
|
+
if (!reporter) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
reporter(Object.freeze({ ...input, timestamp: Date.now() }));
|
|
9
|
+
} catch {
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function classifyP2PRpcFailure(error, signal) {
|
|
13
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
14
|
+
const name = typeof error === "object" && error !== null && "name" in error ? error.name : void 0;
|
|
15
|
+
if (signal?.aborted || code === "CANCELLED" || name === "AbortError") {
|
|
16
|
+
return "rpc-cancelled";
|
|
17
|
+
}
|
|
18
|
+
return code === "TIMEOUT" ? "rpc-timeout" : "rpc-error";
|
|
19
|
+
}
|
|
20
|
+
function createP2PDiagnosticStore(events) {
|
|
21
|
+
let snapshot = Object.freeze({});
|
|
22
|
+
let disposed = false;
|
|
23
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
24
|
+
const lifetime = new AbortController();
|
|
25
|
+
const dispose = () => {
|
|
26
|
+
disposed = true;
|
|
27
|
+
listeners.clear();
|
|
28
|
+
lifetime.abort();
|
|
29
|
+
};
|
|
30
|
+
events.onEvent(EVENT_PLATFORM_UNLOADED, dispose, { signal: lifetime.signal });
|
|
31
|
+
return {
|
|
32
|
+
getSnapshot: () => snapshot,
|
|
33
|
+
subscribe: (listener) => {
|
|
34
|
+
if (!disposed) {
|
|
35
|
+
listeners.add(listener);
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(listener);
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
record: (input) => {
|
|
42
|
+
if (disposed) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const event = Object.freeze({
|
|
46
|
+
category: input.category,
|
|
47
|
+
code: input.code,
|
|
48
|
+
timestamp: input.timestamp,
|
|
49
|
+
...input.peerId !== void 0 ? { peerId: input.peerId } : {},
|
|
50
|
+
...input.reason !== void 0 ? { reason: input.reason } : {}
|
|
51
|
+
});
|
|
52
|
+
const previous = snapshot[event.category];
|
|
53
|
+
if (previous?.code === event.code && previous.peerId === event.peerId && previous.reason === event.reason && previous.timestamp === event.timestamp) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
snapshot = Object.freeze({ ...snapshot, [event.category]: event });
|
|
57
|
+
for (const listener of listeners) {
|
|
58
|
+
try {
|
|
59
|
+
listener(event);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
events.emitEvent(EVENT_P2P_DIAGNOSTIC, event);
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
dispose
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
EVENT_P2P_DIAGNOSTIC,
|
|
73
|
+
classifyP2PRpcFailure,
|
|
74
|
+
createP2PDiagnosticStore,
|
|
75
|
+
reportP2PDiagnostic
|
|
76
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ShimReplicationWriteFailure } from "./ReplicatorShim.js";
|
|
2
|
+
export type MissingRevisionRequest = {
|
|
3
|
+
id: string;
|
|
4
|
+
rev: string;
|
|
5
|
+
};
|
|
6
|
+
/** Parse the numeric part of a PouchDB sequence value ("42-xyz" → 42). */
|
|
7
|
+
export declare function parseSeq(seq: string | number): number;
|
|
8
|
+
/**
|
|
9
|
+
* Build the `{docId: [rev, ...]}` map required by `revsDiff` from a changes
|
|
10
|
+
* feed result set, merging revisions when the same document appears multiple
|
|
11
|
+
* times in a batch.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildRevsDiffParam(changesResults: PouchDB.Core.ChangesResponseChange<object>[]): Record<string, string[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Sort fetched documents in ascending sequence order so that consumers receive
|
|
16
|
+
* them in the same order they were written to the source.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sortBySeq<T extends {
|
|
19
|
+
_id: string;
|
|
20
|
+
}>(docs: T[], changesResults: PouchDB.Core.ChangesResponseChange<object>[]): T[];
|
|
21
|
+
export declare function buildMissingRevisionRequests(diff: PouchDB.Core.RevisionDiffResponse): MissingRevisionRequest[];
|
|
22
|
+
export declare function findUnavailableRevisionRequests(requests: readonly MissingRevisionRequest[], fetchedDocs: readonly {
|
|
23
|
+
_id: string;
|
|
24
|
+
_rev?: string;
|
|
25
|
+
}[]): MissingRevisionRequest[];
|
|
26
|
+
export declare function buildUnavailableRevisionFailures(requests: readonly MissingRevisionRequest[]): ShimReplicationWriteFailure[];
|
|
27
|
+
export declare function mapBulkDocResultsToFailures<T extends {
|
|
28
|
+
_id: string;
|
|
29
|
+
_rev?: string;
|
|
30
|
+
}>(writeResults: (PouchDB.Core.Response | PouchDB.Core.Error)[], fetchedDocs: T[]): ShimReplicationWriteFailure[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function parseSeq(seq) {
|
|
2
|
+
return parseInt(String(seq).split("-")[0], 10);
|
|
3
|
+
}
|
|
4
|
+
function buildRevsDiffParam(changesResults) {
|
|
5
|
+
const param = {};
|
|
6
|
+
for (const { id, changes } of changesResults) {
|
|
7
|
+
param[id] = [...param[id] ?? [], ...changes.map((c) => c.rev)];
|
|
8
|
+
}
|
|
9
|
+
return param;
|
|
10
|
+
}
|
|
11
|
+
function sortBySeq(docs, changesResults) {
|
|
12
|
+
const maxSeqById = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const { id, seq } of changesResults) {
|
|
14
|
+
maxSeqById.set(id, Math.max(maxSeqById.get(id) ?? 0, parseSeq(seq)));
|
|
15
|
+
}
|
|
16
|
+
return docs.slice().sort((a, b) => (maxSeqById.get(a._id) ?? 0) - (maxSeqById.get(b._id) ?? 0));
|
|
17
|
+
}
|
|
18
|
+
function isSuccessfulBulkDocResult(result) {
|
|
19
|
+
return typeof result === "object" && result !== null && "ok" in result && result.ok === true;
|
|
20
|
+
}
|
|
21
|
+
function buildMissingRevisionRequests(diff) {
|
|
22
|
+
return Object.entries(diff).filter(([, entry]) => entry.missing !== void 0).flatMap(([id, entry]) => entry.missing.map((rev) => ({ id, rev })));
|
|
23
|
+
}
|
|
24
|
+
function findUnavailableRevisionRequests(requests, fetchedDocs) {
|
|
25
|
+
const fetchedRevisionKeys = new Set(fetchedDocs.map((doc) => doc._id + "\0" + String(doc._rev)));
|
|
26
|
+
return requests.filter(({ id, rev }) => !fetchedRevisionKeys.has(id + "\0" + rev));
|
|
27
|
+
}
|
|
28
|
+
function buildUnavailableRevisionFailures(requests) {
|
|
29
|
+
return requests.map((request, index) => ({
|
|
30
|
+
index,
|
|
31
|
+
id: request.id,
|
|
32
|
+
revision: request.rev,
|
|
33
|
+
result: void 0
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
function mapBulkDocResultsToFailures(writeResults, fetchedDocs) {
|
|
37
|
+
return writeResults.flatMap((result, index) => {
|
|
38
|
+
if (isSuccessfulBulkDocResult(result)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const resultId = typeof result === "object" && result !== null && "id" in result ? result.id : void 0;
|
|
42
|
+
const owningIndex = typeof resultId === "string" ? fetchedDocs.findIndex((doc) => doc._id === resultId) : -1;
|
|
43
|
+
const owningDocument = fetchedDocs[owningIndex >= 0 ? owningIndex : index];
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
index: owningIndex >= 0 ? owningIndex : index,
|
|
47
|
+
id: typeof resultId === "string" ? resultId : owningDocument?._id ?? "",
|
|
48
|
+
revision: owningDocument?._rev,
|
|
49
|
+
result
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
buildMissingRevisionRequests,
|
|
56
|
+
buildRevsDiffParam,
|
|
57
|
+
buildUnavailableRevisionFailures,
|
|
58
|
+
findUnavailableRevisionRequests,
|
|
59
|
+
mapBulkDocResultsToFailures,
|
|
60
|
+
parseSeq,
|
|
61
|
+
sortBySeq
|
|
62
|
+
};
|
|
@@ -26,6 +26,11 @@ export type ShimReplicationOptionBase = {
|
|
|
26
26
|
batch_size?: number;
|
|
27
27
|
/** Abort before a new batch starts, or after the current batch settles. */
|
|
28
28
|
signal?: AbortSignal;
|
|
29
|
+
/**
|
|
30
|
+
* Observe cumulative counts, including batches with no missing revisions and
|
|
31
|
+
* accepted writes before a batch failure. Observation does not commit a checkpoint.
|
|
32
|
+
*/
|
|
33
|
+
onStatistics?: (info: ProgressInfo) => void | Promise<void>;
|
|
29
34
|
};
|
|
30
35
|
/** Options for a finite replication which settles after catching up or cancellation. */
|
|
31
36
|
export type ShimReplicationOneShot = {
|
|
@@ -66,10 +71,18 @@ export declare class ShimReplicationError extends Error {
|
|
|
66
71
|
readonly failures: readonly ShimReplicationWriteFailure[];
|
|
67
72
|
constructor(message: string, failures: readonly ShimReplicationWriteFailure[]);
|
|
68
73
|
}
|
|
69
|
-
/**
|
|
74
|
+
/** Cumulative work observed during one invocation of the replication shim. */
|
|
75
|
+
export type ReplicationStatistics = {
|
|
76
|
+
/** Successfully transferred document revisions, including Metadata and Chunks. */
|
|
77
|
+
documents: number;
|
|
78
|
+
/** Changes-feed entries compared, including revisions already held by the target. */
|
|
79
|
+
checkedChanges: number;
|
|
80
|
+
};
|
|
81
|
+
/** Source sequence boundary for a batch, with optional cumulative transfer counts. */
|
|
70
82
|
export type ProgressInfo = {
|
|
71
83
|
lastSeq: number;
|
|
72
84
|
maxSeqInBatch: number;
|
|
85
|
+
statistics?: ReplicationStatistics;
|
|
73
86
|
};
|
|
74
87
|
/** Receives the documents and committed sequence boundary for each completed batch. */
|
|
75
88
|
export type ShimReplicationProgressReportFunc<T extends object> = (progress: SomeDocument<T>[], progressInfo: ProgressInfo) => Promise<void>;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { serialized } from "octagonal-wheels/concurrency/lock";
|
|
2
2
|
import { Logger } from "../common/logger.js";
|
|
3
3
|
import { LOG_LEVEL_VERBOSE } from "../common/types.js";
|
|
4
|
+
import {
|
|
5
|
+
buildMissingRevisionRequests,
|
|
6
|
+
buildRevsDiffParam,
|
|
7
|
+
buildUnavailableRevisionFailures,
|
|
8
|
+
findUnavailableRevisionRequests,
|
|
9
|
+
mapBulkDocResultsToFailures,
|
|
10
|
+
parseSeq,
|
|
11
|
+
sortBySeq
|
|
12
|
+
} from "./ReplicatorShim.batch.js";
|
|
4
13
|
async function upsert(db, id, func) {
|
|
5
14
|
try {
|
|
6
15
|
const doc = await db.get(id);
|
|
@@ -31,32 +40,14 @@ class ShimReplicationError extends Error {
|
|
|
31
40
|
}
|
|
32
41
|
failures;
|
|
33
42
|
}
|
|
34
|
-
function parseSeq(seq) {
|
|
35
|
-
return parseInt(String(seq).split("-")[0], 10);
|
|
36
|
-
}
|
|
37
|
-
function buildRevsDiffParam(changesResults) {
|
|
38
|
-
const param = {};
|
|
39
|
-
for (const { id, changes } of changesResults) {
|
|
40
|
-
param[id] = [...param[id] ?? [], ...changes.map((c) => c.rev)];
|
|
41
|
-
}
|
|
42
|
-
return param;
|
|
43
|
-
}
|
|
44
|
-
function sortBySeq(docs, changesResults) {
|
|
45
|
-
const maxSeqById = /* @__PURE__ */ new Map();
|
|
46
|
-
for (const { id, seq } of changesResults) {
|
|
47
|
-
maxSeqById.set(id, Math.max(maxSeqById.get(id) ?? 0, parseSeq(seq)));
|
|
48
|
-
}
|
|
49
|
-
return docs.slice().sort((a, b) => (maxSeqById.get(a._id) ?? 0) - (maxSeqById.get(b._id) ?? 0));
|
|
50
|
-
}
|
|
51
|
-
function isSuccessfulBulkDocResult(result) {
|
|
52
|
-
return typeof result === "object" && result !== null && "ok" in result && result.ok === true;
|
|
53
|
-
}
|
|
54
43
|
function isCancellationError(error) {
|
|
55
44
|
if (typeof error !== "object" || error === null) return false;
|
|
56
45
|
return "code" in error && error.code === "CANCELLED" || "name" in error && error.name === "AbortError";
|
|
57
46
|
}
|
|
58
47
|
async function replicateShim(targetDB, sourceDB, progress, option = {}) {
|
|
59
48
|
const signal = option.signal ?? option.controller?.signal;
|
|
49
|
+
const statistics = { documents: 0, checkedChanges: 0 };
|
|
50
|
+
let lastProcessedSeq = 0;
|
|
60
51
|
if (signal?.aborted) {
|
|
61
52
|
Logger(`Replication cancelled`, LOG_LEVEL_VERBOSE);
|
|
62
53
|
return { status: "cancelled" };
|
|
@@ -96,71 +87,64 @@ async function replicateShim(targetDB, sourceDB, progress, option = {}) {
|
|
|
96
87
|
const changesResults = changes.results;
|
|
97
88
|
const revsDiffParam = buildRevsDiffParam(changesResults);
|
|
98
89
|
const diff = await targetDB.revsDiff(revsDiffParam);
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
90
|
+
statistics.checkedChanges += changesResults.length;
|
|
91
|
+
const progressInfo = (lastSeq = lastProcessedSeq) => ({
|
|
92
|
+
lastSeq,
|
|
93
|
+
maxSeqInBatch: maxNumSeq,
|
|
94
|
+
statistics: { ...statistics }
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
104
97
|
if (signal?.aborted) return { status: "cancelled" };
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
});
|
|
140
|
-
if (failedWrites.length > 0) {
|
|
141
|
-
throw new ShimReplicationError(
|
|
142
|
-
`Target rejected ${failedWrites.length} document write(s)`,
|
|
143
|
-
failedWrites
|
|
144
|
-
);
|
|
98
|
+
const missingRequests = buildMissingRevisionRequests(diff);
|
|
99
|
+
if (missingRequests.length > 0) {
|
|
100
|
+
const bulkGetResult = await sourceDB.bulkGet({ docs: missingRequests, revs: true });
|
|
101
|
+
const fetchedDocs = bulkGetResult.results.flatMap((r) => r.docs).filter((d) => "ok" in d).map((d) => d.ok);
|
|
102
|
+
if (signal?.aborted) return { status: "cancelled" };
|
|
103
|
+
const unavailableRequests = findUnavailableRevisionRequests(missingRequests, fetchedDocs);
|
|
104
|
+
if (unavailableRequests.length > 0) {
|
|
105
|
+
const unavailableFailures = buildUnavailableRevisionFailures(unavailableRequests);
|
|
106
|
+
throw new ShimReplicationError(
|
|
107
|
+
`Could not fetch ${unavailableRequests.length} required revision(s)`,
|
|
108
|
+
unavailableFailures
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const writeResults = await targetDB.bulkDocs(fetchedDocs, { new_edits: false });
|
|
112
|
+
const failedWrites = mapBulkDocResultsToFailures(writeResults, fetchedDocs);
|
|
113
|
+
statistics.documents += Math.max(0, fetchedDocs.length - failedWrites.length);
|
|
114
|
+
if (failedWrites.length > 0) {
|
|
115
|
+
throw new ShimReplicationError(
|
|
116
|
+
`Target rejected ${failedWrites.length} document write(s)`,
|
|
117
|
+
failedWrites
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const uniqueIds = [...new Set(changesResults.map((c) => c.id))];
|
|
121
|
+
const refreshResult = await targetDB.bulkGet({ docs: uniqueIds.map((id) => ({ id })) });
|
|
122
|
+
const refreshedDocs = refreshResult.results.flatMap((r) => r.docs).filter((d) => "ok" in d).map((d) => d.ok);
|
|
123
|
+
const orderedDocs = sortBySeq(refreshedDocs, changesResults);
|
|
124
|
+
try {
|
|
125
|
+
const batchSeq = parseSeq(changes.last_seq);
|
|
126
|
+
await progress(orderedDocs, progressInfo(batchSeq));
|
|
127
|
+
lastProcessedSeq = batchSeq;
|
|
128
|
+
} catch (ex) {
|
|
129
|
+
Logger(`Progress callback failed during shim-replication`, LOG_LEVEL_VERBOSE);
|
|
130
|
+
Logger(ex, LOG_LEVEL_VERBOSE);
|
|
131
|
+
}
|
|
145
132
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
133
|
+
since = changes.last_seq;
|
|
134
|
+
await upsert(targetDB, targetCheckpointID, (doc) => ({
|
|
135
|
+
...doc,
|
|
136
|
+
since
|
|
137
|
+
}));
|
|
138
|
+
if (missingRequests.length === 0) lastProcessedSeq = parseSeq(changes.last_seq);
|
|
139
|
+
if (signal?.aborted) return { status: "cancelled" };
|
|
140
|
+
} finally {
|
|
151
141
|
try {
|
|
152
|
-
await
|
|
153
|
-
} catch (
|
|
154
|
-
Logger(`
|
|
155
|
-
Logger(
|
|
142
|
+
await option.onStatistics?.(progressInfo());
|
|
143
|
+
} catch (error) {
|
|
144
|
+
Logger(`Statistics observer failed during shim-replication`, LOG_LEVEL_VERBOSE);
|
|
145
|
+
Logger(error, LOG_LEVEL_VERBOSE);
|
|
156
146
|
}
|
|
157
147
|
}
|
|
158
|
-
since = changes.last_seq;
|
|
159
|
-
await upsert(targetDB, targetCheckpointID, (doc) => ({
|
|
160
|
-
...doc,
|
|
161
|
-
since
|
|
162
|
-
}));
|
|
163
|
-
if (signal?.aborted) return { status: "cancelled" };
|
|
164
148
|
}
|
|
165
149
|
});
|
|
166
150
|
Logger(outcome.status === "cancelled" ? `Replication cancelled` : `Replication completed`, LOG_LEVEL_VERBOSE);
|
|
@@ -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>;
|