@vrtmrz/livesync-commonlib 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +29 -2
  2. package/dist/managers/EntryManager/EntryManagerImpls.js +1 -1
  3. package/dist/p2p/P2PService.d.ts +10 -1
  4. package/dist/p2p/P2PService.js +25 -2
  5. package/dist/p2p/index.d.ts +4 -1
  6. package/dist/p2p/index.js +2 -0
  7. package/dist/p2p/p2pDiagnostics.d.ts +35 -0
  8. package/dist/p2p/p2pDiagnostics.js +76 -0
  9. package/dist/pouchdb/ReplicatorShim.batch.d.ts +30 -0
  10. package/dist/pouchdb/ReplicatorShim.batch.js +62 -0
  11. package/dist/pouchdb/ReplicatorShim.d.ts +14 -1
  12. package/dist/pouchdb/ReplicatorShim.js +64 -80
  13. package/dist/replication/SyncParamsHandler.d.ts +1 -0
  14. package/dist/replication/SyncParamsHandler.js +5 -1
  15. package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
  16. package/dist/replication/journal/JournalReceiveFailure.js +16 -0
  17. package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
  18. package/dist/replication/journal/JournalSyncCore.js +288 -166
  19. package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
  20. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
  21. package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
  22. package/dist/replication/journal/journalCompatibility.d.ts +9 -0
  23. package/dist/replication/journal/journalCompatibility.js +58 -0
  24. package/dist/replication/journal/journalReceive.d.ts +23 -0
  25. package/dist/replication/journal/journalReceive.js +44 -0
  26. package/dist/replication/journal/journalRecords.d.ts +10 -0
  27. package/dist/replication/journal/journalRecords.js +96 -0
  28. package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
  29. package/dist/replication/trystero/P2PLogCollector.js +107 -72
  30. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
  31. package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
  32. package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
  33. package/dist/replication/trystero/TrysteroReplicator.js +191 -69
  34. package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
  35. package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
  36. package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
  37. package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
  38. package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
  39. package/dist/replication/trystero/types.d.ts +3 -0
  40. package/dist/services/base/APIService.d.ts +10 -0
  41. package/dist/services/base/APIService.js +13 -0
  42. package/dist/services/base/IService.d.ts +2 -0
  43. package/docs/adr/2026_11_shared_storage_path_resolution.md +4 -2
  44. package/docs/design_docs/storage_path_resolution.md +8 -7
  45. package/docs/development.md +2 -2
  46. package/docs/journal-receive-recovery.md +69 -0
  47. package/docs/p2p-transport-lifecycle.md +117 -10
  48. package/docs/platform-storage.md +2 -2
  49. package/docs/releasing.md +6 -0
  50. package/docs/unicode-path-identity.md +9 -8
  51. package/package.json +2 -2
package/README.md CHANGED
@@ -14,7 +14,7 @@ This package is primarily for maintainers of those clients and for integrations
14
14
 
15
15
  ## Status and versioning
16
16
 
17
- Commonlib [0.3.0](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.0)
17
+ Commonlib [0.3.1](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.1)
18
18
  is published on npm. Self-hosted LiveSync
19
19
  [1.0.44](https://github.com/vrtmrz/obsidian-livesync/releases/tag/1.0.44)
20
20
  uses Commonlib 0.2.3. The [0.2.3 release notes](updates.md#023) record its
@@ -45,6 +45,16 @@ while retaining conditional revalidation within a Seed. See
45
45
  [CouchDB response caching](docs/couchdb-http-cache.md) for the request,
46
46
  replication-preparation, and host-adapter boundaries.
47
47
 
48
+ Journal receipt stops at a per-Journal decode, record, or write failure and
49
+ retries that Journal on the next receive attempt. A host can offer the explicit
50
+ 'Skip stopped Journal and resume' operation for the current stopped failure;
51
+ it skips only that Journal, and a later failure stops receipt again. A skip can
52
+ leave content or history incomplete. Use the existing Reset journal received
53
+ history operation to replay entries after correcting the failure, while the
54
+ remote objects remain available and readable. See the
55
+ [Journal receive failures and recovery](docs/journal-receive-recovery.md) for
56
+ the receive, skip, and replay boundaries.
57
+
48
58
  ## Package entry points
49
59
 
50
60
  | Entry point | Intended use | Current contract status |
@@ -139,6 +149,23 @@ After the Self-hosted LiveSync 1.0 work, the planned high-level composition is a
139
149
 
140
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.
141
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
+
142
169
  ## UI commands
143
170
 
144
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.
@@ -315,7 +342,7 @@ Commonlib 0.3.0 provides [Unicode NFC path identity](docs/unicode-path-identity.
315
342
 
316
343
  ## Direct database watches
317
344
 
318
- An unreleased extension lets direct CouchDB integrations select
345
+ Commonlib 0.3.1 lets direct CouchDB integrations select
319
346
  `pathNormalizationVersion: 1` in `DirectFileManipulator` options. Omitted or
320
347
  `0` retains Legacy identity. The configured mode must match the database;
321
348
  NFC does not relabel a populated Legacy database. A confirmed empty database
@@ -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);
@@ -32,6 +32,7 @@ export declare class SyncParamsNotFoundError extends SyncParamsHandlerError {
32
32
  }
33
33
  export declare class SyncParamsUpdateError extends SyncParamsHandlerError {
34
34
  }
35
+ export declare function isSyncParamsError(error: unknown): error is SyncParamsFetchError | SyncParamsNotFoundError | SyncParamsUpdateError;
35
36
  /** Create a handler owned by one operation rather than the process-wide server cache. */
36
37
  export declare function createSyncParamsHandler({ put, get, create }: CreateSyncParamsHanderOptions): SyncParamsHandler;
37
38
  export {};
@@ -22,6 +22,9 @@ class SyncParamsNotFoundError extends SyncParamsHandlerError {
22
22
  }
23
23
  class SyncParamsUpdateError extends SyncParamsHandlerError {
24
24
  }
25
+ function isSyncParamsError(error) {
26
+ return error instanceof SyncParamsFetchError || error instanceof SyncParamsNotFoundError || error instanceof SyncParamsUpdateError;
27
+ }
25
28
  function createSyncParamsHandler({ put, get, create }) {
26
29
  let taskFetchParameters = void 0;
27
30
  const _fetchSyncParameters = async () => {
@@ -115,5 +118,6 @@ export {
115
118
  SyncParamsUpdateError,
116
119
  clearHandlers,
117
120
  createSyncParamsHanderForServer,
118
- createSyncParamsHandler
121
+ createSyncParamsHandler,
122
+ isSyncParamsError
119
123
  };
@@ -0,0 +1,7 @@
1
+ import type { JournalReceiveFailureStage } from "./JournalSyncTypes.js";
2
+ export declare class JournalReceiveFailure extends Error {
3
+ readonly stage: JournalReceiveFailureStage;
4
+ readonly key?: string;
5
+ constructor(stage: JournalReceiveFailureStage, key?: string);
6
+ }
7
+ export declare function isJournalReceiveFailure(error: unknown): error is JournalReceiveFailure;
@@ -0,0 +1,16 @@
1
+ class JournalReceiveFailure extends Error {
2
+ constructor(stage, key) {
3
+ super("Journal receive failed at " + stage);
4
+ this.stage = stage;
5
+ this.key = key;
6
+ }
7
+ stage;
8
+ key;
9
+ }
10
+ function isJournalReceiveFailure(error) {
11
+ return error instanceof JournalReceiveFailure;
12
+ }
13
+ export {
14
+ JournalReceiveFailure,
15
+ isJournalReceiveFailure
16
+ };