@vrtmrz/livesync-commonlib 0.1.25 → 0.1.27

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,8 @@
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,
5
4
  getDocDataAsArray,
6
5
  isDocContentSame,
7
- isTextBlob,
8
6
  readAsBlob,
9
7
  readContent
10
8
  } from "../common/utils.js";
@@ -15,16 +13,6 @@ import { ServiceModuleBase } from "./ServiceModuleBase.js";
15
13
  import { getStoragePathFromUXFileInfo } from "../common/typeUtils.js";
16
14
  import { EVEN } from "../common/models/shared.const.symbols.js";
17
15
  import { tryGetFilePath } from "../common/utils.doc.js";
18
- async function isIncomingTextClearExtension(incomingContent, localContent) {
19
- const incomingBlob = createBlob(incomingContent);
20
- const localBlob = createBlob(localContent);
21
- if (!isTextBlob(incomingBlob) || !isTextBlob(localBlob)) {
22
- return false;
23
- }
24
- const incomingText = await incomingBlob.text();
25
- const localText = await localBlob.text();
26
- return incomingText.startsWith(localText) || incomingText.endsWith(localText);
27
- }
28
16
  async function serializedByKeys(keys, callback) {
29
17
  const [key, ...remainingKeys] = keys;
30
18
  if (key === void 0) return await callback();
@@ -46,6 +34,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
46
34
  setting;
47
35
  vault;
48
36
  fileReflectionProvenance;
37
+ deferredConflictChecks = /* @__PURE__ */ new Map();
49
38
  constructor(services) {
50
39
  super(services);
51
40
  this.events = services.events;
@@ -125,6 +114,91 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
125
114
  }
126
115
  return void 0;
127
116
  }
117
+ /**
118
+ * Classify a storage read against its displayed database branch.
119
+ *
120
+ * A recorded revision identifies the branch and therefore takes precedence
121
+ * over byte matches. Without a readable base, only current live leaves are
122
+ * considered. Any match avoids a duplicate write, but only a unique match
123
+ * identifies provenance; no match is `unknown`.
124
+ * `preferredPath` selects the provenance key during a rename, and
125
+ * `currentLoaded` supplies an already decoded candidate body when available.
126
+ * Missing chunks do not clear provenance unless their metadata is also
127
+ * missing or deleted.
128
+ */
129
+ async classifyStorageContent(file, currentEntry, preferredPath, recordedRevision, currentLoaded) {
130
+ const path = preferredPath ?? file.path;
131
+ if (recordedRevision) {
132
+ const loadedContentIsDecoded = currentLoaded?._rev === recordedRevision;
133
+ const base = loadedContentIsDecoded ? currentLoaded : await this.db.fetchEntry(file, recordedRevision, true, true, true);
134
+ if (base && base._rev === recordedRevision && !("deleted" in base && base.deleted) && !("_deleted" in base && base._deleted)) {
135
+ const baseContent = loadedContentIsDecoded ? base.data : readContent(base);
136
+ return await isDocContentSame(baseContent, file.body) ? { kind: "unchanged", revision: recordedRevision } : { kind: "edited", revision: recordedRevision };
137
+ }
138
+ if (!base) {
139
+ const meta = await this.db.fetchEntryMeta(file, recordedRevision, true);
140
+ if (!meta || meta.deleted || meta._deleted) await this.deleteProvenance(path);
141
+ }
142
+ }
143
+ const matches = currentEntry ? await this.db.findLiveContentRevisions(file, file.body) : [];
144
+ if (matches.length) {
145
+ return { kind: "matching-leaf", revision: matches.length === 1 ? matches[0] : void 0 };
146
+ }
147
+ return { kind: "unknown" };
148
+ }
149
+ /** Record provenance only when storage matches one unambiguous live leaf. */
150
+ async rememberMatchingLeaf(path, revision, mtime) {
151
+ if (revision) await this.setProvenance(path, revision, mtime);
152
+ }
153
+ /**
154
+ * Read the raw provenance value used by an optimistic storage snapshot.
155
+ * Unlike `getProvenance`, this does not validate or remove the referenced
156
+ * database revision; an unreadable value is represented as `undefined`.
157
+ */
158
+ async readProvenanceSnapshot(path) {
159
+ try {
160
+ return (await this.fileReflectionProvenance?.get(path))?.revision;
161
+ } catch {
162
+ return void 0;
163
+ }
164
+ }
165
+ /**
166
+ * Check that storage still has the same path, bytes, timestamp, and
167
+ * provenance as a previously captured snapshot.
168
+ */
169
+ async currentStorageSnapshotMatches(file, revision) {
170
+ const current = await this.storage.getStub(file.path);
171
+ if (!current || isFolderInfo(current) || current.path !== file.path) return false;
172
+ const readCurrent = await this.readFileFromStub(current);
173
+ return readCurrent.stat.mtime === file.stat.mtime && await isDocContentSame(readCurrent.body, file.body) && await this.readProvenanceSnapshot(file.path) === revision;
174
+ }
175
+ /**
176
+ * Publish provenance after a durable database write if storage is unchanged.
177
+ * A failed revalidation leaves the database revision stored and asks the
178
+ * caller to reread storage before proceeding.
179
+ */
180
+ async finaliseStoredRevision(file, observedProvenance, storedRevision) {
181
+ if (await this.currentStorageSnapshotMatches(file, observedProvenance)) {
182
+ await this.setProvenance(file.path, storedRevision, file.stat.mtime);
183
+ return true;
184
+ } else {
185
+ return false;
186
+ }
187
+ }
188
+ /**
189
+ * Reread storage after a restored startup snapshot changed and retry a save.
190
+ * The bounded counter prevents an unstable file from causing an endless
191
+ * startup loop; the next event or scan can reconcile a final refusal.
192
+ */
193
+ async retryCurrentStorage(path, preferredBasePath, snapshotRetry) {
194
+ if (snapshotRetry >= 2) {
195
+ this._log(`Storage kept changing while saving ${path}; a fresh event is required`, LOG_LEVEL_NOTICE);
196
+ return false;
197
+ }
198
+ const fresh = await this.storage.getStub(path);
199
+ if (!fresh || isFolderInfo(fresh)) return false;
200
+ return await this.storeFileToDBFromRevision(fresh, false, false, preferredBasePath, true, snapshotRetry + 1);
201
+ }
128
202
  getPath(entry) {
129
203
  return this.path.getPath(entry);
130
204
  }
