@filen/sync 0.2.1 → 0.3.2

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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. package/jest.config.js +0 -5
@@ -1,7 +1,254 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Deltas = void 0;
4
+ exports.collapseDeltas = collapseDeltas;
5
+ exports.directoriesWithSurvivingChildren = directoriesWithSurvivingChildren;
6
+ exports.rebasePathAcrossRenames = rebasePathAcrossRenames;
7
+ exports.rebaseLocalTreeAcrossRenames = rebaseLocalTreeAcrossRenames;
8
+ exports.rebaseRemoteTreeAcrossRenames = rebaseRemoteTreeAcrossRenames;
4
9
  const utils_1 = require("../utils");
10
+ /**
11
+ * Collapses child rename/move/delete deltas into the parent-directory delta that already covers them.
12
+ *
13
+ * When a directory is renamed or deleted, the tree diff also emits a delta for every descendant (each
14
+ * child's path changed too). Executing all of them is wasteful — the parent op already moves or removes
15
+ * the whole subtree — so each child is folded into its parent here, saving disk usage and API calls.
16
+ *
17
+ * Pure and side-effect free so it can be unit-tested directly against adversarial delta arrays. Two
18
+ * correctness properties that the previous in-place version did NOT guarantee:
19
+ *
20
+ * 1. Most-specific parent wins. A child can sit under more than one renamed ancestor (e.g. both
21
+ * "/a" -> "/x" and "/a/b" -> "/x/y" cover "/a/b/c.txt"). The longest-`from` ancestor is the
22
+ * immediate enclosing renamed directory, and its `to` already reflects every outer rename — so
23
+ * applying ONLY that one yields the correct post-rename source. Composing every match in array
24
+ * order (the old loop) was order-dependent and could mis-compose.
25
+ * 2. No neighbour corruption. We build a fresh array instead of splicing the input while iterating.
26
+ * The old loop spliced `deltas[i]` in place, so a removal shifted an unrelated delta into slot `i`
27
+ * that a later iteration could overwrite — silently dropping a real op and leaving a bogus one.
28
+ */
29
+ /**
30
+ * Walk `path`'s ancestor directories from the immediate parent upward, returning the value of the FIRST
31
+ * (deepest, i.e. longest) ancestor that is a key of `map` — the most-specific enclosing entry — or
32
+ * `undefined`. Equivalent to scanning every entry for the longest key that is a strict ancestor of `path`
33
+ * (`path.startsWith(key + "/")`), but O(depth) map lookups instead of O(entries) prefix comparisons. The
34
+ * walk stops above the root and never returns `path` itself, matching the original `startsWith(key + "/")`
35
+ * (strict-ancestor) semantics, including the "/" boundary that stops "/abc" matching a key of "/ab".
36
+ */
37
+ function nearestAncestor(path, map) {
38
+ let end = path.lastIndexOf("/");
39
+ while (end > 0) {
40
+ const hit = map.get(path.slice(0, end));
41
+ if (hit !== undefined) {
42
+ return hit;
43
+ }
44
+ end = path.lastIndexOf("/", end - 1);
45
+ }
46
+ return undefined;
47
+ }
48
+ /** Whether any strict ancestor directory of `path` is in `set` (same ancestor-walk as {@link nearestAncestor}). */
49
+ function hasAncestorIn(path, set) {
50
+ let end = path.lastIndexOf("/");
51
+ while (end > 0) {
52
+ if (set.has(path.slice(0, end))) {
53
+ return true;
54
+ }
55
+ end = path.lastIndexOf("/", end - 1);
56
+ }
57
+ return false;
58
+ }
59
+ function collapseDeltas({ deltas, renamedLocalDirectories, renamedRemoteDirectories, deletedLocalDirectories, deletedRemoteDirectories }) {
60
+ // Index the renamed/deleted directories by path so each child delta finds its nearest enclosing ancestor
61
+ // via an O(depth) ancestor-walk instead of an O(directories) scan. A deep/wide directory move otherwise
62
+ // makes this pass O(deltas * directories * pathLength) — super-linear, seconds-long for a large move (P1).
63
+ const localRenameByFrom = new Map();
64
+ const remoteRenameByFrom = new Map();
65
+ const localDeletedDirs = new Set();
66
+ const remoteDeletedDirs = new Set();
67
+ for (const directoryDelta of renamedLocalDirectories) {
68
+ if (directoryDelta.type === "renameLocalDirectory") {
69
+ localRenameByFrom.set(directoryDelta.from, directoryDelta);
70
+ }
71
+ }
72
+ for (const directoryDelta of renamedRemoteDirectories) {
73
+ if (directoryDelta.type === "renameRemoteDirectory") {
74
+ remoteRenameByFrom.set(directoryDelta.from, directoryDelta);
75
+ }
76
+ }
77
+ for (const directoryDelta of deletedLocalDirectories) {
78
+ if (directoryDelta.type === "deleteLocalDirectory") {
79
+ localDeletedDirs.add(directoryDelta.path);
80
+ }
81
+ }
82
+ for (const directoryDelta of deletedRemoteDirectories) {
83
+ if (directoryDelta.type === "deleteRemoteDirectory") {
84
+ remoteDeletedDirs.add(directoryDelta.path);
85
+ }
86
+ }
87
+ const collapsed = [];
88
+ for (const delta of deltas) {
89
+ if (delta.type === "renameLocalDirectory" || delta.type === "renameLocalFile") {
90
+ const parent = nearestAncestor(delta.from, localRenameByFrom);
91
+ if (parent) {
92
+ const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, parent.from, parent.to);
93
+ // Equal to the target => the parent rename already lands the child here, so it is redundant.
94
+ if (newFromPath !== delta.to) {
95
+ collapsed.push(Object.assign(Object.assign({}, delta), { from: newFromPath }));
96
+ }
97
+ continue;
98
+ }
99
+ }
100
+ else if (delta.type === "renameRemoteDirectory" || delta.type === "renameRemoteFile") {
101
+ const parent = nearestAncestor(delta.from, remoteRenameByFrom);
102
+ if (parent) {
103
+ const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, parent.from, parent.to);
104
+ if (newFromPath !== delta.to) {
105
+ collapsed.push(Object.assign(Object.assign({}, delta), { from: newFromPath }));
106
+ }
107
+ continue;
108
+ }
109
+ }
110
+ else if (delta.type === "deleteLocalDirectory" || delta.type === "deleteLocalFile") {
111
+ if (hasAncestorIn(delta.path, localDeletedDirs)) {
112
+ continue;
113
+ }
114
+ }
115
+ else if (delta.type === "deleteRemoteDirectory" || delta.type === "deleteRemoteFile") {
116
+ if (hasAncestorIn(delta.path, remoteDeletedDirs)) {
117
+ continue;
118
+ }
119
+ }
120
+ collapsed.push(delta);
121
+ }
122
+ return collapsed;
123
+ }
124
+ /**
125
+ * Find directories slated for deletion that STILL hold live content on the deleting-resistant side — a
126
+ * freshly-added child, or a base child kept alive by newer-modify-wins — and must therefore survive.
127
+ *
128
+ * When one side deletes a directory and the other adds/keeps something inside it in the same cycle, the
129
+ * raw delta set contains both `deleteXDirectory <dir>` and the child's own add. Left alone, collapseDeltas
130
+ * subsumes the child's sibling DELETES under the dir-delete and the dir-delete then cascades over the
131
+ * surviving child at execution time — wiping a brand-new file before it is ever propagated (data loss).
132
+ * Newer content beats a delete (the same rule the per-file passes already apply), so the directory must
133
+ * stay: drop its delete and let the surviving child's own add re-assert it.
134
+ *
135
+ * A child "survives under <dir>" iff it is present in the current tree (`tree`), is NOT itself slated for
136
+ * deletion (same-direction `delete{File,Directory}`), and is NOT leaving via a rename — neither the child
137
+ * itself nor any ancestor is the `from` of a same-direction `rename{File,Directory}` (a renamed directory
138
+ * carries all its descendants out with it). Without the rename exclusion, a directory rename — which is
139
+ * emitted as "rename the children to the new path + delete the now-empty old directory" — would look like
140
+ * the old directory still has live children and never get deleted (its children are still at their old
141
+ * paths in this pre-rebase tree). Returns the set of dir paths to KEEP. Pure: the caller applies the
142
+ * pruning. Linear in tree size times depth; short-circuits when nothing is being deleted.
143
+ */
144
+ function directoriesWithSurvivingChildren(deltas, dirDeleteType, fileDeleteType, renameDirType, renameFileType, tree) {
145
+ const deletedDirPaths = new Set();
146
+ const deletedPaths = new Set();
147
+ const renamedFromPaths = new Set();
148
+ for (const delta of deltas) {
149
+ if (delta.type === dirDeleteType) {
150
+ deletedDirPaths.add(delta.path);
151
+ deletedPaths.add(delta.path);
152
+ }
153
+ else if (delta.type === fileDeleteType) {
154
+ deletedPaths.add(delta.path);
155
+ }
156
+ else if (delta.type === renameDirType || delta.type === renameFileType) {
157
+ renamedFromPaths.add(delta.from);
158
+ }
159
+ }
160
+ const keep = new Set();
161
+ if (deletedDirPaths.size === 0) {
162
+ return keep;
163
+ }
164
+ for (const path in tree) {
165
+ if (deletedPaths.has(path) || renamedFromPaths.has(path)) {
166
+ continue;
167
+ }
168
+ // Walk the ancestor chain once, collecting any deleted-directory ancestors. If ANY ancestor is the
169
+ // source of a rename, this whole subtree is moving away, so it keeps nothing alive — discard them.
170
+ const deletedDirAncestors = [];
171
+ let leaving = false;
172
+ let parent = path;
173
+ let slash = parent.lastIndexOf("/");
174
+ while (slash > 0) {
175
+ parent = parent.slice(0, slash);
176
+ if (renamedFromPaths.has(parent)) {
177
+ leaving = true;
178
+ break;
179
+ }
180
+ if (deletedDirPaths.has(parent)) {
181
+ deletedDirAncestors.push(parent);
182
+ }
183
+ slash = parent.lastIndexOf("/");
184
+ }
185
+ if (!leaving) {
186
+ for (const ancestor of deletedDirAncestors) {
187
+ keep.add(ancestor);
188
+ }
189
+ }
190
+ }
191
+ return keep;
192
+ }
193
+ /**
194
+ * Remaps `path` across a set of propagated directory renames, returning its post-rename path (unchanged if
195
+ * none applies). The most-specific (longest `from`) ancestor wins, and each rename's `to` already encodes
196
+ * any outer renames (the rename pass records every directory's FINAL position), so one pass handles
197
+ * independent AND nested directory renames correctly.
198
+ */
199
+ function rebasePathAcrossRenames(path, renames) {
200
+ let best;
201
+ for (const rename of renames) {
202
+ if (path.startsWith(`${rename.from}/`) && (!best || rename.from.length > best.from.length)) {
203
+ best = rename;
204
+ }
205
+ }
206
+ return best ? best.to + path.slice(best.from.length) : path;
207
+ }
208
+ /**
209
+ * Returns a COPY of `tree` with every DESCENDANT of a renamed directory moved to its post-rename path. The
210
+ * input is never mutated (entries outside any renamed subtree are shared by reference). The renamed
211
+ * directory nodes themselves are NOT moved here — they are handled via `pathsAdded` — only their children.
212
+ * Used to realign the base + the not-yet-renamed side so the per-descendant passes attribute change at the
213
+ * correct post-rename path (BUG-A / BUG-B).
214
+ */
215
+ function rebaseLocalTreeAcrossRenames(tree, renames) {
216
+ // No rename touches this tree (the common single-direction cycle) — return it as-is rather than copying
217
+ // the whole map for nothing. (P1)
218
+ if (renames.length === 0) {
219
+ return tree;
220
+ }
221
+ // Index renames by `from` so each entry locates its nearest renamed ancestor in O(depth), not O(renames).
222
+ const renameByFrom = new Map(renames.map(rename => [rename.from, rename]));
223
+ const newTree = {};
224
+ const newINodes = {};
225
+ for (const path in tree.tree) {
226
+ const item = tree.tree[path];
227
+ const parent = nearestAncestor(path, renameByFrom);
228
+ const rebasedPath = parent ? parent.to + path.slice(parent.from.length) : path;
229
+ const newItem = rebasedPath === path ? item : Object.assign(Object.assign({}, item), { path: rebasedPath });
230
+ newTree[rebasedPath] = newItem;
231
+ newINodes[newItem.inode] = newItem;
232
+ }
233
+ return { tree: newTree, inodes: newINodes, size: tree.size };
234
+ }
235
+ function rebaseRemoteTreeAcrossRenames(tree, renames) {
236
+ if (renames.length === 0) {
237
+ return tree;
238
+ }
239
+ const renameByFrom = new Map(renames.map(rename => [rename.from, rename]));
240
+ const newTree = {};
241
+ const newUUIDs = {};
242
+ for (const path in tree.tree) {
243
+ const item = tree.tree[path];
244
+ const parent = nearestAncestor(path, renameByFrom);
245
+ const rebasedPath = parent ? parent.to + path.slice(parent.from.length) : path;
246
+ const newItem = rebasedPath === path ? item : Object.assign(Object.assign({}, item), { path: rebasedPath });
247
+ newTree[rebasedPath] = newItem;
248
+ newUUIDs[newItem.uuid] = newItem;
249
+ }
250
+ return { tree: newTree, uuids: newUUIDs, size: tree.size };
251
+ }
5
252
  /**
6
253
  * Deltas
7
254
  * @date 3/1/2024 - 11:11:32 PM
@@ -46,19 +293,28 @@ class Deltas {
46
293
  * deleteRemoteFileCountRaw: number
47
294
  * }>}
48
295
  */
