@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.
@@ -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 (conflictBaseRev) {
150
- newDoc._rev = conflictBaseRev;
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 closeRemoteDatabase;
36
- private withRemoteDatabase;
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
- checkReplicationConnectivity(setting: RemoteDBSettings, keepAlive: boolean, skipCheck: boolean, showResult: boolean, ignoreCleanLock?: boolean): Promise<false | {
40
- db: PouchDB.Database<EntryDoc>;
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
- db: PouchDB.Database<EntryDoc>;
53
- info: PouchDB.Core.DatabaseInfo;
54
- }> | "Empty passphrases cannot be used without explicit permission";
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.withRemoteDatabase(d.db, async (db) => this.sendChunks(setting, db, showResult, fromSeq));
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.closeRemoteDatabase(connectivity.db);
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.checkReplicationConnectivity(setting, false, retrying, showResult, ignoreCleanLock);
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.closeRemoteDatabase(db);
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
- // One-shot attempts own their temporary remote handle. Continuous replication
625
- // keeps its handle until the replication controller terminates it.
626
- async closeRemoteDatabase(db) {
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 db.close();
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 withRemoteDatabase(db, task) {
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.closeRemoteDatabase(db);
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
- async checkReplicationConnectivity(setting, keepAlive, skipCheck, showResult, ignoreCleanLock = false) {
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
- const dbRet = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), true);
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 = { db: dbRet.db, info: dbRet.info, syncOptionBase, syncOption };
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.closeRemoteDatabase(dbRet.db);
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.closeRemoteDatabase(db);
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.closeRemoteDatabase(con.db);
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.closeRemoteDatabase(con2.db);
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.withRemoteDatabase(dbRet.db, async (db) => {
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.withRemoteDatabase(dbRet.db, async (db) => {
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 _ensureConnection(settings, performSetup = false) {
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.db;
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 ownsConnection = db === void 0;
1003
- const connDB = db ?? await this._ensureConnection(settings);
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 (ownsConnection) {
1013
- await this.closeRemoteDatabase(connDB);
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 ownsConnection = db === void 0;
1027
- const connDB = db ?? await this._ensureConnection(settings, true);
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 (ownsConnection) {
1032
- await this.closeRemoteDatabase(connDB);
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.withRemoteDatabase(ret.db, async (db) => {
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.withRemoteDatabase(db.db, async () => {
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.withRemoteDatabase(dbRet.db, async (db) => {
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.withRemoteDatabase(dbRet.db, async (db) => {
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.withRemoteDatabase(dbRet.db, async (db) => {
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.withRemoteDatabase(dbRet.db, async (db) => (await db.compact({ interval: 1e3 })).ok);
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.withRemoteDatabase(dbRet.db, async (db) => {
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.withRemoteDatabase(dbRet.db, countCompromisedChunks);
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.withRemoteDatabase(dbRet.db, async (db) => {
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, baseRevision);
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, conflictBaseRev) {
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 (conflictBaseRev) {
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.putDBEntry(d, onlyChunks, conflictBaseRev);
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
- 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>>): Promise<string | {
139
- db: PouchDB.Database<EntryDoc>;
140
- info: PouchDB.Core.DatabaseInfo;
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
- 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>>): Promise<string | {
57
- db: PouchDB.Database<EntryDoc>;
58
- info: PouchDB.Core.DatabaseInfo;
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
- async connect(uri, auth, disableRequestURI, passphrase, useDynamicIterationCount, performSetup, skipInfo, compression, customHeaders, useRequestAPI, getPBKDF2Salt) {
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
- this.showError(`${MARK_LOG_NETWORK_ERROR}Network Error: Failed to fetch: ${msg}`, LOG_LEVEL_INFO);
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
- const db = new this._pouchDB(uri, conf);
191
- replicationFilter(db, compression);
192
- disableEncryption();
193
- if (passphrase !== "false" && typeof passphrase === "string") {
194
- enableEncryption(db, passphrase, useDynamicIterationCount, false, getPBKDF2Salt, setting.E2EEAlgorithm);
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 { db, info: { db_name: "", doc_count: 0, update_seq: "" } };
307
+ return connection;
198
308
  }
199
309
  try {
200
310
  const info = await db.info();
201
- return { db, info };
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
- try {
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.
@@ -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, and exact-revision logical deletion. `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.
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",