@@ -147,9 +221,23 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
147
221
  return file;
148
222
  }
149
223
  async storeFileToDB(info, force = false, onlyChunks = false) {
150
- return await this.storeFileToDBFromRevision(info, force, onlyChunks);
224
+ return await this.serializedByFileEventPaths(
225
+ [getStoragePathFromUXFileInfo(info)],
226
+ () => this.storeFileToDBFromRevision(info, force, onlyChunks)
227
+ );
151
228
  }
152
229
  async storeFileToDBWithBaseRevision(info, baseRevision, createIfDifferent = true) {
230
+ return await this.serializedByFileEventPaths(
231
+ [getStoragePathFromUXFileInfo(info)],
232
+ () => this.storeFileToDBWithBaseRevisionCore(info, baseRevision, createIfDifferent)
233
+ );
234
+ }
235
+ /**
236
+ * Store current storage content below an exact live revision. A differing
237
+ * body is rejected when `createIfDifferent` is false; callers hold the
238
+ * document lock for this core operation.
239
+ */
240
+ async storeFileToDBWithBaseRevisionCore(info, baseRevision, createIfDifferent) {
153
241
  const file = await this.infoToStub(info);
154
242
  if (file == null) {
155
243
  this._log(`File ${tryGetFilePath(info)} is not exist on the storage`, LOG_LEVEL_VERBOSE);
@@ -183,7 +271,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
183
271
  const loadedBase = await this.db.fetchEntry(file, baseRevision, true, true);
184
272
  if (loadedBase && await isDocContentSame(getDocDataAsArray(loadedBase.data), readFile.body)) {
185
273
  await this.setProvenance(file.path, baseRevision, readFile.stat.mtime);
186
- await this.conflict.queueCheckFor(file.path);
274
+ await this.queueConflictCheckFor(file.path);
187
275
  return true;
188
276
  }
189
277
  }
@@ -199,10 +287,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
199
287
  return false;
200
288
  }
201
289
  await this.setProvenance(file.path, storedRevision, readFile.stat.mtime);
202
- await this.conflict.queueCheckFor(file.path);
290
+ await this.queueConflictCheckFor(file.path);
203
291
  return true;
204
292
  }
