@vrtmrz/livesync-commonlib 0.2.0 → 0.2.2

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 (49) hide show
  1. package/README.md +43 -0
  2. package/dist/API/processSetting.d.ts +2 -0
  3. package/dist/common/models/setting.const.defaults.js +2 -0
  4. package/dist/common/models/setting.const.qr.js +3 -0
  5. package/dist/common/models/setting.policy.d.ts +8 -0
  6. package/dist/common/models/setting.policy.js +2 -0
  7. package/dist/common/models/setting.type.d.ts +6 -0
  8. package/dist/common/models/shared.definition.d.ts +20 -0
  9. package/dist/common/models/tweak.definition.d.ts +2 -0
  10. package/dist/common/settingConstants.d.ts +2 -0
  11. package/dist/common/settingConstants.js +10 -2
  12. package/dist/interfaces/FileHandler.d.ts +7 -1
  13. package/dist/interfaces/StorageAccess.d.ts +2 -0
  14. package/dist/interfaces/StorageEventManager.d.ts +7 -0
  15. package/dist/interfaces/StorageEventManager.js +11 -0
  16. package/dist/managers/StorageEventManager.d.ts +20 -10
  17. package/dist/managers/StorageEventManager.js +268 -189
  18. package/dist/replication/LiveSyncAbstractReplicator.d.ts +2 -0
  19. package/dist/replication/LiveSyncAbstractReplicator.js +5 -2
  20. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +6 -0
  21. package/dist/replication/couchdb/LiveSyncReplicator.js +39 -2
  22. package/dist/replication/couchdb/replicationProgress.d.ts +6 -0
  23. package/dist/replication/couchdb/replicationProgress.js +105 -0
  24. package/dist/replication/index.d.ts +1 -0
  25. package/dist/replication/trystero/TrysteroReplicator.d.ts +4 -0
  26. package/dist/serviceFeatures/offlineScanner.d.ts +4 -4
  27. package/dist/serviceFeatures/offlineScanner.js +30 -13
  28. package/dist/serviceFeatures/prepareDatabaseForUse.d.ts +3 -2
  29. package/dist/serviceFeatures/prepareDatabaseForUse.js +5 -4
  30. package/dist/serviceModules/ServiceFileAccessBase.d.ts +1 -0
  31. package/dist/serviceModules/ServiceFileAccessBase.js +8 -0
  32. package/dist/serviceModules/ServiceFileHandlerBase.d.ts +9 -5
  33. package/dist/serviceModules/ServiceFileHandlerBase.js +96 -38
  34. package/dist/services/base/AppLifecycleService.js +1 -1
  35. package/dist/services/base/ControlService.js +6 -2
  36. package/dist/services/base/DatabaseEventService.d.ts +1 -1
  37. package/dist/services/base/FileProcessingService.d.ts +7 -1
  38. package/dist/services/base/FileProcessingService.js +9 -1
  39. package/dist/services/base/IService.d.ts +12 -4
  40. package/dist/services/base/ReplicatorService.d.ts +1 -0
  41. package/dist/services/base/VaultScanResult.d.ts +5 -0
  42. package/dist/services/base/VaultService.d.ts +1 -1
  43. package/dist/services/lib/HandlerUtils.d.ts +60 -0
  44. package/dist/services/lib/HandlerUtils.js +46 -0
  45. package/dist/worker/bgWorker.js +1 -1
  46. package/docs/offline-scanner.md +23 -13
  47. package/docs/settings-lifecycle.md +2 -0
  48. package/docs/storage-events-and-reflection.md +113 -10
  49. package/package.json +2 -2
@@ -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 {
@@ -253,6 +258,27 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
253
258
  }
254
259
  return readFile;
255
260
  }
