@vrtmrz/livesync-commonlib 0.1.0-rc.9 → 0.1.0

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/README.md CHANGED
@@ -9,6 +9,9 @@ This package is primarily for maintainers of those clients and for integrations
9
9
  >
10
10
  > Existing contributions remain welcome and the normal contribution process has not changed. Pull requests which touch packaging, host lifecycle, or compatibility boundaries may need coordination or a rebase while this boundary settles; maintainers will identify affected work rather than asking contributors to adopt a new process pre-emptively.
11
11
 
12
+ > [!NOTE]
13
+ > The multilingual translation resources and application dialogues are now maintained in the main application repository, [Self-hosted LiveSync](https://github.com/vrtmrz/obsidian-livesync). New pull requests for them are welcome there. Thank you to everyone who has contributed translations and dialogue improvements here.
14
+
12
15
  ## Status and versioning
13
16
 
14
17
  The package is pre-1.0. Pin an exact reviewed version, and review changes before upgrading. An entry in the package export map means that the import is deliberate and package-tested; it does not make every semantic detail of that entry a final 1.0 guarantee.
@@ -114,13 +117,13 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
114
117
 
115
118
  ## Contract scope
116
119
 
117
- The package is currently an infrastructure and compatibility boundary. The context, rooted-storage, and standard-I/O result contracts have focused cross-platform or instance-isolation tests. Platform details which cannot be shared, such as file timestamp fidelity and browser permission handling, remain host concerns and are documented separately.
120
+ The package is currently an infrastructure and compatibility boundary. The context, rooted-storage, standard-I/O, and revision-tree safety contracts have focused cross-platform, instance-isolation, or real-PouchDB tests. Platform details which cannot be shared, such as file timestamp fidelity and browser permission handling, remain host concerns and are documented separately.
118
121
 
119
122
  `DirectFileManipulator` is useful for existing integrations, but its enumeration, watch ownership, failure, conflict, concurrency, readiness, and disposal semantics are not a stable high-level SDK contract. It is the migration source for the planned file client, not the shape to preserve as the new API.
120
123
 
121
124
  The accepted replacement direction is an asynchronously created file client with stable `list`, `get`, `put`, `delete`, `watch`, and `close` operations. The operation result, conflict, concurrency, watch checkpoint, retry, and error contracts still require focused decisions and tests before that client can be published.
122
125
 
123
- Package developers should read [the developer guide](docs/development.md). The focused contracts are described in [the storage guide](docs/platform-storage.md), [the standard-I/O guide](docs/platform-standard-io.md), [the settings lifecycle guide](docs/settings-lifecycle.md), and [the remote configuration profile guide](docs/remote-configurations.md).
126
+ Package developers should read [the developer guide](docs/development.md). The focused contracts are described in [the storage guide](docs/platform-storage.md), [the standard-I/O guide](docs/platform-standard-io.md), [the settings lifecycle guide](docs/settings-lifecycle.md), [the remote configuration profile guide](docs/remote-configurations.md), and [the conflict-resolution guide](docs/conflict-resolution.md).
124
127
 
125
128
  ## Proven in maintained hosts
126
129
 
@@ -2,10 +2,18 @@ import type { FilePathWithPrefix, LoadedEntry, MetaEntry, UXFileInfo, UXFileInfo
2
2
  export interface DatabaseFileAccess {
3
3
  delete: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string) => Promise<boolean>;
4
4
  store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
5
+ /** Store a file as a child of an exact revision and return the created revision. */
6
+ storeWithBaseRevision: (file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean) => Promise<string | false>;
5
7
  storeAsConflictedRevision: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<boolean>;
8
+ /** Preserve unknown storage content as a conflict and return its exact revision. */
9
+ storeAsConflictedRevisionWithResult: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<string | false>;
10
+ /** Store a user deletion as a visible logical-deletion child of an exact revision. */
11
+ storeDeletionWithBaseRevision: (file: UXFileInfoStub | FilePathWithPrefix, baseRevision: string) => Promise<string | false>;
6
12
  storeContent(path: FilePathWithPrefix, content: string): Promise<boolean>;
7
13
  createChunks: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
8
14
  hasContentInRevisionHistory: (file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string) => Promise<boolean>;
15
+ /** Return every available revision whose content exactly matches the supplied bytes. */
16
+ findContentRevisions: (file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string) => Promise<string[]>;
9
17
  fetch: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean) => Promise<UXFileInfo | false>;
10
18
  fetchEntryFromMeta: (meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean) => Promise<LoadedEntry | false>;
11
19
  fetchEntryMeta: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string, skipCheck?: boolean) => Promise<MetaEntry | false>;