205
- async storeFileToDBFromRevision(info, force = false, onlyChunks = false, preferredBasePath) {
293
+ /**
294
+ * Save one storage snapshot while the caller holds its document lock.
295
+ * Ordinary saves read once inside the lock and leave retries disabled.
296
+ * Restored startup events retain bounded rereads because file watching has
297
+ * not yet started and cannot supply a new event for a concurrent edit.
298
+ */
299
+ async storeFileToDBFromRevision(info, force = false, onlyChunks = false, preferredBasePath, retryIfStorageChanges = false, snapshotRetry = 0) {
206
300
  const file = await this.infoToStub(info);
207
301
  if (file == null) {
208
302
  this._log(`File ${tryGetFilePath(info)} is not exist on the storage`, LOG_LEVEL_VERBOSE);
@@ -219,10 +313,51 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
219
313
  const readFile2 = await this.readFileFromStub(file);
220
314
  return await this.db.createChunks(readFile2, force, true);
221
315
  }
316
+ const observedProvenance = await this.readProvenanceSnapshot(
317
+ preferredBasePath ?? file.path
318
+ );
222
319
  const readFile = await this.readFileFromStub(file);
223
- const entry = await this.db.fetchEntry(file, void 0, true, true);
320
+ const entry = await this.db.fetchEntry(file, void 0, true, true, true);
321
+ const currentEntry = entry || await this.db.fetchEntryMeta(file, void 0, true);
224
322
  const conflictedRevs = await this.db.getConflictedRevs(file);
225
323
  const isConflicted = conflictedRevs.length > 0;
324
+ if (!force && currentEntry && !currentEntry.deleted && !currentEntry._deleted) {
325
+ const classification = await this.classifyStorageContent(
326
+ readFile,
327
+ currentEntry,
328
+ preferredBasePath,
329
+ observedProvenance,
330
+ entry ? { _rev: entry._rev, data: readContent(entry) } : void 0
331
+ );
332
+ if (retryIfStorageChanges && !await this.currentStorageSnapshotMatches(readFile, observedProvenance)) {
333
+ return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
334
+ }
335
+ if (classification.kind === "unchanged") {
336
+ if (isConflicted) {
337
+ await this.queueConflictCheckFor(file.path);
338
+ } else if (classification.revision !== currentEntry._rev) {
339
+ if (!entry) return false;
340
+ return await this.dbToStorageCore(file.path, file);
341
+ } else {
342
+ this.path.markChangesAreSame(readFile, readFile.stat.mtime, currentEntry.mtime);
343
+ }
344
+ return true;
345
+ }
346
+ if (classification.kind === "matching-leaf") {
347
+ await this.rememberMatchingLeaf(file.path, classification.revision, readFile.stat.mtime);
348
+ if (isConflicted) await this.queueConflictCheckFor(file.path);
349
+ return true;
350
+ }
351
+ const storedRevision2 = classification.kind === "edited" ? await this.db.storeWithBaseRevision(readFile, classification.revision, true) : await this.db.storeIndependentRevision(readFile, true);
352
+ if (storedRevision2 === false) return false;
353
+ if (preferredBasePath && preferredBasePath !== file.path) await this.deleteProvenance(preferredBasePath);
354
+ if (retryIfStorageChanges && !await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision2)) {
355
+ return await this.retryCurrentStorage(file.path, preferredBasePath, snapshotRetry);
356
+ }
357
+ if (!retryIfStorageChanges) await this.setProvenance(file.path, storedRevision2, readFile.stat.mtime);
358
+ if (isConflicted || classification.kind === "unknown") await this.queueConflictCheckFor(file.path);
359
+ return true;
360
+ }
226
361
  if (isConflicted) {
227
362
  const baseRevision = await this.getProvenBaseRevision(readFile, preferredBasePath);
228
363
  if (baseRevision) {
@@ -238,33 +373,33 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
238
373
  await this.deleteProvenance(preferredBasePath);
239
374
  }
240
375
  await this.setProvenance(file.path, storedRevision3, readFile.stat.mtime);
241
- await this.conflict.queueCheckFor(file.path);
376
+ await this.queueConflictCheckFor(file.path);
242
377
  return true;
243
378
  }
244
- const currentEntry = entry || await this.db.fetchEntryMeta(file, void 0, true);
245
- const currentRevision = currentEntry && currentEntry._rev;
379
+ const currentEntry2 = entry || await this.db.fetchEntryMeta(file, void 0, true);
380
+ const currentRevision = currentEntry2 && currentEntry2._rev;
246
381
  if (!currentRevision) {
247
382
  this._log(
248
383
  `Could not preserve the unknown conflict branch for ${file.path}; no current revision is available`,
249
384
  LOG_LEVEL_NOTICE
250
385
  );
251
- await this.conflict.queueCheckFor(file.path);
386
+ await this.queueConflictCheckFor(file.path);
252
387
  return false;
253
388
  }
254
389
  const storedRevision2 = await this.db.storeAsConflictedRevisionWithResult(readFile, currentRevision, true);
255
390
  if (storedRevision2 === false) {
256
391
  this._log(`Could not preserve the unknown conflict branch for ${file.path}`, LOG_LEVEL_NOTICE);
257
- await this.conflict.queueCheckFor(file.path);
392
+ await this.queueConflictCheckFor(file.path);
258
393
  return false;
259
394
  }
260
395
  if (preferredBasePath && preferredBasePath !== file.path) {
261
396
  await this.deleteProvenance(preferredBasePath);
262
397
  }
263
398
  await this.setProvenance(file.path, storedRevision2, readFile.stat.mtime);
264
- await this.conflict.queueCheckFor(file.path);
399
+ await this.queueConflictCheckFor(file.path);
265
400
  return true;
266
401
  }
267
- if (!entry || entry.deleted || entry._deleted) {
402
+ if (!currentEntry || currentEntry.deleted || currentEntry._deleted) {
268
403
  const storedRevision2 = await this.db.storeWithBaseRevision(readFile, entry && entry._rev, true);
269
404
  if (storedRevision2 === false) return false;
270
405
  if (preferredBasePath && preferredBasePath !== file.path) {
@@ -274,7 +409,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
274
409
  return true;
275
410
  }
276
411
  let shouldApplied = false;
277
- if (!force && !onlyChunks) {
412
+ if (!force && !onlyChunks && entry) {
278
413
  if (this.path.compareFileFreshness(file, entry) !== EVEN) {
279
414
  shouldApplied = true;
280
415
  }
@@ -291,7 +426,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
291
426
  return true;
292
427
  }
293
428
  }
294
- const storedRevision = await this.db.storeWithBaseRevision(readFile, entry._rev, true);
429
+ const storedRevision = await this.db.storeWithBaseRevision(readFile, currentEntry._rev, true);
295
430
  if (storedRevision === false) return false;
296
431
  if (preferredBasePath && preferredBasePath !== file.path) {
297
432
  await this.deleteProvenance(preferredBasePath);
@@ -300,6 +435,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
300
435
  return true;
301
436
  }
302
437
  async deleteFileFromDB(info) {
438
+ return await this.serializedByFileEventPaths(
439
+ [getStoragePathFromUXFileInfo(info)],
440
+ () => this.deleteFileFromDBCore(info)
441
+ );
442
+ }
443
+ /**
444
+ * Record a storage deletion under the caller's document lock, preserving
445
+ * the displayed conflict branch when its provenance is known.
446
+ */
447
+ async deleteFileFromDBCore(info) {
303
448
  const file = await this.infoToStub(info);
304
449
  const path = typeof info === "string" ? info : tryGetFilePath(info);
305
450
  if (file == null) {
@@ -323,13 +468,13 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
323
468
  `The deleted storage file ${path} has conflicts, but its displayed revision is unknown; preserving every database branch`,
324
469
  LOG_LEVEL_NOTICE
325
470
  );
326
- await this.conflict.queueCheckFor(path);
471
+ await this.queueConflictCheckFor(path);
327
472
  return true;
328
473
  }
329
474
  const storedRevision = await this.db.storeDeletionWithBaseRevision(path, provenance.revision);
330
475
  if (storedRevision === false) return false;
331
476
  await this.deleteProvenance(path);
332
- await this.conflict.queueCheckFor(path);
477
+ await this.queueConflictCheckFor(path);
333
478
  return true;
334
479
  }
335
480
  this._log(`File ${path} is missing on storage; deleting from the database by path`, LOG_LEVEL_INFO);
@@ -364,13 +509,13 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
364
509
  `The deleted storage file ${file.path} has conflicts, but its displayed revision is unknown; preserving every database branch`,
365
510
  LOG_LEVEL_NOTICE
366
511
  );