261
+ async canSkipUnchangedFile(info, expected) {
262
+ const path = getStoragePathFromUXFileInfo(info);
263
+ const provenance = this.fileReflectionProvenance;
264
+ if (!provenance || !expected._rev || expected.deleted || expected._deleted || expected._conflicts?.length || this.storage.hasPendingFileEvent?.(path) !== false) return false;
265
+ try {
266
+ return await this.serializedByFileEventPaths([path], async () => {
267
+ const provenancePath = this.getProvenancePath(path);
268
+ const record = await provenance.get(provenancePath);
269
+ if (!record || !hasContentHash(record) || record.revision !== expected._rev || !Number.isFinite(record.observedStorageMtime) || record.observedStorageMtime !== info.stat.mtime || record.contentSize !== info.stat.size) return false;
270
+ const current = await this.db.fetchEntryMeta(path, void 0, true);
271
+ if (!current || current._rev !== record.revision || current.deleted || current._deleted || (await this.db.getConflictedRevs(path)).length > 0) return false;
272
+ const stat = await this.storage.stat(path);
273
+ if (!stat || stat.type !== "file" || stat.mtime !== record.observedStorageMtime || stat.size !== record.contentSize) return false;
274
+ return sameProvenance(await provenance.get(provenancePath), record) && this.storage.hasPendingFileEvent?.(path) === false;
275
+ });
276
+ } catch (error) {
277
+ this._log(`Could not inspect unchanged file metadata for ${path}`, LOG_LEVEL_VERBOSE);
278
+ this._log(error, LOG_LEVEL_VERBOSE);
279
+ return false;
280
+ }
281
+ }
256
282
  async tryRecordUntrackedFileRevision(info, expectedRevision) {
257
283
  if (!this.fileReflectionProvenance || !expectedRevision) return false;
258
284
  const path = getStoragePathFromUXFileInfo(info);
@@ -293,10 +319,10 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
293
319
  const file = typeof info === "string" ? await this.storage.getFileStub(info) : info;
294
320
  return file;
295
321
  }
