@vrtmrz/livesync-commonlib 0.1.15 → 0.1.17
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/dist/interfaces/DatabaseFileAccess.d.ts +10 -0
- package/dist/managers/EntryManager/EntryManager.d.ts +1 -0
- package/dist/managers/EntryManager/EntryManager.js +4 -0
- package/dist/managers/EntryManager/EntryManagerImpls.d.ts +7 -0
- package/dist/managers/EntryManager/EntryManagerImpls.js +21 -3
- package/dist/pouchdb/LiveSyncLocalDB.d.ts +1 -0
- package/dist/pouchdb/LiveSyncLocalDB.js +3 -0
- package/dist/replication/couchdb/LiveSyncReplicator.d.ts +35 -12
- package/dist/replication/couchdb/LiveSyncReplicator.js +116 -41
- package/dist/serviceModules/ServiceDatabaseFileAccessBase.d.ts +1 -0
- package/dist/serviceModules/ServiceDatabaseFileAccessBase.js +22 -4
- package/dist/services/base/IService.d.ts +18 -4
- package/dist/services/base/RemoteConnection.d.ts +44 -0
- package/dist/services/base/RemoteConnection.js +0 -0
- package/dist/services/base/RemoteService.d.ts +23 -19
- package/dist/services/base/RemoteService.js +136 -31
- package/docs/conflict-resolution.md +2 -0
- package/docs/development.md +5 -1
- package/docs/proven-in-use.md +3 -1
- package/package.json +1 -1
|
@@ -4,6 +4,16 @@ export interface DatabaseFileAccess {
|
|
|
4
4
|
store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
|
|
5
5
|
/** Store a file as a child of an exact revision and return the created revision. */
|
|
6
6
|
storeWithBaseRevision: (file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean) => Promise<string | false>;
|
|
7
|
+
/**
|
|
8
|
+
* Store a file as a child of an exact revision only while that revision remains a live leaf.
|
|
9
|
+
*
|
|
10
|
+
* Unlike {@link storeWithBaseRevision}, this method does not force a branch below an obsolete
|
|
11
|
+
* revision. 'Live leaf' means any current revision-tree leaf, including a non-winning conflict
|
|
12
|
+
* leaf or a logical-deletion leaf. In particular, it returns `false` when another writer has
|
|
13
|
+
* already advanced the supplied base; ordinary target and Chunk validation can also refuse the
|
|
14
|
+
* write without creating a Metadata successor.
|
|
15
|
+
*/
|
|
16
|
+
storeWithLiveBaseRevision: (file: UXFileInfo, baseRevision: string, skipCheck?: boolean) => Promise<string | false>;
|
|
7
17
|
storeAsConflictedRevision: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<boolean>;
|
|
8
18
|
/** Preserve unknown storage content as a conflict and return its exact revision. */
|
|
9
19
|
storeAsConflictedRevisionWithResult: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<string | false>;
|
|
@@ -35,4 +35,5 @@ export declare class EntryManager {
|
|
|
35
35
|
deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
|
|
36
36
|
storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
|
|
37
37
|
putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
|
|
38
|
+
putDBEntryWithLiveBaseRevision(note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
|
|
38
39
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isTargetFile,
|
|
10
10
|
prepareChunk,
|
|
11
11
|
putDBEntry,
|
|
12
|
+
putDBEntryWithLiveBaseRevision,
|
|
12
13
|
storeDeletionByPathAtRevision
|
|
13
14
|
} from "./EntryManagerImpls.js";
|
|
14
15
|
class EntryManager {
|
|
@@ -71,6 +72,9 @@ class EntryManager {
|
|
|
71
72
|
async putDBEntry(note, onlyChunks, conflictBaseRev) {
|
|
72
73
|
return await putDBEntry(this.serviceHost, this, note, onlyChunks, conflictBaseRev);
|
|
73
74
|
}
|
|
75
|
+
async putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks) {
|
|
76
|
+
return await putDBEntryWithLiveBaseRevision(this.serviceHost, this, note, baseRevision, onlyChunks);
|
|
77
|
+
}
|
|
74
78
|
}
|
|
75
79
|
export {
|
|
76
80
|
EntryManager
|
|
@@ -13,6 +13,13 @@ type Managers = {
|
|
|
13
13
|
type NecessaryManagers<T extends keyof Managers> = Pick<Managers, T>;
|
|
14
14
|
export declare function createChunks(managers: NecessaryManagers<"chunkManager" | "hashManager" | "splitter">, dispFilename: string, note: SavingEntry): Promise<false | DocumentID[]>;
|
|
15
15
|
export declare function putDBEntry(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager" | "hashManager" | "splitter">, note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
|
|
16
|
+
/**
|
|
17
|
+
* Store an entry below an exact revision only while that revision remains a live leaf.
|
|
18
|
+
*
|
|
19
|
+
* PouchDB's ordinary MVCC write is the authority for this check. A 409 response means that the
|
|
20
|
+
* base has already been advanced, so the caller receives `false` and can retry from fresh state.
|
|
21
|
+
*/
|
|
22
|
+
export declare function putDBEntryWithLiveBaseRevision(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager" | "hashManager" | "splitter">, note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
|
|
16
23
|
export declare function isTargetFile(host: NecessaryServicesInterfaces<"setting", never>, filenameSrc: string): boolean;
|
|
17
24
|
export declare function prepareChunk({ chunkManager, hashManager }: NecessaryManagers<"chunkManager" | "hashManager">, piece: string): Promise<GeneratedChunk>;
|
|
18
25
|
export declare function getDBEntryMetaByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, includeDeleted?: boolean): Promise<false | LoadedEntry>;
|
|
@@ -110,6 +110,23 @@ async function createChunks(managers, dispFilename, note) {
|
|
|
110
110
|
return chunks;
|
|
111
111
|
}
|
|
112
112
|
async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
|
|
113
|
+
const revisionTarget = conflictBaseRev ? { mode: "force-base", baseRevision: conflictBaseRev } : { mode: "latest" };
|
|
114
|
+
return await putDBEntryInternal(host, managers, note, onlyChunks, revisionTarget);
|
|
115
|
+
}
|
|
116
|
+
async function putDBEntryWithLiveBaseRevision(host, managers, note, baseRevision, onlyChunks) {
|
|
117
|
+
try {
|
|
118
|
+
return await putDBEntryInternal(host, managers, note, onlyChunks, {
|
|
119
|
+
mode: "live-base",
|
|
120
|
+
baseRevision
|
|
121
|
+
});
|
|
122
|
+
} catch (ex) {
|
|
123
|
+
if (ex && typeof ex === "object" && "status" in ex && ex.status === 409) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
throw ex;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function putDBEntryInternal(host, managers, note, onlyChunks, revisionTarget) {
|
|
113
130
|
const { localDatabase, chunkManager, splitter } = managers;
|
|
114
131
|
const filename = host.services.path.id2path(note._id, note);
|
|
115
132
|
const dispFilename = stripAllPrefixes(filename);
|
|
@@ -146,8 +163,8 @@ async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
|
|
|
146
163
|
eden: {}
|
|
147
164
|
};
|
|
148
165
|
return await serialized("file:" + filename, async () => {
|
|
149
|
-
if (
|
|
150
|
-
newDoc._rev =
|
|
166
|
+
if (revisionTarget.mode !== "latest") {
|
|
167
|
+
newDoc._rev = revisionTarget.baseRevision;
|
|
151
168
|
} else {
|
|
152
169
|
try {
|
|
153
170
|
const old = await localDatabase.get(newDoc._id);
|
|
@@ -159,7 +176,7 @@ async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
|
|
|
159
176
|
}
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
|
-
const r = await localDatabase.put(newDoc, { force: true });
|
|
179
|
+
const r = revisionTarget.mode === "live-base" ? await localDatabase.put(newDoc) : await localDatabase.put(newDoc, { force: true });
|
|
163
180
|
if (r.ok) {
|
|
164
181
|
return r;
|
|
165
182
|
} else {
|
|
@@ -523,5 +540,6 @@ export {
|
|
|
523
540
|
isTargetFile,
|
|
524
541
|
prepareChunk,
|
|
525
542
|
putDBEntry,
|
|
543
|
+
putDBEntryWithLiveBaseRevision,
|
|
526
544
|
storeDeletionByPathAtRevision
|
|
527
545
|
};
|
|
@@ -192,6 +192,7 @@ export declare class LiveSyncLocalDB {
|
|
|
192
192
|
deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
|
|
193
193
|
storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
|
|
194
194
|
putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
|
|
195
|
+
putDBEntryWithLiveBaseRevision(note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
|
|
195
196
|
getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise<false | diff_result_leaf>;
|
|
196
197
|
tryAutoMerge(path: FilePathWithPrefix, enableMarkdownAutoMerge: boolean): AutoMergeResult;
|
|
197
198
|
}
|
|
@@ -370,6 +370,9 @@ class LiveSyncLocalDB {
|
|
|
370
370
|
async putDBEntry(note, onlyChunks, conflictBaseRev) {
|
|
371
371
|
return await this.managers.entryManager.putDBEntry(note, onlyChunks, conflictBaseRev);
|
|
372
372
|
}
|
|
373
|
+
async putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks) {
|
|
374
|
+
return await this.managers.entryManager.putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks);
|
|
375
|
+
}
|
|
373
376
|
async getConflictedDoc(path, rev) {
|
|
374
377
|
return await this.managers.conflictManager.getConflictedDoc(path, rev);
|
|
375
378
|
}
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { type EntryDoc, type RemoteDBSettings, type EntryLeaf, type SyncParameters, type DatabaseEntry, type NodeData, type RemotePreferredTweakResult } from "../../common/types.js";
|
|
2
2
|
import { LiveSyncAbstractReplicator, type LiveSyncReplicatorEnv, type RemoteDBStatus } from "../LiveSyncAbstractReplicator.js";
|
|
3
3
|
import type { ServiceHub } from "../../services/ServiceHub.js";
|
|
4
|
+
import type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "../../services/base/RemoteConnection.js";
|
|
5
|
+
export type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "../../services/base/RemoteConnection.js";
|
|
4
6
|
export interface LiveSyncCouchDBReplicatorEnv extends LiveSyncReplicatorEnv {
|
|
5
7
|
services: ServiceHub;
|
|
8
|
+
/** Internal injection point for the bounded one-shot connectivity preflight. */
|
|
9
|
+
oneShotConnectivityTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* An owned CouchDB connection which has passed LiveSync's compatibility checks
|
|
13
|
+
* and carries the options required to start replication.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* The caller must transfer ownership or call
|
|
17
|
+
* {@link OwnedCouchDBConnection.close}.
|
|
18
|
+
* The connection retains the same `close()` implementation while compatibility
|
|
19
|
+
* checks add replication options to it.
|
|
20
|
+
*/
|
|
21
|
+
export interface CouchDBReplicationConnection extends OwnedCouchDBConnection<EntryDoc> {
|
|
22
|
+
/** Base options shared by finite and continuous replication. */
|
|
23
|
+
readonly syncOptionBase: PouchDB.Replication.SyncOptions;
|
|
24
|
+
/** Options selected for the requested finite or continuous replication. */
|
|
25
|
+
readonly syncOption: PouchDB.Replication.SyncOptions;
|
|
6
26
|
}
|
|
7
27
|
export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicator {
|
|
28
|
+
env: LiveSyncCouchDBReplicatorEnv;
|
|
8
29
|
isMobile(): boolean;
|
|
9
30
|
constructor(env: LiveSyncCouchDBReplicatorEnv);
|
|
10
31
|
getInitialSyncParameters(setting: RemoteDBSettings): Promise<SyncParameters>;
|
|
@@ -32,26 +53,28 @@ export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicato
|
|
|
32
53
|
updateMaxTransferredSeqOnChunks(localDB: PouchDB.Database, remoteID: number, seqStatusMap: Record<number, boolean>): Promise<ReturnType<typeof this.getEmptyMaxEntry>>;
|
|
33
54
|
sendChunks(setting: RemoteDBSettings, remoteDB: PouchDB.Database<EntryDoc> | undefined, showResult: boolean, fromSeq?: number | string): Promise<boolean>;
|
|
34
55
|
openOneShotReplication(setting: RemoteDBSettings, showResult: boolean, retrying: boolean, syncMode: "sync" | "pullOnly" | "pushOnly", ignoreCleanLock?: boolean): Promise<boolean>;
|
|
35
|
-
private
|
|
36
|
-
private
|
|
56
|
+
private checkOneShotReplicationConnectivity;
|
|
57
|
+
private closeRemoteConnection;
|
|
58
|
+
private withRemoteConnection;
|
|
37
59
|
replicateAllToServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
|
|
38
60
|
replicateAllFromServer(setting: RemoteDBSettings, showingNotice?: boolean): Promise<boolean>;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
info: PouchDB.Core.DatabaseInfo;
|
|
42
|
-
syncOptionBase: PouchDB.Replication.SyncOptions;
|
|
43
|
-
syncOption: PouchDB.Replication.SyncOptions;
|
|
44
|
-
}>;
|
|
61
|
+
private reportConnectivityPreflightTimeout;
|
|
62
|
+
checkReplicationConnectivity(setting: RemoteDBSettings, keepAlive: boolean, skipCheck: boolean, showResult: boolean, ignoreCleanLock?: boolean, connectionOptions?: RemoteConnectionOpenOptions): Promise<false | CouchDBReplicationConnection>;
|
|
45
63
|
openContinuousReplication(setting: RemoteDBSettings, showResult: boolean, retrying: boolean): Promise<boolean>;
|
|
46
64
|
closeReplication(): void;
|
|
47
65
|
tryResetRemoteDatabase(setting: RemoteDBSettings): Promise<void>;
|
|
48
66
|
tryCreateRemoteDatabase(setting: RemoteDBSettings): Promise<void>;
|
|
49
67
|
markRemoteLocked(setting: RemoteDBSettings, locked: boolean, lockByClean: boolean): Promise<void>;
|
|
50
68
|
markRemoteResolved(setting: RemoteDBSettings): Promise<void>;
|
|
51
|
-
connectRemoteCouchDBWithSetting(settings: RemoteDBSettings, isMobile: boolean, performSetup?: boolean, skipInfo?: boolean): Promise<string |
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
connectRemoteCouchDBWithSetting(settings: RemoteDBSettings, isMobile: boolean, performSetup?: boolean, skipInfo?: boolean, connectionOptions?: RemoteConnectionOpenOptions): Promise<string | OwnedCouchDBConnection<EntryDoc>> | "Empty passphrases cannot be used without explicit permission";
|
|
70
|
+
private openOwnedConnection;
|
|
71
|
+
/**
|
|
72
|
+
* Opens a raw PouchDB handle for compatibility with earlier Commonlib
|
|
73
|
+
* consumers.
|
|
74
|
+
*
|
|
75
|
+
* @deprecated Use {@link connectRemoteCouchDBWithSetting} and close the
|
|
76
|
+
* complete owned connection after use.
|
|
77
|
+
*/
|
|
55
78
|
_ensureConnection<T extends DatabaseEntry>(settings: RemoteDBSettings, performSetup?: boolean): Promise<PouchDB.Database<T>>;
|
|
56
79
|
/**
|
|
57
80
|
* Fetch a document from the remote database directly.
|
|
@@ -41,12 +41,20 @@ import {
|
|
|
41
41
|
SyncParamsNotFoundError,
|
|
42
42
|
SyncParamsUpdateError
|
|
43
43
|
} from "../SyncParamsHandler.js";
|
|
44
|
+
import { compatGlobal } from "../../common/coreEnvFunctions.js";
|
|
44
45
|
const currentVersionRange = {
|
|
45
46
|
min: 0,
|
|
46
47
|
max: 2400,
|
|
47
48
|
current: 2
|
|
48
49
|
};
|
|
49
50
|
const selectorOnDemandPull = { selector: { type: { $ne: "leaf" } } };
|
|
51
|
+
const DEFAULT_ONE_SHOT_CONNECTIVITY_TIMEOUT_MS = 6e4;
|
|
52
|
+
class OneShotConnectivityPreflightTimeoutError extends Error {
|
|
53
|
+
constructor(timeoutMs) {
|
|
54
|
+
super(`Remote connectivity preflight exceeded ${timeoutMs} ms.`);
|
|
55
|
+
this.name = "OneShotConnectivityPreflightTimeoutError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
50
58
|
async function* genReplication(s, signal) {
|
|
51
59
|
const inbox = new StreamInbox({ capacity: 1e4 });
|
|
52
60
|
const push = function(e) {
|
|
@@ -379,11 +387,11 @@ class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicator {
|
|
|
379
387
|
);
|
|
380
388
|
return false;
|
|
381
389
|
}
|
|
382
|
-
return await this.
|
|
390
|
+
return await this.withRemoteConnection(d, async (db) => this.sendChunks(setting, db, showResult, fromSeq));
|
|
383
391
|
}
|
|
384
392
|
const connectivity = await this.checkReplicationConnectivity(setting, false, false, false, false);
|
|
385
393
|
if (connectivity !== false) {
|
|
386
|
-
await this.
|
|
394
|
+
await this.closeRemoteConnection(connectivity);
|
|
387
395
|
}
|
|
388
396
|
Logger(`Bulk sending chunks to remote database...`, showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "fetch");
|
|
389
397
|
const remoteMilestone = await remoteDB.get(MILESTONE_DOCID);
|
|
@@ -534,7 +542,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
534
542
|
}
|
|
535
543
|
const localDB = this.rawDatabase;
|
|
536
544
|
Logger(this.translate("liveSyncReplicator.oneShotSyncBegin", { syncMode }));
|
|
537
|
-
const ret = await this.
|
|
545
|
+
const ret = await this.checkOneShotReplicationConnectivity(setting, retrying, showResult, ignoreCleanLock);
|
|
538
546
|
if (ret === false) {
|
|
539
547
|
Logger(
|
|
540
548
|
this.translate("liveSyncReplicator.couldNotConnectToServer"),
|
|
@@ -613,7 +621,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
613
621
|
}
|
|
614
622
|
return false;
|
|
615
623
|
} finally {
|
|
616
|
-
await this.
|
|
624
|
+
await this.closeRemoteConnection(ret);
|
|
617
625
|
}
|
|
618
626
|
});
|
|
619
627
|
if (typeof next === "boolean") {
|
|
@@ -621,21 +629,38 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
621
629
|
}
|
|
622
630
|
return await next();
|
|
623
631
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
632
|
+
async checkOneShotReplicationConnectivity(setting, skipCheck, showResult, ignoreCleanLock) {
|
|
633
|
+
if (setting.useRequestAPI) {
|
|
634
|
+
return await this.checkReplicationConnectivity(setting, false, skipCheck, showResult, ignoreCleanLock);
|
|
635
|
+
}
|
|
636
|
+
const timeoutMs = this.env.oneShotConnectivityTimeoutMs ?? DEFAULT_ONE_SHOT_CONNECTIVITY_TIMEOUT_MS;
|
|
637
|
+
const controller = new AbortController();
|
|
638
|
+
const timeout = compatGlobal.setTimeout(
|
|
639
|
+
() => controller.abort(new OneShotConnectivityPreflightTimeoutError(timeoutMs)),
|
|
640
|
+
timeoutMs
|
|
641
|
+
);
|
|
642
|
+
try {
|
|
643
|
+
return await this.checkReplicationConnectivity(setting, false, skipCheck, showResult, ignoreCleanLock, {
|
|
644
|
+
signal: controller.signal,
|
|
645
|
+
allowNativeFallback: false
|
|
646
|
+
});
|
|
647
|
+
} finally {
|
|
648
|
+
compatGlobal.clearTimeout(timeout);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async closeRemoteConnection(connection) {
|
|
627
652
|
try {
|
|
628
|
-
await
|
|
653
|
+
await connection.close();
|
|
629
654
|
} catch (ex) {
|
|
630
655
|
Logger("Failed to close remote database.", LOG_LEVEL_INFO, "sync");
|
|
631
656
|
Logger(ex, LOG_LEVEL_VERBOSE, "sync");
|
|
632
657
|
}
|
|
633
658
|
}
|
|
634
|
-
async
|
|
659
|
+
async withRemoteConnection(connection, task) {
|
|
635
660
|
try {
|
|
636
|
-
return await task(db);
|
|
661
|
+
return await task(connection.db);
|
|
637
662
|
} finally {
|
|
638
|
-
await this.
|
|
663
|
+
await this.closeRemoteConnection(connection);
|
|
639
664
|
}
|
|
640
665
|
}
|
|
641
666
|
replicateAllToServer(setting, showingNotice) {
|
|
@@ -644,7 +669,17 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
644
669
|
replicateAllFromServer(setting, showingNotice) {
|
|
645
670
|
return this.openOneShotReplication(setting, showingNotice ?? false, false, "pullOnly");
|
|
646
671
|
}
|
|
647
|
-
|
|
672
|
+
reportConnectivityPreflightTimeout(connectionOptions, showResult, error) {
|
|
673
|
+
if (!connectionOptions?.signal?.aborted || !(connectionOptions.signal.reason instanceof OneShotConnectivityPreflightTimeoutError)) {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
Logger("The remote connectivity check timed out.", showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "sync");
|
|
677
|
+
if (error !== void 0) {
|
|
678
|
+
Logger(error, LOG_LEVEL_VERBOSE, "sync");
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
async checkReplicationConnectivity(setting, keepAlive, skipCheck, showResult, ignoreCleanLock = false, connectionOptions) {
|
|
648
683
|
if (setting.versionUpFlash != "") {
|
|
649
684
|
Logger(this.translate("Replicator.Message.VersionUpFlash"), LOG_LEVEL_NOTICE);
|
|
650
685
|
return false;
|
|
@@ -654,8 +689,25 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
654
689
|
Logger("Another replication running.");
|
|
655
690
|
return false;
|
|
656
691
|
}
|
|
657
|
-
|
|
692
|
+
let dbRet;
|
|
693
|
+
try {
|
|
694
|
+
dbRet = await this.connectRemoteCouchDBWithSetting(
|
|
695
|
+
setting,
|
|
696
|
+
this.isMobile(),
|
|
697
|
+
true,
|
|
698
|
+
false,
|
|
699
|
+
connectionOptions
|
|
700
|
+
);
|
|
701
|
+
} catch (ex) {
|
|
702
|
+
if (this.reportConnectivityPreflightTimeout(connectionOptions, showResult, ex)) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
throw ex;
|
|
706
|
+
}
|
|
658
707
|
if (typeof dbRet === "string") {
|
|
708
|
+
if (this.reportConnectivityPreflightTimeout(connectionOptions, showResult)) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
659
711
|
Logger(
|
|
660
712
|
this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }),
|
|
661
713
|
showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
@@ -734,13 +786,25 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
734
786
|
if (setting.readChunksOnline) {
|
|
735
787
|
syncOptionBase.pull = { ...selectorOnDemandPull };
|
|
736
788
|
}
|
|
789
|
+
if (this.reportConnectivityPreflightTimeout(connectionOptions, showResult)) {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
737
792
|
const syncOption = keepAlive ? { live: true, retry: true, heartbeat: setting.useTimeouts ? false : 3e4, ...syncOptionBase } : { ...syncOptionBase };
|
|
738
|
-
const connection = {
|
|
793
|
+
const connection = {
|
|
794
|
+
...dbRet,
|
|
795
|
+
syncOptionBase,
|
|
796
|
+
syncOption
|
|
797
|
+
};
|
|
739
798
|
ownershipTransferred = true;
|
|
740
799
|
return connection;
|
|
800
|
+
} catch (ex) {
|
|
801
|
+
if (this.reportConnectivityPreflightTimeout(connectionOptions, showResult, ex)) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
throw ex;
|
|
741
805
|
} finally {
|
|
742
806
|
if (!ownershipTransferred) {
|
|
743
|
-
await this.
|
|
807
|
+
await this.closeRemoteConnection(dbRet);
|
|
744
808
|
}
|
|
745
809
|
}
|
|
746
810
|
}
|
|
@@ -828,7 +892,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
828
892
|
}
|
|
829
893
|
}
|
|
830
894
|
} finally {
|
|
831
|
-
await this.
|
|
895
|
+
await this.closeRemoteConnection(ret);
|
|
832
896
|
}
|
|
833
897
|
}
|
|
834
898
|
return false;
|
|
@@ -862,7 +926,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
862
926
|
Logger(ex, LOG_LEVEL_NOTICE);
|
|
863
927
|
}
|
|
864
928
|
} finally {
|
|
865
|
-
await this.
|
|
929
|
+
await this.closeRemoteConnection(con);
|
|
866
930
|
}
|
|
867
931
|
clearHandlers();
|
|
868
932
|
await this.ensurePBKDF2Salt(setting, true, false);
|
|
@@ -871,7 +935,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
871
935
|
this.closeReplication();
|
|
872
936
|
const con2 = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), true);
|
|
873
937
|
if (typeof con2 === "string") return;
|
|
874
|
-
await this.
|
|
938
|
+
await this.closeRemoteConnection(con2);
|
|
875
939
|
clearHandlers();
|
|
876
940
|
await this.ensurePBKDF2Salt(setting, true, false);
|
|
877
941
|
Logger(this.translate("liveSyncReplicator.remoteDbCreatedOrConnected"), LOG_LEVEL_NOTICE);
|
|
@@ -883,7 +947,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
883
947
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
884
948
|
return;
|
|
885
949
|
}
|
|
886
|
-
await this.
|
|
950
|
+
await this.withRemoteConnection(dbRet, async (db) => {
|
|
887
951
|
if (!await checkRemoteVersion(db, this.migrate.bind(this), VER)) {
|
|
888
952
|
Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
|
|
889
953
|
return;
|
|
@@ -922,7 +986,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
922
986
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
923
987
|
return;
|
|
924
988
|
}
|
|
925
|
-
await this.
|
|
989
|
+
await this.withRemoteConnection(dbRet, async (db) => {
|
|
926
990
|
if (!await checkRemoteVersion(db, this.migrate.bind(this), VER)) {
|
|
927
991
|
Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
|
|
928
992
|
return;
|
|
@@ -952,7 +1016,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
952
1016
|
}
|
|
953
1017
|
});
|
|
954
1018
|
}
|
|
955
|
-
connectRemoteCouchDBWithSetting(settings, isMobile, performSetup = false, skipInfo = false) {
|
|
1019
|
+
connectRemoteCouchDBWithSetting(settings, isMobile, performSetup = false, skipInfo = false, connectionOptions) {
|
|
956
1020
|
if (settings.encrypt && settings.passphrase == "" && !settings.permitEmptyPassphrase) {
|
|
957
1021
|
return "Empty passphrases cannot be used without explicit permission";
|
|
958
1022
|
}
|
|
@@ -980,15 +1044,26 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
980
1044
|
settings.enableCompression,
|
|
981
1045
|
customHeaders,
|
|
982
1046
|
settings.useRequestAPI,
|
|
983
|
-
async () => await this.getReplicationPBKDF2Salt(settings)
|
|
1047
|
+
async () => await this.getReplicationPBKDF2Salt(settings),
|
|
1048
|
+
connectionOptions
|
|
984
1049
|
);
|
|
985
1050
|
}
|
|
986
|
-
async
|
|
1051
|
+
async openOwnedConnection(settings, performSetup = false) {
|
|
987
1052
|
const ret = await this.connectRemoteCouchDBWithSetting(settings, this.isMobile(), performSetup, true);
|
|
988
1053
|
if (typeof ret === "string") {
|
|
989
1054
|
throw new Error(`${this.translate("liveSyncReplicator.couldNotConnectToServer")}:${ret}`);
|
|
990
1055
|
}
|
|
991
|
-
return ret
|
|
1056
|
+
return ret;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Opens a raw PouchDB handle for compatibility with earlier Commonlib
|
|
1060
|
+
* consumers.
|
|
1061
|
+
*
|
|
1062
|
+
* @deprecated Use {@link connectRemoteCouchDBWithSetting} and close the
|
|
1063
|
+
* complete owned connection after use.
|
|
1064
|
+
*/
|
|
1065
|
+
async _ensureConnection(settings, performSetup = false) {
|
|
1066
|
+
return (await this.openOwnedConnection(settings, performSetup)).db;
|
|
992
1067
|
}
|
|
993
1068
|
/**
|
|
994
1069
|
* Fetch a document from the remote database directly.
|
|
@@ -999,8 +1074,8 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
999
1074
|
* @throws {Error} Other errors that may occur during the fetch operation.
|
|
1000
1075
|
*/
|
|
1001
1076
|
async fetchRemoteDocument(settings, id, db) {
|
|
1002
|
-
const
|
|
1003
|
-
const connDB = db ??
|
|
1077
|
+
const connection = db === void 0 ? await this.openOwnedConnection(settings) : void 0;
|
|
1078
|
+
const connDB = db ?? connection.db;
|
|
1004
1079
|
try {
|
|
1005
1080
|
return await connDB.get(id);
|
|
1006
1081
|
} catch (ex) {
|
|
@@ -1009,8 +1084,8 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1009
1084
|
}
|
|
1010
1085
|
throw ex;
|
|
1011
1086
|
} finally {
|
|
1012
|
-
if (
|
|
1013
|
-
await this.
|
|
1087
|
+
if (connection) {
|
|
1088
|
+
await this.closeRemoteConnection(connection);
|
|
1014
1089
|
}
|
|
1015
1090
|
}
|
|
1016
1091
|
}
|
|
@@ -1023,13 +1098,13 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1023
1098
|
* @throws {Error} If the document could not be put.
|
|
1024
1099
|
*/
|
|
1025
1100
|
async putRemoteDocument(settings, doc, db) {
|
|
1026
|
-
const
|
|
1027
|
-
const connDB = db ??
|
|
1101
|
+
const connection = db === void 0 ? await this.openOwnedConnection(settings, true) : void 0;
|
|
1102
|
+
const connDB = db ?? connection.db;
|
|
1028
1103
|
try {
|
|
1029
1104
|
return await connDB.put(doc);
|
|
1030
1105
|
} finally {
|
|
1031
|
-
if (
|
|
1032
|
-
await this.
|
|
1106
|
+
if (connection) {
|
|
1107
|
+
await this.closeRemoteConnection(connection);
|
|
1033
1108
|
}
|
|
1034
1109
|
}
|
|
1035
1110
|
}
|
|
@@ -1043,7 +1118,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1043
1118
|
);
|
|
1044
1119
|
return false;
|
|
1045
1120
|
}
|
|
1046
|
-
return await this.
|
|
1121
|
+
return await this.withRemoteConnection(ret, async (db) => {
|
|
1047
1122
|
const remoteChunks = await db.allDocs({ keys: missingChunks, include_docs: true });
|
|
1048
1123
|
const errorRows = remoteChunks.rows.filter((e) => "error" in e);
|
|
1049
1124
|
if (errorRows.length > 0) {
|
|
@@ -1074,7 +1149,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1074
1149
|
);
|
|
1075
1150
|
return false;
|
|
1076
1151
|
}
|
|
1077
|
-
return await this.
|
|
1152
|
+
return await this.withRemoteConnection(db, async () => {
|
|
1078
1153
|
Logger(`Connected to ${db.info.db_name} successfully`, LOG_LEVEL_NOTICE);
|
|
1079
1154
|
return true;
|
|
1080
1155
|
});
|
|
@@ -1086,7 +1161,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1086
1161
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1087
1162
|
return;
|
|
1088
1163
|
}
|
|
1089
|
-
await this.
|
|
1164
|
+
await this.withRemoteConnection(dbRet, async (db) => {
|
|
1090
1165
|
if (!await checkRemoteVersion(db, this.migrate.bind(this), VER)) {
|
|
1091
1166
|
Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
|
|
1092
1167
|
return;
|
|
@@ -1109,7 +1184,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1109
1184
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1110
1185
|
return;
|
|
1111
1186
|
}
|
|
1112
|
-
await this.
|
|
1187
|
+
await this.withRemoteConnection(dbRet, async (db) => {
|
|
1113
1188
|
if (!await checkRemoteVersion(db, this.migrate.bind(this), VER)) {
|
|
1114
1189
|
Logger(this.translate("liveSyncReplicator.remoteDbCorrupted"), LOG_LEVEL_NOTICE);
|
|
1115
1190
|
return;
|
|
@@ -1145,7 +1220,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1145
1220
|
error: new Error(dbRet)
|
|
1146
1221
|
};
|
|
1147
1222
|
}
|
|
1148
|
-
return await this.
|
|
1223
|
+
return await this.withRemoteConnection(dbRet, async (db) => {
|
|
1149
1224
|
try {
|
|
1150
1225
|
if (!await checkRemoteVersion(db, this.migrate.bind(this), VER)) {
|
|
1151
1226
|
const error = new Error("The remote database version is not compatible");
|
|
@@ -1205,7 +1280,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1205
1280
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1206
1281
|
return false;
|
|
1207
1282
|
}
|
|
1208
|
-
return await this.
|
|
1283
|
+
return await this.withRemoteConnection(dbRet, async (db) => (await db.compact({ interval: 1e3 })).ok);
|
|
1209
1284
|
}
|
|
1210
1285
|
async getRemoteStatus(setting) {
|
|
1211
1286
|
const dbRet = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), true);
|
|
@@ -1214,7 +1289,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1214
1289
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1215
1290
|
return false;
|
|
1216
1291
|
}
|
|
1217
|
-
return await this.
|
|
1292
|
+
return await this.withRemoteConnection(dbRet, async (db) => {
|
|
1218
1293
|
const info = await db.info();
|
|
1219
1294
|
return {
|
|
1220
1295
|
...info,
|
|
@@ -1229,7 +1304,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1229
1304
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1230
1305
|
return false;
|
|
1231
1306
|
}
|
|
1232
|
-
return await this.
|
|
1307
|
+
return await this.withRemoteConnection(dbRet, countCompromisedChunks);
|
|
1233
1308
|
}
|
|
1234
1309
|
async getConnectedDeviceList(setting = this.currentSettings) {
|
|
1235
1310
|
const dbRet = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), true);
|
|
@@ -1238,7 +1313,7 @@ ${sendingDocs}/(${sentDocsCount} done)`,
|
|
|
1238
1313
|
Logger(this.translate("liveSyncReplicator.couldNotConnectToURI", { uri, dbRet }), LOG_LEVEL_NOTICE);
|
|
1239
1314
|
return false;
|
|
1240
1315
|
}
|
|
1241
|
-
return await this.
|
|
1316
|
+
return await this.withRemoteConnection(dbRet, async (db) => {
|
|
1242
1317
|
const milestoneDoc = await db.get(MILESTONE_DOCID);
|
|
1243
1318
|
if (!milestoneDoc) {
|
|
1244
1319
|
Logger("Could not retrieve remote milestone", LOG_LEVEL_NOTICE);
|
|
@@ -27,6 +27,7 @@ export declare class ServiceDatabaseFileAccessBase extends ServiceModuleBase<Ser
|
|
|
27
27
|
createChunks(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
|
|
28
28
|
store(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
|
|
29
29
|
storeWithBaseRevision(file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean): Promise<string | false>;
|
|
30
|
+
storeWithLiveBaseRevision(file: UXFileInfo, baseRevision: string, skipCheck?: boolean): Promise<string | false>;
|
|
30
31
|
storeAsConflictedRevision(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<boolean>;
|
|
31
32
|
storeAsConflictedRevisionWithResult(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<string | false>;
|
|
32
33
|
storeDeletionWithBaseRevision(file: UXFileInfoStub | FilePathWithPrefix, baseRevision: string): Promise<string | false>;
|
|
@@ -53,7 +53,17 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
|
|
|
53
53
|
return await this.__store(file, force, skipCheck, false) !== false;
|
|
54
54
|
}
|
|
55
55
|
async storeWithBaseRevision(file, baseRevision, skipCheck) {
|
|
56
|
-
const result = await this.__store(file, true, skipCheck, false,
|
|
56
|
+
const result = await this.__store(file, true, skipCheck, false, {
|
|
57
|
+
mode: "force-base",
|
|
58
|
+
baseRevision
|
|
59
|
+
});
|
|
60
|
+
return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
|
|
61
|
+
}
|
|
62
|
+
async storeWithLiveBaseRevision(file, baseRevision, skipCheck) {
|
|
63
|
+
const result = await this.__store(file, true, skipCheck, false, {
|
|
64
|
+
mode: "live-base",
|
|
65
|
+
baseRevision
|
|
66
|
+
});
|
|
57
67
|
return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
|
|
58
68
|
}
|
|
59
69
|
async storeAsConflictedRevision(file, currentRev, skipCheck) {
|
|
@@ -97,7 +107,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
|
|
|
97
107
|
};
|
|
98
108
|
return await this.__store(dummyUXFileInfo, true, false, false) !== false;
|
|
99
109
|
}
|
|
100
|
-
async __store(file, force = false, skipCheck, onlyChunks,
|
|
110
|
+
async __store(file, force = false, skipCheck, onlyChunks, revisionTarget = { mode: "default" }) {
|
|
101
111
|
if (!skipCheck) {
|
|
102
112
|
if (!await this.checkIsTargetFile(file)) {
|
|
103
113
|
return true;
|
|
@@ -148,7 +158,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
|
|
|
148
158
|
};
|
|
149
159
|
const msg = `STORAGE -> DB (${datatype}) `;
|
|
150
160
|
const isNotChanged = await serialized("file-" + fullPath, async () => {
|
|
151
|
-
if (
|
|
161
|
+
if (revisionTarget.mode !== "default") {
|
|
152
162
|
return false;
|
|
153
163
|
}
|
|
154
164
|
if (force) {
|
|
@@ -183,7 +193,15 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
|
|
|
183
193
|
this._log(msg + " Skip " + fullPath, LOG_LEVEL_VERBOSE);
|
|
184
194
|
return true;
|
|
185
195
|
}
|
|
186
|
-
const ret = await this.database.localDatabase.
|
|
196
|
+
const ret = revisionTarget.mode === "live-base" ? await this.database.localDatabase.putDBEntryWithLiveBaseRevision(
|
|
197
|
+
d,
|
|
198
|
+
revisionTarget.baseRevision,
|
|
199
|
+
onlyChunks
|
|
200
|
+
) : await this.database.localDatabase.putDBEntry(
|
|
201
|
+
d,
|
|
202
|
+
onlyChunks,
|
|
203
|
+
revisionTarget.mode === "force-base" ? revisionTarget.baseRevision : void 0
|
|
204
|
+
);
|
|
187
205
|
if (ret !== false) {
|
|
188
206
|
this._log(msg + fullPath);
|
|
189
207
|
this.events.emitEvent(EVENT_FILE_SAVED);
|
|
@@ -11,6 +11,7 @@ import type { ReplicatorService } from "./ReplicatorService.js";
|
|
|
11
11
|
import type { DatabaseEventService } from "./DatabaseEventService.js";
|
|
12
12
|
import type { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "../../common/models/shared.const.symbols.js";
|
|
13
13
|
import type { AsyncActivityOptions } from "../../interfaces/AsyncActivityRunner.js";
|
|
14
|
+
import type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "./RemoteConnection.js";
|
|
14
15
|
declare global {
|
|
15
16
|
interface OPTIONAL_SYNC_FEATURES {
|
|
16
17
|
DISABLE: "DISABLE";
|
|
@@ -135,10 +136,23 @@ export interface IReplicationService {
|
|
|
135
136
|
markResolved(): Promise<void>;
|
|
136
137
|
}
|
|
137
138
|
export interface IRemoteService {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Opens an owned remote CouchDB connection.
|
|
141
|
+
*
|
|
142
|
+
* @remarks
|
|
143
|
+
* The caller must transfer ownership or call `close()` on the returned
|
|
144
|
+
* connection. Closing it aborts outstanding abort-capable requests before
|
|
145
|
+
* closing the PouchDB handle. Host-native request adapters may not honour
|
|
146
|
+
* `AbortSignal`; callers must not report those requests as cancelled without
|
|
147
|
+
* a host guarantee.
|
|
148
|
+
*
|
|
149
|
+
* @returns An error description, or an owned remote connection. When
|
|
150
|
+
* `skipInfo` is true, the `info` field retains its compatibility placeholder.
|
|
151
|
+
*
|
|
152
|
+
* @throws When PouchDB construction or local connection configuration fails
|
|
153
|
+
* before a connection result can be produced.
|
|
154
|
+
*/
|
|
155
|
+
connect(uri: string, auth: CouchDBCredentials, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean, customHeaders: Record<string, string>, useRequestAPI: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, options?: RemoteConnectionOpenOptions): Promise<string | OwnedCouchDBConnection<EntryDoc>>;
|
|
142
156
|
/**
|
|
143
157
|
* State if the last POST request failed due to payload size.
|
|
144
158
|
*/
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options which bind requests made through a remote CouchDB connection to the
|
|
3
|
+
* operation which owns it.
|
|
4
|
+
*/
|
|
5
|
+
export interface RemoteConnectionOpenOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Cancels abort-capable requests made through the connection when the
|
|
8
|
+
* owning operation is cancelled.
|
|
9
|
+
*/
|
|
10
|
+
readonly signal?: AbortSignal;
|
|
11
|
+
/**
|
|
12
|
+
* Allows the diagnostic fallback to a host-native request API after a
|
|
13
|
+
* web-compatible request reports a network `TypeError`.
|
|
14
|
+
*
|
|
15
|
+
* Set this to `false` when the owner must retain an abortable request path.
|
|
16
|
+
* A host-native adapter is not assumed to honour `AbortSignal`.
|
|
17
|
+
*/
|
|
18
|
+
readonly allowNativeFallback?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A remote PouchDB handle together with the lifecycle of requests created
|
|
22
|
+
* through that handle.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* The caller owns this connection and must call
|
|
26
|
+
* {@link OwnedCouchDBConnection.close} when ownership is not transferred.
|
|
27
|
+
* `close()` is idempotent: it first cancels abort-capable requests within this
|
|
28
|
+
* connection, then closes the PouchDB handle. Closing the PouchDB handle retains
|
|
29
|
+
* its normal `closed` event behaviour. This contract does not imply exclusive
|
|
30
|
+
* ownership of a physical HTTP socket.
|
|
31
|
+
*
|
|
32
|
+
* @typeParam T - The document type stored in the remote database.
|
|
33
|
+
*/
|
|
34
|
+
export interface OwnedCouchDBConnection<T extends object> {
|
|
35
|
+
/** The remote PouchDB database handle. */
|
|
36
|
+
readonly db: PouchDB.Database<T>;
|
|
37
|
+
/**
|
|
38
|
+
* The database information read while opening the connection. When the
|
|
39
|
+
* caller skips that read, compatibility placeholders are returned.
|
|
40
|
+
*/
|
|
41
|
+
readonly info: PouchDB.Core.DatabaseInfo;
|
|
42
|
+
/** Cancels scoped requests and closes the PouchDB handle exactly once. */
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
File without changes
|
|
@@ -9,6 +9,7 @@ import type { SettingService } from "./SettingService.js";
|
|
|
9
9
|
import { UnresolvedErrorManager } from "./UnresolvedErrorManager.js";
|
|
10
10
|
import { type LogFunction } from "../lib/logUtils.js";
|
|
11
11
|
import type { PouchDBConstructor } from "../../pouchdb/PouchDBConstructor.js";
|
|
12
|
+
import type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "./RemoteConnection.js";
|
|
12
13
|
export interface RemoteServiceDependencies {
|
|
13
14
|
/** PouchDB with the HTTP adapter required by the host runtime already registered. */
|
|
14
15
|
pouchDB: PouchDBConstructor;
|
|
@@ -25,21 +26,6 @@ type FetchMethod = (typeof FetchMethod)[keyof typeof FetchMethod];
|
|
|
25
26
|
* The RemoteService provides methods for interacting with the remote database.
|
|
26
27
|
*/
|
|
27
28
|
export declare abstract class RemoteService<T extends ServiceContext = ServiceContext> extends ServiceBase<T> implements IRemoteService {
|
|
28
|
-
/**
|
|
29
|
-
* Connect to the remote database with the provided settings.
|
|
30
|
-
* @param uri The URI of the remote database.
|
|
31
|
-
* @param auth The authentication credentials for the remote database.
|
|
32
|
-
* @param disableRequestURI Whether to disable the request URI.
|
|
33
|
-
* @param passphrase The passphrase for the remote database.
|
|
34
|
-
* @param useDynamicIterationCount Whether to use dynamic iteration count.
|
|
35
|
-
* @param performSetup Whether to perform setup.
|
|
36
|
-
* @param skipInfo Whether to skip information retrieval.
|
|
37
|
-
* @param compression Whether to enable compression.
|
|
38
|
-
* @param customHeaders Custom headers to include in the request.
|
|
39
|
-
* @param useRequestAPI Whether to use the request API.
|
|
40
|
-
* @param getPBKDF2Salt Function to retrieve the PBKDF2 salt.
|
|
41
|
-
* Note that this function is used for CouchDB and compatible only.
|
|
42
|
-
*/
|
|
43
29
|
protected _log: LogFunction;
|
|
44
30
|
protected _authHeader: AuthorizationHeaderGenerator;
|
|
45
31
|
protected _APIService: APIService;
|
|
@@ -53,9 +39,27 @@ export declare abstract class RemoteService<T extends ServiceContext = ServiceCo
|
|
|
53
39
|
showError(msg: string, max_log_level?: LOG_LEVEL): void;
|
|
54
40
|
clearErrors(): void;
|
|
55
41
|
performFetch(req: string | Request, opts?: RequestInit, fetchMethod?: FetchMethod): Promise<Response>;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Opens a remote CouchDB connection with request cancellation bound to its
|
|
44
|
+
* owner.
|
|
45
|
+
*
|
|
46
|
+
* @param uri - The URI of the remote database.
|
|
47
|
+
* @param auth - Authentication credentials for the remote database.
|
|
48
|
+
* @param disableRequestURI - Whether request URI handling is disabled.
|
|
49
|
+
* @param passphrase - The database encryption passphrase, or `false` when
|
|
50
|
+
* encryption is disabled.
|
|
51
|
+
* @param useDynamicIterationCount - Whether legacy dynamic iteration counts
|
|
52
|
+
* are enabled.
|
|
53
|
+
* @param performSetup - Whether PouchDB may set up a missing database.
|
|
54
|
+
* @param skipInfo - Whether to omit the opening database information request.
|
|
55
|
+
* @param compression - Whether chunk compression is enabled.
|
|
56
|
+
* @param customHeaders - Additional HTTP headers for every request.
|
|
57
|
+
* @param useRequestAPI - Whether to use the host-native request adapter.
|
|
58
|
+
* @param getPBKDF2Salt - Resolves the PBKDF2 salt when encryption needs it.
|
|
59
|
+
* @param connectionOptions - Ownership and request fallback options.
|
|
60
|
+
* @returns An error description, or the owned connection. The caller must
|
|
61
|
+
* transfer ownership or call `close()`.
|
|
62
|
+
*/
|
|
63
|
+
connect(uri: string, auth: CouchDBCredentials, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean, customHeaders: Record<string, string>, useRequestAPI: boolean, getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>, connectionOptions?: RemoteConnectionOpenOptions): Promise<string | OwnedCouchDBConnection<EntryDoc>>;
|
|
60
64
|
}
|
|
61
65
|
export {};
|
|
@@ -13,26 +13,84 @@ import { UnresolvedErrorManager } from "./UnresolvedErrorManager.js";
|
|
|
13
13
|
import { createInstanceLogFunction, MARK_LOG_NETWORK_ERROR } from "../lib/logUtils.js";
|
|
14
14
|
import { runWithTrackedPhysicalRequest } from "../lib/remoteActivity.js";
|
|
15
15
|
import { LiveSyncError } from "../../common/LSError.js";
|
|
16
|
+
function createRemoteConnectionScope(ownerSignal) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const combinedSignals = /* @__PURE__ */ new WeakMap();
|
|
19
|
+
let detachOwner = () => {
|
|
20
|
+
};
|
|
21
|
+
const abort = (reason) => {
|
|
22
|
+
detachOwner();
|
|
23
|
+
if (!controller.signal.aborted) {
|
|
24
|
+
controller.abort(reason);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
if (ownerSignal?.aborted) {
|
|
28
|
+
abort(ownerSignal.reason);
|
|
29
|
+
} else if (ownerSignal) {
|
|
30
|
+
const onOwnerAbort = () => abort(ownerSignal.reason);
|
|
31
|
+
ownerSignal.addEventListener("abort", onOwnerAbort, { once: true });
|
|
32
|
+
detachOwner = () => ownerSignal.removeEventListener("abort", onOwnerAbort);
|
|
33
|
+
}
|
|
34
|
+
const combine = (signal) => {
|
|
35
|
+
if (!signal || signal === controller.signal) return controller.signal;
|
|
36
|
+
const cached = combinedSignals.get(signal);
|
|
37
|
+
if (cached) return cached;
|
|
38
|
+
const combined = combineAbortSignals(signal, controller.signal);
|
|
39
|
+
combinedSignals.set(signal, combined);
|
|
40
|
+
return combined;
|
|
41
|
+
};
|
|
42
|
+
return { signal: controller.signal, combine, abort };
|
|
43
|
+
}
|
|
44
|
+
function combineAbortSignals(first, second) {
|
|
45
|
+
if (first === second) return first;
|
|
46
|
+
const abortSignalWithAny = AbortSignal;
|
|
47
|
+
if (typeof abortSignalWithAny.any === "function") {
|
|
48
|
+
return abortSignalWithAny.any([first, second]);
|
|
49
|
+
}
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
52
|
+
const detach = () => {
|
|
53
|
+
for (const [signal, listener] of listeners) {
|
|
54
|
+
signal.removeEventListener("abort", listener);
|
|
55
|
+
}
|
|
56
|
+
listeners.clear();
|
|
57
|
+
};
|
|
58
|
+
const abortFrom = (signal) => {
|
|
59
|
+
detach();
|
|
60
|
+
if (!controller.signal.aborted) {
|
|
61
|
+
controller.abort(signal.reason);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
for (const signal of [first, second]) {
|
|
65
|
+
if (signal.aborted) {
|
|
66
|
+
abortFrom(signal);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const listener = () => abortFrom(signal);
|
|
70
|
+
listeners.set(signal, listener);
|
|
71
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
72
|
+
}
|
|
73
|
+
return controller.signal;
|
|
74
|
+
}
|
|
75
|
+
function createCouchDBConnection(db, info, scope) {
|
|
76
|
+
let closePromise;
|
|
77
|
+
return {
|
|
78
|
+
db,
|
|
79
|
+
info,
|
|
80
|
+
close: () => {
|
|
81
|
+
closePromise ??= (async () => {
|
|
82
|
+
scope.abort(new Error("Remote connection closed."));
|
|
83
|
+
await db.close();
|
|
84
|
+
})();
|
|
85
|
+
return closePromise;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
16
89
|
const FetchMethod = {
|
|
17
90
|
webCompat: 0,
|
|
18
91
|
native: 1
|
|
19
92
|
};
|
|
20
93
|
class RemoteService extends ServiceBase {
|
|
21
|
-
/**
|
|
22
|
-
* Connect to the remote database with the provided settings.
|
|
23
|
-
* @param uri The URI of the remote database.
|
|
24
|
-
* @param auth The authentication credentials for the remote database.
|
|
25
|
-
* @param disableRequestURI Whether to disable the request URI.
|
|
26
|
-
* @param passphrase The passphrase for the remote database.
|
|
27
|
-
* @param useDynamicIterationCount Whether to use dynamic iteration count.
|
|
28
|
-
* @param performSetup Whether to perform setup.
|
|
29
|
-
* @param skipInfo Whether to skip information retrieval.
|
|
30
|
-
* @param compression Whether to enable compression.
|
|
31
|
-
* @param customHeaders Custom headers to include in the request.
|
|
32
|
-
* @param useRequestAPI Whether to use the request API.
|
|
33
|
-
* @param getPBKDF2Salt Function to retrieve the PBKDF2 salt.
|
|
34
|
-
* Note that this function is used for CouchDB and compatible only.
|
|
35
|
-
*/
|
|
36
94
|
_log;
|
|
37
95
|
_authHeader = new AuthorizationHeaderGenerator();
|
|
38
96
|
_APIService;
|
|
@@ -101,13 +159,35 @@ class RemoteService extends ServiceBase {
|
|
|
101
159
|
return response;
|
|
102
160
|
});
|
|
103
161
|
}
|
|
104
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Opens a remote CouchDB connection with request cancellation bound to its
|
|
164
|
+
* owner.
|
|
165
|
+
*
|
|
166
|
+
* @param uri - The URI of the remote database.
|
|
167
|
+
* @param auth - Authentication credentials for the remote database.
|
|
168
|
+
* @param disableRequestURI - Whether request URI handling is disabled.
|
|
169
|
+
* @param passphrase - The database encryption passphrase, or `false` when
|
|
170
|
+
* encryption is disabled.
|
|
171
|
+
* @param useDynamicIterationCount - Whether legacy dynamic iteration counts
|
|
172
|
+
* are enabled.
|
|
173
|
+
* @param performSetup - Whether PouchDB may set up a missing database.
|
|
174
|
+
* @param skipInfo - Whether to omit the opening database information request.
|
|
175
|
+
* @param compression - Whether chunk compression is enabled.
|
|
176
|
+
* @param customHeaders - Additional HTTP headers for every request.
|
|
177
|
+
* @param useRequestAPI - Whether to use the host-native request adapter.
|
|
178
|
+
* @param getPBKDF2Salt - Resolves the PBKDF2 salt when encryption needs it.
|
|
179
|
+
* @param connectionOptions - Ownership and request fallback options.
|
|
180
|
+
* @returns An error description, or the owned connection. The caller must
|
|
181
|
+
* transfer ownership or call `close()`.
|
|
182
|
+
*/
|
|
183
|
+
async connect(uri, auth, disableRequestURI, passphrase, useDynamicIterationCount, performSetup, skipInfo, compression, customHeaders, useRequestAPI, getPBKDF2Salt, connectionOptions = {}) {
|
|
105
184
|
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
|
106
185
|
if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters.";
|
|
107
186
|
if (uri.indexOf(" ") !== -1) return "Remote URI and database name could not contain spaces.";
|
|
108
187
|
if (!this._APIService.isOnline) {
|
|
109
188
|
return "Network is offline";
|
|
110
189
|
}
|
|
190
|
+
const connectionScope = createRemoteConnectionScope(connectionOptions.signal);
|
|
111
191
|
const conf = {
|
|
112
192
|
adapter: "http",
|
|
113
193
|
auth: "username" in auth ? auth : void 0,
|
|
@@ -143,14 +223,18 @@ class RemoteService extends ServiceBase {
|
|
|
143
223
|
headers.append("authorization", authHeader);
|
|
144
224
|
}
|
|
145
225
|
try {
|
|
226
|
+
const signal = connectionScope.combine(opts?.signal);
|
|
146
227
|
const response = await this.performFetch(
|
|
147
228
|
requestSrc,
|
|
148
|
-
{ ...opts, headers },
|
|
229
|
+
{ ...opts, headers, signal },
|
|
149
230
|
useRequestAPI ? FetchMethod.native : FetchMethod.webCompat
|
|
150
231
|
);
|
|
151
232
|
return response;
|
|
152
233
|
} catch (ex) {
|
|
153
234
|
if (ex instanceof TypeError) {
|
|
235
|
+
if (connectionScope.signal.aborted || connectionOptions.allowNativeFallback === false) {
|
|
236
|
+
throw ex;
|
|
237
|
+
}
|
|
154
238
|
if (useRequestAPI) {
|
|
155
239
|
this._log("Failed to request by API.");
|
|
156
240
|
throw ex;
|
|
@@ -176,7 +260,12 @@ class RemoteService extends ServiceBase {
|
|
|
176
260
|
} catch (ex) {
|
|
177
261
|
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
|
178
262
|
const msg = ex instanceof Error ? `${ex?.name}:${ex?.message}` : ex?.toString();
|
|
179
|
-
|
|
263
|
+
if (!connectionScope.signal.aborted) {
|
|
264
|
+
this.showError(
|
|
265
|
+
`${MARK_LOG_NETWORK_ERROR}Network Error: Failed to fetch: ${msg}`,
|
|
266
|
+
LOG_LEVEL_INFO
|
|
267
|
+
);
|
|
268
|
+
}
|
|
180
269
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
181
270
|
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
|
182
271
|
this.last_successful_post = false;
|
|
@@ -187,28 +276,44 @@ class RemoteService extends ServiceBase {
|
|
|
187
276
|
}
|
|
188
277
|
};
|
|
189
278
|
const setting = this._settingService.currentSettings();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
279
|
+
let db;
|
|
280
|
+
try {
|
|
281
|
+
db = new this._pouchDB(uri, conf);
|
|
282
|
+
} catch (ex) {
|
|
283
|
+
connectionScope.abort(ex);
|
|
284
|
+
throw ex;
|
|
285
|
+
}
|
|
286
|
+
const compatibilityInfo = { db_name: "", doc_count: 0, update_seq: "" };
|
|
287
|
+
const connection = createCouchDBConnection(db, compatibilityInfo, connectionScope);
|
|
288
|
+
const closeAfterConnectionFailure = async () => {
|
|
289
|
+
try {
|
|
290
|
+
await connection.close();
|
|
291
|
+
} catch (closeError) {
|
|
292
|
+
this._log("Failed to close remote database after connection failure.", LOG_LEVEL_INFO);
|
|
293
|
+
this._log(closeError, LOG_LEVEL_VERBOSE);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
replicationFilter(db, compression);
|
|
298
|
+
disableEncryption();
|
|
299
|
+
if (passphrase !== "false" && typeof passphrase === "string") {
|
|
300
|
+
enableEncryption(db, passphrase, useDynamicIterationCount, false, getPBKDF2Salt, setting.E2EEAlgorithm);
|
|
301
|
+
}
|
|
302
|
+
} catch (ex) {
|
|
303
|
+
await closeAfterConnectionFailure();
|
|
304
|
+
throw ex;
|
|
195
305
|
}
|
|
196
306
|
if (skipInfo) {
|
|
197
|
-
return
|
|
307
|
+
return connection;
|
|
198
308
|
}
|
|
199
309
|
try {
|
|
200
310
|
const info = await db.info();
|
|
201
|
-
return {
|
|
311
|
+
return { ...connection, info };
|
|
202
312
|
} catch (ex) {
|
|
203
313
|
const exMsg = ex instanceof Error ? ex : LiveSyncError.fromError(ex);
|
|
204
314
|
const msg = `${exMsg.name}:${exMsg.message}`;
|
|
205
315
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
206
|
-
|
|
207
|
-
await db.close();
|
|
208
|
-
} catch (closeError) {
|
|
209
|
-
this._log("Failed to close remote database after connection failure.", LOG_LEVEL_INFO);
|
|
210
|
-
this._log(closeError, LOG_LEVEL_VERBOSE);
|
|
211
|
-
}
|
|
316
|
+
await closeAfterConnectionFailure();
|
|
212
317
|
return msg;
|
|
213
318
|
}
|
|
214
319
|
}
|
|
@@ -80,6 +80,8 @@ The record is updated only after a successful database-to-storage reflection or
|
|
|
80
80
|
|
|
81
81
|
User-directed reconciliation may select any exact current live revision, including a conflict leaf rather than the deterministic winner. `dbToStorageWithSpecificRev()` rechecks that the selected revision is still live before reflecting its content. `storeFileToDBWithBaseRevision()` performs the same live check before storing current storage content as a child of that revision. If the content already matches the selected non-deleted revision, it records that revision directly without creating a child. A host can set `createIfDifferent` to `false` when it intends only to record an exact content match; differing content then fails instead of changing the revision tree. A host may also create a logical deletion on an explicitly selected branch with `deleteRevisionFromDB()` after performing its own current-live-revision check. This leaves storage unchanged and clears device-local provenance only when that record names the deleted revision.
|
|
82
82
|
|
|
83
|
+
`storeWithBaseRevision()` is the force-write primitive for deliberately preserving a branch below an exact revision, including a revision which is no longer a leaf. It must not be used when an operation means 'advance this revision only if it is still current'. For that compare-and-store boundary, `storeWithLiveBaseRevision()` uses PouchDB's ordinary revision check and returns `false` if the supplied base has already been advanced. Any current revision-tree leaf is eligible, including a non-winning conflict leaf or a logical-deletion leaf. The caller must refresh its state after refusal rather than silently falling back to the deterministic winner.
|
|
84
|
+
|
|
83
85
|
A successful non-deleted reconciliation records the exact selected or newly created revision as provenance. Applying a logical deletion removes the storage item and its provenance record. Deletion provenance is not retained indefinitely: an absent storage item and a current logical-deletion winner already agree and need no further reconciliation.
|
|
84
86
|
|
|
85
87
|
Hosts may construct the namespaced store handle during service composition, before its backing database is open. Store operations begin only after the host's storage lifecycle is ready. They fail rather than wait when that lifecycle contract is violated, because an implicit readiness wait could hang after failed initialisation or wait on its own initialisation handler. Database reset is a transient unavailable boundary; the host must avoid file processing during it and reconstruct derived provenance after reopening.
|
package/docs/development.md
CHANGED
|
@@ -59,10 +59,14 @@ The multiple-remote profile contract has focused unit coverage in `src/serviceFe
|
|
|
59
59
|
|
|
60
60
|
The full offline scanner applies both the host's target-file policy and Commonlib's built-in `shouldBeIgnored` policy before constructing storage/database pairs. This keeps legacy LiveSync log files and flag files outside ordinary create, restore, and delete decisions even when a host accepts their file extension. Lower-level write guards remain a defence for direct calls; they are not the normal full-scan filter. The complete pair-state decision matrix and database-to-storage reflection result contract are documented in [Full offline scan](offline-scanner.md). Extend that document, the Cartesian action test, and both collection-side tests whenever the policy changes.
|
|
61
61
|
|
|
62
|
-
The revision-tree safety rules are documented in [Conflict resolution and file provenance](conflict-resolution.md). `ConflictManager.unit.spec.ts` owns common-ancestor and conservative merge behaviour, deterministic pair selection for multiple live leaves, and reconstruction from the committed tree between stages. `ServiceDatabaseFileAccessBase.unit.spec.ts` owns all-branch content discovery, short-circuiting history existence checks after an exact match,
|
|
62
|
+
The revision-tree safety rules are documented in [Conflict resolution and file provenance](conflict-resolution.md). `ConflictManager.unit.spec.ts` owns common-ancestor and conservative merge behaviour, deterministic pair selection for multiple live leaves, and reconstruction from the committed tree between stages. `EntryManagerImpls.unit.spec.ts` owns the distinction between force-writing below an exact revision and conditionally advancing a current live leaf through PouchDB's ordinary revision check. `ServiceDatabaseFileAccessBase.unit.spec.ts` owns all-branch content discovery, short-circuiting history existence checks after an exact match, exact-revision logical deletion, and the public live-base result and event boundary. `ServiceFileHandlerBase.unit.spec.ts` owns the injected provenance policy for edit, deletion, and rename operations, together with user-selected reflection, child creation, strict matching, matching-provenance cleanup after exact branch deletion, and stale-revision refusal. `LiveSyncLocalDB.unit.spec.ts` owns chunk reachability across current winners, shared chunks, live conflicts, available merge ancestry, conflict resolution, and PouchDB replication of collection and recreation. Extend the relevant sides when changing conflict resolution, compaction handling, provenance, chunk collection, or database-to-storage overwrite protection.
|
|
63
63
|
|
|
64
64
|
`LiveSyncReplicator.unit.spec.ts` owns the CouchDB remote-chunk batch contract: a mixed `_all_docs` result must return each available document even when another keyed row reports an error. `ChunkFetcher.unit.spec.ts` owns the consumer behaviour: it writes valid returned chunks and emits `missingChunkRemote` only for requested IDs which are absent from the returned set. Extend both sides when changing remote chunk acquisition or missing-chunk delivery.
|
|
65
65
|
|
|
66
|
+
`RemoteService.connect()` returns an owned CouchDB connection as the existing flat `{ db, info }` result with an idempotent `close()` method. `close()` first aborts outstanding abort-capable requests in that connection, then closes the PouchDB handle. The owner must transfer or close the complete connection; passing only `db` loses the cancellation boundary. A skipped information request retains the established placeholder in `info` for compatibility. `OwnedCouchDBConnection`, its opening options, and the checked `CouchDBReplicationConnection` are Commonlib-owned types exported by the existing CouchDB replicator module. The legacy `_ensureConnection()` helper continues to return a raw PouchDB handle for source compatibility; new Commonlib code must retain and close the complete owned connection instead.
|
|
67
|
+
|
|
68
|
+
The one-shot CouchDB connectivity preflight is the first bounded consumer. It applies an internal 60-second safety fuse to the complete preflight on the web-compatible fetch path, disables fallback into the non-abortable native request adapter, and clears the fuse before transferring the connection to replication. The fuse is a last-resort wall-clock bound on an owner which would otherwise remain pending indefinitely; it is not the expected completion time, a per-request inactivity timeout, a user setting, or the `useTimeouts` changes-feed setting. The explicitly selected native Request API retains its previous unbounded behaviour because its current host adapter cannot honour `AbortSignal`. Extend `RemoteService.unit.spec.ts` and the one-shot ownership table in `LiveSyncReplicator.unit.spec.ts` when changing this boundary. Do not use `Promise.race()` to report a mutating native request as cancelled while it can still complete remotely.
|
|
69
|
+
|
|
66
70
|
`openSimpleStore()` may create an inert namespace handle during composition, before the backing key-value database has opened. Maintained hosts must complete their sequential settings lifecycle before scans, watchers, or replication invoke the handle. Operations fail on a lifecycle violation rather than waiting for readiness; do not introduce an implicit wait which can hang after failed initialisation or become self-referential from an initialisation handler. Treat database reset as a transient unavailable boundary and reconstruct derived local state after reopening.
|
|
67
71
|
|
|
68
72
|
### P2P composition ownership
|
package/docs/proven-in-use.md
CHANGED
|
@@ -14,12 +14,14 @@ The consumer contract in `test/contracts/serviceContext.ts` checks shared result
|
|
|
14
14
|
|
|
15
15
|
Commonlib supplies the two sleep preferences and preserves them through Setup URI encoding. Self-hosted LiveSync applies those values once at its injected bounded-activity runner, so remote operations and downloaded-document application share the same host-owned Wake Lock policy. Focused downstream tests cover the platform preference table and both activity categories; operating-system suspend behaviour remains real-platform integration evidence rather than a Commonlib guarantee.
|
|
16
16
|
|
|
17
|
-
Commonlib's real-PouchDB and injected-boundary unit tests prove that conservative merge uses the nearest revision actually shared by both conflict leaves, multiple remaining leaves are selected in a deterministic order, each completed pair is reconstructed from the committed tree, storage content from a deleted losing branch remains recognisable after resolution, and conflict-time operations retain the exact displayed branch or preserve every branch when that identity cannot be proved. They also verify that a boolean history check stops after its first exact content match without reading an older unavailable revision, while revision enumeration still returns every match. A local edit remains a readable sibling branch when the winning body is unavailable but its exact revision and parent metadata remain, a generation-one revision without a parent is left unresolved, and a separately confirmed exact deletion of that unreadable live leaf permits a replacement to be stored without reading the missing chunk. Exact reconciliation tests cover reflecting a selected live leaf, storing Vault content as its child, recording a matching leaf without creating a revision, clearing matching provenance after exact branch deletion, and refusing to record a stale revision or a revision whose content differs. Self-hosted LiveSync supplies the host composition, persistent device-local provenance, and the per-revision repair interface. Its optional two-Vault scopes cover post-resolution propagation and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active. The conflict-operation scope passes in real Obsidian against the exact locally packed Commonlib artefact, including replicated parent revisions and preservation of the other live branches.
|
|
17
|
+
Commonlib's real-PouchDB and injected-boundary unit tests prove that conservative merge uses the nearest revision actually shared by both conflict leaves, multiple remaining leaves are selected in a deterministic order, each completed pair is reconstructed from the committed tree, storage content from a deleted losing branch remains recognisable after resolution, and conflict-time operations retain the exact displayed branch or preserve every branch when that identity cannot be proved. They also verify that a boolean history check stops after its first exact content match without reading an older unavailable revision, while revision enumeration still returns every match. A local edit remains a readable sibling branch when the winning body is unavailable but its exact revision and parent metadata remain, a generation-one revision without a parent is left unresolved, and a separately confirmed exact deletion of that unreadable live leaf permits a replacement to be stored without reading the missing chunk. Exact reconciliation tests cover reflecting a selected live leaf, storing Vault content as its child, recording a matching leaf without creating a revision, clearing matching provenance after exact branch deletion, and refusing to record a stale revision or a revision whose content differs. Separate live-base tests advance a selected conflict leaf or logical-deletion leaf through PouchDB's ordinary revision check, retain the other live leaf, and reject reuse of a stale base. Self-hosted LiveSync supplies the host composition, persistent device-local provenance, and the per-revision repair interface. Its optional two-Vault scopes cover post-resolution propagation and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active. The conflict-operation scope passes in real Obsidian against the exact locally packed Commonlib artefact, including replicated parent revisions and preservation of the other live branches. Its Document History scenario restores readable historical bytes as a new live successor and reflects that exact revision to the Vault.
|
|
18
18
|
|
|
19
19
|
Commonlib's chunk-reachability tests use real PouchDB revision trees. They verify that an ordinary superseded revision releases its unique chunks, a chunk shared by current files remains reachable, live conflict branches and their nearest available shared ancestor remain protected, and losing-branch ancestry becomes eligible after resolution. They also replicate a collected chunk deletion to another PouchDB database and verify that writing the same content can recreate and replicate the content-addressed chunk. Self-hosted LiveSync owns the CouchDB-specific Garbage Collection V3 workflow, including device checks, confirmation, deletion, push-only replication, and remote compaction.
|
|
20
20
|
|
|
21
21
|
Commonlib's injected CouchDB tests verify that a mixed remote chunk batch preserves available documents when another requested ID is absent. Its chunk-delivery tests separately verify that available chunks reach local storage and only absent IDs are marked unavailable. Self-hosted LiveSync supplies the real CouchDB connection and start-up composition, so device-specific replication timing remains downstream integration evidence rather than Commonlib unit coverage.
|
|
22
22
|
|
|
23
|
+
Commonlib's injected remote-service tests verify that an owning operation can abort a pending CouchDB request, that closing the flat connection interrupts a response body which is already being consumed, that repeated closure closes PouchDB once, and that a bounded request does not fall back into the non-abortable native adapter. Its one-shot replicator tests verify deadline expiry, distinct timeout reporting, cleanup before release of the shared attempt, later invocation, successful ownership transfer, and the unchanged native-request boundary. Self-hosted LiveSync still supplies the actual Obsidian fetch adapters and real CouchDB environment; confirmation on the reporter's platform remains downstream evidence rather than a Commonlib guarantee.
|
|
24
|
+
|
|
23
25
|
Commonlib's remote-preference result contract distinguishes available synchronisation settings, a remote which has not stored those settings yet, a failed read, and a remote type which does not support central preferences. Focused CouchDB and Object Storage tests cover absent milestone data separately from transport failures, while Self-hosted LiveSync owns the initialisation choices, cancellation policy, and user-facing explanation.
|
|
24
26
|
|
|
25
27
|
Commonlib's full offline scanner distinguishes completed, deliberately skipped, and failed storage/database pairs. Before pair construction, it withholds Metadata whose actual document ID disagrees with its recorded path, without reflection, deletion, or last-seen side effects. A consistently addressed entry for the same logical path continues through the established path-based flow; only a path without such an entry also withholds storage processing. The read-only actual-ID Inspector and separately invoked one-entry repair retain ambiguous states for review, revalidate an exact source revision, verify a target-first copy, and preserve the source on failure. Self-hosted LiveSync keeps the existing Fast Setup aggregate policy and owns confirmation and presentation in its file/database repair interface.
|
package/package.json
CHANGED