@vrtmrz/livesync-commonlib 0.1.44 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +80 -4
  2. package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
  3. package/dist/API/DirectFileManipulatorV2.js +3 -0
  4. package/dist/API/processSetting.d.ts +1 -0
  5. package/dist/common/configForDoc.d.ts +1 -0
  6. package/dist/common/configForDoc.js +11 -1
  7. package/dist/common/models/db.definition.d.ts +1 -0
  8. package/dist/common/models/setting.const.defaults.js +1 -0
  9. package/dist/common/models/setting.const.preferred.js +1 -3
  10. package/dist/common/models/setting.const.qr.js +1 -0
  11. package/dist/common/models/setting.policy.d.ts +4 -0
  12. package/dist/common/models/setting.policy.js +1 -0
  13. package/dist/common/models/setting.type.d.ts +2 -0
  14. package/dist/common/models/shared.definition.d.ts +20 -0
  15. package/dist/common/models/tweak.definition.d.ts +1 -0
  16. package/dist/common/settingConstants.d.ts +1 -0
  17. package/dist/common/settingConstants.js +6 -2
  18. package/dist/interfaces/FileHandler.d.ts +5 -1
  19. package/dist/interfaces/StorageEventManager.d.ts +5 -0
  20. package/dist/interfaces/StorageEventManager.js +7 -0
  21. package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
  22. package/dist/managers/StorageEventManager.d.ts +19 -10
  23. package/dist/managers/StorageEventManager.js +259 -189
  24. package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
  25. package/dist/pouchdb/StreamingFetch.d.ts +5 -1
  26. package/dist/pouchdb/StreamingFetch.js +529 -65
  27. package/dist/replication/LiveSyncAbstractReplicator.d.ts +2 -0
  28. package/dist/replication/LiveSyncAbstractReplicator.js +5 -2
  29. package/dist/replication/RemoteResource.d.ts +23 -2
  30. package/dist/replication/RemoteResource.js +6 -2
  31. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +14 -0
  32. package/dist/replication/couchdb/LiveSyncReplicator.js +171 -3
  33. package/dist/replication/couchdb/fastForward.d.ts +19 -0
  34. package/dist/replication/couchdb/fastForward.js +119 -0
  35. package/dist/replication/couchdb/replicationProgress.d.ts +6 -0
  36. package/dist/replication/couchdb/replicationProgress.js +105 -0
  37. package/dist/replication/index.d.ts +4 -1
  38. package/dist/replication/joinedDevices.d.ts +25 -0
  39. package/dist/replication/joinedDevices.js +123 -0
  40. package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
  41. package/dist/replication/journal/JournalSyncCore.js +11 -1
  42. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
  43. package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
  44. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
  45. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
  46. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
  47. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
  48. package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
  49. package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
  50. package/dist/serviceFeatures/offlineScanner.js +19 -7
  51. package/dist/serviceModules/Rebuilder.js +41 -15
  52. package/dist/serviceModules/ServiceFileAccessBase.js +5 -0
  53. package/dist/serviceModules/ServiceFileHandlerBase.d.ts +8 -5
  54. package/dist/serviceModules/ServiceFileHandlerBase.js +75 -38
  55. package/dist/services/BrowserServices.d.ts +4 -2
  56. package/dist/services/BrowserServices.js +2 -5
  57. package/dist/services/HeadlessServices.d.ts +3 -0
  58. package/dist/services/HeadlessServices.js +2 -5
  59. package/dist/services/ServiceHub.d.ts +4 -4
  60. package/dist/services/base/AppLifecycleService.js +1 -1
  61. package/dist/services/base/ControlService.js +6 -2
  62. package/dist/services/base/FileProcessingService.d.ts +7 -1
  63. package/dist/services/base/FileProcessingService.js +9 -1
  64. package/dist/services/base/IService.d.ts +19 -19
  65. package/dist/services/base/ReplicatorService.d.ts +1 -0
  66. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
  67. package/dist/services/base/SettingService.d.ts +8 -18
  68. package/dist/services/base/SettingService.js +28 -46
  69. package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
  70. package/dist/services/implements/base/UIService.d.ts +2 -0
  71. package/dist/services/implements/base/UIService.js +5 -0
  72. package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
  73. package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
  74. package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
  75. package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
  76. package/dist/services/lib/HandlerUtils.d.ts +60 -0
  77. package/dist/services/lib/HandlerUtils.js +46 -0
  78. package/dist/string_and_binary/chunks.js +7 -5
  79. package/dist/uiCommands.d.ts +26 -0
  80. package/dist/uiCommands.js +57 -0
  81. package/dist/worker/bgWorker.js +1 -1
  82. package/docs/development.md +15 -3
  83. package/docs/offline-scanner.md +15 -13
  84. package/docs/proven-in-use.md +38 -4
  85. package/docs/releasing.md +20 -13
  86. package/docs/settings-lifecycle.md +39 -5
  87. package/docs/storage-events-and-reflection.md +109 -10
  88. package/package.json +13 -18
  89. package/dist/services/base/ConfigService.d.ts +0 -7
  90. package/dist/services/base/ConfigService.js +0 -6
  91. package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
  92. package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