296
- async storeFileToDB(info, force = false, onlyChunks = false) {
322
+ async storeFileToDB(info, force = false, onlyChunks = false, options = {}) {
297
323
  return await this.serializedByFileEventPaths(
298
324
  [getStoragePathFromUXFileInfo(info)],
299
- () => this.storeFileToDBFromRevision(info, force, onlyChunks)
325
+ () => this.storeFileToDBFromRevision(info, force, onlyChunks, void 0, options.revalidateStorage)
300
326
  );
301
327
  }
302
328
  async storeFileToDBWithBaseRevision(info, baseRevision, createIfDifferent = true) {
@@ -366,8 +392,8 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
366
392
  /**
367
393
  * Save one storage snapshot while the caller holds its document lock.
368
394
  * 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.
395
+ * Restored start-up events and recovery scans retain bounded rereads
396
+ * because a watcher event may not cover a concurrent edit.
371
397
  */
372
398
  async storeFileToDBFromRevision(info, force = false, onlyChunks = false, preferredBasePath, retryIfStorageChanges = false, snapshotRetry = 0) {
373
399
  const file = await this.infoToStub(info);
@@ -387,32 +413,36 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
387
413
  return await this.db.createChunks(readFile2, force, true);
388
414
  }
389
415
  const observedProvenance = await this.readProvenanceSnapshot(
390
- preferredBasePath ?? file.path
416
+ preferredBasePath ?? file.path,
417
+ retryIfStorageChanges
391
418
  );
392
419
  const readFile = await this.readFileFromStub(file);
393
420
  const entry = await this.db.fetchEntry(file, void 0, true, true, true);
394
421
  const currentEntry = entry || await this.db.fetchEntryMeta(file, void 0, true);
395
422
  const conflictedRevs = await this.db.getConflictedRevs(file);
396
423
  const isConflicted = conflictedRevs.length > 0;
397
- if (!force && currentEntry && !currentEntry.deleted && !currentEntry._deleted) {
424
+ const currentIsLive = currentEntry && !currentEntry.deleted && !currentEntry._deleted;
425
+ if (!force && (currentIsLive || retryIfStorageChanges)) {
426
+ if (retryIfStorageChanges && currentIsLive && !entry) return false;
398
427
  const classification = await this.classifyStorageContent(
399
428
  readFile,
400
429
  currentEntry,
401
430
  preferredBasePath,
402
431
  observedProvenance,
403
- entry ? { _rev: entry._rev, data: readContent(entry) } : void 0
432
+ entry ? { _rev: entry._rev, data: readContent(entry) } : void 0,
433
+ retryIfStorageChanges
404
434
  );
405
- const upgradeCurrentRecord = classification.kind === "unchanged" && !isConflicted && classification.revision === currentEntry._rev && !hasContentHash(observedProvenance);
435
+ const upgradeCurrentRecord = classification.kind === "unchanged" && !isConflicted && currentEntry && !currentEntry.deleted && !currentEntry._deleted && classification.revision === currentEntry._rev && !hasContentHash(observedProvenance);
406
436
  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;
437
+ const snapshotMatches = retryIfStorageChanges || upgradeCurrentRecord ? await this.currentStorageSnapshotMatches(readFile, observedProvenance, retryIfStorageChanges) : true;
408
438
  if (retryIfStorageChanges && !snapshotMatches) {
409
439
  return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
410
440
  }
411
441
  if (classification.kind === "unchanged") {
412
442
  if (isConflicted) {
413
443
  await this.queueConflictCheckFor(file.path);
414
- } else if (classification.revision !== currentEntry._rev) {
415
- if (!entry) return false;
444
+ } else if (!currentEntry || currentEntry.deleted || currentEntry._deleted || classification.revision !== currentEntry._rev) {
445
+ if (!currentEntry) return false;
416
446
  return await this.dbToStorageCore(file.path, file);
417
447
  } else {
418
448
  if (upgradeCurrentRecord && snapshotMatches) {
@@ -430,7 +460,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
430
460
  const storedRevision2 = classification.kind === "edited" ? await this.db.storeWithBaseRevision(readFile, classification.revision, true) : await this.db.storeIndependentRevision(readFile, true);
431
461
  if (storedRevision2 === false) return false;
432
462
  if (preferredBasePath && preferredBasePath !== file.path) await this.deleteProvenance(preferredBasePath);
433
- if (retryIfStorageChanges && !await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision2)) {
463
+ if (retryIfStorageChanges && !await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision2, true)) {
434
464
  return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
435
465
  }
436
466
  if (!retryIfStorageChanges) await this.setProvenance(file.path, storedRevision2, readFile.stat.mtime, readFile.body);
@@ -519,6 +549,12 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
519
549
  () => this.deleteFileFromDBCore(info)
520
550
  );
521
551
  }
552
+ /** Complete a validated storage deletion when its database effect is already represented. */
553
+ async deleteFileFromDBIfPresent(info) {
554
+ const entry = await this.db.fetchEntryMeta(info, void 0, true);
555
+ if (!entry || entry.deleted || entry._deleted) return true;
556
+ return await this.deleteFileFromDBCore(info);
557
+ }
522
558
  /**
523
559
  * Record a storage deletion under the caller's document lock, preserving
524
560
  * the displayed conflict branch when its provenance is known.
@@ -892,17 +928,17 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
892
928
  this._log(`File ${path} is not exist on the database`, LOG_LEVEL_VERBOSE);
893
929
  return false;
894
930
  }
931
+ const docData = readContent(docRead);
932
+ const reflectedContent = createBlob(docData);
895
933
  if (!settings.processSizeMismatchedFiles) {
896
- if (docRead.size != 0 && docRead.size !== readAsBlob(docRead).size) {
934
+ if (docRead.size != 0 && docRead.size !== reflectedContent.size) {
897
935
  this._log(
898
- `File ${path} seems to be corrupted! Writing prevented. (${docRead.size} != ${readAsBlob(docRead).size})`,
936
+ `File ${path} seems to be corrupted! Writing prevented. (${docRead.size} != ${reflectedContent.size})`,
899
937
  LOG_LEVEL_NOTICE
900
938
  );
901
939
  return false;
902
940
  }
903
941
  }
904
- const docData = readContent(docRead);
905
- const reflectedContent = readAsBlob(docRead);
906
942
  const reflectedEvidence = await this.contentEvidence(reflectedContent);
907
943
  if (allowExistingConflicts && existDoc && !force) {
908
944
  const readFile = await this.readFileFromStub(existDoc);
@@ -1080,14 +1116,23 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1080
1116
  const relatedPath = type === "RENAME" ? eventItem.oldPath : type === "DELETE" ? eventItem.renameTarget : void 0;
1081
1117
  const eventPaths = relatedPath === void 0 ? [path] : [path, relatedPath];
1082
1118
  return await this.serializedByFileEventPaths(eventPaths, async () => {
1083
- if (!await this.isCurrentPathSelected(path)) return false;
1119
+ if (!await this.isCurrentPathSelected(path)) {
1120
+ return shouldBeIgnored(path) || !await this.vault.isTargetFile(path, { skipCaseCollisionCheck: true });
1121
+ }
1084
1122
  switch (type) {
1085
1123
  case "CREATE":
1086
- case "CHANGED":
1087
- return await this.storeFileToDBFromRevision(item.args.file);
1124
+ case "CHANGED": {
1125
+ const current = this.getExactCurrentFile(await this.storage.getStub(path), path);
1126
+ if (!current || this.vault.isFileSizeTooLarge(current.stat.size)) return true;
1127
+ return await this.storeFileToDBFromRevision(current);
1128
+ }
1088
1129
  case "DELETE":
1089
- if (!await this.canRecordStorageDeletion(item)) return true;
1090
- return await this.deleteFileFromDBCore(item.args.file);
1130
+ try {
1131
+ if (!await this.canRecordStorageDeletion(item)) return true;
1132
+ } catch {
1133
+ return false;
1134
+ }
1135
+ return await this.deleteFileFromDBIfPresent(item.args.file);
1091
1136
  case "RENAME":
1092
1137
  return await this.renameFileInDBCore(
1093
1138
  item.args.file,
@@ -1137,7 +1182,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1137
1182
  } catch (ex) {
1138
1183
  this._log(`Could not validate deletion of ${path}; preserved the database entry`, LOG_LEVEL_NOTICE);
1139
1184
  this._log(ex, LOG_LEVEL_VERBOSE);
1140
- return false;
1185
+ throw ex;
1141
1186
  }
1142
1187
  }
1143
1188
  /**
@@ -1222,15 +1267,19 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1222
1267
  action = await this._planRestoredFileEvent(item, isSameDocument);
1223
1268
  } catch (ex) {
1224
1269
  this.logRestoredEventValidationFailure(path, ex);
1225
- return true;
1270
+ return false;
1226
1271
  }
1227
1272
  switch (action.kind) {
1228
1273
  case "none":
1229
1274
  return true;
1230
- case "store":
1231
- return await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
1275
+ case "unresolved":
1276
+ return false;
1277
+ case "store": {
1278
+ const stored = await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
1279
+ return stored && action.complete !== false;
1280
+ }
1232
1281
  case "delete":
1233
- return await this.deleteFileFromDBCore(action.path);
1282
+ return await this.deleteFileFromDBIfPresent(action.path);
1234
1283
  case "rename":
1235
1284
  return await this.renameFileInDBCore(action.file, action.oldPath, true);
1236
1285
  }
@@ -1266,6 +1315,10 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1266
1315
  if (!currentTarget2 || !await this.isCurrentFileSelected(currentTarget2)) {
1267
1316
  return { kind: "none" };
1268
1317
  }
1318
+ const entry = await this.db.fetchEntryMeta(newPath, void 0, true);
1319
+ if (entry && !entry.deleted && !entry._deleted && this.storage.normalisePath(this.getPath(entry)) === this.storage.normalisePath(newPath)) {
1320
+ return { kind: "store", file: currentTarget2 };
1321
+ }
1269
1322
  return { kind: "rename", file: currentTarget2, oldPath };
1270
1323
  }
1271
1324
  const currentTargetItem = await this.storage.getStub(newPath);
@@ -1283,11 +1336,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1283
1336
  return { kind: "none" };
1284
1337
  }
1285
1338
  if (!sourceWasInspected || currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1339
+ return { kind: "store", file: currentTarget, complete: sourceWasInspected };
1340
+ }
1341
+ const sourceEntry = await this.db.fetchEntryMeta(oldPath, void 0, true);
1342
+ if (!sourceEntry || sourceEntry.deleted || sourceEntry._deleted) {
1286
1343
  return { kind: "store", file: currentTarget };
1287
1344
  }
1288
1345
  return { kind: "rename", file: currentTarget, oldPath };
1289
1346
  }
1290
- if (!sourceWasInspected || currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1347
+ if (!sourceWasInspected) return { kind: "unresolved" };
1348
+ if (currentSourceItem !== null || !await this.canApplyRestoredDeletion(oldPath)) {
1291
1349
  return { kind: "none" };
1292
1350
  }
1293
1351
  return { kind: "delete", path: oldPath };
@@ -1324,12 +1382,12 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
1324
1382
  return await this.isCurrentPathSelected(path);
1325
1383
  } catch (ex) {
1326
1384
  this.logRestoredEventValidationFailure(path, ex);
1327
- return false;
1385
+ throw ex;
1328
1386
  }
1329
1387
  }
1330
1388
  logRestoredEventValidationFailure(path, ex) {
1331
1389
  this._log(
1332
- `Could not validate the saved storage operation for ${path} against current storage; the Offline Scanner will reconcile the current state`,
1390
+ `Could not validate the saved storage operation for ${path} against current storage; the operation remains pending for recovery`,
1333
1391
  LOG_LEVEL_NOTICE
1334
1392
  );
1335
1393
  this._log(ex, LOG_LEVEL_VERBOSE);
@@ -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
  *
@@ -40,7 +40,9 @@ class ControlService extends ServiceBase {
40
40
  await this.services.appLifecycleService.onSuspending();
41
41
  await this.services.settingService.onBeforeRealiseSetting();
42
42
  this.services.databaseService.localDatabase.refreshSettings();
43
- await this.services.fileProcessingService.commitPendingFileEvents();
43
+ if (!await this.services.fileProcessingService.commitPendingFileEvents()) {
44
+ throw new Error("Pending local file operations could not be completed before applying settings");
45
+ }
44
46
  await this.services.settingService.onRealiseSetting();
45
47
  if (this.services.appLifecycleService.isSuspended()) return;
46
48
  await this.services.appLifecycleService.onResuming();
@@ -49,7 +51,8 @@ class ControlService extends ServiceBase {
49
51
  }
50
52
  async _onLiveSyncUnload() {
51
53
  this.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
52
- await this.services.appLifecycleService.onBeforeUnload();
54
+ const prepared = await this.services.appLifecycleService.onBeforeUnload();
55
+ if (!prepared) this._log("Unload preparation failed; pending storage work may require recovery", LOG_LEVEL_URGENT);
53
56
  await Promise.resolve(this.services.appLifecycleService.onAppUnload());
54
57
  cancelAllPeriodicTask();
55
58
  cancelAllTasks();
@@ -64,6 +67,7 @@ class ControlService extends ServiceBase {
64
67
  this.context.events.emitEvent(EVENT_PLATFORM_UNLOADED);
65
68
  this.context.events.offAll();
66
69
  this._log(this.context.translate("moduleLiveSyncMain.logUnloadingPlugin"));
70
+ if (!prepared) throw new Error("Unload preparation failed");
67
71
  return;
68
72
  }
69
73
  /**
@@ -34,5 +34,5 @@ export declare abstract class DatabaseEventService<T extends ServiceContext = Se
34
34
  * @param reopenDatabase Whether to reopen the database if it is already open.
35
35
  * @param ignoreSuspending Whether to ignore any suspending state.
36
36
  */
37
- readonly initialiseDatabase: import("../lib/HandlerUtils.js").MultipleHandlerFunction<(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean) => Promise<import("./VaultScanResult.js").VaultScanResult>, unknown>;
37
+ readonly initialiseDatabase: import("../lib/HandlerUtils.js").MultipleHandlerFunction<(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean, scanOptions?: import("./VaultScanResult.js").VaultScanOptions) => Promise<import("./VaultScanResult.js").VaultScanResult>, unknown>;
38
38
  }
@@ -9,10 +9,16 @@ export declare class FileProcessingService<T extends ServiceContext = ServiceCon
9
9
  */
10
10
  readonly processFileEvent: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(item: import("../../common/types.js").FileEventItem) => Promise<boolean>>;
11
11
  /**
12
- * Process a file event item optionally, if any handler is registered.
12
+ * Dispatch a legacy optional notification, without confirming persistence.
13
13
  * i.e., hidden files synchronisation or customisation sync.
14
14
  */
15
15
  readonly processOptionalFileEvent: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(path: import("../../common/types.js").FilePath) => Promise<boolean>>;
16
+ /**
17
+ * Complete optional file operations through the first applicable handler.
18
+ * Hosts migrate all optional providers together. Once any typed provider is
19
+ * registered, dispatch uses this contract exclusively.
20
+ */
21
+ readonly processOptionalFileEventWithResult?: import("../lib/HandlerUtils.js").FirstHandledFunction<(path: import("../../common/types.js").FilePath, options?: import("./IService.js").OptionalFileEventOptions) => Promise<import("../lib/HandlerUtils.js").HandlerOutcome<void, string>>>;
16
22
  /**
17
23
  * Commit any pending file events that have been queued for processing.
18
24
  */
@@ -7,10 +7,18 @@ class FileProcessingService extends ServiceBase {
7
7
  */
8
8
  processFileEvent = handlers().anySuccess("processFileEvent");
9
9
  /**
10
- * Process a file event item optionally, if any handler is registered.
10
+ * Dispatch a legacy optional notification, without confirming persistence.
11
11
  * i.e., hidden files synchronisation or customisation sync.
12
12
  */
13
13
  processOptionalFileEvent = handlers().anySuccess("processOptionalFileEvent");
14
+ /**
15
+ * Complete optional file operations through the first applicable handler.
16
+ * Hosts migrate all optional providers together. Once any typed provider is
17
+ * registered, dispatch uses this contract exclusively.
18
+ */
19
+ processOptionalFileEventWithResult = handlers().firstHandled(
20
+ "processOptionalFileEventWithResult"
21
+ );
14
22
  /**
15
23
  * Commit any pending file events that have been queued for processing.
16
24
  */
@@ -16,8 +16,8 @@ import type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "./Remo
16
16
  import type { ActiveReplicatorContext, ContinuousReplicationRequest, ReplicationAttemptFailure, ReplicationFailureRequest, ReplicatorProviderDefinitionMap, ReplicationOutcome, ReplicationReadinessRequirements, UnattendedOneShotRequest, UserInitiatedOneShotRequest } from "../../replication/ReplicatorProvider.js";
17
17
  import type { RemoteResourceKind, RemoteResourceMap } from "../../replication/RemoteResource.js";
18
18
  import type { CentralRemoteAdministrationRequest, CentralRemoteAdministrationResult } from "../../replication/CentralRemoteAdministration.js";
19
- import type { MultipleHandlerFunction } from "../lib/HandlerUtils.js";
20
- import type { VaultScanResult } from "./VaultScanResult.js";
19
+ import type { HandlerOutcome, MultipleHandlerFunction } from "../lib/HandlerUtils.js";
20
+ import type { VaultScanResult, VaultScanOptions } from "./VaultScanResult.js";
21
21
  import type { UICommandRegistry } from "../../uiCommands.js";
22
22
  declare global {
23
23
  interface OPTIONAL_SYNC_FEATURES {
@@ -91,15 +91,23 @@ export interface IDatabaseEventService {
91
91
  onDatabaseInitialised(showNotice: boolean): Promise<boolean>;
92
92
  onDatabaseHasReady(): Promise<boolean>;
93
93
  onResetDatabase(db: LiveSyncLocalDB): Promise<boolean>;
94
- initialiseDatabase(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean): Promise<VaultScanResult>;
94
+ initialiseDatabase(showingNotice?: boolean, reopenDatabase?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean, scanOptions?: VaultScanOptions): Promise<VaultScanResult>;
95
95
  }
96
96
  export interface IKeyValueDBService {
97
97
  openSimpleStore<T>(kind: string): SimpleStore<T>;
98
98
  simpleStore: SimpleStore<unknown>;
99
99
  }
100
+ /** Options for completing an admitted optional file event. */
101
+ export interface OptionalFileEventOptions {
102
+ /** Resolves when a finite flush needs this event to finish without its debounce delay. */
103
+ whenFlushRequested?: Promise<void>;
104
+ }
100
105
  export interface IFileProcessingService {
101
106
  processFileEvent(item: FileEventItem): Promise<boolean>;
107
+ /** Legacy notification contract; its boolean does not confirm persistence. */
102
108
  processOptionalFileEvent(path: FilePath): Promise<boolean>;
109
+ /** Complete the actual optional file operation, or explain why it is not completed. */
110
+ processOptionalFileEventWithResult?(path: FilePath, options?: OptionalFileEventOptions): Promise<HandlerOutcome<void, string>>;
103
111
  commitPendingFileEvents(): Promise<boolean>;
104
112
  batched: ReactiveSource<number>;
105
113
  processing: ReactiveSource<number>;
@@ -347,7 +355,7 @@ export interface ITweakValueService {
347
355
  export interface IVaultService {
348
356
  vaultName(): string;
349
357
  getVaultName(): string;
350
- scanVault(showingNotice?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean): Promise<VaultScanResult>;
358
+ scanVault(showingNotice?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean, scanOptions?: VaultScanOptions): Promise<VaultScanResult>;
351
359
  isIgnoredByIgnoreFile(file: string | UXFileInfoStub): Promise<boolean>;
352
360
  isTargetFile(file: string | UXFileInfoStub,
353
361
  /** Inspect selection policy without treating a filename collision as an exclusion. */
@@ -125,5 +125,6 @@ export declare abstract class ReplicatorService<T extends ServiceContext = Servi
125
125
  lastSyncPullSeq: number;
126
126
  lastSyncPushSeq: number;
127
127
  syncStatus: import("../../common/types.js").DatabaseConnectingStatus;
128
+ couchDB?: import("../../common/models/shared.definition.js").CouchDBReplicationProgress;
128
129
  }>;
129
130
  }
@@ -10,4 +10,9 @@ export declare const VaultScanResults: {
10
10
  readonly COMPLETED: true;
11
11
  readonly COMPLETED_WITH_FILE_FAILURES: "completed-with-file-failures";
12
12
  };
13
+ /** Optional policy for an ordinary scan; omitted options retain strict content checks. */
14
+ export interface VaultScanOptions {
15
+ /** False permits unchanged tracked files to be skipped using storage and revision metadata. */
16
+ strictContentCheck?: boolean;
17
+ }
13
18
  export type VaultScanResult = (typeof VaultScanResults)[keyof typeof VaultScanResults];
@@ -26,7 +26,7 @@ export declare abstract class VaultService<T extends ServiceContext = ServiceCon
26
26
  * @param showingNotice Whether to show a notice to the user.
27
27
  * @param ignoreSuspending Whether to ignore any suspending state.
28
28
  */
29
- readonly scanVault: import("../lib/HandlerUtils.js").MultipleHandlerFunction<(showingNotice?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean) => Promise<import("./VaultScanResult.js").VaultScanResult>, unknown>;
29
+ readonly scanVault: import("../lib/HandlerUtils.js").MultipleHandlerFunction<(showingNotice?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean, scanOptions?: import("./VaultScanResult.js").VaultScanOptions) => Promise<import("./VaultScanResult.js").VaultScanResult>, unknown>;
30
30
  /**
31
31
  * Check if a file is ignored by the ignore file (e.g., .gitignore, .obsidianignore).
32
32
  * @param file The file path or file info stub to check.
@@ -2,6 +2,27 @@
2
2
  * A function type that can be used as a handler.
3
3
  */
4
4
  type HandlerFunc<TArg extends unknown[], TResult> = (...args: TArg) => TResult | Promise<TResult>;
5
+ /**
6
+ * The outcome of a handler.
7
+ *
8
+ * A completed outcome carries a value when `TValue` is not void, and a veto
9
+ * carries a reason when `TVetoReason` is not void. Not-applicable outcomes
10
+ * always continue to the next registered handler.
11
+ */
12
+ export type HandlerOutcome<TValue = void, TVetoReason = void> = {
13
+ kind: "not-applicable";
14
+ } | ([TValue] extends [void] ? {
15
+ kind: "completed";
16
+ } : {
17
+ kind: "completed";
18
+ value: TValue;
19
+ }) | ([TVetoReason] extends [void] ? {
20
+ kind: "veto";
21
+ } : {
22
+ kind: "veto";
23
+ value: TVetoReason;
24
+ });
25
+ type AnyHandlerOutcome = HandlerOutcome | HandlerOutcome<unknown, unknown>;
5
26
  /**
6
27
  * A function type that returns a boolean or a Promise of boolean.
7
28
  */
@@ -244,6 +265,29 @@ export declare class FirstResultHandler<T extends unknown[], U> extends MultiBin
244
265
  */
245
266
  invoke(...args: T): Promise<U | false>;
246
267
  }
268
+ /**
269
+ * Invokes registered handlers in priority and registration order until one
270
+ * completes or vetoes. Each invocation uses a snapshot of registrations.
271
+ * Callback errors and rejections propagate unchanged, and registrations are
272
+ * owned by this handler.
273
+ */
274
+ export declare class FirstHandledHandler<T extends unknown[], U extends AnyHandlerOutcome = HandlerOutcome> extends MultiBinder<HandlerFunc<T, U>> implements InvokableHandler<T, U | {
275
+ kind: "not-applicable";
276
+ }> {
277
+ /**
278
+ * Whether at least one callback is currently registered.
279
+ */
280
+ get hasHandlers(): boolean;
281
+ /**
282
+ * Invokes handlers until one returns a completed or veto outcome.
283
+ * @param args The arguments to pass to the handlers.
284
+ * @returns The first handled outcome, or a not-applicable outcome when no handler applies.
285
+ * @throws Any synchronous callback error or rejected callback value, unchanged.
286
+ */
287
+ invoke(...args: T): Promise<U | {
288
+ kind: "not-applicable";
289
+ }>;
290
+ }
247
291
  /**
248
292
  * A function type that can be used as a handler with assignable functionality.
249
293
  */
@@ -297,6 +341,11 @@ export interface MultipleHandlerFunction<TFunc extends (...args: any[]) => U | P
297
341
  */
298
342
  removeHandler: (callback: TFunc) => void;
299
343
  }
344
+ export type FirstHandledFunction<TFunc extends (...args: any[]) => AnyHandlerOutcome | Promise<AnyHandlerOutcome>> = ((...args: Parameters<TFunc>) => Promise<Awaited<ReturnType<TFunc>> | {
345
+ kind: "not-applicable";
346
+ }>) & Pick<MultipleHandlerFunction<TFunc>, "addHandler" | "removeHandler"> & {
347
+ readonly hasHandlers: boolean;
348
+ };
300
349
  /**
301
350
  * A function type that can be used as a value-collecting handler with add/remove functionality.
302
351
  */
@@ -351,12 +400,16 @@ export declare function bailFirstFailureWithResultFunction<TFunc extends (...arg
351
400
  export declare function allParallelFunction<TFunc extends (...args: any[]) => Promise<boolean>>(name?: string): BooleanMultipleHandlerFunction<TFunc>;
352
401
  export declare function anySuccessFunction<TFunc extends (...args: any[]) => Promise<boolean>>(name?: string): BooleanMultipleHandlerFunction<TFunc>;
353
402
  export declare function firstResultFunction<TFunc extends (...args: any[]) => Promise<unknown>>(name?: string): MultipleHandlerFunction<TFunc>;
403
+ export declare function firstHandledFunction<TFunc extends (...args: any[]) => AnyHandlerOutcome | Promise<AnyHandlerOutcome>>(name?: string): FirstHandledFunction<TFunc>;
354
404
  export declare function dispatchParallelFunction<TFunc extends (...args: any[]) => Promise<unknown[]>>(name?: string): CollectiveHandlerFunction<TFunc>;
355
405
  export declare function bindableFunction<TFunc extends (...args: any[]) => any>(name?: string): HandlerFunction<TFunc>;
356
406
  export declare function lazyBindableFunction<TFunc extends (...args: any[]) => any>(name?: string): LazyHandlerFunction<TFunc>;
357
407
  type FunctionKeys<T> = Extract<{
358
408
  [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;
359
409
  }[keyof T], string>;
410
+ type FirstHandledFunctionKeys<T> = Extract<{
411
+ [K in keyof T]: [NonNullable<T[K]>] extends [never] ? never : NonNullable<T[K]> extends (...args: any[]) => AnyHandlerOutcome | Promise<AnyHandlerOutcome> ? K : never;
412
+ }[keyof T], string>;
360
413
  export declare function handlers<T extends object>(): {
361
414
  /**
362
415
  * Create a handler that invokes all added handler functions sequentially until one returns false.
@@ -403,6 +456,13 @@ export declare function handlers<T extends object>(): {
403
456
  * @returns
404
457
  */
405
458
  firstResult<K extends FunctionKeys<T>>(name: K): MultipleHandlerFunction<Extract<T[K], (...args: any[]) => Promise<unknown>>>;
459
+ /**
460
+ * Create a handler that invokes registered handlers until one completes or vetoes.
461
+ * Callback errors and rejections propagate unchanged.
462
+ * @param name The name of the callback on `T`.
463
+ * @returns A bound handler with a live `hasHandlers` property.
464
+ */
465
+ firstHandled<K extends FirstHandledFunctionKeys<T>>(name: K): FirstHandledFunction<Extract<NonNullable<T[K]>, (...args: any[]) => AnyHandlerOutcome | Promise<AnyHandlerOutcome>>>;
406
466
  /**
407
467
  * Create a handler that invokes all added handler functions in parallel.
408
468
  * @param name