367
- await this.conflict.queueCheckFor(file.path);
512
+ await this.queueConflictCheckFor(file.path);
368
513
  return true;
369
514
  }
370
515
  const storedRevision = await this.db.storeDeletionWithBaseRevision(file.path, baseRevision);
371
516
  if (storedRevision === false) return false;
372
517
  await this.deleteProvenance(file.path);
373
- await this.conflict.queueCheckFor(file.path);
518
+ await this.queueConflictCheckFor(file.path);
374
519
  return true;
375
520
  }
376
521
  const deleted = await this.db.delete(file);
@@ -378,6 +523,18 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
378
523
  return deleted;
379
524
  }
380
525
  async renameFileInDB(info, oldPath) {
526
+ const newPath = getStoragePathFromUXFileInfo(info);
527
+ return await this.serializedByFileEventPaths(
528
+ [oldPath, newPath],
529
+ () => this.renameFileInDBCore(info, oldPath)
530
+ );
531
+ }
532
+ /**
533
+ * Record a rename under the caller's source and target locks. Case-only
534
+ * renames retain the source provenance while other renames handle the
535
+ * source conflict before deleting its old path.
536
+ */
537
+ async renameFileInDBCore(info, oldPath, retryIfStorageChanges = false) {
381
538
  const newPath = getStoragePathFromUXFileInfo(info);
382
539
  const [oldDocumentId, newDocumentId] = await Promise.all([
383
540
  this.path.path2id(oldPath),
@@ -385,7 +542,13 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
385
542
  ]);
386
543
  if (oldDocumentId === newDocumentId) {
387
544
  this._log(`Updating the stored path for case-only rename: ${oldPath} -> ${newPath}`, LOG_LEVEL_VERBOSE);
388
- return await this.storeFileToDBFromRevision(info, true, false, oldPath);
545
+ return await this.storeFileToDBFromRevision(
546
+ info,
547
+ true,
548
+ false,
549
+ oldPath,
550
+ retryIfStorageChanges
551
+ );
389
552
  }
390
553
  const oldEntry = await this.db.fetchEntryMeta(oldPath, void 0, true);
391
554
  const newEntry = await this.db.fetchEntryMeta(newPath, void 0, true);
@@ -396,7 +559,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
396
559
  );
397
560
  return false;
398
561
  }
399
- if (!await this.storeFileToDB(info, true)) {
562
+ if (!await this.storeFileToDBFromRevision(info, true, false, void 0, retryIfStorageChanges)) {
400
563
  this._log(`Failed to store rename target; preserving source in the database: ${oldPath}`, LOG_LEVEL_NOTICE);
401
564
  return false;
402
565
  }
@@ -417,7 +580,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
417
580
  `Renamed ${oldPath} to ${newPath}, but preserved every conflicted source branch because the displayed source revision is unknown`,
418
581
  LOG_LEVEL_NOTICE
419
582
  );
420
- await this.conflict.queueCheckFor(oldPath);
583
+ await this.queueConflictCheckFor(oldPath);
421
584
  return true;
422
585
  }
423
586
  const storedRevision = await this.db.storeDeletionWithBaseRevision(
@@ -426,7 +589,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
426
589
  );
427
590
  if (storedRevision === false) return false;
428
591
  await this.deleteProvenance(oldPath);
429
- await this.conflict.queueCheckFor(oldPath);
592
+ await this.queueConflictCheckFor(oldPath);
430
593
  return true;
431
594
  }
432
595
  const deleted = await this.db.delete(oldPath);
@@ -434,6 +597,13 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
434
597
  return deleted;
435
598
  }
436
599
  async deleteRevisionFromDB(info, rev) {
600
+ return await this.serializedByFileEventPaths(
601
+ [getStoragePathFromUXFileInfo(info)],
602
+ () => this.deleteRevisionFromDBCore(info, rev)
603
+ );
604
+ }
605
+ /** Delete one selected revision and clear matching provenance under the caller's document lock. */
606
+ async deleteRevisionFromDBCore(info, rev) {
437
607
  const path = getStoragePathFromUXFileInfo(info);
438
608
  const provenance = await this.getProvenance(path);
439
609
  const deleted = await this.db.delete(info, rev);
@@ -443,6 +613,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
443
613
  return deleted;
444
614
  }
