@vrtmrz/livesync-commonlib 0.1.15 → 0.1.16

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.
@@ -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);
@@ -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
  }
@@ -63,6 +63,10 @@ The revision-tree safety rules are documented in [Conflict resolution and file p
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
@@ -20,6 +20,8 @@ Commonlib's chunk-reachability tests use real PouchDB revision trees. They verif
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.16",
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",