@@ -3,11 +3,27 @@ import type { UXFileInfo, UXFileInfoStub, UXInternalFileInfoStub } from "../comm
3
3
  export interface IFileHandler {
4
4
  readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise<UXFileInfo>;
5
5
  storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise<boolean>;
6
+ /**
7
+ * Store the current storage content as a child of an exact live database revision.
8
+ *
9
+ * This explicit branch-selection boundary is intended for user-directed
10
+ * reconciliation. It rechecks that the selected revision is still live,
11
+ * and records the resulting revision as the storage provenance. When
12
+ * `createIfDifferent` is false, differing storage content is rejected
13
+ * instead of creating a child revision.
14
+ */
15
+ storeFileToDBWithBaseRevision(info: UXFileInfoStub | UXFileInfo | FilePathWithPrefix, baseRevision: string, createIfDifferent?: boolean): Promise<boolean>;
6
16
  deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean>;
7
17
  renameFileInDB(info: UXFileInfoStub | UXFileInfo, oldPath: FilePath | FilePathWithPrefix): Promise<boolean>;
18
+ /**
19
+ * Create a logical deletion on one exact database revision.
20
+ *
21
+ * When device-local storage provenance names the deleted revision, the
22
+ * implementation clears that stale provenance without changing storage.
23
+ */
8
24
  deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise<boolean | undefined>;
9
25
  resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise<boolean | undefined>;
10
- dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | null, rev: string, force?: boolean): Promise<boolean>;
26
+ dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | FilePathWithPrefix | null, rev: string, force?: boolean): Promise<boolean>;
11
27
  dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise<boolean>;
12
28
  createAllChunks(showingNotice?: boolean): Promise<void>;
13
29
  }
@@ -0,0 +1,32 @@
1
+ import type { FilePathWithPrefix } from "../common/types.js";
2
+ import type { SimpleStore } from "../common/utils.js";
3
+ export type FileReflectionProvenanceRecord = {
4
+ /** Exact database revision which most recently produced the storage state. */
5
+ revision: string;
6
+ /** Raw modification time observed from this device's storage after reflection. */
7
+ observedStorageMtime?: number;
8
+ };
9
+ export interface FileReflectionProvenance {
10
+ get(path: FilePathWithPrefix): Promise<FileReflectionProvenanceRecord | undefined>;
11
+ set(path: FilePathWithPrefix, record: FileReflectionProvenanceRecord): Promise<void>;
12
+ delete(path: FilePathWithPrefix): Promise<void>;
13
+ move(from: FilePathWithPrefix, to: FilePathWithPrefix): Promise<void>;
14
+ }
15
+ /**
16
+ * Device-local provenance backed by an existing host-owned key-value store.
17
+ *
18
+ * The revision is authoritative. The raw storage mtime is diagnostic and may
19
+ * be used as a fast change hint, but it never proves content or branch identity.
20
+ * The host may construct this object before opening its store, but it must not
21
+ * invoke provenance operations until its normal storage lifecycle is ready.
22
+ * Store failures are reported to the caller rather than hidden by readiness
23
+ * waits, so lifecycle violations and reset races cannot hang file processing.
24
+ */
25
+ export declare class StoredFileReflectionProvenance implements FileReflectionProvenance {
26
+ private readonly store;
27
+ constructor(store: SimpleStore<FileReflectionProvenanceRecord>);
28
+ get(path: FilePathWithPrefix): Promise<FileReflectionProvenanceRecord | undefined>;
29
+ set(path: FilePathWithPrefix, record: FileReflectionProvenanceRecord): Promise<void>;
30
+ delete(path: FilePathWithPrefix): Promise<void>;
31
+ move(from: FilePathWithPrefix, to: FilePathWithPrefix): Promise<void>;
32
+ }
@@ -0,0 +1,25 @@
1
+ class StoredFileReflectionProvenance {
2
+ constructor(store) {
3
+ this.store = store;
4
+ }
5
+ store;
6
+ async get(path) {
7
+ return await this.store.get(path) ?? void 0;
8
+ }
9
+ async set(path, record) {
10
+ await this.store.set(path, record);
11
+ }
12
+ async delete(path) {
13
+ await this.store.delete(path);
14
+ }
15
+ async move(from, to) {
16
+ const record = await this.get(from);
17
+ if (record) {
18
+ await this.set(to, record);
19
+ }
20
+ await this.delete(from);
21
+ }
22
+ }
23
+ export {
24
+ StoredFileReflectionProvenance
25
+ };
@@ -15,6 +15,21 @@ import {
15
15
  isObjectMargeApplicable
16
16
  } from "../common/utils.js";
