@vrtmrz/livesync-commonlib 0.1.22 → 0.1.23

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.
@@ -53,6 +53,8 @@ export type FileEventArgs = {
53
53
  file: UXFileInfoStub | UXInternalFileInfoStub;
54
54
  cache?: CacheData;
55
55
  oldPath?: string;
56
+ /** The destination of a rename converted to DELETE after target filtering. */
57
+ renameTarget?: string;
56
58
  ctx?: unknown;
57
59
  };
58
60
  export type FileEventItem = {
@@ -166,6 +166,7 @@ class StorageEventManagerBase extends StorageEventManager {
166
166
  args: {
167
167
  file,
168
168
  oldPath: type === "RENAME" ? oldPath : void 0,
169
+ ...param.type === "RENAME" && type === "DELETE" ? { renameTarget: param.file.path } : {},
169
170
  cache,
170
171
  ctx
171
172
  },
@@ -1,6 +1,7 @@
1
1
  import { type UXFileInfoStub } from "../common/types.js";
2
2
  import { type LogFunction } from "../services/lib/logUtils.js";
3
3
  import type { NecessaryServices } from "../interfaces/ServiceModule.js";
4
+ import type { IVaultService } from "../services/base/IService.js";
4
5
  /**
5
6
  * This is a simple handler that accepts all files.
6
7
  */
@@ -8,7 +9,7 @@ export declare function isAcceptedAlwaysFactory(host: NecessaryServices<never, n
8
9
  /**
9
10
  * Check if a file is accepted based on filename duplication in the vault.
10
11
  */
11
- export declare function isAcceptedInFilenameDuplicationFactory(host: NecessaryServices<"vault" | "fileProcessing", "storageAccess">, log: LogFunction): (file: string | UXFileInfoStub) => Promise<boolean>;
12
+ export declare function isAcceptedInFilenameDuplicationFactory(host: NecessaryServices<"vault" | "fileProcessing", "storageAccess">, log: LogFunction): (file: string | UXFileInfoStub, options?: Parameters<IVaultService["isTargetFile"]>[1]) => Promise<boolean>;
12
13
  /**
13
14
  * Check if a file is accepted by the local database (e.g., not rejected by the local DB's target file check).
14
15
  * Local database responsible for non-internal files, syncOnlyRegEx, syncIgnoreRegEx
@@ -35,7 +35,8 @@ function isAcceptedInFilenameDuplicationFactory(host, log) {
35
35
  return true;
36
36
  }
37
37
  });
38
- return async function isAcceptedInFilenameDuplication(file) {
38
+ return async function isAcceptedInFilenameDuplication(file, options) {
39
+ if (options?.skipCaseCollisionCheck) return true;
39
40
  const fileCountMap = (await fileCountMapComputed.update(host.services.fileProcessing.totalStorageFileEventCount)).value;
40
41
  const filepath = getStoragePathFromUXFileInfo(file);
41
42
  const lc = filepath.toLowerCase();
@@ -68,6 +68,7 @@ export declare abstract class ServiceFileHandlerBase extends ServiceModuleBase<S
68
68
  private applyDatabaseEntryToStorage;
69
69
  private preserveUnsyncedStorageAsConflict;
70
70
  private _anyHandlerProcessesFileEvent;
71
+ private canRecordStorageDeletion;
71
72
  private serializedByFileEventPaths;
72
73
  /**
73
74
  * Revalidate a persisted storage operation against the current storage state.
@@ -657,13 +657,15 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
657
657
  this._log(`Rename event for ${path} has no source path`, LOG_LEVEL_VERBOSE);
658
658
  return false;
659
659
  }
660
- const eventPaths = type === "RENAME" ? [path, eventItem.oldPath] : [path];
660
+ const relatedPath = type === "RENAME" ? eventItem.oldPath : type === "DELETE" ? eventItem.renameTarget : void 0;
661
+ const eventPaths = relatedPath === void 0 ? [path] : [path, relatedPath];
661
662
  return await this.serializedByFileEventPaths(eventPaths, async () => {
662
663
  switch (type) {
663
664
  case "CREATE":
664
665
  case "CHANGED":
665
666
  return await this.storeFileToDB(item.args.file);
666
667
  case "DELETE":
668
+ if (!await this.canRecordStorageDeletion(item)) return true;
667
669
  return await this.deleteFileFromDB(item.args.file);
668
670
  case "RENAME":
669
671
  return await this.renameFileInDB(
@@ -678,6 +680,45 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
678
680
  }
679
681
  });
680
682
  }
683
+ async canRecordStorageDeletion(item) {
684
+ const path = item.args.file.path;
685
+ try {
686
+ const current = await this.storage.getStub(path);
687
+ let excludedRenameTarget = null;
688
+ if (item.args.renameTarget !== void 0) {
689
+ const targetPath = item.args.renameTarget;
690
+ const targetItem = await this.storage.getStub(targetPath);
691
+ const target = this.getExactCurrentFile(targetItem, targetPath);
692
+ if (targetItem !== null && !target) {
693
+ this._log(`Preserved ${path}: the rename target no longer resolves to ${targetPath}`, LOG_LEVEL_NOTICE);
694
+ return false;
695
+ }
696
+ const targetIsSelected = !shouldBeIgnored(targetPath) && await this.vault.isTargetFile(targetPath, { skipCaseCollisionCheck: true }) && !(target && this.vault.isFileSizeTooLarge(target.stat.size));
697
+ if (targetIsSelected) {
698
+ this._log(`Preserved ${path}: rename target ${targetPath} is still selected`, LOG_LEVEL_NOTICE);
699
+ return false;
700
+ }
701
+ excludedRenameTarget = target;
702
+ }
703
+ if (current === null) return true;
704
+ if (!isFolderInfo(current)) {
705
+ const [currentId, deletedId] = await Promise.all([
706
+ this.path.path2id(current.path),
707
+ this.path.path2id(path)
708
+ ]);
709
+ if (currentId !== deletedId) return true;
710
+ if (excludedRenameTarget && this.storage.normalisePath(current.path) !== this.storage.normalisePath(path) && this.storage.normalisePath(current.path) === this.storage.normalisePath(excludedRenameTarget.path)) {
711
+ return true;
712
+ }
713
+ }
714
+ this._log(`Preserved ${path}: a current storage item contradicts its deletion event`, LOG_LEVEL_NOTICE);
715
+ return false;
716
+ } catch (ex) {
717
+ this._log(`Could not validate deletion of ${path}; preserved the database entry`, LOG_LEVEL_NOTICE);
718
+ this._log(ex, LOG_LEVEL_VERBOSE);
719
+ return false;
720
+ }
721
+ }
681
722
  async serializedByFileEventPaths(eventPaths, callback) {
682
723
  const documentIds = await Promise.all(eventPaths.map((eventPath) => this.path.path2id(eventPath)));
683
724
  const lockKeys = [...new Set(documentIds)].sort().map((documentId) => `processFileEvent-${documentId}`);
@@ -702,7 +743,8 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
702
743
  this._log(`Rename event for ${path} has no source path`, LOG_LEVEL_VERBOSE);
703
744
  return true;
704
745
  }
705
- const eventPaths = type === "RENAME" ? [path, eventItem.oldPath] : [path];
746
+ const relatedPath = type === "RENAME" ? eventItem.oldPath : type === "DELETE" ? eventItem.renameTarget : void 0;
747
+ const eventPaths = relatedPath === void 0 ? [path] : [path, relatedPath];
706
748
  return await this.serializedByFileEventPaths(eventPaths, async (isSameDocument) => {
707
749
  let action;
708
750
  try {
@@ -732,6 +774,9 @@ class ServiceFileHandlerBase extends ServiceModuleBase {
732
774
  return current && await this.isCurrentFileSelected(current) ? { kind: "store", file: current } : { kind: "none" };
733
775
  }
734
776
  case "DELETE": {
777
+ if (item.args.renameTarget !== void 0) {
778
+ return await this.canRecordStorageDeletion(item) && await this.canApplyRestoredDeletion(path) ? { kind: "delete", path } : { kind: "none" };
779
+ }
735
780
  const current = await this.storage.getStub(path);
736
781
  return current === null && await this.canApplyRestoredDeletion(path) ? { kind: "delete", path } : { kind: "none" };
737
782
  }
@@ -351,7 +351,11 @@ export interface IVaultService {
351
351
  getVaultName(): string;
352
352
  scanVault(showingNotice?: boolean, ignoreSuspending?: boolean, continueOnFileFailure?: boolean): Promise<VaultScanResult>;
353
353
  isIgnoredByIgnoreFile(file: string | UXFileInfoStub): Promise<boolean>;
354
- isTargetFile(file: string | UXFileInfoStub): Promise<boolean>;
354
+ isTargetFile(file: string | UXFileInfoStub,
355
+ /** Inspect selection policy without treating a filename collision as an exclusion. */
356
+ options?: {
357
+ skipCaseCollisionCheck?: boolean;
358
+ }): Promise<boolean>;
355
359
  isTargetFileInExtra(file: string | UXFileInfoStub): Promise<boolean>;
356
360
  isFileSizeTooLarge(size: number): boolean;
357
361
  getActiveFilePath(): FilePath | undefined;
@@ -35,9 +35,11 @@ export declare abstract class VaultService<T extends ServiceContext = ServiceCon
35
35
  /**
36
36
  * Check if a file is a target file for synchronisation.
37
37
  * @param file The file path or file info stub to check.
38
- * @param keepFileCheckList Whether to keep the file in the check list.
38
+ * @param options Optional selection inspection without filename collision rejection.
39
39
  */
40
- readonly isTargetFile: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(file: string | import("../../common/types.js").UXFileInfoStub) => Promise<boolean>>;
40
+ readonly isTargetFile: import("../lib/HandlerUtils.js").BooleanMultipleHandlerFunction<(file: string | import("../../common/types.js").UXFileInfoStub, options?: {
41
+ skipCaseCollisionCheck?: boolean;
42
+ }) => Promise<boolean>>;
41
43
  /**
42
44
  * Check if a file is a target file for some extra feature
43
45
  */
@@ -37,7 +37,7 @@ class VaultService extends ServiceBase {
37
37
  /**
38
38
  * Check if a file is a target file for synchronisation.
39
39
  * @param file The file path or file info stub to check.
40
- * @param keepFileCheckList Whether to keep the file in the check list.
40
+ * @param options Optional selection inspection without filename collision rejection.
41
41
  */
42
42
  isTargetFile = handlers().bailFirstFailure("isTargetFile");
43
43
  /**
@@ -83,6 +83,8 @@ The multiple-remote profile contract has focused unit coverage in `src/serviceFe
83
83
 
84
84
  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.
85
85
 
86
+ The unreleased watcher deletion checks and the separate database-to-storage reflection path are recorded in [Storage events and database-to-storage reflection](storage-events-and-reflection.md).
87
+
86
88
  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.
87
89
 
88
90
  `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.
@@ -0,0 +1,159 @@
1
+ ---
2
+ date: 2026-09-04
3
+ commonlib-version: "0.1.21"
4
+ self-hosted-livesync-version: "1.0.24"
5
+ status: unreleased
6
+ ---
7
+
8
+ # Full offline scan
9
+
10
+ This document defines the developer contract for `synchroniseAllFilesBetweenDBandStorage`. It describes how the scanner classifies each selected path, chooses an action, and records a database-to-storage reflection. Host-specific setup dialogue and recovery choices remain outside this contract.
11
+
12
+ Before applying the host's target-file policy, the scanner validates each decoded Metadata document against its actual local document ID. Consistent internal, customisation, and plug-in storage namespaces remain owned by their dedicated features. A namespace disagreement, or a normal-file ID which does not match the ID derived from its recorded path, is quarantined.
13
+
14
+ The malformed Metadata entry never enters pair processing. If consistent, selected Metadata represents the same case-normalised path, that entry and its storage file continue through the established path-based flow. Otherwise, the storage path is also quarantined. The scanner performs no storage write, database deletion, or last-seen update for an unresolved path, and it retains expired logical deletion history whose identity is inconsistent. This does not add a fourth pair result: quarantined entries do not enter pair processing, and the complete-scan result retains its meaning.
15
+
16
+ `inspectMetadataDocumentIdentities` provides the separate read-only, actual-ID-first report used by a host repair interface. `repairMetadataDocumentIdentity` revalidates one exact source revision, clears its last-seen state, writes and verifies the expected target, and only then tombstones the obsolete ID. It does not provide batch repair. An exact target left by an interrupted attempt permits the same one-entry repair to finish safely.
17
+
18
+ The tables below cover only paths which remain after identity validation, the host's target-file policy, and Commonlib's built-in exclusions.
19
+
20
+ ## Full-scan lifecycle
21
+
22
+ `performFullScan` owns the lifecycle around the pair processor:
23
+
24
+ 1. confirm that settings permit the scan;
25
+ 2. read the scanner's `initialized` marker;
26
+ 3. when the marker is present, load and complete the stored storage-event operations from the previous runtime;
27
+ 4. process expired logical deletion history;
28
+ 5. collect and process the selected storage and database entries;
29
+ 6. after the first permitted invocation reaches its aggregate-result boundary, record `initialized = true`; and
30
+ 7. return the aggregate pair result under the caller's selected handling of file failures.
31
+
32
+ The `initialized` marker records that a full-scan invocation has reached its aggregate-result boundary and that later invocations may restore stored storage-event operations. It is not a successful-scan marker: the first invocation records it after an aggregate `false` result, `true`, or an accepted partial result. An exception before the aggregate result is produced does not reach that write.
33
+
34
+ Storage-event restoration is a bounded start-up recovery step. Restored events bypass their former batch delay, but `restoreState()` waits for their actual file operations to finish and preserves snapshot sentinel ordering before the file collections are compared. Saved events run first because a deletion or rename can carry operation intent which is no longer visible in the current storage listing. An individual restored operation failure is logged and does not prevent the scan from examining the resulting current state. The following full scan then makes the final reconciliation decision.
35
+
36
+ This ordering does not mean that storage unconditionally overwrites the replay result. The action matrix remains authoritative: when a replayed operation leaves an ambiguous pair, such as a storage file alongside a deleted database entry, the selected policy may deliberately return `skipped` rather than guess which side is intended. This boundary does not include events which are buffered or received later, and it must not be confused with a host's separate replication-result queue.
37
+
38
+ ### Stored event interpretation
39
+
40
+ The storage-event snapshot contains operations which the previous runtime observed but had not begun or which were still waiting for their batch boundary. It is not a file-content journal and does not discover changes made while the application was stopped. An operation which had already been removed from the queue and entered file processing is also outside the persisted boundary. The following full scan remains responsible for observing the current storage state across all of these gaps.
41
+
42
+ A restored event is an operation intent which must be revalidated against the current exact storage path before it can cause a database side effect. Its saved file stub, timestamps, and optional editor cache describe the earlier observation; they are not authoritative file content after a restart. Whenever an inclusion is still applicable, the handler reads a fresh stub and content from current storage. Whenever an ordinary deletion's expected absence is contradicted by a current storage item, that deletion is suppressed and the full scan evaluates that current item instead.
43
+
44
+ For this check, an exact path means that the canonical path returned by storage has the saved spelling after ordinary separator normalisation. A file returned only through a case-insensitive lookup does not prove the saved inclusion path. A same-ID rename remains the explicit case-change path described below.
45
+
46
+ The standard-file revalidation contract is:
47
+
48
+ | Saved operation | Current exact storage state | Operation admitted before the full scan |
49
+ | --------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
50
+ | `CREATE` or `CHANGED` | The path is still a selected regular file | Store the current stub and content; do not use saved content as a historical version |
51
+ | `CREATE` or `CHANGED` | The path is absent, no longer selected, or no longer a regular file | Do not replay the inclusion |
52
+ | `DELETE` without `renameTarget` | The selected path remains absent | Apply the saved path's deletion intent |
53
+ | `DELETE` without `renameTarget` | Any current storage item occupies the path | Suppress the stale deletion |
54
+ | `DELETE` retaining `renameTarget` | Current source selection and rename-exclusion checks permit deletion | Apply the old document's deletion, including a deliberately excluded same-ID destination |
55
+ | `RENAME` between distinct document IDs | The new selected path exists | Admit the inclusion half using the new path's current stub and content |
56
+ | `RENAME` between distinct document IDs | The old selected path is absent | Admit the deletion half for the old path |
57
+ | `RENAME` between distinct document IDs | Either half is contradicted by current storage | Suppress only that half; the other independently validated half may proceed |
58
+ | Case-only or otherwise same-ID `RENAME` | A current selected file resolves to the renamed canonical path | Update that single document's path and current content without a separate deletion |
59
+ | Case-only or otherwise same-ID `RENAME` | The current identity does not support the saved rename | Do not replay the rename; let the full scan evaluate the current path |
60
+
61
+ An inability to inspect the current path is not proof of absence. Revalidation therefore suppresses a destructive half when storage inspection fails, records the failure, and leaves the following scan to report whether reconciliation can proceed. Target-file policy and file-size limits are evaluated from current settings rather than inherited from the saved event.
62
+
63
+ The unreleased rename-exclusion guard retains `renameTarget` when a rename is converted to deletion during target filtering. Unlike an ordinary deletion, this intent can remove a file from synchronisation without removing it from storage. The guard distinguishes deliberate exclusion from transient case-collision rejection, preserves a recreated source, and permits a deliberately excluded same-ID destination without requiring that destination to disappear. Older snapshots without this optional field retain the absence-only rule. See [Storage events and database-to-storage reflection](storage-events-and-reflection.md) for the exact admission conditions and the separate ordinary watcher boundary.
64
+
65
+ Independent validation of rename halves does not remove target-first execution. When the new path currently exists, its inclusion must finish successfully before the old-path deletion may run. A failed inclusion preserves the old database entry. When the new path is currently absent, there is no inclusion to publish, and a separately validated absence at the old path may be applied as deletion intent.
66
+
67
+ `INTERNAL` operations remain owned by the host feature which registered the optional-file handler. That owner must read its current internal storage state; the ordinary file-pair scan does not make a recovery guarantee for an internal path. `SENTINEL_FLUSH` carries ordering only and never represents file state.
68
+
69
+ The full scan is not a transaction. Expired deletion history may be updated before a later pair fails, and the device-local last-seen map is saved asynchronously after pair processing. The aggregate result does not promise rollback of those earlier side effects or durable completion of that deferred map save.
70
+
71
+ ## Pair states
72
+
73
+ | State | Storage | Local database |
74
+ | ----------------- | ------------------- | -------------------------------- |
75
+ | `both` | File exists | Live metadata entry exists |
76
+ | `storage-only` | File exists | No metadata entry exists |
77
+ | `db-only` | File does not exist | Live metadata entry exists |
78
+ | `both-db-deleted` | File exists | Entry records a logical deletion |
79
+ | `db-only-deleted` | File does not exist | Entry records a logical deletion |
80
+
81
+ A metadata entry with live conflicts is skipped before the ordinary action is selected. It must not be applied to storage by a full scan.
82
+
83
+ ## Option predicates
84
+
85
+ The action matrix uses these predicates:
86
+
87
+ - `delete-when-remote-missing` is enabled by `ExtraOnRemote.DELETE_LOCAL_MISSING` or `ExtraOnLocal.DELETE_DB_MISSING`;
88
+ - `delete-when-remote-deleted` is enabled by `ExtraOnRemote.DELETE_LOCAL_MISSING`, `ExtraOnLocal.DELETE_DB_DELETED`, or `ExtraOnLocal.DELETE_DB_MISSING`; and
89
+ - `append-storage-only` is enabled by `ExtraOnLocal.APPEND_STORAGE_ONLY`.
90
+
91
+ These names explain the table only. The source constants remain the API.
92
+
93
+ ## Action matrix
94
+
95
+ | Pair state | `DB_APPLY` | `NEWER_WINS` |
96
+ | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
97
+ | `both` | `update-storage` | `sync-newer` |
98
+ | `storage-only` | `delete-local` when `delete-when-remote-missing`; otherwise `skip` | `delete-local` when `delete-when-remote-missing`; otherwise `update-db` |
99
+ | `db-only` | `update-storage` | `update-storage`, subject to the device-local offline-deletion override described below |
100
+ | `both-db-deleted` | `delete-local` when `delete-when-remote-deleted`; otherwise `skip` | `delete-local` when `delete-when-remote-deleted`; otherwise `update-db` when `append-storage-only`, or `skip` |
101
+ | `db-only-deleted` | `skip` | `skip` |
102
+
103
+ `NEWER_WINS` changes a `db-only` action to `delete-db` only when the device-local last-seen map proves that the file existed on an earlier scan and that observed storage mtime is not older than the database mtime under `compareMTime`. This represents a file deleted while the scanner was offline. A database mtime copied from a failed reflection is not such evidence.
104
+
105
+ The Cartesian `resolveFilePairAction` unit test is the executable counterpart of this matrix. Change the implementation, table, and test together.
106
+
107
+ ## Pair processing result
108
+
109
+ Each selected pair finishes in one of three states:
110
+
111
+ | Result | Meaning | Complete-scan result |
112
+ | ----------- | ------------------------------------------------------------------------------------- | -------------------- |
113
+ | `completed` | The selected action completed, or storage and the database were already equal | Success |
114
+ | `skipped` | Scanner policy deliberately left the pair unchanged | Success |
115
+ | `failed` | The selected action could not complete, returned a failure result, or raised an error | Failure |
116
+
117
+ Conflict guards, size limits, and the explicit `skip` action produce `skipped`. They allow the scan to finish successfully, but do not establish that database content was reflected to storage. In particular, skipping a `db-only` entry must not copy its database mtime into the device-local last-seen map.
118
+
119
+ An actual storage file remains valid evidence of its own observed mtime even when another action for that pair is skipped. This is distinct from recording the database mtime after a successful database-to-storage reflection.
120
+
121
+ ## Database-to-storage reflection result
122
+
123
+ When `update-storage` invokes `dbToStorage`, its result controls every success side effect:
124
+
125
+ | `dbToStorage` outcome | Pair result | Success event and log | Last-seen entry for the database mtime | Later `NEWER_WINS` scan |
126
+ | --------------------- | ----------- | --------------------- | -------------------------------------- | ------------------------------------------------- |
127
+ | `true` | `completed` | Emitted | Recorded | May recognise a later absence as a local deletion |
128
+ | `false` | `failed` | Not emitted | Not recorded | Retries `update-storage` while the file is absent |
129
+ | Throws | `failed` | Not emitted | Not recorded | Retries after the failed scan can be run again |
130
+
131
+ The same result applies when `sync-newer` finds an existing storage file and attempts to reflect a newer database entry. A `false` result must not be converted into `completed`, emit a success event, or replace the observed storage mtime with the database mtime.
132
+
133
+ By default, the complete scan returns `false` when any pair is `failed`, and returns `true` when every pair is either `completed` or `skipped`. Quarantined entries do not enter this aggregate, so a strict `true` means that no selected pair failed rather than that every discovered entry was resolved or that storage and the database fully converge.
134
+
135
+ `continueOnFileFailure` is an explicit host completion policy. When it is `true`, the scanner still processes every selected pair, logs each failed path at verbose level, preserves every failed pair and its retry state, and then returns `completed-with-file-failures` from the aggregate boundary. The existing completion log records the total failed count. The option does not turn a failed pair into `completed`, create success events or last-seen evidence, bypass a rejected scan precondition, or accept an exception raised before the aggregate boundary.
136
+
137
+ `VaultScanResult` therefore has three outcomes: `false` for a rejected or strict failure, `true` for a complete scan without pair failures, and `completed-with-file-failures` when the caller explicitly accepts readiness after one or more pair failures. The non-boolean outcome lets the host present an actionable warning without making Commonlib responsible for user-facing notices.
138
+
139
+ Self-hosted LiveSync enables this policy only for ordinary Obsidian start-up, where one unavailable path must not prevent unaffected files from synchronising. Fast Setup, Fetch, Rebuild, direct scans, and CLI mirror or daemon paths retain the strict default.
140
+
141
+ ## Result-state verification
142
+
143
+ Focused two-pass coverage starts with a `DB_APPLY` reflection whose file handler returns `false`, persists the resulting scanner state, and then runs `NEWER_WINS`. It verifies that:
144
+
145
+ - both scans report failure;
146
+ - the failed path is retried through `dbToStorage`;
147
+ - no success event or success log is emitted;
148
+ - the database mtime does not enter the last-seen map; and
149
+ - `deleteFileFromDB` is not called.
150
+
151
+ Full-scan coverage also verifies that the strict default returns `false`, an ordinary-start-up scan which explicitly continues returns `completed-with-file-failures` while logging the affected path at verbose level, and a later clean scan returns `true`.
152
+
153
+ A second two-pass case starts with a `db-only` entry skipped by the size limit. It then removes that limit and verifies that the entry is reflected to storage rather than misclassified as an offline local deletion. Coverage for an existing storage file verifies that a failed `sync-newer` reflection retains the observed storage mtime, and full-scan coverage verifies that the aggregate failure reaches its caller after scanner initialisation completes.
154
+
155
+ Identity coverage verifies early quarantine when no consistent target exists, continued processing when consistent Metadata represents the same logical path, expired-deletion retention, special-namespace routing, actual-ID-first inspection, stale and ambiguous repair rejection, target-first ordering, exact-target retry, source preservation after failure, and last-seen clearing.
156
+
157
+ Stored-event coverage verifies that current storage content replaces saved observations, a recreated path suppresses a stale deletion, distinct-ID rename halves are admitted independently from current path state, and a same-ID rename never performs a separate deletion. It also verifies that a storage-inspection failure does not become evidence for a destructive operation and that an individual replay failure does not prevent the full scan from running.
158
+
159
+ Maintain that interaction boundary when changing file-handler results, last-seen persistence, Fast Setup reflection, or offline deletion detection.
@@ -40,6 +40,10 @@ Commonlib's confirmation contract can also convey a host-neutral vertical action
40
40
 
41
41
  Self-hosted LiveSync's Obsidian and browser dialogue hosts now share an application-owned `SvelteDialogSession` lifecycle. They continue to consume Commonlib's dialogue types and manager base, but no maintained LiveSync path uses `SvelteDialogMixIn`. The dynamic-inheritance helper remains available through the compatibility boundary and is deprecated in favour of host-owned composition.
42
42
 
43
+ Commonlib's storage-event tests verify that a current file with the same canonical document ID suppresses a watcher deletion, while observed absence still permits deletion and inspection failure preserves Metadata. Rename-derived deletions retain their destination and distinguish deliberate target exclusion from transient filename collision rejection, including after snapshot restoration. A separate reflection test preserves incoming logical-deletion behaviour. These injected-boundary tests establish the [storage-event admission contract](storage-events-and-reflection.md), not the native event ordering of an external folder rename in a particular Obsidian version.
44
+
45
+ The downstream `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION` scenario additionally exercises an external parent-directory case change in real Linux Obsidian 1.12.7. With released Commonlib 0.1.21, native `create` events for the new paths followed by `delete` events for the old paths produce logically deleted Metadata. The `delete` notification carries no `renameTarget`; the guard preserves current same-ID storage without inferring a causal link from the earlier `create`. With the implementation included in Commonlib 0.1.23 installed from its checked package artefact, the same sequence preserves Metadata, Chunks, and unchanged content locally, in CouchDB, on a second Vault, and after restart. This establishes the tested build's deletion protection, not full directory rename support or confirmation in the reporter's Obsidian 1.13.7 environment.
46
+
43
47
  ## CLI
44
48
 
45
49
  The CLI extends the same neutral context with its database path and injected `StandardIo` in `src/apps/cli/services/NodeServiceContext.ts`. Its entry point creates the Node standard-I/O adapter once and passes it to command composition, while unit tests provide stream-independent fakes for parsing, prompts, standard output, and standard error.
@@ -0,0 +1,107 @@
1
+ ---
2
+ date: 2026-09-05
3
+ commonlib-version: "0.1.23"
4
+ self-hosted-livesync-version: "1.0.25"
5
+ ---
6
+
7
+ # Storage events and database-to-storage reflection
8
+
9
+ This note separates storage-to-database event handling from
10
+ database-to-storage reflection. It describes the Commonlib 0.1.23 guard against
11
+ a stale `DELETE` notification logically deleting Metadata while the same file
12
+ is still present in storage. The version fields identify the reviewed source
13
+ versions.
14
+
15
+ ## Processing directions
16
+
17
+ `StorageEventManager.appendQueue` receives watcher events, applies target and
18
+ ignore policies, and queues `CREATE`, `CHANGED`, `DELETE`, or `RENAME` work. A
19
+ rename from a selected path to a rejected path becomes a `DELETE` with an
20
+ old-path deleted stub and the destination in `FileEventArgs.renameTarget`.
21
+ A rename from an excluded path to a selected path becomes a `CREATE`.
22
+
23
+ `renameTarget` is recorded only when one incoming `RENAME` is converted in
24
+ this way. It is not inferred by pairing independent `CREATE` and `DELETE`
25
+ notifications. A `DELETE` without it cannot establish that a current same-ID
26
+ file is the deliberately excluded destination of a rename. The ordinary
27
+ current-storage check therefore suppresses that deletion, even when the
28
+ current file's path would itself be excluded from synchronisation.
29
+
30
+ `ServiceFileHandlerBase` serialises this work by the canonical document ID
31
+ derived from each path. Before a watcher `DELETE` reaches `deleteFileFromDB`,
32
+ the handler rechecks current storage under that lock. Rename-derived deletions
33
+ lock both source and destination document IDs. This direction updates database
34
+ Metadata; it does not remove the physical file from storage.
35
+
36
+ An event marked `restoredFromPreviousRuntime` is separately revalidated against
37
+ current storage and selection before the full offline scan. An ordinary
38
+ restored deletion requires current absence; one retaining `renameTarget` uses
39
+ the rename-exclusion checks below as well as current source selection. Older
40
+ snapshots without that field keep their conservative absence-only rule.
41
+
42
+ Database-to-storage work follows the opposite direction. Replicated Metadata
43
+ reaches `_anyProcessReplicatedDoc`, which applies target and size checks,
44
+ obtains a storage stub, and calls `dbToStorage`. Logical deletion, conflicts,
45
+ and unsynchronised storage content retain their existing reflection rules.
46
+ The watcher guard does not intercept this path or general explicit database
47
+ deletion operations.
48
+
49
+ ## Event intent and current storage state
50
+
51
+ | Event intent | Current storage observation | Admitted operation |
52
+ | --- | --- | --- |
53
+ | Watcher `CREATE` or `CHANGED` | A selected regular file is present | Keep the existing storage-to-database write. |
54
+ | Watcher `DELETE` without `renameTarget` | A current file resolves to the same canonical document ID, including at an excluded path, or a folder occupies the path | Suppress the database deletion; no rename destination is known. |
55
+ | Watcher `DELETE` without `renameTarget` | The host lookup reports no current item, or a file belonging to a distinct document ID | Use the existing deletion operation. |
56
+ | Watcher `DELETE` without `renameTarget` | Lookup fails | Leave the database unchanged; failure is unknown, not absence. |
57
+ | `RENAME` converted to `DELETE` | The destination is still selected when case-collision rejection is omitted | Suppress the database deletion. |
58
+ | `RENAME` converted to `DELETE` | The destination is deliberately excluded, and the source is absent | Apply the old document's deletion. |
59
+ | Case-only `RENAME` converted to `DELETE` | Source lookup resolves to the exact deliberately excluded destination, not the original path | Apply the old document's deletion despite the shared ID. |
60
+ | Restored `DELETE` without `renameTarget` | A current item occupies the saved path | Keep the existing restored-event suppression. |
61
+ | Replicated logical deletion | Storage contains a file | Keep the existing database-to-storage conflict and preservation rules. |
62
+
63
+ ## Rename exclusion and preservation
64
+
65
+ The retained destination distinguishes deliberate exclusion from temporary
66
+ rejection by the filename collision check, which counts the host's indexed
67
+ paths. The handler queries `isTargetFile` with `skipCaseCollisionCheck` only
68
+ for that classification; other target policies, built-in exclusions, and the
69
+ current file-size limit still apply. This option does not authorise storing a
70
+ duplicate file.
71
+
72
+ A target lookup which resolves to another spelling or a folder is ambiguous
73
+ and preserves the database entry. A file recreated at the exact original path
74
+ also prevents deletion. The same-ID exception in the table applies only when
75
+ source lookup resolves to the exact excluded destination instead.
76
+
77
+ Suppression leaves storage and Metadata unchanged and records a notice. It
78
+ does not automatically write content, schedule a retry, or converge path
79
+ spelling. A later scan or event may reconcile the state, subject to its own
80
+ policy. The storage lookup may be a host index rather than an operating-system
81
+ existence check: the observation is made under the application lock, not in
82
+ an atomic filesystem transaction.
83
+
84
+ ## Verification and scope
85
+
86
+ Focused unit tests cover a current canonical file, absence, lookup failure,
87
+ distinct document IDs, an excluded same-ID file without inferred rename
88
+ intent, retained rename destinations, transient collision rejection,
89
+ deliberate exclusion, and source recreation. Live and restored deletions are
90
+ exercised separately. A database-to-storage test also verifies that an
91
+ incoming logical deletion can still remove unchanged storage content.
92
+
93
+ These tests do not establish the native watcher event sequence reported in
94
+ Issue 1168. Real-runtime verification belongs to the host and must distinguish
95
+ observed events from inferred causes. This change does not promise path-case
96
+ convergence or full folder-rename support. It does not rewrite the queue,
97
+ introduce a retry subsystem, or change the database schema.
98
+
99
+ The maintained host's real-runtime comparison is recorded in
100
+ [Proven in maintained hosts](proven-in-use.md#obsidian-plug-in), separately
101
+ from these unit-test guarantees.
102
+
103
+ ## Related documents
104
+
105
+ - [Full offline scan](offline-scanner.md), especially [Stored event interpretation](offline-scanner.md#stored-event-interpretation).
106
+ - [Conflict resolution and file provenance](conflict-resolution.md).
107
+ - [Commonlib development](development.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
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",