@vrtmrz/livesync-commonlib 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +17 -0
  2. package/dist/managers/EntryManager/EntryManagerImpls.js +1 -1
  3. package/dist/p2p/P2PService.d.ts +10 -1
  4. package/dist/p2p/P2PService.js +25 -2
  5. package/dist/p2p/index.d.ts +4 -1
  6. package/dist/p2p/index.js +2 -0
  7. package/dist/p2p/p2pDiagnostics.d.ts +35 -0
  8. package/dist/p2p/p2pDiagnostics.js +76 -0
  9. package/dist/pouchdb/ReplicatorShim.batch.d.ts +30 -0
  10. package/dist/pouchdb/ReplicatorShim.batch.js +62 -0
  11. package/dist/pouchdb/ReplicatorShim.d.ts +14 -1
  12. package/dist/pouchdb/ReplicatorShim.js +64 -80
  13. package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
  14. package/dist/replication/trystero/P2PLogCollector.js +107 -72
  15. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
  16. package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
  17. package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
  18. package/dist/replication/trystero/TrysteroReplicator.js +191 -69
  19. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
  20. package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
  21. package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
  22. package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
  23. package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
  24. package/dist/replication/trystero/types.d.ts +3 -0
  25. package/dist/services/base/APIService.d.ts +10 -0
  26. package/dist/services/base/APIService.js +13 -0
  27. package/dist/services/base/IService.d.ts +2 -0
  28. package/docs/journal-receive-recovery.md +7 -4
  29. package/docs/p2p-transport-lifecycle.md +117 -10
  30. package/docs/releasing.md +6 -0
  31. package/package.json +2 -2
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.
@@ -162,7 +162,7 @@ async function putDBEntryInternal(host, managers, note, onlyChunks, revisionTarg
162
162
  path: note.path,
163
163
  ctime: note.ctime,
164
164
  mtime: note.mtime,
165
- size: note.size,
165
+ size: data.size,
166
166
  type: note.datatype,
167
167
  eden: {}
168
168
  };
@@ -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. */
@@ -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 roomSessionOwner = new P2PRoomSessionOwner(env, void 0, options);
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 context = { env, roomSessionOwner, compatibilityReplicator, state };
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),
@@ -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 { P2PReplicationResult } from "../replication/trystero/TrysteroReplicator.js";
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
- /** Sequence boundary committed by one successfully written batch. */
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
- if (signal?.aborted) return { status: "cancelled" };
100
- const missingRequests = Object.entries(diff).filter(([, entry]) => entry.missing !== void 0).flatMap(([id, entry]) => entry.missing.map((rev) => ({ id, rev })));
101
- if (missingRequests.length > 0) {
102
- const bulkGetResult = await sourceDB.bulkGet({ docs: missingRequests, revs: true });
103
- const fetchedDocs = bulkGetResult.results.flatMap((r) => r.docs).filter((d) => "ok" in d).map((d) => d.ok);
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 fetchedRevisionKeys = new Set(
106
- fetchedDocs.map((doc) => `${doc._id}\0${String(doc._rev)}`)
107
- );
108
- const unavailableRequests = missingRequests.filter(
109
- ({ id, rev }) => !fetchedRevisionKeys.has(`${id}\0${rev}`)
110
- );
111
- if (unavailableRequests.length > 0) {
112
- const unavailableFailures = unavailableRequests.map(
113
- (request, index) => ({
114
- index,
115
- id: request.id,
116
- revision: request.rev,
117
- result: void 0
118
- })
119
- );
120
- throw new ShimReplicationError(
121
- `Could not fetch ${unavailableRequests.length} required revision(s)`,
122
- unavailableFailures
123
- );
124
- }
125
- const writeResults = await targetDB.bulkDocs(fetchedDocs, { new_edits: false });
126
- const failedWrites = writeResults.flatMap((result, index) => {
127
- if (isSuccessfulBulkDocResult(result)) return [];
128
- const resultId = typeof result === "object" && result !== null && "id" in result ? result.id : void 0;
129
- const owningIndex = typeof resultId === "string" ? fetchedDocs.findIndex((doc) => doc._id === resultId) : -1;
130
- const owningDocument = fetchedDocs[owningIndex >= 0 ? owningIndex : index];
131
- return [
132
- {
133
- index: owningIndex >= 0 ? owningIndex : index,
134
- id: typeof resultId === "string" ? resultId : owningDocument?._id ?? "",
135
- revision: owningDocument?._rev,
136
- result
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
- const uniqueIds = [...new Set(changesResults.map((c) => c.id))];
147
- const refreshResult = await targetDB.bulkGet({ docs: uniqueIds.map((id) => ({ id })) });
148
- const refreshedDocs = refreshResult.results.flatMap((r) => r.docs).filter((d) => "ok" in d).map((d) => d.ok);
149
- const orderedDocs = sortBySeq(refreshedDocs, changesResults);
150
- const lastSeqNum = parseSeq(changes.last_seq);
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 progress(orderedDocs, { lastSeq: lastSeqNum, maxSeqInBatch: maxNumSeq });
153
- } catch (ex) {
154
- Logger(`Progress callback failed during shim-replication`, LOG_LEVEL_VERBOSE);
155
- Logger(ex, LOG_LEVEL_VERBOSE);
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>;