@@ -404,7 +404,7 @@ Are you sure you wish to proceed?`;
404
404
  await this.suspendReflectingDatabase(!autoResume);
405
405
  await this.control.applySettings();
406
406
  await this.resetLocalDatabase();
407
- this.clearFastFetchCheckpoint();
407
+ await this.clearFastFetchCheckpoint();
408
408
  await delay(1e3);
409
409
  if (isRemediationModeActive(this.setting.currentSettings())) {
410
410
  this._log(
@@ -470,10 +470,11 @@ Are you sure you wish to proceed?`;
470
470
  await this.checkPathIdentitiesBeforeReconstruction(false);
471
471
  await checkRemoteFeaturesForInitialSync(remote, authHeader, customHeaders, settings);
472
472
  this.appLifecycle.resetIsReady();
473
- let checkpoint = this.getFastFetchCheckpoint(remote);
473
+ let checkpoint = await this.getFastFetchCheckpoint(remote);
474
474
  await this.suspendReflectingDatabase();
475
475
  await this.control.applySettings();
476
476
  let since = checkpoint?.sequence ?? "0";
477
+ let startsFresh = !checkpoint;
477
478
  if (checkpoint) {
478
479
  this._log(
479
480
  `Resuming fast database fetch from sequence: ${checkpoint.sequence}`,
@@ -497,16 +498,19 @@ Are you sure you wish to proceed?`;
497
498
  LOG_LEVEL_NOTICE,
498
499
  "fetch-init-resume"
499
500
  );
500
- this.clearFastFetchCheckpoint();
501
+ await this.clearFastFetchCheckpoint();
501
502
  await this.resetLocalDatabase();
502
503
  await delay(1e3);
503
504
  localDB = this.database.localDatabase.localDatabase;
504
505
  since = "0";
506
+ startsFresh = true;
505
507
  }
506
508
  const securitySeed = await this.replicator.createRemoteResource(REMOTE_RESOURCE_KINDS.SECURITY_SEED, settings);
507
509
  if (!securitySeed) {
508
510
  throw new Error("The selected provider cannot supply a Security Seed for Fast Fetch.");
509
511
  }
512
+ let fastForwardResource;
513
+ let fastForward;
510
514
  try {
511
515
  const enc = getConfiguredFunctionsForEncryption(
512
516
  settings.passphrase,
@@ -515,6 +519,13 @@ Are you sure you wish to proceed?`;
515
519
  () => securitySeed.read(),
516
520
  settings.E2EEAlgorithm
517
521
  );
522
+ if (startsFresh && !settings.doNotSuspendOnFetching && (await localDB.info()).doc_count === 0) {
523
+ fastForwardResource = await this.replicator.createRemoteResource(
524
+ REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD,
525
+ this.setting.currentSettings()
526
+ );
527
+ fastForward = await fastForwardResource?.prepare();
528
+ }
518
529
  for (let attempt = 0; ; attempt++) {
519
530
  try {
520
531
  await fetchChangesForInitialSync(
@@ -531,13 +542,17 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
531
542
  "fetch-init-progress"
532
543
  );
533
544
  },
534
- (sequence) => this.saveFastFetchCheckpoint(remote, sequence),
535
- customHeaders
545
+ async (sequence) => {
546
+ await this.saveFastFetchCheckpoint(remote, sequence);
547
+ fastForward?.recordCheckpoint(sequence);
548
+ },
549
+ customHeaders,
550
+ fastForward?.recordStoredRevisions
536
551
  );
537
552
  break;
538
553
  } catch (ex) {
539
554
  if (!isRetryableStreamingFetchFailure(ex) || attempt >= FAST_FETCH_RETRY_DELAYS.length) throw ex;
540
- checkpoint = this.getFastFetchCheckpoint(remote);
555
+ checkpoint = await this.getFastFetchCheckpoint(remote);
541
556
  since = checkpoint?.sequence ?? since;
542
557
  this._log(
543
558
  `Fast fetch interrupted. Retrying from sequence: ${since}`,
@@ -547,6 +562,13 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
547
562
  await delay(FAST_FETCH_RETRY_DELAYS[attempt]);
548
563
  }
549
564
  }
565
+ if (fastForward) {
566
+ const advanced = await fastForward.fastForward();
567
+ this._log(
568
+ advanced ? "Advanced the ordinary replication checkpoints through the verified initial Fast Fetch." : "The initial Fast Fetch did not advance ordinary replication checkpoints.",
569
+ LOG_LEVEL_VERBOSE
570
+ );
571
+ }
550
572
  const allDocs = await localDB.allDocs({ include_docs: false });
551
573
  this._log(
552
574
  `Fast database fetch completed. Total documents in local database: ${allDocs.total_rows}`,
@@ -559,9 +581,13 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
559
581
  throw new Error("Fast Fetch could not be finalised.");
560
582
  }
561
583
  }
