@vrtmrz/livesync-commonlib 0.1.25 → 0.1.26
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.
- package/dist/interfaces/DatabaseFileAccess.d.ts +31 -3
- package/dist/interfaces/FileReflectionProvenance.d.ts +2 -2
- package/dist/managers/EntryManager/EntryManager.d.ts +4 -2
- package/dist/managers/EntryManager/EntryManager.js +9 -4
- package/dist/managers/EntryManager/EntryManagerImpls.d.ts +12 -4
- package/dist/managers/EntryManager/EntryManagerImpls.js +27 -9
- package/dist/pouchdb/LiveSyncLocalDB.d.ts +4 -2
- package/dist/pouchdb/LiveSyncLocalDB.js +8 -4
- package/dist/serviceModules/ServiceDatabaseFileAccessBase.d.ts +26 -2
- package/dist/serviceModules/ServiceDatabaseFileAccessBase.js +57 -5
- package/dist/serviceModules/ServiceFileHandlerBase.d.ts +119 -1
- package/dist/serviceModules/ServiceFileHandlerBase.js +450 -70
- package/docs/conflict-resolution.md +28 -15
- package/docs/proven-in-use.md +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
290
|
+
await this.queueConflictCheckFor(file.path);
|
|
203
291
|
return true;
|
|
204
292
|
}
|
|
205
|
-
|
|
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.
|
|
376
|
+
await this.queueConflictCheckFor(file.path);
|
|
242
377
|
return true;
|
|
243
378
|
}
|
|
244
|
-
const
|
|
245
|
-
const currentRevision =
|
|
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.
|
|
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.
|
|
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.
|
|
399
|
+
await this.queueConflictCheckFor(file.path);
|
|
265
400
|
return true;
|
|
266
401
|
}
|
|
267
|
-
if (!
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
527
|
-
|
|
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
|
|
604
|
-
|
|
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 (
|
|
634
|
-
return false;
|
|
923
|
+
if (classification.kind === "unchanged") {
|
|
924
|
+
return { preserved: false, snapshot };
|
|
635
925
|
}
|
|
636
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
999
|
+
return await this.deleteFileFromDBCore(item.args.file);
|
|
670
1000
|
case "RENAME":
|
|
671
|
-
return await this.
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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.
|
|
1140
|
+
return await this.storeFileToDBFromRevision(action.file, false, false, void 0, true);
|
|
761
1141
|
case "delete":
|
|
762
|
-
return await this.
|
|
1142
|
+
return await this.deleteFileFromDBCore(action.path);
|
|
763
1143
|
case "rename":
|
|
764
|
-
return await this.
|
|
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
|
|
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.
|
|
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
|
}
|