49
- async process({ currentLocalTree, currentRemoteTree, previousLocalTree, previousRemoteTree, currentLocalTreeErrors }) {
296
+ async process({ currentLocalTree, currentRemoteTree, previousLocalTree, previousRemoteTree, currentLocalTreeErrors, currentLocalTreeIgnored }) {
297
+ // Snapshot the mode ONCE for the whole cycle. process() is async (it awaits hashing), and updateMode()
298
+ // mutates this.sync.mode synchronously from the main thread at any time. Reading this.sync.mode live in
299
+ // each of the ~20 passes below let a mid-cycle mode switch split one cycle across two modes — e.g. the
300
+ // local-deletions pass running as twoWay while the remote-deletions pass runs as cloudToLocal — yielding
301
+ // a self-contradictory delta set. The snapshot is returned so the caller's deletion-confirmation gate
302
+ // evaluates against the SAME mode the deltas were computed under; a mode change takes effect next cycle.
303
+ const mode = this.sync.mode;
50
304
  if (this.sync.removed) {
51
305
  return {
52
306
  deltas: [],
53
307
  deleteLocalDirectoryCountRaw: 0,
54
308
  deleteLocalFileCountRaw: 0,
55
309
  deleteRemoteDirectoryCountRaw: 0,
56
- deleteRemoteFileCountRaw: 0
310
+ deleteRemoteFileCountRaw: 0,
311
+ mode
57
312
  };
58
313
  }
59
314
  let deltas = [];
60
315
  const pathsAdded = {};
61
316
  const erroredLocalPaths = {};
317
+ const ignoredLocalPaths = {};
62
318
  const renamedRemoteDirectories = [];
63
319
  const renamedLocalDirectories = [];
64
320
  const deletedRemoteDirectories = [];
@@ -70,6 +326,15 @@ class Deltas {
70
326
  for (const error of currentLocalTreeErrors) {
71
327
  erroredLocalPaths[error.relativePath] = true;
72
328
  }
329
+ // A path that physically exists locally but is currently NOT syncable — a symlink, an
330
+ // unreadable/permission-denied entry, a case-insensitive duplicate, a special device, an
331
+ // over-long name — is recorded as "ignored", never as a deletion. Suppressing remote-deletes for
332
+ // these preserves the cloud copy of a path that was synced before it became ignored (e.g. a file
333
+ // now skipped because it is a symlink after upgrading to lstat), mirroring how the .filenignore
334
+ // filter below already protects ignored paths. (BUG-006)
335
+ for (const ignored of currentLocalTreeIgnored) {
336
+ ignoredLocalPaths[ignored.relativePath] = true;
337
+ }
73
338
  // The order of delta processing:
74
339
  // 1. Local directory/file move/rename
75
340
  // 2. Remote directory/file move/rename
@@ -78,7 +343,7 @@ class Deltas {
78
343
  // 5. Local additions/filemodifications
79
344
  // 6. Remote additions/filemodifications
80
345
  // Local file/directory move/rename
81
- if (this.sync.mode === "twoWay" || this.sync.mode === "localBackup" || this.sync.mode === "localToCloud") {
346
+ if (mode === "twoWay" || mode === "localBackup" || mode === "localToCloud") {
82
347
  for (const inode in currentLocalTree.inodes) {
83
348
  const currentItem = currentLocalTree.inodes[inode];
84
349
  const previousItem = previousLocalTree.inodes[inode];
@@ -93,10 +358,33 @@ class Deltas {
93
358
  // Path from current item changed, it was either renamed or moved (same type)
94
359
  if (currentItem.path !== previousItem.path &&
95
360
  currentItem.type === previousItem.type &&
361
+ // A matching inode is NOT sufficient proof of a rename. The OS recycles inode numbers, and
362
+ // ext4 hands a freed inode straight to the next file created — so "delete a.txt + create
363
+ // c.txt" can land c.txt on a.txt's old inode and be misread as "rename a.txt -> c.txt".
364
+ // That phantom rename propagates as a REMOTE rename and DELETES the original: silent data
365
+ // loss in modes that keep deletions (localBackup), and invisible in twoWay only because the
366
+ // stale path was going to be deleted anyway. A genuine rename preserves the file's
367
+ // creation/birthtime (even a rename+modify only touches mtime/size); a reused inode belongs
368
+ // to a freshly-created file with a newer birthtime. Require both to match. A filesystem that
369
+ // cannot report birthtime reads 0 on both sides — still equal — so this degrades safely to
370
+ // the old inode-only behavior rather than dropping genuine renames. (F8 — inode reuse)
371
+ currentItem.creation === previousItem.creation &&
96
372
  // Does an item with the same path and type already exist in the current remote tree (probably moved by something else prior)?
97
373
  !(currentRemoteTree.tree[currentItem.path] && currentRemoteTree.tree[currentItem.path].type === currentItem.type) &&
98
374
  // Because only comparing strings can be weird sometimes
99
375
  Buffer.from(currentItem.path, "utf-8").toString("hex") !== Buffer.from(previousItem.path, "utf-8").toString("hex")) {
376
+ // Only propagate the rename if the REMOTE source is unchanged since the base — the same
377
+ // node (uuid) still sits at the old path. If the other side deleted, modified, or renamed
378
+ // that file/directory in the same window, renaming the stale remote node is invalid: skip
379
+ // it, leave the paths unmarked, and let the renamed item be re-added under its new name
380
+ // while the other side's change is applied by its own pass. The worlds still converge,
381
+ // keeping both edits rather than silently dropping one. (F2–F4)
382
+ const remoteSource = currentRemoteTree.tree[previousItem.path];
383
+ const baseRemoteSource = previousRemoteTree.tree[previousItem.path];
384
+ const remoteSourceUnchanged = !!remoteSource && !!baseRemoteSource && remoteSource.uuid === baseRemoteSource.uuid;
385
+ if (!remoteSourceUnchanged) {
386
+ continue;
387
+ }
100
388
  const delta = {
101
389
  type: currentItem.type === "directory" ? "renameRemoteDirectory" : "renameRemoteFile",
102
390
  path: currentItem.path,
@@ -109,11 +397,28 @@ class Deltas {
109
397
  }
110
398
  pathsAdded[currentItem.path] = true;
111
399
  pathsAdded[previousItem.path] = true;
400
+ // Rename + in-place content modify of the SAME file in ONE cycle: the rename marks the new
401
+ // path "added", so the modification pass below would skip it and the new bytes would never
402
+ // upload (the remote would keep the OLD content under the new name, forever). Detect the
403
+ // content change here against the base and emit the upload too; phase order runs the rename
404
+ // (phase 4) before the upload (phase 11), so it lands on the renamed remote node. (F1)
405
+ if (currentItem.type === "file") {
406
+ const contentChanged = currentItem.size !== previousItem.size ||
407
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentItem.lastModified) !==
408
+ (0, utils_1.normalizeLastModifiedMsForComparison)(previousItem.lastModified);
409
+ if (contentChanged) {
410
+ deltas.push({
411
+ type: "uploadFile",
412
+ path: currentItem.path,
413
+ size: currentItem.size
414
+ });
415
+ }
416
+ }
112
417
  }
113
418
  }
114
419
  }
115
420
  // Remote file/directory move/rename
116
- if (this.sync.mode === "twoWay" || this.sync.mode === "cloudBackup" || this.sync.mode === "cloudToLocal") {
421
+ if (mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal") {
117
422
  for (const uuid in currentRemoteTree.uuids) {
118
423
  const currentItem = currentRemoteTree.uuids[uuid];
119
424
  const previousItem = previousRemoteTree.uuids[uuid];
@@ -127,6 +432,24 @@ class Deltas {
127
432
  !(currentLocalTree.tree[currentItem.path] && currentLocalTree.tree[currentItem.path].type === currentItem.type) &&
128
433
  // Because only comparing strings can be weird sometimes
129
434
  Buffer.from(currentItem.path, "utf-8").toString("hex") !== Buffer.from(previousItem.path, "utf-8").toString("hex")) {
435
+ // Symmetric to the local rename pass: only propagate the remote rename if the LOCAL source
436
+ // is unchanged since the base — same inode AND (for files) same content. A remote modify
437
+ // mints a new uuid, but a LOCAL modify keeps the inode, so checking the inode alone would
438
+ // let a remote rename fire over a file the user edited in the same window and silently drop
439
+ // that edit. If the local side deleted, modified, or renamed it, skip the rename and let
440
+ // the file be handled by the deletion/addition passes → convergence, keeping both. (F2–F4)
441
+ const localSource = currentLocalTree.tree[previousItem.path];
442
+ const baseLocalSource = previousLocalTree.tree[previousItem.path];
443
+ const localSourceUnchanged = !!localSource &&
444
+ !!baseLocalSource &&
445
+ localSource.inode === baseLocalSource.inode &&
446
+ (currentItem.type === "directory" ||
447
+ (localSource.size === baseLocalSource.size &&
448
+ (0, utils_1.normalizeLastModifiedMsForComparison)(localSource.lastModified) ===
449
+ (0, utils_1.normalizeLastModifiedMsForComparison)(baseLocalSource.lastModified)));
450
+ if (!localSourceUnchanged) {
451
+ continue;
452
+ }
130
453
  const delta = {
131
454
  type: currentItem.type === "directory" ? "renameLocalDirectory" : "renameLocalFile",
132
455
  path: currentItem.path,
@@ -142,11 +465,36 @@ class Deltas {
142
465
  }
143
466
  }
144
467
  }
468
+ // Rename-aware rebase (BUG-A / BUG-B): a propagated directory rename relocates its ENTIRE subtree, but
469
+ // every per-descendant pass below compares the current tree against the base BY PATH. A child changed
470
+ // on the OTHER side still sits at the pre-rename path, so without realigning it is mis-attributed — the
471
+ // rename moves it to the new path while a stale same-path upload/download/delete clobbers the change
472
+ // (silent data loss, BUG-A) or resurrects a deleted child (BUG-B). Model the post-rename state: for
473
+ // every propagated dir rename F->T, remap the affected subtree to T in the BASE (both sides) and in the
474
+ // OTHER side's CURRENT tree (the emitted rename physically moves it there; the child transfer runs
475
+ // AFTER the rename in phase order). The renamed side's current tree already sits at T, and the directory
476
+ // nodes themselves are handled via pathsAdded, so only descendants are remapped. Skipped entirely when
477
+ // no directory rename happened, so the common case pays nothing. The originals are not mutated — these
478
+ // computation-only copies never feed the persisted base (advanced from the task results), they only
479
+ // correct THIS cycle's delta attribution.
480
+ if (renamedRemoteDirectories.length > 0 || renamedLocalDirectories.length > 0) {
481
+ // renameRemoteDirectory deltas come from LOCAL-originated dir renames; renameLocalDirectory deltas
482
+ // from REMOTE-originated ones.
483
+ const localOriginatedRenames = renamedRemoteDirectories.flatMap(delta => delta.type === "renameRemoteDirectory" ? [{ from: delta.from, to: delta.to }] : []);
484
+ const remoteOriginatedRenames = renamedLocalDirectories.flatMap(delta => delta.type === "renameLocalDirectory" ? [{ from: delta.from, to: delta.to }] : []);
485
+ const allDirRenames = [...localOriginatedRenames, ...remoteOriginatedRenames];
486
+ // The base follows BOTH sides' renames; each side's CURRENT tree follows only the OTHER side's
487
+ // rename (its own rename already moved its current tree to the new path).
488
+ previousLocalTree = rebaseLocalTreeAcrossRenames(previousLocalTree, allDirRenames);
489
+ previousRemoteTree = rebaseRemoteTreeAcrossRenames(previousRemoteTree, allDirRenames);
490
+ currentRemoteTree = rebaseRemoteTreeAcrossRenames(currentRemoteTree, localOriginatedRenames);
491
+ currentLocalTree = rebaseLocalTreeAcrossRenames(currentLocalTree, remoteOriginatedRenames);
492
+ }
145
493
  // Local deletions
146
- if (this.sync.mode === "twoWay" || this.sync.mode === "localToCloud") {
147
- if (this.sync.mode === "twoWay") {
494
+ if (mode === "twoWay" || mode === "localToCloud") {
495
+ if (mode === "twoWay") {
148
496
  for (const path in previousLocalTree.tree) {
149
- if (pathsAdded[path] || erroredLocalPaths[path]) {
497
+ if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
150
498
  continue;
151
499
  }
152
500
  const previousLocalItem = previousLocalTree.tree[path];
@@ -155,8 +503,23 @@ class Deltas {
155
503
  // We also check if the previous inode does not exist in the current tree, and if so, we skip it (only in cloud -> local modes. It should always be deleted in local -> cloud modes if it exists remotely).
156
504
  if (!currentLocalItem &&
157
505
  previousLocalItem //&&
158
- //(this.sync.mode !== "localToCloud" ? !currentLocalTree.inodes[previousLocalItem.inode] : true)
506
+ //(mode !== "localToCloud" ? !currentLocalTree.inodes[previousLocalItem.inode] : true)
159
507
  ) {
508
+ // Symmetric to the remote-deletions resurrect (OBS-001): the local copy was deleted, but
509
+ // if the REMOTE file was modified since the base — a new uuid, i.e. a real re-upload — the
510
+ // newer modification wins over the delete. Skip the delete and leave the path unmarked so
511
+ // the remote-additions pass downloads (resurrects) it locally. A newer modify always beats
512
+ // a delete, in either direction. (F7)
513
+ if (previousLocalItem.type === "file") {
514
+ const previousRemoteItem = previousRemoteTree.tree[path];
515
+ const currentRemoteItem = currentRemoteTree.tree[path];
516
+ if (currentRemoteItem &&
517
+ currentRemoteItem.type === "file" &&
518
+ previousRemoteItem &&
519
+ currentRemoteItem.uuid !== previousRemoteItem.uuid) {
520
+ continue;
521
+ }
522
+ }
160
523
  const delta = {
161
524
  type: previousLocalItem.type === "directory" ? "deleteRemoteDirectory" : "deleteRemoteFile",
162
525
  path
@@ -176,7 +539,7 @@ class Deltas {
176
539
  }
177
540
  else {
178
541
  for (const path in currentRemoteTree.tree) {
179
- if (pathsAdded[path] || erroredLocalPaths[path]) {
542
+ if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
180
543
  continue;
181
544
  }
182
545
  const currentLocalItem = currentLocalTree.tree[path];
@@ -201,10 +564,17 @@ class Deltas {
201
564
  }
202
565
  }
203
566
  // Remote deletions
204
- if (this.sync.mode === "twoWay" || this.sync.mode === "cloudToLocal") {
205
- if (this.sync.mode === "twoWay") {
567
+ if (mode === "twoWay" || mode === "cloudToLocal") {
568
+ if (mode === "twoWay") {
206
569
  for (const path in previousRemoteTree.tree) {
207
- if (pathsAdded[path]) {
570
+ // Symmetric to the local-deletions guard (BUG-006): never delete the LOCAL copy of a path the
571
+ // local scan ignored or errored on. Such a path is physically present on disk but absent from
572
+ // the scanned tree (an over-long name, an invalid path, a default-ignore that grew across an
573
+ // upgrade, a case-duplicate); when its REMOTE copy is deleted, propagating that delete would
574
+ // wipe a file the user never asked to sync. The end-of-process ignore filter does not catch
575
+ // these — it only knows the .filenignore matcher, not the nameLength/pathLength/invalidPath/
576
+ // defaultIgnore/duplicate reasons carried in ignoredLocalPaths. (M4)
577
+ if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
208
578
  continue;
209
579
  }
210
580
  const previousRemoteItem = previousRemoteTree.tree[path];
@@ -213,8 +583,44 @@ class Deltas {
213
583
  // We also check if the previous UUID does not exist in the current tree, and if so, we skip it (only in local -> cloud modes. It should always be deleted in cloud -> local modes if it exists locally).
214
584
  if (!currentRemoteItem &&
215
585
  previousRemoteItem //&&
216
- //(this.sync.mode !== "cloudToLocal" ? !currentRemoteTree.uuids[previousRemoteItem.uuid] : true)
586
+ //(mode !== "cloudToLocal" ? !currentRemoteTree.uuids[previousRemoteItem.uuid] : true)
217
587
  ) {
588
+ // E2E-OBS-001 (newer-modify-wins): the remote deleted this path, but if the local FILE
589
+ // was modified since the last sync the modification wins — skip the delete and leave the
590
+ // path unmarked so the local-additions pass re-uploads (resurrects) it remotely. "Modified"
591
+ // requires a real CONTENT change: a size difference, or (same size, mtime moved) a cached
592
+ // upload hash that no longer matches. A bare mtime touch — or a same-size change we cannot
593
+ // confirm because no hash was stored — is NOT a modification, so the delete proceeds. The
594
+ // hash is an optional signal (older files carry none).
595
+ if (previousRemoteItem.type === "file") {
596
+ const previousLocalItem = previousLocalTree.tree[path];
597
+ const currentLocalItem = currentLocalTree.tree[path];
598
+ if (currentLocalItem && currentLocalItem.type === "file" && previousLocalItem) {
599
+ let localContentChanged = currentLocalItem.size !== previousLocalItem.size;
600
+ if (!localContentChanged &&
601
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) !==
602
+ (0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified)) {
603
+ const cachedHash = this.sync.localFileHashes[currentLocalItem.path];
604
+ if (cachedHash) {
605
+ try {
606
+ const currentHash = await this.sync.localFileSystem.createFileHash({
607
+ relativePath: path,
608
+ algorithm: "md5"
609
+ });
610
+ localContentChanged = currentHash !== cachedHash;
611
+ }
612
+ catch (_a) {
613
+ // The file is mid-rename (a pending dir rename will move it to `path`); we cannot
614
+ // confirm a same-size content change without reading it, so leave it as "not
615
+ // modified" — the hash-optional policy already lets such a delete proceed.
616
+ }
617
+ }
618
+ }
619
+ if (localContentChanged) {
620
+ continue;
621
+ }
622
+ }
623
+ }
218
624
  const delta = {
219
625
  type: previousRemoteItem.type === "directory" ? "deleteLocalDirectory" : "deleteLocalFile",
220
626
  path
@@ -258,8 +664,135 @@ class Deltas {
258
664
  }
259
665
  }
260
666
  }
667
+ // Don't cascade a directory deletion over live content the OTHER side did not delete. When one side
668
+ // removes a directory while this side adds (or keeps a modified) child inside it, drop the directory
669
+ // delete so the surviving child's own add re-creates the directory — the genuinely-deleted siblings
670
+ // still delete individually. Without this the dir-delete subsumes the child at execution and the
671
+ // brand-new file is lost (newer content must beat a delete, symmetric to the per-file passes). (H5)
672
+ const localDirsToKeep = directoriesWithSurvivingChildren(deltas, "deleteLocalDirectory", "deleteLocalFile", "renameLocalDirectory", "renameLocalFile", currentLocalTree.tree);
673
+ const remoteDirsToKeep = directoriesWithSurvivingChildren(deltas, "deleteRemoteDirectory", "deleteRemoteFile", "renameRemoteDirectory", "renameRemoteFile", currentRemoteTree.tree);
674
+ if (localDirsToKeep.size > 0 || remoteDirsToKeep.size > 0) {
675
+ deltas = deltas.filter(delta => {
676
+ if (delta.type === "deleteLocalDirectory" && localDirsToKeep.has(delta.path)) {
677
+ deleteLocalDirectoryCountRaw -= 1;
678
+ // Un-mark so the additions pass re-creates the surviving directory on the deleting side.
679
+ delete pathsAdded[delta.path];
680
+ return false;
681
+ }
682
+ if (delta.type === "deleteRemoteDirectory" && remoteDirsToKeep.has(delta.path)) {
683
+ deleteRemoteDirectoryCountRaw -= 1;
684
+ delete pathsAdded[delta.path];
685
+ return false;
686
+ }
687
+ return true;
688
+ });
689
+ // Keep the bookkeeping arrays consistent — collapseDeltas uses them to subsume descendant
690
+ // deletes, and a kept directory must no longer subsume its (legitimately deleted) children.
691
+ for (let i = deletedLocalDirectories.length - 1; i >= 0; i--) {
692
+ if (localDirsToKeep.has(deletedLocalDirectories[i].path)) {
693
+ deletedLocalDirectories.splice(i, 1);
694
+ }
695
+ }
696
+ for (let i = deletedRemoteDirectories.length - 1; i >= 0; i--) {
697
+ if (remoteDirsToKeep.has(deletedRemoteDirectories[i].path)) {
698
+ deletedRemoteDirectories.splice(i, 1);
699
+ }
700
+ }
701
+ }
702
+ // Type change at a path (file <-> directory). The path exists in BOTH current trees but as
703
+ // different types, so it slips through every other pass: the rename passes need a matching
704
+ // inode/uuid (a type change has neither), the deletion passes need the path ABSENT on the other
705
+ // side (here it is present, only a different type), and the addition passes skip a path whose
706
+ // other-side item already exists. Left unhandled, the stale-type item lingers and its replacement
707
+ // cannot be created over it (E2E-BUG-001).
708
+ //
709
+ // We attribute the change against the last-synced base: whichever side's type differs from the
710
+ // base is the one that changed. In twoWay, if both changed (or neither is in the base) local wins,
711
+ // matching the tie policy used elsewhere; directional modes force the authoritative side. The
712
+ // authoritative side's new item is created and the other side's stale item is deleted — the
713
+ // phase-ordered task runner always runs deletes before creates, so the delete lands first. When
714
+ // the deleted (stale) side is a directory, its descendants are marked "added" so the addition
715
+ // passes don't try to sync now-obsolete children; the recursive delete removes them (and any
716
+ // child-delete deltas already emitted collapse into the parent). The surviving side's descendants
717
+ // stay unmarked and sync normally.
718
+ {
719
+ const canWriteRemote = mode === "twoWay" || mode === "localBackup" || mode === "localToCloud";
720
+ const canWriteLocal = mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal";
721
+ const markSubtreeAdded = (treePaths, parentPath) => {
722
+ const prefix = `${parentPath}/`;
723
+ for (const descendantPath in treePaths) {
724
+ if (descendantPath.startsWith(prefix)) {
725
+ pathsAdded[descendantPath] = true;
726
+ }
727
+ }
728
+ };
729
+ for (const path in currentLocalTree.tree) {
730
+ if (pathsAdded[path] || erroredLocalPaths[path] || ignoredLocalPaths[path]) {
731
+ continue;
732
+ }
733
+ const currentLocalItem = currentLocalTree.tree[path];
734
+ const currentRemoteItem = currentRemoteTree.tree[path];
735
+ if (!currentLocalItem || !currentRemoteItem || currentLocalItem.type === currentRemoteItem.type) {
736
+ continue;
737
+ }
738
+ // Both sides have this path, with different types. Attribute the change against the base.
739
+ const previousLocalItem = previousLocalTree.tree[path];
740
+ const previousRemoteItem = previousRemoteTree.tree[path];
741
+ const localChangedType = !previousLocalItem || previousLocalItem.type !== currentLocalItem.type;
742
+ const remoteChangedType = !previousRemoteItem || previousRemoteItem.type !== currentRemoteItem.type;
743
+ let localWins;
744
+ if (mode === "localBackup" || mode === "localToCloud") {
745
+ localWins = true;
746
+ }
747
+ else if (mode === "cloudBackup" || mode === "cloudToLocal") {
748
+ localWins = false;
749
+ }
750
+ else {
751
+ // twoWay: whoever's type diverged from base wins; local wins a tie / a both-changed conflict.
752
+ localWins = localChangedType || !remoteChangedType;
753
+ }
754
+ if (localWins && canWriteRemote) {
755
+ const deleteDelta = {
756
+ type: currentRemoteItem.type === "directory" ? "deleteRemoteDirectory" : "deleteRemoteFile",
757
+ path
758
+ };
759
+ deltas.push(deleteDelta);
760
+ if (currentRemoteItem.type === "directory") {
761
+ deletedRemoteDirectories.push(deleteDelta);
762
+ deleteRemoteDirectoryCountRaw += 1;
763
+ markSubtreeAdded(currentRemoteTree.tree, path);
764
+ }
765
+ else {
766
+ deleteRemoteFileCountRaw += 1;
767
+ }
768
+ deltas.push(currentLocalItem.type === "directory"
769
+ ? { type: "createRemoteDirectory", path }
770
+ : { type: "uploadFile", path, size: currentLocalItem.size });
771
+ pathsAdded[path] = true;
772
+ }
773
+ else if (!localWins && canWriteLocal) {
774
+ const deleteDelta = {
775
+ type: currentLocalItem.type === "directory" ? "deleteLocalDirectory" : "deleteLocalFile",
776
+ path
777
+ };
778
+ deltas.push(deleteDelta);
779
+ if (currentLocalItem.type === "directory") {
780
+ deletedLocalDirectories.push(deleteDelta);
781
+ deleteLocalDirectoryCountRaw += 1;
782
+ markSubtreeAdded(currentLocalTree.tree, path);
783
+ }
784
+ else {
785
+ deleteLocalFileCountRaw += 1;
786
+ }
787
+ deltas.push(currentRemoteItem.type === "directory"
788
+ ? { type: "createLocalDirectory", path }
789
+ : { type: "downloadFile", path, size: currentRemoteItem.size });
790
+ pathsAdded[path] = true;
791
+ }
792
+ }
793
+ }
261
794
  // Local additions/fileModifications
262
- if (this.sync.mode === "twoWay" || this.sync.mode === "localBackup" || this.sync.mode === "localToCloud") {
795
+ if (mode === "twoWay" || mode === "localBackup" || mode === "localToCloud") {
263
796
  for (const path in currentLocalTree.tree) {
264
797
  if (pathsAdded[path] || erroredLocalPaths[path]) {
265
798
  continue;
@@ -270,7 +803,7 @@ class Deltas {
270
803
  // We also check if it in fact has existed before (the inode), if so, we skip it (only in cloud -> local modes. It should always be uploaded in local -> cloud modes if it does not exist remotely).
271
804
  if (!currentRemoteItem &&
272
805
  currentLocalItem &&
273
- //(this.sync.mode !== "localBackup" && this.sync.mode !== "localToCloud"
806
+ //(mode !== "localBackup" && mode !== "localToCloud"
274
807
  // ? !previousLocalTree.inodes[currentLocalItem.inode]
275
808
  // : true) &&
276
809
  // Does an item with the same path and type already exist in the current remote tree (probably uploaded by something else prior)?
@@ -283,31 +816,78 @@ class Deltas {
283
816
  pathsAdded[path] = true;
284
817
  continue;
285
818
  }
286
- // If the item exists in both trees and has a different mod time + hash, we upload it again.
287
- if (currentRemoteItem &&
288
- currentRemoteItem.type === "file" &&
289
- currentLocalItem &&
290
- currentLocalItem.type === "file" &&
291
- (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) >
292
- (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified)) {
293
- const md5Hash = await this.sync.localFileSystem.createFileHash({
294
- relativePath: path,
295
- algorithm: "md5"
296
- });
297
- if (md5Hash !== this.sync.localFileHashes[currentLocalItem.path]) {
298
- deltas.push({
299
- type: "uploadFile",
300
- path,
301
- size: currentLocalItem.size,
302
- md5Hash
303
- });
304
- pathsAdded[path] = true;
819
+ // If the item exists in both trees and the local copy changed since the last sync, upload it.
820
+ // The change is attributed against the last-synced BASE (previousLocalTree) by size + whole-
821
+ // second mtime — not by comparing the two current sides — so an edit that lands in the same
822
+ // whole-second as the base mtime, or that only changes the size (e.g. 0 -> N bytes), is still
823
+ // detected (E2E-OBS-002). The remote side's change is detected by a new uuid (every re-upload
824
+ // mints one). On a both-changed conflict the newer mtime wins; an unorderable same-second tie
825
+ // resolves to local because this pass runs before the download pass and marks the path added.
826
+ // The md5 comparison stays as an OPTIONAL dedup (so a pure mtime touch with identical bytes is
827
+ // not re-uploaded) — never a required signal, since older files carry no stored hash.
828
+ if (currentRemoteItem && currentRemoteItem.type === "file" && currentLocalItem && currentLocalItem.type === "file") {
829
+ const previousLocalItem = previousLocalTree.tree[path];
830
+ const previousRemoteItem = previousRemoteTree.tree[path];
831
+ // With a persisted base, attribute the change against it (size + whole-second mtime). With
832
+ // NO base — a genuine first sync, or lost/corrupt state — there is no common ancestor, so
833
+ // the remote copy is the only reference: fall back to the side-vs-side comparison and treat
834
+ // the file as changed only when the local copy is strictly newer (otherwise identical
835
+ // content on both sides would be needlessly re-uploaded). OBS-002's same-second case
836
+ // requires a base, so this fallback cannot reintroduce it.
837
+ const localChanged = previousLocalItem
838
+ ? previousLocalItem.size !== currentLocalItem.size ||
839
+ (0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) !==
840
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified)
841
+ : (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) >
842
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified);
843
+ const remoteChanged = !previousRemoteItem || currentRemoteItem.uuid !== previousRemoteItem.uuid;
844
+ // Directional push modes (localToCloud / localBackup): the LOCAL side is authoritative, so a
845
+ // local change ALWAYS wins. The newer-mtime tiebreak is twoWay conflict resolution and must
846
+ // not let a foreign remote edit (with a newer mtime) suppress the push. (F5)
847
+ const directionalPush = mode === "localToCloud" || mode === "localBackup";
848
+ const localWins = directionalPush ||
849
+ !remoteChanged ||
850
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) >=
851
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified);
852
+ // Strict mirror (localToCloud only): the remote MUST equal local. If the remote was edited
853
+ // away from what we last pushed (a new uuid) — even when the local file itself is unchanged —
854
+ // re-assert the local copy to revert that foreign edit, bypassing the md5 dedup below (local
855
+ // bytes still equal the last upload, so the dedup would otherwise skip). localBackup is
856
+ // additive and deliberately tolerates foreign edits, so it is excluded. (F6)
857
+ const mirrorRevert = mode === "localToCloud" && remoteChanged;
858
+ // Directional push with NO remote base: a stray remote file occupies a path local also has.
859
+ // It cannot be the synced copy if the SIZES differ, so the authoritative local wins — push it
860
+ // up over the stray. (F9, symmetric to the pull side.)
861
+ const noBaseSizeDiverged = directionalPush && !previousRemoteItem && currentLocalItem.size !== currentRemoteItem.size;
862
+ if ((localChanged || mirrorRevert || noBaseSizeDiverged) && localWins) {
863
+ // The md5 is an OPTIONAL dedup. When a pending directory rename will move this file to
864
+ // `path` (a rebased descendant — BUG-A/BUG-B), the file is not at `path` yet at delta time,
865
+ // so the read throws; we already KNOW it changed (size/mtime vs base), so emit the upload
866
+ // and let the upload task hash the moved file. A same-content touch is still deduped when
867
+ // the hash IS readable.
868
+ let md5Hash;
869
+ try {
870
+ md5Hash = await this.sync.localFileSystem.createFileHash({
871
+ relativePath: path,
872
+ algorithm: "md5"
873
+ });
874
+ }
875
+ catch (_b) {
876
+ md5Hash = undefined;
877
+ }
878
+ if (mirrorRevert ||
879
+ noBaseSizeDiverged ||
880
+ md5Hash === undefined ||
881
+ md5Hash !== this.sync.localFileHashes[currentLocalItem.path]) {
882
+ deltas.push(Object.assign({ type: "uploadFile", path, size: currentLocalItem.size }, (md5Hash !== undefined ? { md5Hash } : {})));
883
+ pathsAdded[path] = true;
884
+ }
305
885
  }
306
886
  }
307
887
  }
308
888
  }
309
889
  // Remote additions/changes
310
- if (this.sync.mode === "twoWay" || this.sync.mode === "cloudBackup" || this.sync.mode === "cloudToLocal") {
890
+ if (mode === "twoWay" || mode === "cloudBackup" || mode === "cloudToLocal") {
311
891
  for (const path in currentRemoteTree.tree) {
312
892
  if (pathsAdded[path]) {
313
893
  continue;
@@ -318,7 +898,7 @@ class Deltas {
318
898
  // We also check if it in fact has existed before (the UUID), if so, we skip it (only in local -> cloud modes. It should always be downloaded in cloud -> local modes if it does not exist locally).
319
899
  if (!currentLocalItem &&
320
900
  currentRemoteItem &&
321
- //(this.sync.mode !== "cloudBackup" && this.sync.mode !== "cloudToLocal"
901
+ //(mode !== "cloudBackup" && mode !== "cloudToLocal"
322
902
  // ? !previousRemoteTree.uuids[currentRemoteItem.uuid]
323
903
  // : true) &&
324
904
  // Does an item with the same path and type already exist in the current local tree (probably downloaded by something else prior)?
@@ -332,27 +912,68 @@ class Deltas {
332
912
  continue;
333
913
  }
334
914
  const previousRemoteItem = previousRemoteTree.tree[path];
335
- // If the item exists in both trees and the mod time changed, we download it.
336
- if (currentRemoteItem &&
337
- currentRemoteItem.type === "file" &&
338
- currentLocalItem &&
339
- previousRemoteItem &&
340
- (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified) >
341
- (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified) &&
342
- currentRemoteItem.uuid !== previousRemoteItem.uuid) {
343
- deltas.push({
344
- type: "downloadFile",
345
- path,
346
- size: currentRemoteItem.size
347
- });
348
- pathsAdded[path] = true;
915
+ // If the item exists in both trees and the remote copy changed since the base, download it.
916
+ // This MIRRORS the local-additions modify branch. With a base the remote changed iff its uuid
917
+ // changed (every re-upload mints one); with NO base — a fresh add-vs-add, or lost/corrupt state
918
+ // — fall back to side-vs-side and treat it as changed only when the remote is strictly newer
919
+ // (otherwise identical content on both sides would be needlessly downloaded). The local pass ran
920
+ // first and claimed the path (pathsAdded) when local won; reaching here means the remote wins.
921
+ // Without this no-base fallback an add-vs-add where the REMOTE is newer never converged. (F8)
922
+ if (currentRemoteItem && currentRemoteItem.type === "file" && currentLocalItem && currentLocalItem.type === "file") {
923
+ const previousLocalItem = previousLocalTree.tree[path];
924
+ const remoteChanged = previousRemoteItem
925
+ ? currentRemoteItem.uuid !== previousRemoteItem.uuid
926
+ : (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified) >
927
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
928
+ // Directional pull modes (cloudToLocal / cloudBackup): the REMOTE side is authoritative, so a
929
+ // remote change ALWAYS wins; the newer-mtime tiebreak is twoWay-only and must not let a
930
+ // foreign local edit (with a newer mtime) suppress the pull. (F5)
931
+ const directionalPull = mode === "cloudToLocal" || mode === "cloudBackup";
932
+ // The local copy is unchanged since the base (size + whole-second mtime both match). Then a
933
+ // remote change is NOT a conflict — there is nothing local to lose — so it must win outright.
934
+ // Mirrors the local pass, which already declines to claim an unchanged-local path. Without
935
+ // this, a genuine remote edit (new uuid) whose mtime was not strictly newer than the local
936
+ // mtime — equal whole-second, or an out-of-sync editing clock — was dropped and never pulled.
937
+ const localUnchanged = !!previousLocalItem &&
938
+ previousLocalItem.size === currentLocalItem.size &&
939
+ (0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) ===
940
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
941
+ // The newer-mtime tiebreak (last term) stays the CONFLICT resolver: it only decides the case
942
+ // where the local copy ALSO changed. The local pass ran first and claimed the path when local
943
+ // won that conflict, so reaching here with a changed local means the remote is the newer side.
944
+ const remoteWins = directionalPull ||
945
+ !previousLocalItem ||
946
+ localUnchanged ||
947
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentRemoteItem.lastModified) >
948
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified);
949
+ // Strict mirror (cloudToLocal only): local MUST equal remote. If the local copy was edited
950
+ // away from what we last pulled (size or whole-second mtime differs from the base) — even when
951
+ // the remote is unchanged — re-download to revert that foreign local edit. cloudBackup is
952
+ // additive and deliberately tolerates local edits, so it is excluded. (F6)
953
+ const localDiverged = !!previousLocalItem &&
954
+ (previousLocalItem.size !== currentLocalItem.size ||
955
+ (0, utils_1.normalizeLastModifiedMsForComparison)(previousLocalItem.lastModified) !==
956
+ (0, utils_1.normalizeLastModifiedMsForComparison)(currentLocalItem.lastModified));
957
+ const mirrorRevert = mode === "cloudToLocal" && localDiverged;
958
+ // Directional pull with NO local base: a stray local file occupies a path the remote also has
959
+ // (e.g. the local copy was deleted then re-created with different content). It cannot be the
960
+ // synced copy if the SIZES differ, so the authoritative remote wins — pull it down. (F9)
961
+ const noBaseSizeDiverged = directionalPull && !previousLocalItem && currentLocalItem.size !== currentRemoteItem.size;
962
+ if ((remoteChanged || mirrorRevert || noBaseSizeDiverged) && remoteWins) {
963
+ deltas.push({
964
+ type: "downloadFile",
965
+ path,
966
+ size: currentRemoteItem.size
967
+ });
968
+ pathsAdded[path] = true;
969
+ }
349
970
  }
350
971
  }
351
972
  }
352
- // Work on deltas from "left to right" (ascending order, path length).
973
+ // Filter out ignored paths. Ordering is deferred to the single sort on the collapsed result below:
974
+ // sorting here would order the larger PRE-collapse set, and collapseDeltas never depends on its input
975
+ // order (it indexes the renamed/deleted directories up front), so one sort on the final set suffices.
353
976
  deltas = deltas
354
- .sort((a, b) => a.path.split("/").length - b.path.split("/").length)
355
- // Filter by ignored paths
356
977
  .filter(delta => {
357
978
  const trailingSlash = delta.type === "renameLocalDirectory" ||
358
979
  delta.type === "createLocalDirectory" ||
@@ -389,65 +1010,29 @@ class Deltas {
389
1010
  // This is pretty unnecessary, hence we filter them here.
390
1011
  // Same for deletions. We only ever need to rename/move/delete the parent directory if the children did not change.
391
1012
  // This saves a lot of disk usage and API requests. This also saves time applying all done tasks to the overall state,
392
- // since we need to loop through less doneTasks.
393
- for (let i = 0; i < deltas.length; i++) {
394
- const delta = deltas[i];
395
- let moveUp = false;
396
- if (delta.type === "renameLocalDirectory" || delta.type === "renameLocalFile") {
397
- for (const directoryDelta of renamedLocalDirectories) {
398
- if (directoryDelta.type === "renameLocalDirectory" && delta.from.startsWith(directoryDelta.from + "/")) {
399
- const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, directoryDelta.from, directoryDelta.to);
400
- if (newFromPath === delta.to) {
401
- deltas.splice(i, 1);
402
- moveUp = true;
403
- }
404
- else {
405
- deltas.splice(i, 1, Object.assign(Object.assign({}, delta), { from: newFromPath }));
406
- }
407
- }
408
- }
409
- }
410
- else if (delta.type === "renameRemoteDirectory" || delta.type === "renameRemoteFile") {
411
- for (const directoryDelta of renamedRemoteDirectories) {
412
- if (directoryDelta.type === "renameRemoteDirectory" && delta.from.startsWith(directoryDelta.from + "/")) {
413
- const newFromPath = (0, utils_1.replacePathStartWithFromAndTo)(delta.from, directoryDelta.from, directoryDelta.to);
414
- if (newFromPath === delta.to) {
415
- deltas.splice(i, 1);
416
- moveUp = true;
417
- }
418
- else {
419
- deltas.splice(i, 1, Object.assign(Object.assign({}, delta), { from: newFromPath }));
420
- }
421
- }
422
- }
423
- }
424
- else if (delta.type === "deleteLocalDirectory" || delta.type === "deleteLocalFile") {
425
- for (const directoryDelta of deletedLocalDirectories) {
426
- if (directoryDelta.type === "deleteLocalDirectory" && delta.path.startsWith(directoryDelta.path + "/")) {
427
- deltas.splice(i, 1);
428
- moveUp = true;
429
- }
430
- }
431
- }
432
- else if (delta.type === "deleteRemoteDirectory" || delta.type === "deleteRemoteFile") {
433
- for (const directoryDelta of deletedRemoteDirectories) {
434
- if (directoryDelta.type === "deleteRemoteDirectory" && delta.path.startsWith(directoryDelta.path + "/")) {
435
- deltas.splice(i, 1);
436
- moveUp = true;
437
- }
438
- }
439
- }
440
- if (moveUp) {
441
- i--;
442
- }
443
- }
444
- // Work on deltas from "left to right" (ascending order, path length).
1013
+ // since we need to loop through less doneTasks. See collapseDeltas for the correctness properties.
1014
+ const collapsedDeltas = collapseDeltas({
1015
+ deltas,
1016
+ renamedLocalDirectories,
1017
+ renamedRemoteDirectories,
1018
+ deletedLocalDirectories,
1019
+ deletedRemoteDirectories
1020
+ });
1021
+ // Order the collapsed deltas "left to right" (ascending path depth) so the executor creates/renames
1022
+ // parents before children. Each delta's depth is computed ONCE here — a path.split inside the comparator
1023
+ // is otherwise recomputed on every comparison (O(n log n) splits) — and only the already-collapsed
1024
+ // (smallest) set is sorted.
1025
+ const sortedDeltas = collapsedDeltas
1026
+ .map(delta => [delta.path.split("/").length, delta])
1027
+ .sort((a, b) => a[0] - b[0])
1028
+ .map(entry => entry[1]);
445
1029
  return {
446
- deltas: deltas.sort((a, b) => a.path.split("/").length - b.path.split("/").length),
1030
+ deltas: sortedDeltas,
447
1031
  deleteLocalDirectoryCountRaw,
448
1032
  deleteLocalFileCountRaw,
449
1033
  deleteRemoteDirectoryCountRaw,
450
- deleteRemoteFileCountRaw
1034
+ deleteRemoteFileCountRaw,
1035
+ mode
451
1036
  };
452
1037
  }
453
1038
  }