445
615
  async resolveConflictedByDeletingRevision(info, rev) {
616
+ return await this.serializedByFileEventPaths(
617
+ [getStoragePathFromUXFileInfo(info)],
618
+ () => this.resolveConflictedByDeletingRevisionCore(info, rev)
619
+ );
620
+ }
621
+ /**
622
+ * Apply the legacy resolve-by-delete sequence while holding one document
623
+ * lock; metadata is captured before the selected revision is removed.
624
+ */
625
+ async resolveConflictedByDeletingRevisionCore(info, rev) {
446
626
  const path = getStoragePathFromUXFileInfo(info);
447
627
  const file = await this.infoToStub(info);
448
628
  const docEntry = await this.db.fetchEntryMeta(file ?? info, rev, true);
@@ -450,7 +630,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
450
630
  this._log(`Failed to read the conflicted revision ${rev} of ${path}`, LOG_LEVEL_VERBOSE);
451
631
  return false;
452
632
  }
453
- if (!await this.deleteRevisionFromDB(info, rev)) {
633
+ if (!await this.deleteRevisionFromDBCore(info, rev)) {
454
634
  this._log(`Failed to delete the conflicted revision ${rev} of ${path}`, LOG_LEVEL_VERBOSE);
455
635
  return false;
456
636
  }
@@ -465,6 +645,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
465
645
  this._log(`Cannot select database revision ${rev} without a file path`, LOG_LEVEL_VERBOSE);
466
646
  return false;
467
647
  }
