@vrtmrz/livesync-commonlib 0.1.16 → 0.1.17

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.
@@ -4,6 +4,16 @@ export interface DatabaseFileAccess {
4
4
  store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
5
5
  /** Store a file as a child of an exact revision and return the created revision. */
6
6
  storeWithBaseRevision: (file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean) => Promise<string | false>;
7
+ /**
8
+ * Store a file as a child of an exact revision only while that revision remains a live leaf.
9
+ *
10
+ * Unlike {@link storeWithBaseRevision}, this method does not force a branch below an obsolete
11
+ * revision. 'Live leaf' means any current revision-tree leaf, including a non-winning conflict
12
+ * leaf or a logical-deletion leaf. In particular, it returns `false` when another writer has
13
+ * already advanced the supplied base; ordinary target and Chunk validation can also refuse the
14
+ * write without creating a Metadata successor.
15
+ */
16
+ storeWithLiveBaseRevision: (file: UXFileInfo, baseRevision: string, skipCheck?: boolean) => Promise<string | false>;
7
17
  storeAsConflictedRevision: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<boolean>;
8
18
  /** Preserve unknown storage content as a conflict and return its exact revision. */
9
19
  storeAsConflictedRevisionWithResult: (file: UXFileInfo, currentRev: string, skipCheck?: boolean) => Promise<string | false>;
@@ -35,4 +35,5 @@ export declare class EntryManager {
35
35
  deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
36
36
  storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
37
37
  putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
38
+ putDBEntryWithLiveBaseRevision(note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
38
39
  }
@@ -9,6 +9,7 @@ import {
9
9
  isTargetFile,
10
10
  prepareChunk,
11
11
  putDBEntry,
12
+ putDBEntryWithLiveBaseRevision,
12
13
  storeDeletionByPathAtRevision
13
14
  } from "./EntryManagerImpls.js";
14
15
  class EntryManager {
@@ -71,6 +72,9 @@ class EntryManager {
71
72
  async putDBEntry(note, onlyChunks, conflictBaseRev) {
72
73
  return await putDBEntry(this.serviceHost, this, note, onlyChunks, conflictBaseRev);
73
74
  }
75
+ async putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks) {
76
+ return await putDBEntryWithLiveBaseRevision(this.serviceHost, this, note, baseRevision, onlyChunks);
77
+ }
74
78
  }
75
79
  export {
76
80
  EntryManager
@@ -13,6 +13,13 @@ type Managers = {
13
13
  type NecessaryManagers<T extends keyof Managers> = Pick<Managers, T>;
14
14
  export declare function createChunks(managers: NecessaryManagers<"chunkManager" | "hashManager" | "splitter">, dispFilename: string, note: SavingEntry): Promise<false | DocumentID[]>;
15
15
  export declare function putDBEntry(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager" | "hashManager" | "splitter">, note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
16
+ /**
17
+ * Store an entry below an exact revision only while that revision remains a live leaf.
18
+ *
19
+ * PouchDB's ordinary MVCC write is the authority for this check. A 409 response means that the
20
+ * base has already been advanced, so the caller receives `false` and can retry from fresh state.
21
+ */
22
+ export declare function putDBEntryWithLiveBaseRevision(host: NecessaryServicesInterfaces<"path" | "setting", never>, managers: NecessaryManagers<"localDatabase" | "chunkManager" | "hashManager" | "splitter">, note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
16
23
  export declare function isTargetFile(host: NecessaryServicesInterfaces<"setting", never>, filenameSrc: string): boolean;
17
24
  export declare function prepareChunk({ chunkManager, hashManager }: NecessaryManagers<"chunkManager" | "hashManager">, piece: string): Promise<GeneratedChunk>;
18
25
  export declare function getDBEntryMetaByPath(host: NecessaryServicesInterfaces<"path" | "setting", never>, { localDatabase }: NecessaryManagers<"localDatabase">, path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions, includeDeleted?: boolean): Promise<false | LoadedEntry>;
@@ -110,6 +110,23 @@ async function createChunks(managers, dispFilename, note) {
110
110
  return chunks;
111
111
  }
112
112
  async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
113
+ const revisionTarget = conflictBaseRev ? { mode: "force-base", baseRevision: conflictBaseRev } : { mode: "latest" };
114
+ return await putDBEntryInternal(host, managers, note, onlyChunks, revisionTarget);
115
+ }
116
+ async function putDBEntryWithLiveBaseRevision(host, managers, note, baseRevision, onlyChunks) {
117
+ try {
118
+ return await putDBEntryInternal(host, managers, note, onlyChunks, {
119
+ mode: "live-base",
120
+ baseRevision
121
+ });
122
+ } catch (ex) {
123
+ if (ex && typeof ex === "object" && "status" in ex && ex.status === 409) {
124
+ return false;
125
+ }
126
+ throw ex;
127
+ }
128
+ }
129
+ async function putDBEntryInternal(host, managers, note, onlyChunks, revisionTarget) {
113
130
  const { localDatabase, chunkManager, splitter } = managers;
114
131
  const filename = host.services.path.id2path(note._id, note);
115
132
  const dispFilename = stripAllPrefixes(filename);
@@ -146,8 +163,8 @@ async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
146
163
  eden: {}
147
164
  };
148
165
  return await serialized("file:" + filename, async () => {
149
- if (conflictBaseRev) {
150
- newDoc._rev = conflictBaseRev;
166
+ if (revisionTarget.mode !== "latest") {
167
+ newDoc._rev = revisionTarget.baseRevision;
151
168
  } else {
152
169
  try {
153
170
  const old = await localDatabase.get(newDoc._id);
@@ -159,7 +176,7 @@ async function putDBEntry(host, managers, note, onlyChunks, conflictBaseRev) {
159
176
  }
160
177
  }
161
178
  }
162
- const r = await localDatabase.put(newDoc, { force: true });
179
+ const r = revisionTarget.mode === "live-base" ? await localDatabase.put(newDoc) : await localDatabase.put(newDoc, { force: true });
163
180
  if (r.ok) {
164
181
  return r;
165
182
  } else {
@@ -523,5 +540,6 @@ export {
523
540
  isTargetFile,
524
541
  prepareChunk,
525
542
  putDBEntry,
543
+ putDBEntryWithLiveBaseRevision,
526
544
  storeDeletionByPathAtRevision
527
545
  };
@@ -192,6 +192,7 @@ export declare class LiveSyncLocalDB {
192
192
  deleteDBEntry(path: FilePathWithPrefix | FilePath, opt?: PouchDB.Core.GetOptions): Promise<boolean>;
193
193
  storeDeletionAtRevision(path: FilePathWithPrefix | FilePath, baseRevision: string): Promise<false | PouchDB.Core.Response>;
194
194
  putDBEntry(note: SavingEntry, onlyChunks?: boolean, conflictBaseRev?: string): Promise<false | PouchDB.Core.Response>;
195
+ putDBEntryWithLiveBaseRevision(note: SavingEntry, baseRevision: string, onlyChunks?: boolean): Promise<false | PouchDB.Core.Response>;
195
196
  getConflictedDoc(path: FilePathWithPrefix, rev: string): Promise<false | diff_result_leaf>;
196
197
  tryAutoMerge(path: FilePathWithPrefix, enableMarkdownAutoMerge: boolean): AutoMergeResult;
197
198
  }
@@ -370,6 +370,9 @@ class LiveSyncLocalDB {
370
370
  async putDBEntry(note, onlyChunks, conflictBaseRev) {
371
371
  return await this.managers.entryManager.putDBEntry(note, onlyChunks, conflictBaseRev);
372
372
  }
373
+ async putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks) {
374
+ return await this.managers.entryManager.putDBEntryWithLiveBaseRevision(note, baseRevision, onlyChunks);
375
+ }
373
376
  async getConflictedDoc(path, rev) {
374
377
  return await this.managers.conflictManager.getConflictedDoc(path, rev);
375
378
  }
@@ -27,6 +27,7 @@ export declare class ServiceDatabaseFileAccessBase extends ServiceModuleBase<Ser
27
27
  createChunks(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
28
28
  store(file: UXFileInfo, force?: boolean, skipCheck?: boolean): Promise<boolean>;
29
29
  storeWithBaseRevision(file: UXFileInfo, baseRevision: string | undefined, skipCheck?: boolean): Promise<string | false>;
30
+ storeWithLiveBaseRevision(file: UXFileInfo, baseRevision: string, skipCheck?: boolean): Promise<string | false>;
30
31
  storeAsConflictedRevision(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<boolean>;
31
32
  storeAsConflictedRevisionWithResult(file: UXFileInfo, currentRev: string, skipCheck?: boolean): Promise<string | false>;
32
33
  storeDeletionWithBaseRevision(file: UXFileInfoStub | FilePathWithPrefix, baseRevision: string): Promise<string | false>;
@@ -53,7 +53,17 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
53
53
  return await this.__store(file, force, skipCheck, false) !== false;
54
54
  }
55
55
  async storeWithBaseRevision(file, baseRevision, skipCheck) {
56
- const result = await this.__store(file, true, skipCheck, false, baseRevision);
56
+ const result = await this.__store(file, true, skipCheck, false, {
57
+ mode: "force-base",
58
+ baseRevision
59
+ });
60
+ return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
61
+ }
62
+ async storeWithLiveBaseRevision(file, baseRevision, skipCheck) {
63
+ const result = await this.__store(file, true, skipCheck, false, {
64
+ mode: "live-base",
65
+ baseRevision
66
+ });
57
67
  return result !== null && typeof result === "object" && "rev" in result ? result.rev : false;
58
68
  }
59
69
  async storeAsConflictedRevision(file, currentRev, skipCheck) {
@@ -97,7 +107,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
97
107
  };
98
108
  return await this.__store(dummyUXFileInfo, true, false, false) !== false;
99
109
  }
100
- async __store(file, force = false, skipCheck, onlyChunks, conflictBaseRev) {
110
+ async __store(file, force = false, skipCheck, onlyChunks, revisionTarget = { mode: "default" }) {
101
111
  if (!skipCheck) {
102
112
  if (!await this.checkIsTargetFile(file)) {
103
113
  return true;
@@ -148,7 +158,7 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
148
158
  };
149
159
  const msg = `STORAGE -> DB (${datatype}) `;
150
160
  const isNotChanged = await serialized("file-" + fullPath, async () => {
151
- if (conflictBaseRev) {
161
+ if (revisionTarget.mode !== "default") {
152
162
  return false;
153
163
  }
154
164
  if (force) {
@@ -183,7 +193,15 @@ class ServiceDatabaseFileAccessBase extends ServiceModuleBase {
183
193
  this._log(msg + " Skip " + fullPath, LOG_LEVEL_VERBOSE);
184
194
  return true;
185
195
  }
186
- const ret = await this.database.localDatabase.putDBEntry(d, onlyChunks, conflictBaseRev);
196
+ const ret = revisionTarget.mode === "live-base" ? await this.database.localDatabase.putDBEntryWithLiveBaseRevision(
197
+ d,
198
+ revisionTarget.baseRevision,
199
+ onlyChunks
200
+ ) : await this.database.localDatabase.putDBEntry(
201
+ d,
202
+ onlyChunks,
203
+ revisionTarget.mode === "force-base" ? revisionTarget.baseRevision : void 0
204
+ );
187
205
  if (ret !== false) {
188
206
  this._log(msg + fullPath);
189
207
  this.events.emitEvent(EVENT_FILE_SAVED);
@@ -80,6 +80,8 @@ The record is updated only after a successful database-to-storage reflection or
80
80
 
81
81
  User-directed reconciliation may select any exact current live revision, including a conflict leaf rather than the deterministic winner. `dbToStorageWithSpecificRev()` rechecks that the selected revision is still live before reflecting its content. `storeFileToDBWithBaseRevision()` performs the same live check before storing current storage content as a child of that revision. If the content already matches the selected non-deleted revision, it records that revision directly without creating a child. A host can set `createIfDifferent` to `false` when it intends only to record an exact content match; differing content then fails instead of changing the revision tree. A host may also create a logical deletion on an explicitly selected branch with `deleteRevisionFromDB()` after performing its own current-live-revision check. This leaves storage unchanged and clears device-local provenance only when that record names the deleted revision.
82
82
 
83
+ `storeWithBaseRevision()` is the force-write primitive for deliberately preserving a branch below an exact revision, including a revision which is no longer a leaf. It must not be used when an operation means 'advance this revision only if it is still current'. For that compare-and-store boundary, `storeWithLiveBaseRevision()` uses PouchDB's ordinary revision check and returns `false` if the supplied base has already been advanced. Any current revision-tree leaf is eligible, including a non-winning conflict leaf or a logical-deletion leaf. The caller must refresh its state after refusal rather than silently falling back to the deterministic winner.
84
+
83
85
  A successful non-deleted reconciliation records the exact selected or newly created revision as provenance. Applying a logical deletion removes the storage item and its provenance record. Deletion provenance is not retained indefinitely: an absent storage item and a current logical-deletion winner already agree and need no further reconciliation.
84
86
 
85
87
  Hosts may construct the namespaced store handle during service composition, before its backing database is open. Store operations begin only after the host's storage lifecycle is ready. They fail rather than wait when that lifecycle contract is violated, because an implicit readiness wait could hang after failed initialisation or wait on its own initialisation handler. Database reset is a transient unavailable boundary; the host must avoid file processing during it and reconstruct derived provenance after reopening.
@@ -59,7 +59,7 @@ The multiple-remote profile contract has focused unit coverage in `src/serviceFe
59
59
 
60
60
  The full offline scanner applies both the host's target-file policy and Commonlib's built-in `shouldBeIgnored` policy before constructing storage/database pairs. This keeps legacy LiveSync log files and flag files outside ordinary create, restore, and delete decisions even when a host accepts their file extension. Lower-level write guards remain a defence for direct calls; they are not the normal full-scan filter. The complete pair-state decision matrix and database-to-storage reflection result contract are documented in [Full offline scan](offline-scanner.md). Extend that document, the Cartesian action test, and both collection-side tests whenever the policy changes.
61
61
 
62
- The revision-tree safety rules are documented in [Conflict resolution and file provenance](conflict-resolution.md). `ConflictManager.unit.spec.ts` owns common-ancestor and conservative merge behaviour, deterministic pair selection for multiple live leaves, and reconstruction from the committed tree between stages. `ServiceDatabaseFileAccessBase.unit.spec.ts` owns all-branch content discovery, short-circuiting history existence checks after an exact match, and exact-revision logical deletion. `ServiceFileHandlerBase.unit.spec.ts` owns the injected provenance policy for edit, deletion, and rename operations, together with user-selected reflection, child creation, strict matching, matching-provenance cleanup after exact branch deletion, and stale-revision refusal. `LiveSyncLocalDB.unit.spec.ts` owns chunk reachability across current winners, shared chunks, live conflicts, available merge ancestry, conflict resolution, and PouchDB replication of collection and recreation. Extend the relevant sides when changing conflict resolution, compaction handling, provenance, chunk collection, or database-to-storage overwrite protection.
62
+ The revision-tree safety rules are documented in [Conflict resolution and file provenance](conflict-resolution.md). `ConflictManager.unit.spec.ts` owns common-ancestor and conservative merge behaviour, deterministic pair selection for multiple live leaves, and reconstruction from the committed tree between stages. `EntryManagerImpls.unit.spec.ts` owns the distinction between force-writing below an exact revision and conditionally advancing a current live leaf through PouchDB's ordinary revision check. `ServiceDatabaseFileAccessBase.unit.spec.ts` owns all-branch content discovery, short-circuiting history existence checks after an exact match, exact-revision logical deletion, and the public live-base result and event boundary. `ServiceFileHandlerBase.unit.spec.ts` owns the injected provenance policy for edit, deletion, and rename operations, together with user-selected reflection, child creation, strict matching, matching-provenance cleanup after exact branch deletion, and stale-revision refusal. `LiveSyncLocalDB.unit.spec.ts` owns chunk reachability across current winners, shared chunks, live conflicts, available merge ancestry, conflict resolution, and PouchDB replication of collection and recreation. Extend the relevant sides when changing conflict resolution, compaction handling, provenance, chunk collection, or database-to-storage overwrite protection.
63
63
 
64
64
  `LiveSyncReplicator.unit.spec.ts` owns the CouchDB remote-chunk batch contract: a mixed `_all_docs` result must return each available document even when another keyed row reports an error. `ChunkFetcher.unit.spec.ts` owns the consumer behaviour: it writes valid returned chunks and emits `missingChunkRemote` only for requested IDs which are absent from the returned set. Extend both sides when changing remote chunk acquisition or missing-chunk delivery.
65
65
 
@@ -14,7 +14,7 @@ The consumer contract in `test/contracts/serviceContext.ts` checks shared result
14
14
 
15
15
  Commonlib supplies the two sleep preferences and preserves them through Setup URI encoding. Self-hosted LiveSync applies those values once at its injected bounded-activity runner, so remote operations and downloaded-document application share the same host-owned Wake Lock policy. Focused downstream tests cover the platform preference table and both activity categories; operating-system suspend behaviour remains real-platform integration evidence rather than a Commonlib guarantee.
16
16
 
17
- Commonlib's real-PouchDB and injected-boundary unit tests prove that conservative merge uses the nearest revision actually shared by both conflict leaves, multiple remaining leaves are selected in a deterministic order, each completed pair is reconstructed from the committed tree, storage content from a deleted losing branch remains recognisable after resolution, and conflict-time operations retain the exact displayed branch or preserve every branch when that identity cannot be proved. They also verify that a boolean history check stops after its first exact content match without reading an older unavailable revision, while revision enumeration still returns every match. A local edit remains a readable sibling branch when the winning body is unavailable but its exact revision and parent metadata remain, a generation-one revision without a parent is left unresolved, and a separately confirmed exact deletion of that unreadable live leaf permits a replacement to be stored without reading the missing chunk. Exact reconciliation tests cover reflecting a selected live leaf, storing Vault content as its child, recording a matching leaf without creating a revision, clearing matching provenance after exact branch deletion, and refusing to record a stale revision or a revision whose content differs. Self-hosted LiveSync supplies the host composition, persistent device-local provenance, and the per-revision repair interface. Its optional two-Vault scopes cover post-resolution propagation and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active. The conflict-operation scope passes in real Obsidian against the exact locally packed Commonlib artefact, including replicated parent revisions and preservation of the other live branches.
17
+ Commonlib's real-PouchDB and injected-boundary unit tests prove that conservative merge uses the nearest revision actually shared by both conflict leaves, multiple remaining leaves are selected in a deterministic order, each completed pair is reconstructed from the committed tree, storage content from a deleted losing branch remains recognisable after resolution, and conflict-time operations retain the exact displayed branch or preserve every branch when that identity cannot be proved. They also verify that a boolean history check stops after its first exact content match without reading an older unavailable revision, while revision enumeration still returns every match. A local edit remains a readable sibling branch when the winning body is unavailable but its exact revision and parent metadata remain, a generation-one revision without a parent is left unresolved, and a separately confirmed exact deletion of that unreadable live leaf permits a replacement to be stored without reading the missing chunk. Exact reconciliation tests cover reflecting a selected live leaf, storing Vault content as its child, recording a matching leaf without creating a revision, clearing matching provenance after exact branch deletion, and refusing to record a stale revision or a revision whose content differs. Separate live-base tests advance a selected conflict leaf or logical-deletion leaf through PouchDB's ordinary revision check, retain the other live leaf, and reject reuse of a stale base. Self-hosted LiveSync supplies the host composition, persistent device-local provenance, and the per-revision repair interface. Its optional two-Vault scopes cover post-resolution propagation and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active. The conflict-operation scope passes in real Obsidian against the exact locally packed Commonlib artefact, including replicated parent revisions and preservation of the other live branches. Its Document History scenario restores readable historical bytes as a new live successor and reflects that exact revision to the Vault.
18
18
 
19
19
  Commonlib's chunk-reachability tests use real PouchDB revision trees. They verify that an ordinary superseded revision releases its unique chunks, a chunk shared by current files remains reachable, live conflict branches and their nearest available shared ancestor remain protected, and losing-branch ancestry becomes eligible after resolution. They also replicate a collected chunk deletion to another PouchDB database and verify that writing the same content can recreate and replicate the content-addressed chunk. Self-hosted LiveSync owns the CouchDB-specific Garbage Collection V3 workflow, including device checks, confirmation, deletion, push-only replication, and remote compaction.
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",