562
- this.clearFastFetchCheckpoint();
584
+ await this.clearFastFetchCheckpoint();
563
585
  } finally {
564
- await securitySeed.dispose();
586
+ try {
587
+ await fastForwardResource?.dispose();
588
+ } finally {
589
+ await securitySeed.dispose();
590
+ }
565
591
  }
566
592
  }
567
593
  /**
@@ -627,8 +653,8 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
627
653
  }
628
654
  this.events.emitEvent(EVENT_DATABASE_REBUILT);
629
655
  }
630
- getFastFetchCheckpoint(remote) {
631
- const rawCheckpoint = this.setting.getSmallConfig(FAST_FETCH_CHECKPOINT_KEY);
656
+ async getFastFetchCheckpoint(remote) {
657
+ const rawCheckpoint = await this.setting.config.getValue(FAST_FETCH_CHECKPOINT_KEY);
632
658
  if (!rawCheckpoint) return void 0;
633
659
  try {
634
660
  const checkpoint = JSON.parse(rawCheckpoint);
@@ -640,11 +666,11 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
640
666
  }
641
667
  } catch {
642
668
  }
643
- this.clearFastFetchCheckpoint();
669
+ await this.clearFastFetchCheckpoint();
644
670
  return void 0;
645
671
  }
646
- saveFastFetchCheckpoint(remote, sequence) {
647
- this.setting.setSmallConfig(
672
+ async saveFastFetchCheckpoint(remote, sequence) {
673
+ await this.setting.config.setValue(
648
674
  FAST_FETCH_CHECKPOINT_KEY,
649
675
  JSON.stringify({
650
676
  remote,
@@ -653,8 +679,8 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
653
679
  })
654
680
  );
655
681
  }
656
- clearFastFetchCheckpoint() {
657
- this.setting.deleteSmallConfig(FAST_FETCH_CHECKPOINT_KEY);
682
+ async clearFastFetchCheckpoint() {
683
+ await this.setting.config.delete(FAST_FETCH_CHECKPOINT_KEY);
658
684
  }
659
685
  }
660
686
  export {
@@ -15,6 +15,11 @@ class ServiceFileAccessBase extends ServiceModuleBase {
15
15
  this.vaultAccess = services.vaultAccess;
16
16
  services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
17
17
  services.fileProcessing.commitPendingFileEvents.addHandler(this._everyCommitPendingFileEvent.bind(this));
18
+ services.appLifecycle.onBeforeUnload.addHandler(async () => {
19
+ if (this.vaultManager.stopWatching) await this.vaultManager.stopWatching();
20
+ else await this.vaultManager.waitForIdle();
21
+ return true;
22
+ });
18
23
  }
19
24
  restoreState() {
20
25
  return this.vaultManager.restoreState();
@@ -1,6 +1,6 @@
1
1
  import type { AnyEntry, FilePath, FilePathWithPrefix, MetaEntry, UXFileInfo, UXFileInfoStub, UXInternalFileInfoStub } from "../common/types.js";
2
2
  import type { LiveSyncEventHub } from "../hub/hub.js";
3
- import type { IFileHandler } from "../interfaces/FileHandler.js";
3
+ import type { IFileHandler, StoreFileOptions } from "../interfaces/FileHandler.js";
4
4
  import { ServiceModuleBase } from "./ServiceModuleBase.js";
5
5
  import type { APIService } from "../services/base/APIService.js";
6
6
  import type { DatabaseFileAccess } from "../interfaces/DatabaseFileAccess.js";
@@ -74,7 +74,8 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
74
74
  /**
75
75
  * Read the raw provenance value used by an optimistic storage snapshot.
76
76
  * Unlike `getProvenance`, this does not validate or remove the referenced
77
- * database revision; an unreadable value is represented as `undefined`.
77
+ * database revision. Recovery callers require a readable value so a store
78
+ * failure cannot be mistaken for absent provenance.
78
79
  */
79
80
  private readProvenanceSnapshot;
80
81
  /**
@@ -99,7 +100,7 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
99
100
  readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise<UXFileInfo>;
100
101
  tryRecordUntrackedFileRevision(info: UXFileInfoStub | UXFileInfo, expectedRevision: string): Promise<boolean>;
101
102
  private infoToStub;
102
- storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise<boolean>;
103
+ storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean, options?: StoreFileOptions): Promise<boolean>;
103
104
  storeFileToDBWithBaseRevision(info: UXFileInfoStub | UXFileInfo | FilePathWithPrefix, baseRevision: string, createIfDifferent?: boolean): Promise<boolean>;
104
105
  /**
105
106
  * Store current storage content below an exact live revision. A differing
@@ -110,11 +111,13 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
110
111
  /**
111
112
  * Save one storage snapshot while the caller holds its document lock.
112
113
  * Ordinary saves read once inside the lock and leave retries disabled.
113
- * Restored startup events retain bounded rereads because file watching has
114
- * not yet started and cannot supply a new event for a concurrent edit.
114
+ * Restored start-up events and recovery scans retain bounded rereads
115
+ * because a watcher event may not cover a concurrent edit.
115
116
  */