17
17
  import { isErrorOfMissingDoc } from "../pouchdb/utils_couchdb.js";
18
+ function revisionGeneration(revision) {
19
+ const generation = Number(revision.split("-", 1)[0]);
20
+ return Number.isFinite(generation) ? generation : Number.MAX_SAFE_INTEGER;
21
+ }
22
+ function compareConflictCandidates(left, right) {
23
+ const generationDifference = revisionGeneration(left.revision) - revisionGeneration(right.revision);
24
+ if (generationDifference !== 0) return generationDifference;
25
+ const leftMtime = left.leaf === false || !Number.isFinite(left.leaf.mtime) ? Number.NEGATIVE_INFINITY : left.leaf.mtime;
26
+ const rightMtime = right.leaf === false || !Number.isFinite(right.leaf.mtime) ? Number.NEGATIVE_INFINITY : right.leaf.mtime;
27
+ const mtimeDifference = leftMtime - rightMtime;
28
+ if (mtimeDifference !== 0) return mtimeDifference;
29
+ if (left.revision < right.revision) return -1;
30
+ if (left.revision > right.revision) return 1;
31
+ return 0;
32
+ }
18
33
  class ConflictManager {
19
34
  options;
20
35
  constructor(options) {
@@ -258,13 +273,22 @@ class ConflictManager {
258
273
  }
259
274
  async tryAutoMergeSensibly(path, test, conflicts) {
260
275
  const conflictedRev = conflicts[0];
261
- const conflictedRevNo = Number(conflictedRev.split("-")[0]);
262
- const revFrom = await this.database.get(await this.options.pathService.path2id(path), {
263
- revs_info: true
264
- });
265
- const commonBase = (revFrom._revs_info || []).filter(
266
- (e) => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo
267
- )?.[0]?.rev ?? "";
276
+ let commonBase = "";
277
+ try {
278
+ const documentId = await this.options.pathService.path2id(path);
279
+ const [currentBranch, conflictedBranch] = await Promise.all([
280
+ this.database.get(documentId, { rev: test._rev, revs_info: true }),
281
+ this.database.get(documentId, { rev: conflictedRev, revs_info: true })
282
+ ]);
283
+ const currentAvailable = new Set(
284
+ (currentBranch._revs_info || []).filter((revision) => revision.status === "available").map((revision) => revision.rev)
285
+ );
286
+ commonBase = (conflictedBranch._revs_info || []).filter((revision) => revision.status === "available" && currentAvailable.has(revision.rev)).sort((left, right) => Number(right.rev.split("-")[0]) - Number(left.rev.split("-")[0]))[0]?.rev ?? "";
287
+ } catch (ex) {
288
+ Logger(`Could not determine the common revision for ${path}`, LOG_LEVEL_VERBOSE);
289
+ Logger(ex, LOG_LEVEL_VERBOSE);
290
+ return false;
291
+ }
268
292
  let p = void 0;
269
293
  if (commonBase) {
270
294
  if (isSensibleMargeApplicable(path)) {
@@ -302,9 +326,18 @@ class ConflictManager {
302
326
  if (test == null) return { ok: MISSING_OR_ERROR };
303
327
  if (!test._conflicts) return { ok: NOT_CONFLICTED };
304
328
  if (test._conflicts.length == 0) return { ok: NOT_CONFLICTED };
305
- const conflicts = test._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0]));
329
+ const conflictCandidates = await Promise.all(
330
+ test._conflicts.map(
331
+ async (revision) => ({
332
+ revision,
333
+ leaf: await this.getConflictedDoc(path, revision)
334
+ })
335
+ )
336
+ );
337
+ conflictCandidates.sort(compareConflictCandidates);
338
+ const conflicts = conflictCandidates.map(({ revision }) => revision);
306
339
  const leftLeaf = await this.getConflictedDoc(path, test._rev);
307
- const rightLeaf = await this.getConflictedDoc(path, conflicts[0]);
340
+ const rightLeaf = conflictCandidates[0].leaf;
308
341
  if (leftLeaf !== false && rightLeaf !== false && leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted) {
309
342
  return { leftRev: test._rev, rightRev: conflicts[0], leftLeaf, rightLeaf };
310
343
  }
@@ -33,5 +33,6 @@ export declare class EntryManager {
33
33
  getDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, dump?: boolean, waitForReady?: boolean, includeDeleted?: boolean): Promise<false | LoadedEntry>;
34
34
  getDBEntryFromMeta(meta: LoadedEntry | MetaEntry, dump?: boolean, waitForReady?: boolean): Promise<false | LoadedEntry>;
35
35
  deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
36
+ storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
36
37
  putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
37
38
  }
@@ -8,7 +8,8 @@ import {
8
8
  getDBEntryMetaByPath,
9
9
  isTargetFile,
10
10
  prepareChunk,
11
- putDBEntry
11
+ putDBEntry,
12
+ storeDeletionByPathAtRevision
12
13
  } from "./EntryManagerImpls.js";
13
14
  class EntryManager {
14
15
  options;
@@ -64,6 +65,9 @@ class EntryManager {
64
65
  async deleteDBEntry(path, opt) {
65
66
  return await deleteDBEntryByPath(this.serviceHost, this, path, opt);
66
67
  }
68
+ async storeDeletionAtRevision(path, baseRevision) {
69
+ return await storeDeletionByPathAtRevision(this.serviceHost, this, path, baseRevision);
70
+ }
67
71
  async putDBEntry(note, onlyChunks, conflictBaseRev) {
68
72
  return await putDBEntry(this.serviceHost, this, note, onlyChunks, conflictBaseRev);
69
73
  }
@@ -71,4 +71,5 @@ export declare function getDBEntryByPath(host: NecessaryServicesInterfaces<"path
71
71
  datatype: import("../../common/types.js").EntryTypeNotes;
72
72
  } & PouchDB.Core.IdMeta)>;
73
73
  export declare function deleteDBEntryByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
74
+ export declare function storeDeletionByPathAtRevision(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<PouchDB.Core.Response | false>;
74
75
  export {};
@@ -484,6 +484,33 @@ async function deleteDBEntryByPath(host, { localDatabase }, path, opt) {
484
484
  throw ex;
485
485
  }
486
486
  }
487
+ async function storeDeletionByPathAtRevision(host, { localDatabase }, path, baseRevision) {
488
+ if (!isTargetFile(host, path)) {
489
+ return false;
490
+ }
491
+ const id = await host.services.path.path2id(path);
492
+ try {
493
+ return await serialized("file:" + path, async () => {
494
+ const obj = await localDatabase.get(id, {
495
+ rev: baseRevision
496
+ });
497
+ if (obj.type === "leaf") {
498
+ return false;
499
+ }
500
+ delete obj._deleted;
501
+ obj.deleted = true;
502
+ obj.mtime = Date.now();
503
+ const result = await localDatabase.put(obj, { force: true });
504
+ Logger(`Entry soft-deleted from revision: ${path} (${obj._id.substring(0, 8)}-${result.rev})`);
505
+ return result;
506
+ }) ?? false;
507
+ } catch (ex) {
508
+ if (isErrorOfMissingDoc(ex)) {
509
+ return false;
510
+ }
511
+ throw ex;
512
+ }
513
+ }
487
514
  export {
488
515
  canUseOnDemandChunking,
489
516
  computeChunkRetrievalMethod,
@@ -495,5 +522,6 @@ export {
495
522
  isLegacyNote,
496
523
  isTargetFile,
497
524
  prepareChunk,
498
- putDBEntry
525
+ putDBEntry,
526
+ storeDeletionByPathAtRevision
499
527
  };
@@ -190,6 +190,7 @@ export declare class LiveSyncLocalDB {
190
190
  getDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, dump?: boolean, waitForReady?: boolean, includeDeleted?: boolean): Promise<false | LoadedEntry>;
191
191
  getDBEntryFromMeta(meta: LoadedEntry | MetaEntry, dump?: boolean, waitForReady?: boolean): Promise<false | LoadedEntry>;
192
192
  deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
193
+ storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
193
194
  putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
194
195
  getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise<false | diff_result_leaf>;
195
196
  tryAutoMerge(path: FilePathWithPrefix, enableMarkdownAutoMerge: boolean): AutoMergeResult;
@@ -364,6 +364,9 @@ class LiveSyncLocalDB {
364
364
  async deleteDBEntry(path, opt) {
365
365
  return await this.managers.entryManager.deleteDBEntry(path, opt);
366
366
  }
367
+ async storeDeletionAtRevision(path, baseRevision) {
368
+ return await this.managers.entryManager.storeDeletionAtRevision(path, baseRevision);
369
+ }
367
370
  async putDBEntry(note, onlyChunks, conflictBaseRev) {
368
371
  return await this.managers.entryManager.putDBEntry(note, onlyChunks, conflictBaseRev);
369
372
  }
@@ -8,7 +8,7 @@ import {
8
8
  isMetaEntry
9
9
  } from "../common/types.js";
10
10
  import { compareMTime, isAnyNote } from "../common/utils.js";
11
- import { stripAllPrefixes } from "../string_and_binary/path.js";
11
+ import { shouldBeIgnored, stripAllPrefixes } from "../string_and_binary/path.js";
12
12
  import { createInstanceLogFunction } from "../services/lib/logUtils.js";
13
13
  import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "../common/models/shared.const.symbols.js";
14
14
  import { UnresolvedErrorManager } from "../services/base/UnresolvedErrorManager.js";
@@ -124,6 +124,7 @@ async function collectFilesOnStorage(host, settings, log) {
124
124
  const filesStorageSrc = await host.serviceModules.storageAccess.getFiles();
125
125
  const _filesStorage = [];
126
126
  for (const f of filesStorageSrc) {
127
+ if (shouldBeIgnored(f.path)) continue;
127
128
  if (await host.services.vault.isTargetFile(f.path)) {
128
129
  _filesStorage.push(f);
129
130
  }
@@ -147,7 +148,7 @@ async function collectDatabaseFiles(host, settings, log, showingNotice) {
147
148
  "syncAll"
148
149
  );
149
150
  const path = getPathFromEntry(host, doc);
150
- if (host.services.vault.isValidPath(path) && await host.services.vault.isTargetFile(path)) {
151
+ if (host.services.vault.isValidPath(path) && !shouldBeIgnored(path) && await host.services.vault.isTargetFile(path)) {
151
152
  if (!isMetaEntry(doc)) {
152
153
  log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
153
154
  continue;
@@ -65,11 +65,11 @@ export declare function activateRemoteConfiguration(settings: ObsidianLiveSyncSe
65
65
  */
66
66
  export declare function activateP2PRemoteConfiguration(settings: ObsidianLiveSyncSettings, id: string): ObsidianLiveSyncSettings | false;
67
67
  /**
68
- * Command: Switch Active Remote
68
+ * Command: Switch active connection
69
69
  */
70
70
  export declare function commandSwitchActiveRemote(host: RemoteConfigHost): Promise<void>;
71
71
  /**
72
- * Command: Replicate with specific remote
72
+ * Command: Sync with a saved connection
73
73
  */
74
74
  export declare function commandReplicateWithSpecificRemote(host: RemoteConfigHost): Promise<void>;
75
75
  /**
@@ -311,12 +311,12 @@ function useRemoteConfigurationMigration(host) {
311
311
  function useRemoteConfiguration(host) {
312
312
  host.services.API.addCommand({
313
313
  id: "livesync-switch-remote",
314
- name: "Switch Active Remote",
314
+ name: "Switch active connection",
315
315
  callback: () => commandSwitchActiveRemote(host)
316
316
  });
317
317
  host.services.API.addCommand({
318
318
  id: "livesync-replicate-with-specific",
319
- name: "Replicate with specific remote",
319
+ name: "Sync with a saved connection",
320
320
  callback: () => commandReplicateWithSpecificRemote(host)
321
321
  });
322
322
  return true;
@@ -26,10 +26,15 @@ 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
+ storeWithBaseRevision(file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean): Promise<string | false>;
29
30
  storeAsConflictedRevision(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<boolean>;
31
+ storeAsConflictedRevisionWithResult(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<string | false>;
32
+ storeDeletionWithBaseRevision(file: UXFileInfoStub | FilePathWithPrefix, baseRevision: string): Promise<string | false>;
30
33
  storeContent(path: FilePathWithPrefix, content: string): Promise<boolean>;
31
34
  private __store;
32
35
  private getParentRev;
36
+ private findContentRevisionsInternal;
37
+ findContentRevisions(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string): Promise<string[]>;
33
38
  hasContentInRevisionHistory(file: UXFileInfoStub | FilePathWithPrefix, content: string | string[] | Blob | ArrayBuffer, currentRev?: string): Promise<boolean>;
34
39
  getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise<string[]>;
35
40
  fetch(file: UXFileInfoStub | FilePathWithPrefix, rev?: string, waitForReady?: boolean, skipCheck?: boolean): Promise<UXFileInfo | false>;
@@ -47,18 +47,37 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
47
47
  }
48
48
  }
49
49
  async createChunks(file, force = false, skipCheck) {
50
- return await this.__store(file, force, skipCheck, true);
50
+ return await this.__store(file, force, skipCheck, true) !== false;
51
51
  }
52
52
  async store(file, force = false, skipCheck) {
53
- return await this.__store(file, force, skipCheck, false);
53
+ return await this.__store(file, force, skipCheck, false) !== false;
54
+ }
55
+ async storeWithBaseRevision(file, baseRevision, skipCheck) {
56
+ const result = await this.__store(file, true, skipCheck, false, baseRevision);
57
+ return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
54
58
  }
55
59
  async storeAsConflictedRevision(file, currentRev, skipCheck) {
60
+ return await this.storeAsConflictedRevisionWithResult(file, currentRev, skipCheck) !== false;
61
+ }
62
+ async storeAsConflictedRevisionWithResult(file, currentRev, skipCheck) {
56
63
  const conflictBaseRev = await this.getParentRev(file, currentRev);
57
64
  if (!conflictBaseRev) {
58
65
  this._log(`Could not find parent revision for ${file.path} (${currentRev})`, LOG_LEVEL_VERBOSE);
59
66
  return false;
60
67
  }
61
- return await this.__store(file, true, skipCheck, false, conflictBaseRev);
68
+ return await this.storeWithBaseRevision(file, conflictBaseRev, skipCheck);
69
+ }
70
+ async storeDeletionWithBaseRevision(file, baseRevision) {
71
+ if (!await this.checkIsTargetFile(file)) {
72
+ return false;
73
+ }
74
+ const fullPath = getDatabasePathFromUXFileInfo(file);
75
+ const result = await this.database.localDatabase.storeDeletionAtRevision(fullPath, baseRevision);
76
+ if (result === false) {
77
+ return false;
78
+ }
79
+ this.events.emitEvent(EVENT_FILE_SAVED);
80
+ return result.rev;
62
81
  }
63
82
  async storeContent(path, content) {
64
83
  const blob = createTextBlob(content);
@@ -76,7 +95,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
76
95
  body: blob,
77
96
  isInternal
78
97
  };
79
- return await this.__store(dummyUXFileInfo, true, false, false);
98
+ return await this.__store(dummyUXFileInfo, true, false, false) !== false;
80
99
  }
81
100
  async __store(file, force = false, skipCheck, onlyChunks, conflictBaseRev) {
82
101
  if (!skipCheck) {
@@ -169,7 +188,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
169
188
  this._log(msg + fullPath);
170
189
  this.events.emitEvent(EVENT_FILE_SAVED);
171
190
  }
172
- return ret != false;
191
+ return ret;
173
192
  }
174
193
  async getParentRev(file, rev) {
175
194
  const filename = getDatabasePathFromUXFileInfo(file);
@@ -189,10 +208,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
189
208
  return false;
190
209
  }
191
210
  }
192
- async hasContentInRevisionHistory(file, content, currentRev) {
193
- if (!await this.checkIsTargetFile(file)) {
194
- return true;
195
- }
211
+ async findContentRevisionsInternal(file, content, currentRev) {
196
212
  const filename = getDatabasePathFromUXFileInfo(file);
197
213
  try {
198
214
  const doc = await this.database.localDatabase.getDBEntryMeta(
@@ -201,24 +217,64 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
201
217
  true
202
218
  );
203
219
  if (doc === false) {
204
- return false;
220
+ return [];
205
221
  }
206
222
  const revisions = doc._revs_info;
207
223
  const availableRevs = new Set((revisions || []).filter((e) => e.status === "available").map((e) => e.rev));
208
224
  if (currentRev) {
209
225
  availableRevs.add(currentRev);
210
226
  }
227
+ const leaves = await this.database.localDatabase.getRaw(doc._id, {
228
+ open_revs: "all"
229
+ });
230
+ if (!Array.isArray(leaves)) {
231
+ return [];
232
+ }
233
+ for (const leaf of leaves) {
234
+ const leafRev = leaf.ok?._rev;
235
+ if (!leafRev) {
236
+ continue;
237
+ }
238
+ const branch = await this.database.localDatabase.getDBEntryMeta(
239
+ filename,
240
+ { rev: leafRev, revs_info: true },
241
+ true
242
+ );
243
+ if (branch === false) {
244
+ continue;
245
+ }
246
+ const branchRevisions = branch._revs_info;
247
+ for (const revision of branchRevisions || []) {
248
+ if (revision.status === "available") {
249
+ availableRevs.add(revision.rev);
250
+ }
251
+ }
252
+ }
253
+ const matchingRevisions = [];
211
254
  for (const rev of availableRevs) {
212
255
  const entry = await this.database.localDatabase.getDBEntry(filename, { rev }, false, true, true);
213
- if (entry !== false && await isDocContentSame(readContent(entry), content)) {
214
- return true;
256
+ if (entry !== false && !entry._deleted && await isDocContentSame(readContent(entry), content)) {
257
+ matchingRevisions.push(rev);
215
258
  }
216
259
  }
260
+ return matchingRevisions;
217
261
  } catch (ex) {
218
262
  this._log(`Could not check revision history for ${filename}`, LOG_LEVEL_VERBOSE);
219
263
  this._log(ex, LOG_LEVEL_VERBOSE);
220
264
  }
221
- return false;
265
+ return [];
266
+ }
267
+ async findContentRevisions(file, content, currentRev) {
268
+ if (!await this.checkIsTargetFile(file)) {
269
+ return [];
270
+ }
271
+ return await this.findContentRevisionsInternal(file, content, currentRev);
272
+ }
273
+ async hasContentInRevisionHistory(file, content, currentRev) {
274
+ if (!await this.checkIsTargetFile(file)) {
275
+ return true;
276
+ }
277
+ return (await this.findContentRevisionsInternal(file, content, currentRev)).length > 0;
222
278
  }
223
279
  async getConflictedRevs(file) {
224
280
  if (!await this.checkIsTargetFile(file)) {
@@ -11,6 +11,7 @@ import type { ConflictService } from "../services/base/ConflictService.js";
11
11
  import type { PathService } from "../services/base/PathService.js";
12
12
  import type { SettingService } from "../services/base/SettingService.js";
13
13
  import type { VaultService } from "../services/base/VaultService.js";
14
+ import type { FileReflectionProvenance } from "../interfaces/FileReflectionProvenance.js";
14
15
  export interface ServiceFileHandlerDependencies {
15
16
  events: LiveSyncEventHub;
16
17
  API: APIService;
@@ -22,6 +23,17 @@ export interface ServiceFileHandlerDependencies {
22
23
  path: PathService;
23
24
  setting: SettingService;
24
25
  vault: VaultService;
26
+ /**
27
+ * Device-local record of the exact database revision reflected in storage.
28
+ *
29
+ * This is optional for compatibility hosts. Maintained hosts should provide
30
+ * it so edits made while a document is conflicted extend the displayed
31
+ * branch instead of whichever branch PouchDB currently selects as winner.
32
+ * The host must finish opening its backing store before it dispatches file
33
+ * or replication events; provenance does not hide lifecycle violations by
34
+ * waiting for readiness.
35
+ */
36
+ fileReflectionProvenance?: FileReflectionProvenance;
25
37
  }
26
38
  export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<ServiceFileHandlerDependencies> implements IFileHandler {
27
39
  private events;
@@ -31,20 +43,29 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
31
43
  private path;
32
44
  private setting;
33
45
  private vault;
46
+ private fileReflectionProvenance?;
34
47
  constructor(services: ServiceFileHandlerDependencies);
35
48
  get db(): DatabaseFileAccess;
36
49
  get storage(): StorageAccess;
50
+ private getProvenance;
51
+ private setProvenance;
52
+ private deleteProvenance;
53
+ private findUniqueContentRevision;
54
+ private getProvenBaseRevision;
37
55
  getPath(entry: AnyEntry): FilePathWithPrefix;
38
56
  getPathWithoutPrefix(entry: AnyEntry): FilePathWithPrefix;
39
57
  readFileFromStub(file: UXFileInfoStub | UXFileInfo): Promise<UXFileInfo>;
40
58
  private infoToStub;
41
59
  storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force?: boolean, onlyChunks?: boolean): Promise<boolean>;
60
+ storeFileToDBWithBaseRevision(info: UXFileInfoStub | UXFileInfo | FilePathWithPrefix, baseRevision: string, createIfDifferent?: boolean): Promise<boolean>;
61
+ private storeFileToDBFromRevision;
42
62
  deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean>;
43
63
  renameFileInDB(info: UXFileInfoStub | UXFileInfo, oldPath: FilePath | FilePathWithPrefix): Promise<boolean>;
44
64
  deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise<boolean | undefined>;
45
65
  resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise<boolean | undefined>;
46
- dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | null, rev: string, force?: boolean): Promise<boolean>;
66
+ dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | FilePathWithPrefix | null, rev: string, force?: boolean): Promise<boolean>;
47
67
  dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise<boolean>;
68
+ private applyDatabaseEntryToStorage;
48
69
  private preserveUnsyncedStorageAsConflict;
49
70
  private _anyHandlerProcessesFileEvent;
50
71
  _anyProcessReplicatedDoc(entry: MetaEntry): Promise<boolean>;