@vrtmrz/livesync-commonlib 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/dist/managers/EntryManager/EntryManagerImpls.js +1 -1
- package/dist/p2p/P2PService.d.ts +10 -1
- package/dist/p2p/P2PService.js +25 -2
- package/dist/p2p/index.d.ts +4 -1
- package/dist/p2p/index.js +2 -0
- package/dist/p2p/p2pDiagnostics.d.ts +35 -0
- package/dist/p2p/p2pDiagnostics.js +76 -0
- package/dist/pouchdb/ReplicatorShim.batch.d.ts +30 -0
- package/dist/pouchdb/ReplicatorShim.batch.js +62 -0
- package/dist/pouchdb/ReplicatorShim.d.ts +14 -1
- package/dist/pouchdb/ReplicatorShim.js +64 -80
- package/dist/replication/SyncParamsHandler.d.ts +1 -0
- package/dist/replication/SyncParamsHandler.js +5 -1
- package/dist/replication/journal/JournalReceiveFailure.d.ts +7 -0
- package/dist/replication/journal/JournalReceiveFailure.js +16 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +22 -3
- package/dist/replication/journal/JournalSyncCore.js +288 -166
- package/dist/replication/journal/JournalSyncTypes.d.ts +13 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +6 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.js +31 -44
- package/dist/replication/journal/journalCompatibility.d.ts +9 -0
- package/dist/replication/journal/journalCompatibility.js +58 -0
- package/dist/replication/journal/journalReceive.d.ts +23 -0
- package/dist/replication/journal/journalReceive.js +44 -0
- package/dist/replication/journal/journalRecords.d.ts +10 -0
- package/dist/replication/journal/journalRecords.js +96 -0
- package/dist/replication/trystero/P2PLogCollector.d.ts +12 -0
- package/dist/replication/trystero/P2PLogCollector.js +107 -72
- package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +4 -0
- package/dist/replication/trystero/P2PRoomSessionOwner.js +39 -2
- package/dist/replication/trystero/TrysteroReplicator.d.ts +21 -24
- package/dist/replication/trystero/TrysteroReplicator.js +191 -69
- package/dist/replication/trystero/TrysteroReplicatorP2PServer.js +11 -0
- package/dist/replication/trystero/p2pReplicationDiagnostics.d.ts +9 -0
- package/dist/replication/trystero/p2pReplicationDiagnostics.js +51 -0
- package/dist/replication/trystero/p2pReplicationProgress.d.ts +15 -0
- package/dist/replication/trystero/p2pReplicationProgress.js +37 -0
- package/dist/replication/trystero/types.d.ts +3 -0
- package/dist/services/base/APIService.d.ts +10 -0
- package/dist/services/base/APIService.js +13 -0
- package/dist/services/base/IService.d.ts +2 -0
- package/docs/adr/2026_11_shared_storage_path_resolution.md +4 -2
- package/docs/design_docs/storage_path_resolution.md +8 -7
- package/docs/development.md +2 -2
- package/docs/journal-receive-recovery.md +69 -0
- package/docs/p2p-transport-lifecycle.md +117 -10
- package/docs/platform-storage.md +2 -2
- package/docs/releasing.md +6 -0
- package/docs/unicode-path-identity.md +9 -8
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
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
|
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);
|
|
@@ -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
|
+
};
|