648
+ return await this.serializedByFileEventPaths(
649
+ [getStoragePathFromUXFileInfo(info)],
650
+ () => this.dbToStorageWithSpecificRevCore(info, rev, force)
651
+ );
652
+ }
653
+ /**
654
+ * Reflect a selected live revision under the caller's document lock.
655
+ * The public wrapper performs the same lock acquisition before entering here.
656
+ */
657
+ async dbToStorageWithSpecificRevCore(info, rev, force) {
468
658
  const file = await this.infoToStub(info);
469
659
  const databaseTarget = file ?? info;
470
660
  const [docEntry, currentEntry, conflictedRevisions] = await Promise.all([
@@ -490,6 +680,14 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
490
680
  return await this.applyDatabaseEntryToStorage(docEntry, file, force, true);
491
681
  }
492
682
  async dbToStorage(entryInfo, info, force) {
683
+ const pathFromEntryInfo = typeof entryInfo === "string" ? entryInfo : this.getPath(entryInfo);
684
+ return await this.serializedByFileEventPaths(
685
+ [pathFromEntryInfo],
686
+ () => this.dbToStorageCore(entryInfo, info, force)
687
+ );
688
+ }
689
+ /** Fetch the current metadata and reflect it while the caller holds its lock. */
690
+ async dbToStorageCore(entryInfo, info, force) {
493
691
  const file = await this.infoToStub(info);
494
692
  const pathFromEntryInfo = typeof entryInfo === "string" ? entryInfo : this.getPath(entryInfo);
495
693
  const docEntry = await this.db.fetchEntryMeta(pathFromEntryInfo, void 0, true);
@@ -499,7 +697,13 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
499
697
  }
500
698
  return await this.applyDatabaseEntryToStorage(docEntry, file, force);
501
699
  }
502
- async applyDatabaseEntryToStorage(docEntry, file, force, allowExistingConflicts = false) {
700
+ /**
701
+ * Reflect one database revision while protecting concurrent storage edits.
702
+ * Overwrite and deletion paths preserve changed bytes first, then revalidate
703
+ * their snapshot and the current database revision immediately before the
704
+ * destructive operation.
705
+ */
706
+ async applyDatabaseEntryToStorage(docEntry, file, force, allowExistingConflicts = false, incomingRetry = 0) {
503
707
  const mode = file == null ? "create" : "modify";
504
708
  const path = this.getPath(docEntry);
505
709
  const settings = this.setting.currentSettings();
@@ -507,7 +711,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
507
711
  if (revs.length > 0 && !allowExistingConflicts) {
508
712
  if (settings.writeDocumentsIfConflicted) {
509
713
  } else {
510
- await this.conflict.queueCheckForIfOpen(path);
714
+ await this.queueConflictCheckFor(path, true);
511
715
  return true;
512
716
  }
513
717
  }
@@ -523,8 +727,45 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
523
727
  return true;
524
728
  }
525
729
  if (!existOnDB && existDoc) {
526
- if (!force && !settings.writeDocumentsIfConflicted && await this.preserveUnsyncedStorageAsConflict(path, existDoc, docEntry)) {
527
- return true;
730
+ let safeDeletionSnapshot;
731
+ if (!force && !settings.writeDocumentsIfConflicted) {
732
+ let protection = await this.preserveUnsyncedStorageAsConflict(path, existDoc, docEntry);
733
+ if (protection.preserved) return true;
734
+ if (!protection.snapshot || !await this.currentStorageSnapshotMatches(protection.snapshot.file, protection.snapshot.revision)) {
735
+ protection = await this.recheckIncomingStorage(path, docEntry, void 0, allowExistingConflicts);
736
+ if (protection.preserved) return true;
737
+ if (!protection.snapshot || !await this.currentStorageSnapshotMatches(protection.snapshot.file, protection.snapshot.revision)) {
738
+ return false;
739
+ }
740
+ }
741
+ safeDeletionSnapshot = protection.snapshot;
742
+ }
743
+ let latest2 = await this.reconcileAdvancedIncomingRevision(
744
+ docEntry,
745
+ path,
746
+ force,
747
+ allowExistingConflicts,
748
+ incomingRetry
749
+ );
750
+ if (latest2 !== void 0) return latest2;
751
+ if (safeDeletionSnapshot && !await this.currentStorageSnapshotMatches(safeDeletionSnapshot.file, safeDeletionSnapshot.revision)) {
752
+ const protection = await this.recheckIncomingStorage(path, docEntry, void 0, allowExistingConflicts);
753
+ if (protection.preserved) return true;
754
+ if (!protection.snapshot || !await this.currentStorageSnapshotMatches(protection.snapshot.file, protection.snapshot.revision)) {
755
+ return false;
756
+ }
757
+ safeDeletionSnapshot = protection.snapshot;
758
+ latest2 = await this.reconcileAdvancedIncomingRevision(
759
+ docEntry,
760
+ path,
761
+ force,
762
+ allowExistingConflicts,
763
+ incomingRetry
764
+ );
765
+ if (latest2 !== void 0) return latest2;
766
+ if (!await this.currentStorageSnapshotMatches(safeDeletionSnapshot.file, safeDeletionSnapshot.revision)) {
767
+ return false;
768
+ }
528
769
  }
529
770
  await this.storage.deleteVaultItem(path);
530
771
  await this.deleteProvenance(path);
@@ -580,6 +821,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
580
821
  return true;
581
822
  }
582
823
  }
824
+ let safeSnapshot;
583
825
  if (existDoc && !force) {
584
826
  let shouldApplied = false;
585
827
  const freshness = this.path.compareFileFreshness(existDoc, docEntry);
@@ -600,8 +842,15 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
600
842
  this._log(`File ${docRead.path} is not changed`, LOG_LEVEL_VERBOSE);
601
843
  return true;
602
844
  }
603
- if (!force && !settings.writeDocumentsIfConflicted && await this.preserveUnsyncedStorageAsConflict(path, existDoc, docEntry, docData)) {
604
- return true;
845
+ if (!force && !settings.writeDocumentsIfConflicted) {
846
+ let protection = await this.preserveUnsyncedStorageAsConflict(path, existDoc, docEntry, docData);
847
+ if (protection.preserved) return true;
848
+ if (!protection.snapshot) {
849
+ protection = await this.recheckIncomingStorage(path, docEntry, docData, allowExistingConflicts);
850
+ if (protection.preserved) return true;
851
+ if (!protection.snapshot) return false;
852
+ }
853
+ safeSnapshot = protection.snapshot;
605
854
  }
606
855
  } else {
607
856
  this._log(
@@ -610,38 +859,121 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
610
859
  );
611
860
  }
612
861
  await this.storage.ensureDir(path);
862
+ if (safeSnapshot && !await this.currentStorageSnapshotMatches(safeSnapshot.file, safeSnapshot.revision)) {
863
+ const protection = await this.recheckIncomingStorage(path, docEntry, docData, allowExistingConflicts);
864
+ if (protection.preserved) return true;
865
+ if (!protection.snapshot || !await this.currentStorageSnapshotMatches(protection.snapshot.file, protection.snapshot.revision)) {
866
+ return false;
867
+ }
868
+ safeSnapshot = protection.snapshot;
869
+ }
870
+ const latest = await this.reconcileAdvancedIncomingRevision(
871
+ docEntry,
872
+ path,
873
+ force,
874
+ allowExistingConflicts,
875
+ incomingRetry
876
+ );
877
+ if (latest !== void 0) return latest;
878
+ if (safeSnapshot && !await this.currentStorageSnapshotMatches(safeSnapshot.file, safeSnapshot.revision)) {
879
+ return false;
880
+ }
613
881
  const ret = await this.storage.writeFileAuto(path, docData, { ctime: docRead.ctime, mtime: docRead.mtime });
614
882
  await this.storage.touched(path);
615
883
  this.storage.triggerFileEvent(mode, path);
616
884
  if (ret && this.fileReflectionProvenance) {
617
885
  const storedStat = await this.storage.stat(path);
886
+ if (safeSnapshot && await this.readProvenanceSnapshot(path) !== safeSnapshot.revision) {
887
+ return ret;
888
+ }
618
889
  await this.setProvenance(path, docEntry._rev, storedStat?.mtime);
619
890
  }
620
891
  return ret;
621
892
  }
893
+ /**
894
+ * Preserve storage bytes before an incoming reflection can replace them.
895
+ *
896
+ * The initial read and provenance form one candidate snapshot. Classification
897
+ * gives a recorded branch priority, deduplicates matching live leaves, or
898
+ * creates an independent root for unknown origin. Optional `incomingContent` avoids
899
+ * re-reading an already loaded incoming body. Storage is revalidated before
900
+ * and after the database write; `preserved: true` stops the incoming write,
901
+ * while a returned `snapshot` lets the caller continue only after revalidation.
902
+ */
622
903
  async preserveUnsyncedStorageAsConflict(path, existDoc, incomingEntry, incomingContent) {
904
+ const observedProvenance = await this.readProvenanceSnapshot(path);
623
905
  const readFile = await this.readFileFromStub(existDoc);
906
+ const snapshot = { file: readFile, revision: observedProvenance };
624
907
  if (incomingContent && await isDocContentSame(incomingContent, readFile.body)) {
625
- return false;
626
- }
627
- if (incomingContent && await isIncomingTextClearExtension(incomingContent, readFile.body)) {
628
- return false;
908
+ return await this.currentStorageSnapshotMatches(readFile, observedProvenance) ? { preserved: false, snapshot } : { preserved: false };
629
909
  }
630
910
  if (!incomingEntry._rev) {
631
- return false;
911
+ return { preserved: false };
912
+ }
913
+ const classification = await this.classifyStorageContent(
914
+ readFile,
915
+ incomingEntry,
916
+ void 0,
917
+ observedProvenance,
918
+ incomingContent ? { _rev: incomingEntry._rev, data: incomingContent } : void 0
919
+ );
920
+ if (!await this.currentStorageSnapshotMatches(readFile, observedProvenance)) {
921
+ return { preserved: false };
632
922
  }
633
- if (await this.db.hasContentInRevisionHistory(path, readFile.body, incomingEntry._rev)) {
634
- return false;
923
+ if (classification.kind === "unchanged") {
924
+ return { preserved: false, snapshot };
635
925
  }
636
- const storedRevision = await this.db.storeAsConflictedRevisionWithResult(readFile, incomingEntry._rev, true);
926
+ if (classification.kind === "matching-leaf") {
927
+ await this.rememberMatchingLeaf(path, classification.revision, readFile.stat.mtime);
928
+ await this.queueConflictCheckFor(path);
929
+ return { preserved: true };
930
+ }
931
+ const storedRevision = classification.kind === "edited" ? await this.db.storeWithBaseRevision(readFile, classification.revision, true) : await this.db.storeIndependentRevision(readFile, true);
637
932
  if (storedRevision === false) {
638
933
  this._log(`Prevented overwriting unsynchronised local changes for ${path}`, LOG_LEVEL_NOTICE);
639
- return true;
934
+ return { preserved: true };
935
+ }
936
+ if (!await this.finaliseStoredRevision(readFile, observedProvenance, storedRevision)) {
937
+ return { preserved: false };
640
938
  }
641
- await this.setProvenance(path, storedRevision, readFile.stat.mtime);
642
939
  this._log(`Preserved unsynchronised local changes as a conflict for ${path}`, LOG_LEVEL_NOTICE);
643
- await this.conflict.queueCheckFor(path);
644
- return true;
940
+ await this.queueConflictCheckFor(path);
941
+ return { preserved: true };
942
+ }
943
+ /**
944
+ * Repeat overwrite protection from a fresh storage read after a snapshot
945
+ * becomes stale. Existing conflicts may defer reflection and request an
946
+ * open-only check while leaving the current storage untouched.
947
+ */
948
+ async recheckIncomingStorage(path, incomingEntry, incomingContent, allowExistingConflicts) {
949
+ const current = await this.storage.getStub(path);
950
+ if (!current || isFolderInfo(current)) return { preserved: false };
951
+ const protection = await this.preserveUnsyncedStorageAsConflict(path, current, incomingEntry, incomingContent);
952
+ if (protection.preserved || !protection.snapshot) return protection;
953
+ if (!allowExistingConflicts && !this.setting.currentSettings().writeDocumentsIfConflicted && (await this.db.getConflictedRevs(path)).length > 0) {
954
+ await this.queueConflictCheckFor(path, true);
955
+ return { preserved: true };
956
+ }
957
+ return protection;
958
+ }
959
+ /**
960
+ * Re-read database metadata immediately before reflecting an incoming entry.
961
+ * If the current revision advanced, recurse with fresh storage; force and
962
+ * explicitly allowed conflict writes retain the caller's selected entry.
963
+ * `incomingRetry` bounds repeated database churn.
964
+ */
965
+ async reconcileAdvancedIncomingRevision(incomingEntry, path, force, allowExistingConflicts, incomingRetry) {
966
+ if (force || allowExistingConflicts) return void 0;
967
+ const current = await this.db.fetchEntryMeta(path, void 0, true);
968
+ if (!current) return false;
969
+ if (current._rev === incomingEntry._rev) return void 0;
970
+ if (incomingRetry >= 2) {
971
+ this._log(`Database kept changing while reflecting ${path}`, LOG_LEVEL_NOTICE);
972
+ return false;
973
+ }
974
+ const storage = await this.storage.getStub(path);
975
+ if (isFolderInfo(storage)) return false;
976
+ return await this.applyDatabaseEntryToStorage(current, storage, force, allowExistingConflicts, incomingRetry + 1);
645
977
  }
646
978
  async _anyHandlerProcessesFileEvent(item) {
647
979
  if (item.restoredFromPreviousRuntime) {
@@ -650,9 +982,6 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
650
982
  const eventItem = item.args;
651
983
  const type = item.type;
652
984
  const path = eventItem.file.path;
653
- if (!await this.isCurrentPathSelected(path)) {
654
- return false;
655
- }
656
985
  if (type === "RENAME" && !eventItem.oldPath) {
657
986
  this._log(`Rename event for ${path} has no source path`, LOG_LEVEL_VERBOSE);
658
987
  return false;
@@ -660,15 +989,16 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
660
989
  const relatedPath = type === "RENAME" ? eventItem.oldPath : type === "DELETE" ? eventItem.renameTarget : void 0;
661
990
  const eventPaths = relatedPath === void 0 ? [path] : [path, relatedPath];
662
991
  return await this.serializedByFileEventPaths(eventPaths, async () => {
992
+ if (!await this.isCurrentPathSelected(path)) return false;
663
993
  switch (type) {
664
994
  case "CREATE":
665
995
  case "CHANGED":
666
- return await this.storeFileToDB(item.args.file);
996
+ return await this.storeFileToDBFromRevision(item.args.file);
667
997
  case "DELETE":
668
998
  if (!await this.canRecordStorageDeletion(item)) return true;
669
- return await this.deleteFileFromDB(item.args.file);
999
+ return await this.deleteFileFromDBCore(item.args.file);
670
1000
  case "RENAME":
671
- return await this.renameFileInDB(
1001
+ return await this.renameFileInDBCore(
672
1002
  item.args.file,
673
1003
  item.args.oldPath
674
1004
  );
@@ -719,13 +1049,61 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
719
1049
  return false;
720
1050
  }
721
1051
  }
1052
+ /**
1053
+ * Serialise work by every document ID represented by the supplied paths.
1054
+ * IDs are deduplicated and sorted so aliases take one lock and cross-document renames have one order.
1055
+ * The callback runs under those locks and must use core methods; conflict
1056
+ * hooks requested inside it are deferred until all locks have been released.
1057
+ */
722
1058
  async serializedByFileEventPaths(eventPaths, callback) {
723
1059
  const documentIds = await Promise.all(eventPaths.map((eventPath) => this.path.path2id(eventPath)));
724
- const lockKeys = [...new Set(documentIds)].sort().map((documentId) => `processFileEvent-${documentId}`);
725
- return await serializedByKeys(
726
- lockKeys,
727
- () => callback(documentIds.length === 2 && documentIds[0] === documentIds[1])
728
- );
1060
+ const uniqueIds = [...new Set(documentIds)].sort();
1061
+ const lockKeys = uniqueIds.map((documentId) => `processFileEvent-${documentId}`);
1062
+ const pending = [];
1063
+ let result;
1064
+ let failed = false;
1065
+ let failure;
1066
+ try {
1067
+ result = await serializedByKeys(lockKeys, async () => {
1068
+ for (const id of uniqueIds) this.deferredConflictChecks.set(id, pending);
1069
+ try {
1070
+ return await callback(documentIds.length === 2 && documentIds[0] === documentIds[1]);
1071
+ } finally {
1072
+ for (const id of uniqueIds) this.deferredConflictChecks.delete(id);
1073
+ }
1074
+ });
1075
+ } catch (ex) {
1076
+ failed = true;
1077
+ failure = ex;
1078
+ }
1079
+ for (const check of pending) {
1080
+ try {
1081
+ if (check.ifOpen) await this.conflict.queueCheckForIfOpen(check.path);
1082
+ else await this.conflict.queueCheckFor(check.path);
1083
+ } catch (ex) {
1084
+ if (!failed) {
1085
+ failed = true;
1086
+ failure = ex;
1087
+ }
1088
+ break;
1089
+ }
1090
+ }
1091
+ if (failed) throw failure;
1092
+ return result;
1093
+ }
1094
+ /**
1095
+ * Queue a conflict hook while its document lock is held, or run it directly
1096
+ * when no matching lock is active. Preserve `ifOpen` across deferral.
1097
+ */
1098
+ async queueConflictCheckFor(path, ifOpen = false) {
1099
+ const documentId = await this.path.path2id(path);
1100
+ const pending = this.deferredConflictChecks.get(documentId);
1101
+ if (pending) {
1102
+ pending.push({ path, ifOpen });
1103
+ return;
1104
+ }
1105
+ if (ifOpen) await this.conflict.queueCheckForIfOpen(path);
1106
+ else await this.conflict.queueCheckFor(path);
729
1107
  }
730
1108
  /**
731
1109
  * Revalidate a persisted storage operation against the current storage state.
@@ -733,7 +1111,9 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
733
1111
  * Snapshot entries preserve operation intent and ordering only. Their file
734
1112
  * stub, timestamps, and existence assumptions can be stale after a restart.
735
1113
  * Current inclusion is therefore read from storage, while destructive work
736
- * is allowed only after current absence has been observed.
1114
+ * is allowed only after current absence has been observed. Store and rename
1115
+ * actions use the fresh reread with bounded retry because no later watcher
1116
+ * event is guaranteed after startup reconciliation.
737
1117
  */
738
1118
  async _processRestoredFileEvent(item) {
739
1119
  const eventItem = item.args;
@@ -757,11 +1137,11 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
757
1137
  case "none":
758
1138
  return true;
759
1139
  case "store":
760
- return await this.storeFileToDB(action.file);
1140
+ return await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
761
1141
  case "delete":
762
- return await this.deleteFileFromDB(action.path);
1142
+ return await this.deleteFileFromDBCore(action.path);
763
1143
  case "rename":
764
- return await this.renameFileInDB(action.file, action.oldPath);
1144
+ return await this.renameFileInDBCore(action.file, action.oldPath, true);
765
1145
  }
766
1146
  });
767
1147
  }
@@ -864,7 +1244,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
864
1244
  this._log(ex, LOG_LEVEL_VERBOSE);
865
1245
  }
866
1246
  async _anyProcessReplicatedDoc(entry) {
867
- return await serialized(`processReplicatedDoc-${entry._id}`, async () => {
1247
+ return await this.serializedByFileEventPaths([this.getPath(entry)], async () => {
868
1248
  if (!await this.vault.isTargetFile(entry.path)) {
869
1249
  this._log(`File ${entry.path} is not the target file`, LOG_LEVEL_VERBOSE);
870
1250
  return false;
@@ -892,7 +1272,7 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
892
1272
  LOG_LEVEL_VERBOSE
893
1273
  );
894
1274
  this.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
895
- const ret = await this.dbToStorage(entry, targetFile);
1275
+ const ret = await this.dbToStorageCore(entry, targetFile);
896
1276
  this._log(`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Done`);
897
1277
  return ret;
898
1278
  }