@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.
@@ -26,21 +26,45 @@ export declare class ServiceDatabaseFileAccessBase extends ServiceModuleBase<Ser
26
26
  delete(file: UXFileInfoStub | FilePathWithPrefix, rev?: string): Promise<boolean>;
27
27
  createChunks(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
28
28
  store(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
29
+ /** Force a child below the supplied base, even if advanced; an undefined base uses the winner. */
29
30
  storeWithBaseRevision(file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean): Promise<string | false>;
31
+ /** Extend a current leaf with ordinary MVCC; an advanced base returns `false`. */
30
32
  storeWithLiveBaseRevision(file: UXFileInfo, baseRevision: string, skipCheck?: boolean): Promise<string | false>;
33
+ /**
34
+ * Store Chunks and a fresh Metadata root for content with unknown ancestry.
35
+ * Return its exact revision for provenance, or `false` if no Metadata write succeeded.
36
+ */
37
+ storeIndependentRevision(file: UXFileInfo, skipCheck?: boolean): Promise<string | false>;
31
38
  storeAsConflictedRevision(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<boolean>;
39
+ /** Create a sibling of the selected revision; refuse when its parent cannot be found. */
32
40
  storeAsConflictedRevisionWithResult(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<string | false>;
41
+ /** Record a logical deletion below the displayed branch and return its exact revision. */
33
42
  storeDeletionWithBaseRevision(file: UXFileInfoStub | FilePathWithPrefix, baseRevision: string): Promise<string | false>;
34
43
  storeContent(path: FilePathWithPrefix, content: string): Promise<boolean>;
35
44
  private __store;
45
+ /** Reconstruct the immediate parent ID from revision history, or return `false` if unavailable. */
36
46
  private getParentRev;
47
+ /**
48
+ * Search available revision bodies across branches and their ancestors.
49
+ * `stopAfterFirstMatch` serves existence checks without decoding the remaining candidates.
50
+ * Historical matches alone do not establish the origin of current storage content.
51
+ */
37
52
  private findContentRevisionsInternal;
53
+ /** Find matching content throughout available history, including non-leaf revisions. */
38
54
  findContentRevisions(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string): Promise<string[]>;
55
+ /**
56
+ * Find live leaves which already represent these bytes, for deduplication when provenance
57
+ * is unknown. `open_revs` includes conflicting branches; both deletion markers are excluded.
58
+ * Unlike the historical search, ancestors cannot match and missing Chunks are not fetched.
59
+ * A read failure yields no confirmed match. Callers decide whether the result identifies a
60
+ * unique origin; this method does not mutate provenance or hold the revision tree stable.
61
+ */
62
+ findLiveContentRevisions(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer): Promise<string[]>;
39
63
  hasContentInRevisionHistory(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string): Promise<boolean>;
40
64
  getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise<string[]>;
41
65
  fetch(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean): Promise<UXFileInfo | false>;
42
66
  fetchEntryMeta(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, skipCheck?: boolean): Promise<MetaEntry | false>;
43
- fetchEntryFromMeta(meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean): Promise<LoadedEntry | false>;
44
- fetchEntry(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean): Promise<LoadedEntry | false>;
67
+ fetchEntryFromMeta(meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean, localOnly?: boolean): Promise<LoadedEntry | false>;
68
+ fetchEntry(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean, localOnly?: boolean): Promise<LoadedEntry | false>;
45
69
  deleteFromDBbyPath(fullPath: FilePath | FilePathWithPrefix, rev?: string): Promise<boolean>;
46
70
  }
@@ -52,6 +52,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
52
52
  async store(file, force = false, skipCheck) {
53
53
  return await this.__store(file, force, skipCheck, false) !== false;
54
54
  }
55
+ /** Force a child below the supplied base, even if advanced; an undefined base uses the winner. */
55
56
  async storeWithBaseRevision(file, baseRevision, skipCheck) {
56
57
  const result = await this.__store(file, true, skipCheck, false, {
57
58
  mode: "force-base",
@@ -59,6 +60,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
59
60
  });
60
61
  return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
61
62
  }
63
+ /** Extend a current leaf with ordinary MVCC; an advanced base returns `false`. */
62
64
  async storeWithLiveBaseRevision(file, baseRevision, skipCheck) {
63
65
  const result = await this.__store(file, true, skipCheck, false, {
64
66
  mode: "live-base",
@@ -66,9 +68,18 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
66
68
  });
67
69
  return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
68
70
  }
71
+ /**
72
+ * Store Chunks and a fresh Metadata root for content with unknown ancestry.
73
+ * Return its exact revision for provenance, or `false` if no Metadata write succeeded.
74
+ */
75
+ async storeIndependentRevision(file, skipCheck) {
76
+ const result = await this.__store(file, true, skipCheck, false, { mode: "independent-root" });
77
+ return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
78
+ }
69
79
  async storeAsConflictedRevision(file, currentRev, skipCheck) {
70
80
  return await this.storeAsConflictedRevisionWithResult(file, currentRev, skipCheck) !== false;
71
81
  }
82
+ /** Create a sibling of the selected revision; refuse when its parent cannot be found. */
72
83
  async storeAsConflictedRevisionWithResult(file, currentRev, skipCheck) {
73
84
  const conflictBaseRev = await this.getParentRev(file, currentRev);
74
85
  if (!conflictBaseRev) {
@@ -77,6 +88,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
77
88
  }
78
89
  return await this.storeWithBaseRevision(file, conflictBaseRev, skipCheck);
79
90
  }
91
+ /** Record a logical deletion below the displayed branch and return its exact revision. */
80
92
  async storeDeletionWithBaseRevision(file, baseRevision) {
81
93
  if (!await this.checkIsTargetFile(file)) {
82
94
  return false;
@@ -193,7 +205,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
193
205
  this._log(msg + " Skip " + fullPath, LOG_LEVEL_VERBOSE);
194
206
  return true;
195
207
  }
196
- const ret = revisionTarget.mode === "live-base" ? await this.database.localDatabase.putDBEntryWithLiveBaseRevision(
208
+ const ret = revisionTarget.mode === "independent-root" ? await this.database.localDatabase.putDBEntryAsIndependentRoot(d) : revisionTarget.mode === "live-base" ? await this.database.localDatabase.putDBEntryWithLiveBaseRevision(
197
209
  d,
198
210
  revisionTarget.baseRevision,
199
211
  onlyChunks
@@ -208,6 +220,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
208
220
  }
209
221
  return ret;
210
222
  }
223
+ /** Reconstruct the immediate parent ID from revision history, or return `false` if unavailable. */
211
224
  async getParentRev(file, rev) {
212
225
  const filename = getDatabasePathFromUXFileInfo(file);
213
226
  try {
@@ -226,6 +239,11 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
226
239
  return false;
227
240
  }
228
241
  }
242
+ /**
243
+ * Search available revision bodies across branches and their ancestors.
244
+ * `stopAfterFirstMatch` serves existence checks without decoding the remaining candidates.
245
+ * Historical matches alone do not establish the origin of current storage content.
246
+ */
229
247
  async findContentRevisionsInternal(file, content, currentRev, stopAfterFirstMatch = false) {
230
248
  const filename = getDatabasePathFromUXFileInfo(file);
231
249
  try {
@@ -285,12 +303,46 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
285
303
  }
286
304
  return [];
287
305
  }
306
+ /** Find matching content throughout available history, including non-leaf revisions. */
288
307
  async findContentRevisions(file, content, currentRev) {
289
308
  if (!await this.checkIsTargetFile(file)) {
290
309
  return [];
291
310
  }
292
311
  return await this.findContentRevisionsInternal(file, content, currentRev);
293
312
  }
313
+ /**
314
+ * Find live leaves which already represent these bytes, for deduplication when provenance
315
+ * is unknown. `open_revs` includes conflicting branches; both deletion markers are excluded.
316
+ * Unlike the historical search, ancestors cannot match and missing Chunks are not fetched.
317
+ * A read failure yields no confirmed match. Callers decide whether the result identifies a
318
+ * unique origin; this method does not mutate provenance or hold the revision tree stable.
319
+ */
320
+ async findLiveContentRevisions(file, content) {
321
+ if (!await this.checkIsTargetFile(file)) return [];
322
+ const filename = getDatabasePathFromUXFileInfo(file);
323
+ try {
324
+ const current = await this.database.localDatabase.getDBEntryMeta(filename, void 0, true);
325
+ if (current === false) return [];
326
+ const leaves = await this.database.localDatabase.getRaw(current._id, {
327
+ open_revs: "all"
328
+ });
329
+ if (!Array.isArray(leaves)) return [];
330
+ const matches = [];
331
+ for (const leaf of leaves) {
332
+ const rev = leaf.ok?._rev;
333
+ if (!rev || leaf.ok?.deleted || leaf.ok?._deleted) continue;
334
+ const entry = await this.database.localDatabase.getDBEntry(filename, { rev }, false, true, true, true);
335
+ if (entry !== false && !entry.deleted && !entry._deleted && await isDocContentSame(readContent(entry), content)) {
336
+ matches.push(rev);
337
+ }
338
+ }
339
+ return matches;
340
+ } catch (ex) {
341
+ this._log(`Could not check live revision content for ${filename}`, LOG_LEVEL_VERBOSE);
342
+ this._log(ex, LOG_LEVEL_VERBOSE);
343
+ return [];
344
+ }
345
+ }
294
346
  async hasContentInRevisionHistory(file, content, currentRev) {
295
347
  if (!await this.checkIsTargetFile(file)) {
296
348
  return true;
@@ -346,17 +398,17 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
346
398
  }
347
399
  return doc;
348
400
  }
349
- async fetchEntryFromMeta(meta, waitForReady = true, skipCheck = false) {
401
+ async fetchEntryFromMeta(meta, waitForReady = true, skipCheck = false, localOnly = false) {
350
402
  if (skipCheck && !await this.checkIsTargetFile(meta.path)) {
351
403
  return false;
352
404
  }
353
- const doc = await this.database.localDatabase.getDBEntryFromMeta(meta, false, waitForReady);
405
+ const doc = await this.database.localDatabase.getDBEntryFromMeta(meta, false, waitForReady, localOnly);
354
406
  if (doc === false) {
355
407
  return false;
356
408
  }
357
409
  return doc;
358
410
  }
359
- async fetchEntry(file, rev, waitForReady = true, skipCheck = false) {
411
+ async fetchEntry(file, rev, waitForReady = true, skipCheck = false, localOnly = false) {
360
412
  if (skipCheck && !await this.checkIsTargetFile(file)) {
361
413
  return false;
362
414
  }
@@ -364,7 +416,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
364
416
  if (entry === false) {
365
417
  return false;
366
418
  }
367
- const doc = await this.fetchEntryFromMeta(entry, waitForReady, true);
419
+ const doc = await this.fetchEntryFromMeta(entry, waitForReady, true, localOnly);
368
420
  return doc;
369
421
  }
370
422
  async deleteFromDBbyPath(fullPath, rev) {
@@ -44,6 +44,7 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
44
44
  private setting;
45
45
  private vault;
46
46
  private fileReflectionProvenance?;
47
+ private readonly deferredConflictChecks;
47
48
  constructor(services: ServiceFileHandlerDependencies);
48
49
  get db(): DatabaseFileAccess;
49
50
  get storage(): StorageAccess;
@@ -52,31 +53,148 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
52
53
  private deleteProvenance;
53
54
  private findUniqueContentRevision;
54
55
  private getProvenBaseRevision;
56
+ /**
57
+ * Classify a storage read against its displayed database branch.
58
+ *
59
+ * A recorded revision identifies the branch and therefore takes precedence
60
+ * over byte matches. Without a readable base, only current live leaves are
61
+ * considered. Any match avoids a duplicate write, but only a unique match
62
+ * identifies provenance; no match is `unknown`.
63
+ * `preferredPath` selects the provenance key during a rename, and
64
+ * `currentLoaded` supplies an already decoded candidate body when available.
65
+ * Missing chunks do not clear provenance unless their metadata is also
66
+ * missing or deleted.
67
+ */
68
+ private classifyStorageContent;
69
+ /** Record provenance only when storage matches one unambiguous live leaf. */
70
+ private rememberMatchingLeaf;
71
+ /**
72
+ * Read the raw provenance value used by an optimistic storage snapshot.
73
+ * Unlike `getProvenance`, this does not validate or remove the referenced
74
+ * database revision; an unreadable value is represented as `undefined`.
75
+ */
76
+ private readProvenanceSnapshot;
77
+ /**
78
+ * Check that storage still has the same path, bytes, timestamp, and
79
+ * provenance as a previously captured snapshot.
80
+ */
81
+ private currentStorageSnapshotMatches;
82
+ /**
83
+ * Publish provenance after a durable database write if storage is unchanged.
84
+ * A failed revalidation leaves the database revision stored and asks the
85
+ * caller to reread storage before proceeding.
86
+ */
87
+ private finaliseStoredRevision;
88
+ /**
89
+ * Reread storage after a restored startup snapshot changed and retry a save.
90
+ * The bounded counter prevents an unstable file from causing an endless
91
+ * startup loop; the next event or scan can reconcile a final refusal.
92
+ */
93
+ private retryCurrentStorage;
55
94
  getPath(entry: AnyEntry): FilePathWithPrefix;
56
95
  getPathWithoutPrefix(entry: AnyEntry): FilePathWithPrefix;
57
96
  readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise<UXFileInfo>;
58
97
  private infoToStub;
59
98
  storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise<boolean>;
60
99
  storeFileToDBWithBaseRevision(info: UXFileInfoStub | UXFileInfo | FilePathWithPrefix, baseRevision: string, createIfDifferent?: boolean): Promise<boolean>;
100
+ /**
101
+ * Store current storage content below an exact live revision. A differing
102
+ * body is rejected when `createIfDifferent` is false; callers hold the
103
+ * document lock for this core operation.
104
+ */
105
+ private storeFileToDBWithBaseRevisionCore;
106
+ /**
107
+ * Save one storage snapshot while the caller holds its document lock.
108
+ * Ordinary saves read once inside the lock and leave retries disabled.
109
+ * Restored startup events retain bounded rereads because file watching has
110
+ * not yet started and cannot supply a new event for a concurrent edit.
111
+ */
61
112
  private storeFileToDBFromRevision;
62
113
  deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean>;
114
+ /**
115
+ * Record a storage deletion under the caller's document lock, preserving
116
+ * the displayed conflict branch when its provenance is known.
117
+ */
118
+ private deleteFileFromDBCore;
63
119
  renameFileInDB(info: UXFileInfoStub | UXFileInfo, oldPath: FilePath | FilePathWithPrefix): Promise<boolean>;
120
+ /**
121
+ * Record a rename under the caller's source and target locks. Case-only
122
+ * renames retain the source provenance while other renames handle the
123
+ * source conflict before deleting its old path.
124
+ */
125
+ private renameFileInDBCore;
64
126
  deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise<boolean | undefined>;
127
+ /** Delete one selected revision and clear matching provenance under the caller's document lock. */
128
+ private deleteRevisionFromDBCore;
65
129
  resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise<boolean | undefined>;
130
+ /**
131
+ * Apply the legacy resolve-by-delete sequence while holding one document
132
+ * lock; metadata is captured before the selected revision is removed.
133
+ */
134
+ private resolveConflictedByDeletingRevisionCore;
66
135
  dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | FilePathWithPrefix | null, rev: string, force?: boolean): Promise<boolean>;
136
+ /**
137
+ * Reflect a selected live revision under the caller's document lock.
138
+ * The public wrapper performs the same lock acquisition before entering here.
139
+ */
140
+ private dbToStorageWithSpecificRevCore;
67
141
  dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise<boolean>;
142
+ /** Fetch the current metadata and reflect it while the caller holds its lock. */
143
+ private dbToStorageCore;
144
+ /**
145
+ * Reflect one database revision while protecting concurrent storage edits.
146
+ * Overwrite and deletion paths preserve changed bytes first, then revalidate
147
+ * their snapshot and the current database revision immediately before the
148
+ * destructive operation.
149
+ */
68
150
  private applyDatabaseEntryToStorage;
151
+ /**
152
+ * Preserve storage bytes before an incoming reflection can replace them.
153
+ *
154
+ * The initial read and provenance form one candidate snapshot. Classification
155
+ * gives a recorded branch priority, deduplicates matching live leaves, or
156
+ * creates an independent root for unknown origin. Optional `incomingContent` avoids
157
+ * re-reading an already loaded incoming body. Storage is revalidated before
158
+ * and after the database write; `preserved: true` stops the incoming write,
159
+ * while a returned `snapshot` lets the caller continue only after revalidation.
160
+ */
69
161
  private preserveUnsyncedStorageAsConflict;
162
+ /**
163
+ * Repeat overwrite protection from a fresh storage read after a snapshot
164
+ * becomes stale. Existing conflicts may defer reflection and request an
165
+ * open-only check while leaving the current storage untouched.
166
+ */
167
+ private recheckIncomingStorage;
168
+ /**
169
+ * Re-read database metadata immediately before reflecting an incoming entry.
170
+ * If the current revision advanced, recurse with fresh storage; force and
171
+ * explicitly allowed conflict writes retain the caller's selected entry.
172
+ * `incomingRetry` bounds repeated database churn.
173
+ */
174
+ private reconcileAdvancedIncomingRevision;
70
175
  private _anyHandlerProcessesFileEvent;
71
176
  private canRecordStorageDeletion;
177
+ /**
178
+ * Serialise work by every document ID represented by the supplied paths.
179
+ * IDs are deduplicated and sorted so aliases take one lock and cross-document renames have one order.
180
+ * The callback runs under those locks and must use core methods; conflict
181
+ * hooks requested inside it are deferred until all locks have been released.
182
+ */
72
183
  private serializedByFileEventPaths;
184
+ /**
185
+ * Queue a conflict hook while its document lock is held, or run it directly
186
+ * when no matching lock is active. Preserve `ifOpen` across deferral.
187
+ */
188
+ private queueConflictCheckFor;
73
189
  /**
74
190
  * Revalidate a persisted storage operation against the current storage state.
75
191
  *
76
192
  * Snapshot entries preserve operation intent and ordering only. Their file
77
193
  * stub, timestamps, and existence assumptions can be stale after a restart.
78
194
  * Current inclusion is therefore read from storage, while destructive work
79
- * is allowed only after current absence has been observed.
195
+ * is allowed only after current absence has been observed. Store and rename
196
+ * actions use the fresh reread with bounded retry because no later watcher
197
+ * event is guaranteed after startup reconciliation.
80
198
  */
81
199
  private _processRestoredFileEvent;
82
200
  private _planRestoredFileEvent;