116
117
  private storeFileToDBFromRevision;
117
118
  deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean>;
119
+ /** Complete a validated storage deletion when its database effect is already represented. */
120
+ private deleteFileFromDBIfPresent;
118
121
  /**
119
122
  * Record a storage deletion under the caller's document lock, preserving
120
123
  * the displayed conflict branch when its provenance is known.
@@ -1,9 +1,9 @@
1
1
  import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
2
2
  import { serialized } from "octagonal-wheels/concurrency/lock";
3
3
  import {
4
+ createBlob,
4
5
  getDocDataAsArray,
5
6
  isDocContentSame,
6
- readAsBlob,
7
7
  readContent
8
8
  } from "../common/utils.js";
9
9
  import { computeFileContentHash } from "../common/fileContentHash.js";
@@ -158,7 +158,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
158
158
  * A valid saved content hash can prove unchanged bytes after revision
159
159
  * pruning. It cannot reconstruct ancestry for an edited file.
160
160
  */
161
- async classifyStorageContent(file, currentEntry, preferredPath, recorded, currentLoaded) {
161
+ async classifyStorageContent(file, currentEntry, preferredPath, recorded, currentLoaded, requireReadableContent = false) {
162
162
  const path = preferredPath ?? file.path;
163
163
  const recordedRevision = recorded?.revision;
164
164
  if (recordedRevision) {
@@ -173,6 +173,9 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
173
173
  }
174
174
  if (!base) {
175
175
  const meta = await this.db.fetchEntryMeta(file, recordedRevision, true);
176
+ if (requireReadableContent && meta && !meta.deleted && !meta._deleted) {
177
+ throw new Error(`Could not read the recorded file revision for ${file.path}`);
178
+ }
176
179
  if ((!meta || meta.deleted || meta._deleted) && !hasContentHash(recorded)) await this.deleteProvenance(path);
177
180
  }
178
181
  }
@@ -189,13 +192,15 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
189
192
  /**
190
193
  * Read the raw provenance value used by an optimistic storage snapshot.
191
194
  * Unlike `getProvenance`, this does not validate or remove the referenced
192
- * database revision; an unreadable value is represented as `undefined`.
195
+ * database revision. Recovery callers require a readable value so a store
196
+ * failure cannot be mistaken for absent provenance.
193
197
  */
194
- async readProvenanceSnapshot(path) {
198
+ async readProvenanceSnapshot(path, requireReadable = false) {
195
199
  try {
196
200
  const record = await this.fileReflectionProvenance?.get(this.getProvenancePath(path));
197
201
  return record ? { ...record } : void 0;
198
- } catch {
202
+ } catch (error) {
203
+ if (requireReadable) throw error;
199
204
  return void 0;
200
205
  }
201
206
  }
@@ -203,20 +208,20 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
203
208
  * Check that storage still has the same path, bytes, timestamp, and
204
209
  * provenance as a previously captured snapshot.
205
210
  */
206
- async currentStorageSnapshotMatches(file, revision) {
211
+ async currentStorageSnapshotMatches(file, revision, requireReadableProvenance = false) {
207
212
  const current = await this.storage.getStub(file.path);
208
213
  if (!current || isFolderInfo(current) || current.path !== file.path) return false;
209
214
  const readCurrent = await this.readFileFromStub(current);
210
- return readCurrent.stat.mtime === file.stat.mtime && await isDocContentSame(readCurrent.body, file.body) && sameProvenance(await this.readProvenanceSnapshot(file.path), revision);
215
+ return readCurrent.stat.mtime === file.stat.mtime && await isDocContentSame(readCurrent.body, file.body) && sameProvenance(await this.readProvenanceSnapshot(file.path, requireReadableProvenance), revision);
211
216
  }
212
217
  /**
213
218
  * Publish provenance after a durable database write if storage is unchanged.
214
219
  * A failed revalidation leaves the database revision stored and asks the
215
220
  * caller to reread storage before proceeding.
216
221
  */
217
- async finaliseStoredRevision(file, observedProvenance, storedRevision) {
222
+ async finaliseStoredRevision(file, observedProvenance, storedRevision, requireReadableProvenance = false) {
218
223
  const evidence = await this.contentEvidence(file.body);
219
- if (await this.currentStorageSnapshotMatches(file, observedProvenance)) {
224
+ if (await this.currentStorageSnapshotMatches(file, observedProvenance, requireReadableProvenance)) {
220
225
  await this.setProvenance(file.path, storedRevision, file.stat.mtime, file.body, evidence);
221
226
  return true;
222
227
  } else {
@@ -293,10 +298,10 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
293
298
  const file = typeof info === "string" ? await this.storage.getFileStub(info) : info;
294
299
  return file;
295
300
  }
296
- async storeFileToDB(info, force = false, onlyChunks = false) {
301
+ async storeFileToDB(info, force = false, onlyChunks = false, options = {}) {
297
302
  return await this.serializedByFileEventPaths(
298
303
  [getStoragePathFromUXFileInfo(info)],
299
- () => this.storeFileToDBFromRevision(info, force, onlyChunks)
304
+ () => this.storeFileToDBFromRevision(info, force, onlyChunks, void 0, options.revalidateStorage)
300
305
  );
301
306
  }
302
307
  async storeFileToDBWithBaseRevision(info, baseRevision, createIfDifferent = true) {
@@ -366,8 +371,8 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
366
371
  /**
367
372
  * Save one storage snapshot while the caller holds its document lock.
368
373
  * Ordinary saves read once inside the lock and leave retries disabled.
369
- * Restored startup events retain bounded rereads because file watching has
370
- * not yet started and cannot supply a new event for a concurrent edit.
374
+ * Restored start-up events and recovery scans retain bounded rereads
375
+ * because a watcher event may not cover a concurrent edit.
371
376
  */
372
377
  async storeFileToDBFromRevision(info, force = false, onlyChunks = false, preferredBasePath, retryIfStorageChanges = false, snapshotRetry = 0) {
373
378
  const file = await this.infoToStub(info);
@@ -387,32 +392,36 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
387
392
  return await this.db.createChunks(readFile2, force, true);
388
393
  }
389
394
  const observedProvenance = await this.readProvenanceSnapshot(
390
- preferredBasePath ?? file.path
395
+ preferredBasePath ?? file.path,
396
+ retryIfStorageChanges
391
397
  );
392
398
  const readFile = await this.readFileFromStub(file);
393
399
  const entry = await this.db.fetchEntry(file, void 0, true, true, true);
394
400
  const currentEntry = entry || await this.db.fetchEntryMeta(file, void 0, true);
395
401
  const conflictedRevs = await this.db.getConflictedRevs(file);
396
402
  const isConflicted = conflictedRevs.length > 0;
397
- if (!force && currentEntry && !currentEntry.deleted && !currentEntry._deleted) {
403
+ const currentIsLive = currentEntry && !currentEntry.deleted && !currentEntry._deleted;
404
+ if (!force && (currentIsLive || retryIfStorageChanges)) {
405
+ if (retryIfStorageChanges && currentIsLive && !entry) return false;
398
406
  const classification = await this.classifyStorageContent(
399
407
  readFile,
400
408
  currentEntry,
401
409
  preferredBasePath,
402
410
  observedProvenance,
403
- entry ? { _rev: entry._rev, data: readContent(entry) } : void 0
411
+ entry ? { _rev: entry._rev, data: readContent(entry) } : void 0,
412
+ retryIfStorageChanges
404
413
  );
405
- const upgradeCurrentRecord = classification.kind === "unchanged" && !isConflicted && classification.revision === currentEntry._rev && !hasContentHash(observedProvenance);
414
+ const upgradeCurrentRecord = classification.kind === "unchanged" && !isConflicted && currentEntry && !currentEntry.deleted && !currentEntry._deleted && classification.revision === currentEntry._rev && !hasContentHash(observedProvenance);
406
415
  const evidence = upgradeCurrentRecord || classification.kind === "matching-leaf" && classification.revision ? await this.contentEvidence(readFile.body) : void 0;
407
- const snapshotMatches = retryIfStorageChanges || upgradeCurrentRecord ? await this.currentStorageSnapshotMatches(readFile, observedProvenance) : true;
416
+ const snapshotMatches = retryIfStorageChanges || upgradeCurrentRecord ? await this.currentStorageSnapshotMatches(readFile, observedProvenance, retryIfStorageChanges) : true;
408
417
  if (retryIfStorageChanges && !snapshotMatches) {
409
418
  return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
410
419
  }
411
420
  if (classification.kind === "unchanged") {
412
421
  if (isConflicted) {
413
422
  await this.queueConflictCheckFor(file.path);
414
- } else if (classification.revision !== currentEntry._rev) {
415
- if (!entry) return false;
423
+ } else if (!currentEntry || currentEntry.deleted || currentEntry._deleted || classification.revision !== currentEntry._rev) {
424
+ if (!currentEntry) return false;
416
425
  return await this.dbToStorageCore(file.path, file);
417
426
  } else {
418
427
  if (upgradeCurrentRecord && snapshotMatches) {
@@ -430,7 +439,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
430
439
  const storedRevision2 = classification.kind === "edited" ? await this.db.storeWithBaseRevision(readFile, classification.revision, true) : await this.db.storeIndependentRevision(readFile, true);
431
440
  if (storedRevision2 === false) return false;
432
441
  if (preferredBasePath && preferredBasePath !== file.path) await this.deleteProvenance(preferredBasePath);
433
- if (retryIfStorageChanges && !await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision2)) {
442
+ if (retryIfStorageChanges && !await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision2, true)) {
434
443
  return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
435
444
  }
436
445
  if (!retryIfStorageChanges) await this.setProvenance(file.path, storedRevision2, readFile.stat.mtime, readFile.body);
@@ -519,6 +528,12 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
519
528
  () => this.deleteFileFromDBCore(info)
520
529
  );
521
530
  }
531
+ /** Complete a validated storage deletion when its database effect is already represented. */
532
+ async deleteFileFromDBIfPresent(info) {
533
+ const entry = await this.db.fetchEntryMeta(info, void 0, true);
534
+ if (!entry || entry.deleted || entry._deleted) return true;
535
+ return await this.deleteFileFromDBCore(info);
536
+ }
522
537
  /**
523
538
  * Record a storage deletion under the caller's document lock, preserving
524
539
  * the displayed conflict branch when its provenance is known.
@@ -892,17 +907,17 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
892
907
  this._log(`File ${path} is not exist on the database`, LOG_LEVEL_VERBOSE);
893
908
  return false;
894
909
  }
910
+ const docData = readContent(docRead);
911
+ const reflectedContent = createBlob(docData);
895
912
  if (!settings.processSizeMismatchedFiles) {
896
- if (docRead.size != 0 && docRead.size !== readAsBlob(docRead).size) {
913
+ if (docRead.size != 0 && docRead.size !== reflectedContent.size) {
897
914
  this._log(
898
- `File ${path} seems to be corrupted! Writing prevented. (${docRead.size} != ${readAsBlob(docRead).size})`,
915
+ `File ${path} seems to be corrupted! Writing prevented. (${docRead.size} != ${reflectedContent.size})`,
899
916
  LOG_LEVEL_NOTICE
900
917
  );
901
918
  return false;
902
919
  }
903
920
  }
904
- const docData = readContent(docRead);
905
- const reflectedContent = readAsBlob(docRead);
906
921
  const reflectedEvidence = await this.contentEvidence(reflectedContent);
907
922
  if (allowExistingConflicts && existDoc && !force) {
908
923
  const readFile = await this.readFileFromStub(existDoc);
@@ -1080,14 +1095,23 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1080
1095
  const relatedPath = type === "RENAME" ? eventItem.oldPath : type === "DELETE" ? eventItem.renameTarget : void 0;
1081
1096
  const eventPaths = relatedPath === void 0 ? [path] : [path, relatedPath];
1082
1097
  return await this.serializedByFileEventPaths(eventPaths, async () => {
1083
- if (!await this.isCurrentPathSelected(path)) return false;
1098
+ if (!await this.isCurrentPathSelected(path)) {
1099
+ return shouldBeIgnored(path) || !await this.vault.isTargetFile(path, { skipCaseCollisionCheck: true });
1100
+ }
1084
1101
  switch (type) {
1085
1102
  case "CREATE":
1086
- case "CHANGED":
1087
- return await this.storeFileToDBFromRevision(item.args.file);
1103
+ case "CHANGED": {
1104
+ const current = this.getExactCurrentFile(await this.storage.getStub(path), path);
1105
+ if (!current || this.vault.isFileSizeTooLarge(current.stat.size)) return true;
1106
+ return await this.storeFileToDBFromRevision(current);
1107
+ }
1088
1108
  case "DELETE":
1089
- if (!await this.canRecordStorageDeletion(item)) return true;
1090
- return await this.deleteFileFromDBCore(item.args.file);
1109
+ try {
1110
+ if (!await this.canRecordStorageDeletion(item)) return true;
1111
+ } catch {
1112
+ return false;
1113
+ }
1114
+ return await this.deleteFileFromDBIfPresent(item.args.file);
1091
1115
  case "RENAME":
1092
1116
  return await this.renameFileInDBCore(
1093
1117
  item.args.file,
@@ -1137,7 +1161,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1137
1161
  } catch (ex) {
1138
1162
  this._log(`Could not validate deletion of ${path}; preserved the database entry`, LOG_LEVEL_NOTICE);
1139
1163
  this._log(ex, LOG_LEVEL_VERBOSE);
1140
- return false;
1164
+ throw ex;
1141
1165
  }
1142
1166
  }
1143
1167
  /**
@@ -1222,15 +1246,19 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1222
1246
  action = await this._planRestoredFileEvent(item, isSameDocument);
1223
1247
  } catch (ex) {
1224
1248
  this.logRestoredEventValidationFailure(path, ex);
1225
- return true;
1249
+ return false;
1226
1250
  }
1227
1251
  switch (action.kind) {
1228
1252
  case "none":
1229
1253
  return true;
1230
- case "store":
1231
- return await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
1254
+ case "unresolved":
1255
+ return false;
1256
+ case "store": {
1257
+ const stored = await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
1258
+ return stored && action.complete !== false;
1259
+ }
1232
1260
  case "delete":
1233
- return await this.deleteFileFromDBCore(action.path);
1261
+ return await this.deleteFileFromDBIfPresent(action.path);
1234
1262
  case "rename":
1235
1263
  return await this.renameFileInDBCore(action.file, action.oldPath, true);
1236
1264
  }
@@ -1266,6 +1294,10 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1266
1294
  if (!currentTarget2 || !await this.isCurrentFileSelected(currentTarget2)) {
1267
1295
  return { kind: "none" };
1268
1296
  }
1297
+ const entry = await this.db.fetchEntryMeta(newPath, void 0, true);
1298
+ if (entry && !entry.deleted && !entry._deleted && this.storage.normalisePath(this.getPath(entry)) === this.storage.normalisePath(newPath)) {
1299
+ return { kind: "store", file: currentTarget2 };
1300
+ }
1269
1301
  return { kind: "rename", file: currentTarget2, oldPath };
1270
1302
  }
1271
1303
  const currentTargetItem = await this.storage.getStub(newPath);
@@ -1283,11 +1315,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1283
1315
  return { kind: "none" };
1284
1316
  }
1285
1317
  if (!sourceWasInspected || currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1318
+ return { kind: "store", file: currentTarget, complete: sourceWasInspected };
1319
+ }
1320
+ const sourceEntry = await this.db.fetchEntryMeta(oldPath, void 0, true);
1321
+ if (!sourceEntry || sourceEntry.deleted || sourceEntry._deleted) {
1286
1322
  return { kind: "store", file: currentTarget };
1287
1323
  }
1288
1324
  return { kind: "rename", file: currentTarget, oldPath };
1289
1325
  }
1290
- if (!sourceWasInspected || currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1326
+ if (!sourceWasInspected) return { kind: "unresolved" };
1327
+ if (currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1291
1328
  return { kind: "none" };
1292
1329
  }
1293
1330
  return { kind: "delete", path: oldPath };
@@ -1324,12 +1361,12 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1324
1361
  return await this.isCurrentPathSelected(path);
1325
1362
  } catch (ex) {
1326
1363
  this.logRestoredEventValidationFailure(path, ex);
1327
- return false;
1364
+ throw ex;
1328
1365
  }
1329
1366
  }
1330
1367
  logRestoredEventValidationFailure(path, ex) {
1331
1368
  this._log(
1332
- `Could not validate the saved storage operation for ${path} against current storage; the Offline Scanner will reconcile the current state`,
1369
+ `Could not validate the saved storage operation for ${path} against current storage; the operation remains pending for recovery`,
1333
1370
  LOG_LEVEL_NOTICE
1334
1371
  );
1335
1372
  this._log(ex, LOG_LEVEL_VERBOSE);
@@ -1,9 +1,9 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
2
3
  import { ServiceContext } from "./base/ServiceBase.js";
3
4
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
4
5
  import { ControlService } from "./base/ControlService.js";
5
6
  import type { AppLifecycleService } from "./base/AppLifecycleService.js";
6
- import type { ConfigService } from "./base/ConfigService.js";
7
7
  import type { ReplicatorService } from "./base/ReplicatorService.js";
8
8
  import type { UIService } from "./implements/base/UIService.js";
9
9
  import type { InjectableAPIService } from "./implements/injectable/InjectableAPIService.js";
@@ -12,7 +12,7 @@ import type { ObsidianLiveSyncSettings } from "../common/types.js";
12
12
  export type BrowserServiceHostDependencies<T extends ServiceContext> = {
13
13
  API: InjectableAPIService<T>;
14
14
  appLifecycle: AppLifecycleService<T>;
15
- config: ConfigService<T>;
15
+ config: IConfigService;
16
16
  control: ControlService<T>;
17
17
  replicator: ReplicatorService<T>;
18
18
  };
@@ -29,6 +29,8 @@ export interface BrowserServiceHost<T extends ServiceContext> {
29
29
  export type BrowserServiceHubOptions<T extends ServiceContext> = {
30
30
  context?: T;
31
31
  host: BrowserServiceHost<T>;
32
+ /** Exact-key storage selected and owned by the application. */
33
+ deviceValues: IConfigService;
32
34
  openKeyValueDatabase?: KeyValueDatabaseFactory;
33
35
  onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
34
36
  };
@@ -11,7 +11,6 @@ import { InjectableTestService } from "./implements/injectable/InjectableTestSer
11
11
  import { InjectableTweakValueService } from "./implements/injectable/InjectableTweakValueService.js";
12
12
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
13
13
  import { ServiceContext } from "./base/ServiceBase.js";
14
- import { ConfigServiceBrowserCompat } from "./implements/browser/ConfigServiceBrowserCompat.js";
15
14
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
16
15
  import { BrowserDatabaseService, BrowserKeyValueDBService } from "./implements/browser/BrowserDatabaseService.js";
17
16
  import { ControlService } from "./base/ControlService.js";
@@ -34,6 +33,7 @@ class BrowserServiceHub extends InjectableServiceHub {
34
33
  const fileProcessing = new InjectableFileProcessingService(context);
35
34
  const setting = new InjectableSettingService(context, {
36
35
  APIService: API,
36
+ deviceValues: options.deviceValues,
37
37
  onDisplayLanguageChanged: options.onDisplayLanguageChanged
38
38
  });
39
39
  const appLifecycle = new BrowserAppLifecycleService(context, { settingService: setting });
@@ -60,10 +60,7 @@ class BrowserServiceHub extends InjectableServiceHub {
60
60
  setting,
61
61
  API
62
62
  });
63
- const config = new ConfigServiceBrowserCompat(context, {
64
- settingService: setting,
65
- APIService: API
66
- });
63
+ const config = setting.config;
67
64
  const replicator = new InjectableReplicatorService(context, {
68
65
  settingService: setting,
69
66
  appLifecycleService: appLifecycle,
@@ -1,3 +1,4 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import { ServiceContext } from "./base/ServiceBase.js";
2
3
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
3
4
  import type { DatabaseService } from "./base/DatabaseService.js";
@@ -9,6 +10,8 @@ export declare class HeadlessServiceHub<T extends ServiceContext> extends Inject
9
10
  constructor(_context: T | undefined, overrideServiceConstructor: {
10
11
  /** PouchDB with the adapters required by this headless runtime. */
11
12
  pouchDB: PouchDBConstructor;
13
+ /** Exact-key storage selected and owned by the application. */
14
+ deviceValues: IConfigService;
12
15
  database?: Constructor<DatabaseService<T>>;
13
16
  openKeyValueDatabase?: KeyValueDatabaseFactory;
14
17
  onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
@@ -11,7 +11,6 @@ import { InjectableTestService } from "./implements/injectable/InjectableTestSer
11
11
  import { InjectableTweakValueService } from "./implements/injectable/InjectableTweakValueService.js";
12
12
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
13
13
  import { ServiceContext } from "./base/ServiceBase.js";
14
- import { ConfigServiceBrowserCompat } from "./implements/browser/ConfigServiceBrowserCompat.js";
15
14
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
16
15
  import { UIService } from "./implements/base/UIService.js";
17
16
  import { HeadlessAPIService } from "./implements/headless/HeadlessAPIService.js";
@@ -56,6 +55,7 @@ class HeadlessServiceHub extends InjectableServiceHub {
56
55
  const fileProcessing = new InjectableFileProcessingService(context);
57
56
  const setting = new InjectableSettingService(context, {
58
57
  APIService: API,
58
+ deviceValues: overrideServiceConstructor.deviceValues,
59
59
  onDisplayLanguageChanged: overrideServiceConstructor.onDisplayLanguageChanged
60
60
  });
61
61
  const appLifecycle = new HeadlessAppLifecycleService(context, {
@@ -85,10 +85,7 @@ class HeadlessServiceHub extends InjectableServiceHub {
85
85
  vault,
86
86
  setting
87
87
  });
88
- const config = new ConfigServiceBrowserCompat(context, {
89
- settingService: setting,
90
- APIService: API
91
- });
88
+ const config = setting.config;
92
89
  const replicator = new InjectableReplicatorService(context, {
93
90
  settingService: setting,
94
91
  appLifecycleService: appLifecycle,
@@ -1,5 +1,5 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import type { UIService } from "./implements/base/UIService.js";
2
- import type { ConfigService } from "./base/ConfigService.js";
3
3
  import type { TestService } from "./base/TestService.js";
4
4
  import type { VaultService } from "./base/VaultService.js";
5
5
  import type { TweakValueService } from "./base/TweakValueService.js";
@@ -34,7 +34,7 @@ export type ServiceInstances<T extends ServiceContext = ServiceContext> = {
34
34
  vault?: VaultService<T>;
35
35
  test?: TestService<T>;
36
36
  ui?: UIService<T>;
37
- config?: ConfigService<T>;
37
+ config?: IConfigService;
38
38
  keyValueDB?: KeyValueDBService<T>;
39
39
  control?: ControlService<T>;
40
40
  };
@@ -56,7 +56,7 @@ export declare abstract class ServiceHub<T extends ServiceContext = ServiceConte
56
56
  protected abstract _vault: VaultService<T>;
57
57
  protected abstract _test: TestService<T>;
58
58
  protected abstract _ui: UIService<T>;
59
- protected abstract _config: ConfigService<T>;
59
+ protected abstract _config: IConfigService;
60
60
  protected abstract _keyValueDB: KeyValueDBService<T>;
61
61
  protected abstract _control: ControlService<T>;
62
62
  protected _injected: ServiceInstances<T>;
@@ -76,7 +76,7 @@ export declare abstract class ServiceHub<T extends ServiceContext = ServiceConte
76
76
  get vault(): VaultService<T>;
77
77
  get test(): TestService<T>;
78
78
  get UI(): UIService<T>;
79
- get config(): ConfigService<T>;
79
+ get config(): IConfigService;
80
80
  get keyValueDB(): KeyValueDBService<T>;
81
81
  get control(): ControlService<T>;
82
82
  }
@@ -60,7 +60,7 @@ class AppLifecycleService extends ServiceBase {
60
60
  * Event triggered before the plug-in is unloaded.
61
61
  * This is typically used to perform any necessary cleanup or save state before the plug-in is unloaded.
62
62
  */
63
- onBeforeUnload = handlers().all("onBeforeUnload");
63
+ onBeforeUnload = handlers().allSettled("onBeforeUnload");
64
64
  /**
65
65
  * Event triggered when the plug-in is being unloaded.
66
66
  *