@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
@@ -0,0 +1,497 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate, control } from "../harness/runner"
3
+ import { messagesOfType } from "../harness/snapshot"
4
+ import { mkdirLocal, rmLocal, renameLocal, writeLocalAt } from "../harness/mutations"
5
+ import { BASE_TIME } from "../harness/world"
6
+ import { makeErrnoError } from "../fakes/virtual-fs"
7
+ import { type SyncMessage, type TransferData } from "../../src/types"
8
+
9
+ const SECOND = 1000
10
+
11
+ /**
12
+ * Category O — per-task-type error paths (resilience / §H). This extends Category H to every task
13
+ * type whose `catch` branch H did not exercise. The guarantee under test for each task type: when its
14
+ * I/O fails with the target still present, the engine emits a `transfer`/`type:"error"` message of that
15
+ * task's `of`, records a task error (which gates the cycle and prevents state persistence), and a retry
16
+ * after the fault clears converges. The mirror guarantee: when the target has already vanished, the
17
+ * re-check skips the task silently with no error.
18
+ *
19
+ * H already covers `uploadFile` (surface) and `deleteRemoteFile` (surface + not_found swallow); those
20
+ * are not duplicated here.
21
+ *
22
+ * Deferred (noted, not faked): the REMOTE re-check SWALLOW branches — a deleteRemote/renameRemote/
23
+ * downloadFile task whose target vanishes strictly BETWEEN delta-computation and the task's
24
+ * existence re-check returning false. The fake cloud refreshes the engine's tree cache on every
25
+ * revision bump, so a genuinely-gone target leaves the cache too (→ the op early-returns inside
26
+ * RemoteFileSystem.unlink before the task catch, rather than throwing into it); holding the cache
27
+ * stale enough to reach the catch's `!fileExists`/`!directoryExists` branch would require forcing the
28
+ * existence probe to lie, i.e. faking the vanish. Those are true mid-I/O races, deferred to the live
29
+ * e2e suite (same framing as H). The LOCAL re-check swallows ARE genuine and covered here (O9, O10).
30
+ */
31
+
32
+ /** Total number of individual task errors reported across the message stream. */
33
+ function taskErrorCount(messages: SyncMessage[]): number {
34
+ return messagesOfType(messages, "taskErrors").reduce((sum, message) => sum + message.data.errors.length, 0)
35
+ }
36
+
37
+ /** Whether a `transfer` message with the given `of` discriminator and `type` exists in the stream. */
38
+ function hasTransfer(messages: SyncMessage[], of: TransferData["of"], type: "error" | "success"): boolean {
39
+ return messagesOfType(messages, "transfer").some(message => message.data.of === of && message.data.type === type)
40
+ }
41
+
42
+ describe("Category O — per-task-type error paths", () => {
43
+ // REMOTE error-surface: inject the mutation method; the node stays present, so the catch's
44
+ // existence re-check returns true and the error surfaces as a task error.
45
+
46
+ it("O1: a remote mkdir failure surfaces a createRemoteDirectory error, and a retry converges", async () => {
47
+ const result = await runScenario({
48
+ name: "O1",
49
+ mode: "twoWay",
50
+ steps: [
51
+ runCycle(),
52
+ localMutate(world => mkdirLocal(world, "newdir")),
53
+ control(world => world.cloud.controls.setError("createDirectory", new Error("mkdir boom"))),
54
+ runCycle(),
55
+ control(world => {
56
+ world.cloud.controls.clearError("createDirectory")
57
+ world.worker.resetTaskErrors(world.syncPair.uuid)
58
+ world.triggerWatcher()
59
+ }),
60
+ runCycle(),
61
+ runCycle()
62
+ ]
63
+ })
64
+
65
+ const failCycle = result.cycles[1]!
66
+
67
+ // createRemoteDirectory has no existence re-check — any error surfaces directly.
68
+ expect(hasTransfer(failCycle.messages, "createRemoteDirectory", "error")).toBe(true)
69
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
70
+ // After recovery the directory exists remotely and the worlds converge.
71
+ expect(result.finalRemote["/newdir"]).toMatchObject({ type: "directory" })
72
+ expect(result.finalLocal).toEqual(result.finalRemote)
73
+ })
74
+
75
+ it("O2: a remote dir-trash failure surfaces a deleteRemoteDirectory error, and a retry converges", async () => {
76
+ // An EMPTY directory: deleting a non-empty one would also generate a child deleteRemoteFile,
77
+ // muddying the per-task assertion. The empty dir yields exactly one deleteRemoteDirectory task.
78
+ const result = await runScenario({
79
+ name: "O2",
80
+ mode: "twoWay",
81
+ initialLocal: { "/local/d": null },
82
+ steps: [
83
+ runCycle(),
84
+ localMutate(world => rmLocal(world, "d")),
85
+ control(world => world.cloud.controls.setError("trashDirectory", new Error("trash dir boom"))),
86
+ runCycle(),
87
+ control(world => {
88
+ world.cloud.controls.clearError("trashDirectory")
89
+ world.worker.resetTaskErrors(world.syncPair.uuid)
90
+ world.triggerWatcher()
91
+ }),
92
+ runCycle(),
93
+ runCycle()
94
+ ]
95
+ })
96
+
97
+ const failCycle = result.cycles[1]!
98
+
99
+ // trashDirectory threw a non-not_found error; the dir is still present, so directoryExists() is
100
+ // true and the error surfaces (a not_found would have been swallowed inside RemoteFileSystem.unlink).
101
+ expect(hasTransfer(failCycle.messages, "deleteRemoteDirectory", "error")).toBe(true)
102
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
103
+ expect(result.finalRemote["/d"]).toBeUndefined()
104
+ expect(result.finalLocal["/d"]).toBeUndefined()
105
+ expect(result.finalLocal).toEqual(result.finalRemote)
106
+ })
107
+
108
+ it("O3: a remote renameFile failure surfaces a renameRemoteFile error, and a retry converges", async () => {
109
+ // The file lives in a subdirectory: the catch's fileExists(from) re-check resolves the parent from
110
+ // the tree cache, and the cache has no "/" entry — so a root-level file would re-check false and be
111
+ // swallowed rather than surfaced (matching H3/H4's subdirectory choice).
112
+ const result = await runScenario({
113
+ name: "O3",
114
+ mode: "twoWay",
115
+ initialLocal: { "/local/sub/a.txt": "x" },
116
+ steps: [
117
+ runCycle(),
118
+ localMutate(world => renameLocal(world, "sub/a.txt", "sub/b.txt")),
119
+ control(world => world.cloud.controls.setError("renameFile", new Error("rename file boom"))),
120
+ runCycle(),
121
+ control(world => {
122
+ world.cloud.controls.clearError("renameFile")
123
+ world.worker.resetTaskErrors(world.syncPair.uuid)
124
+ world.triggerWatcher()
125
+ }),
126
+ runCycle(),
127
+ runCycle()
128
+ ]
129
+ })
130
+
131
+ const failCycle = result.cycles[1]!
132
+
133
+ // The remote source (sub/a.txt) is still present, so fileExists(from) is true and the error surfaces.
134
+ expect(hasTransfer(failCycle.messages, "renameRemoteFile", "error")).toBe(true)
135
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
136
+ expect(result.finalRemote["/sub/b.txt"]).toMatchObject({ type: "file", size: 1 })
137
+ expect(result.finalRemote["/sub/a.txt"]).toBeUndefined()
138
+ expect(result.finalLocal).toEqual(result.finalRemote)
139
+ })
140
+
141
+ it("O4: a remote renameDirectory failure surfaces a renameRemoteDirectory error, and a retry converges", async () => {
142
+ const result = await runScenario({
143
+ name: "O4",
144
+ mode: "twoWay",
145
+ initialLocal: { "/local/d": null },
146
+ steps: [
147
+ runCycle(),
148
+ localMutate(world => renameLocal(world, "d", "e")),
149
+ control(world => world.cloud.controls.setError("renameDirectory", new Error("rename dir boom"))),
150
+ runCycle(),
151
+ control(world => {
152
+ world.cloud.controls.clearError("renameDirectory")
153
+ world.worker.resetTaskErrors(world.syncPair.uuid)
154
+ world.triggerWatcher()
155
+ }),
156
+ runCycle(),
157
+ runCycle()
158
+ ]
159
+ })
160
+
161
+ const failCycle = result.cycles[1]!
162
+
163
+ // The remote source (d) is still present, so directoryExists(from) is true and the error surfaces.
164
+ expect(hasTransfer(failCycle.messages, "renameRemoteDirectory", "error")).toBe(true)
165
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
166
+ expect(result.finalRemote["/e"]).toMatchObject({ type: "directory" })
167
+ expect(result.finalRemote["/d"]).toBeUndefined()
168
+ expect(result.finalLocal).toEqual(result.finalRemote)
169
+ })
170
+
171
+ it("O5: a download failure surfaces both a download and a downloadFile error, and a retry converges", async () => {
172
+ // Subdirectory again, so the downloadFile catch's fileExists(path) re-check can resolve the parent.
173
+ const result = await runScenario({
174
+ name: "O5",
175
+ mode: "cloudToLocal",
176
+ initialRemote: { "/sub/a.txt": "x" },
177
+ steps: [
178
+ // Fail the very first cycle, while sub/a.txt must download.
179
+ control(world => world.cloud.controls.setError("downloadFileToLocal", new Error("dl boom"))),
180
+ runCycle(),
181
+ control(world => {
182
+ world.cloud.controls.clearError("downloadFileToLocal")
183
+ world.worker.resetTaskErrors(world.syncPair.uuid)
184
+ world.triggerWatcher()
185
+ }),
186
+ runCycle(),
187
+ runCycle()
188
+ ]
189
+ })
190
+
191
+ const failCycle = result.cycles[0]!
192
+
193
+ // RemoteFileSystem.download posts the `download` error before rethrowing; the downloadFile task then
194
+ // re-checks fileExists (still present → true) and posts the `downloadFile` error + records a task error.
195
+ expect(hasTransfer(failCycle.messages, "download", "error")).toBe(true)
196
+ expect(hasTransfer(failCycle.messages, "downloadFile", "error")).toBe(true)
197
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
198
+ expect(result.finalLocal["/sub/a.txt"]).toMatchObject({ type: "file", size: 1 })
199
+ expect(result.finalLocal).toEqual(result.finalRemote)
200
+ })
201
+
202
+ it("O5b: an aborted download that RESOLVES with a short file is discarded by the integrity guard, then a retry converges", async () => {
203
+ // Unlike O5 (where the SDK throws), the real SDK RESOLVES an aborted download — the read stream ends
204
+ // cleanly, so the pipeline reports no error even though the staged file is incomplete. The engine must
205
+ // NOT commit that short/0-byte file as synced (which would leave local & remote permanently diverged,
206
+ // since the cached size then matches the base). The remote.ts size guard discards it and the next cycle
207
+ // re-downloads in full. The fake's `simulateIncompleteDownload` reproduces the SDK's resolve-with-short
208
+ // behavior, which `setError` (a throw) does not — this pins the guard the live suite first surfaced.
209
+ const result = await runScenario({
210
+ name: "O5b",
211
+ mode: "cloudToLocal",
212
+ initialRemote: { "/r.txt": "remote-content" },
213
+ steps: [
214
+ control(world => world.cloud.controls.simulateIncompleteDownload("/r.txt")),
215
+ runCycle(),
216
+ control(world => {
217
+ world.worker.resetTaskErrors(world.syncPair.uuid)
218
+ world.triggerWatcher()
219
+ }),
220
+ runCycle(),
221
+ runCycle()
222
+ ]
223
+ })
224
+
225
+ const failCycle = result.cycles[0]!
226
+
227
+ // The incomplete download was detected and surfaced as a download error — not silently committed.
228
+ expect(hasTransfer(failCycle.messages, "download", "error")).toBe(true)
229
+ // The regression guard: WITHOUT the size check the 0-byte file is committed + cached as synced, so the
230
+ // base matches and it never re-downloads (finalLocal stays size 0, diverged). WITH the guard the short
231
+ // file is discarded and the engine re-fetches the full 14 bytes, converging.
232
+ expect(result.finalLocal["/r.txt"]).toMatchObject({ type: "file", size: 14 })
233
+ expect(result.finalLocal).toEqual(result.finalRemote)
234
+ })
235
+
236
+ // LOCAL error-surface: inject on a sub-path the op touches but the existence re-check does NOT.
237
+
238
+ it("O6: a local mkdir failure surfaces a createLocalDirectory error, and a retry converges", async () => {
239
+ const result = await runScenario({
240
+ name: "O6",
241
+ mode: "cloudToLocal",
242
+ initialRemote: { "/sub/keep.txt": "x" },
243
+ steps: [
244
+ // createLocalDirectory("/sub") does ensureDir("/local/sub"); inject EACCES there. This case has
245
+ // no existence re-check, so the error surfaces directly. (/local/sub is not in the local tree
246
+ // scan yet, so the injection only affects the mkdir.)
247
+ control(world => world.vfs.controls.setError("/local/sub", makeErrnoError("EACCES"))),
248
+ runCycle(),
249
+ control(world => {
250
+ world.vfs.controls.clearError("/local/sub")
251
+ world.worker.resetTaskErrors(world.syncPair.uuid)
252
+ world.triggerWatcher()
253
+ }),
254
+ runCycle(),
255
+ runCycle()
256
+ ]
257
+ })
258
+
259
+ const failCycle = result.cycles[0]!
260
+
261
+ expect(hasTransfer(failCycle.messages, "createLocalDirectory", "error")).toBe(true)
262
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
263
+ expect(result.finalLocal["/sub"]).toMatchObject({ type: "directory" })
264
+ expect(result.finalLocal["/sub/keep.txt"]).toMatchObject({ type: "file", size: 1 })
265
+ expect(result.finalLocal).toEqual(result.finalRemote)
266
+ })
267
+
268
+ it("O7: a local delete I/O failure surfaces a deleteLocalFile error (not silently swallowed)", async () => {
269
+ // The deleteLocal catch re-checks `localFileSystem.pathExists(join(syncPair.localPath, delta.path))`,
270
+ // so when the unlink fails while the file is still present the re-check returns true and the error
271
+ // surfaces — a failed delete is no longer swallowed and mis-recorded as success. (BUG-007 fix: the
272
+ // re-check previously used the RELATIVE path, which never existed, so every failure was swallowed.)
273
+ const result = await runScenario({
274
+ name: "O7",
275
+ mode: "cloudToLocal",
276
+ initialRemote: { "/a.txt": "x" },
277
+ steps: [
278
+ runCycle(),
279
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
280
+ // ensureDir(<localRoot>/.filen.trash.local) throws while /local/a.txt still exists → the unlink
281
+ // genuinely fails with the target present, which SHOULD surface a deleteLocalFile error.
282
+ control(world => world.vfs.controls.setError("/local/.filen.trash.local", makeErrnoError("EACCES"))),
283
+ runCycle()
284
+ ]
285
+ })
286
+
287
+ const failCycle = result.cycles[1]!
288
+
289
+ expect(hasTransfer(failCycle.messages, "deleteLocalFile", "error")).toBe(true)
290
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
291
+ })
292
+
293
+ it("O8: a local rename failure surfaces a renameLocalFile error, and a retry converges", async () => {
294
+ const result = await runScenario({
295
+ name: "O8",
296
+ mode: "cloudToLocal",
297
+ initialRemote: { "/a.txt": "x" },
298
+ steps: [
299
+ runCycle(),
300
+ // Remote-move a.txt into a NEW subdir: the local rename's ensureDir(<dest parent>) is then
301
+ // "/local/sub" — a path the local tree scan never touches and which leaves the source
302
+ // "/local/a.txt" untouched. So ensureDir throws, the re-check pathExists(from) stays true, and
303
+ // the renameLocalFile error surfaces (rather than being swallowed as a vanished source).
304
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/sub/b.txt")),
305
+ control(world => world.vfs.controls.setError("/local/sub", makeErrnoError("EACCES"))),
306
+ runCycle(),
307
+ control(world => {
308
+ world.vfs.controls.clearError("/local/sub")
309
+ world.worker.resetTaskErrors(world.syncPair.uuid)
310
+ world.triggerWatcher()
311
+ }),
312
+ runCycle(),
313
+ runCycle()
314
+ ]
315
+ })
316
+
317
+ const failCycle = result.cycles[1]!
318
+
319
+ expect(hasTransfer(failCycle.messages, "renameLocalFile", "error")).toBe(true)
320
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
321
+ expect(result.finalLocal["/sub/b.txt"]).toMatchObject({ type: "file", size: 1 })
322
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
323
+ expect(result.finalLocal).toEqual(result.finalRemote)
324
+ })
325
+
326
+ // "Already-vanished" SWALLOW: the target is gone before the task runs → silent skip, no task error.
327
+
328
+ it("O9: a concurrent local+remote delete converges with no task error (deleteLocalFile vanish)", async () => {
329
+ const result = await runScenario({
330
+ name: "O9",
331
+ mode: "cloudToLocal",
332
+ initialRemote: { "/a.txt": "x" },
333
+ steps: [
334
+ runCycle(),
335
+ // The remote copy is trashed and the local copy is removed in the same beat: the fresh local
336
+ // scan no longer sees a.txt, so no deleteLocalFile is generated — a silent, error-free no-op.
337
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
338
+ localMutate(world => rmLocal(world, "a.txt")),
339
+ runCycle(),
340
+ runCycle()
341
+ ]
342
+ })
343
+
344
+ expect(taskErrorCount(result.messages)).toBe(0)
345
+ expect(hasTransfer(result.messages, "deleteLocalFile", "error")).toBe(false)
346
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
347
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
348
+ expect(result.finalLocal).toEqual(result.finalRemote)
349
+ })
350
+
351
+ it("O10: cloudToLocal — a remote rename racing a local delete of the source converges (no doomed rename)", async () => {
352
+ const result = await runScenario({
353
+ name: "O10",
354
+ mode: "cloudToLocal",
355
+ initialRemote: { "/a.txt": "x" },
356
+ steps: [
357
+ runCycle(),
358
+ // The remote renames a.txt→b.txt while the local source a is removed in the same beat. In
359
+ // cloudToLocal the remote is authoritative: renaming a local source that the local side just
360
+ // changed (here: deleted) would target a stale/absent node, so the engine correctly does NOT
361
+ // emit the rename — it downloads the remote's b instead, so the worlds still converge. (F2)
362
+ remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")),
363
+ localMutate(world => rmLocal(world, "a.txt")),
364
+ runCycle(),
365
+ runCycle()
366
+ ]
367
+ })
368
+
369
+ // No task error and no errored rename transfer; the remote's b is mirrored down and both sides agree.
370
+ expect(taskErrorCount(result.messages)).toBe(0)
371
+ expect(hasTransfer(result.messages, "renameLocalFile", "error")).toBe(false)
372
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: 1 })
373
+ expect(result.finalLocal).toEqual(result.finalRemote)
374
+ })
375
+
376
+ // Directory variants of the shared deleteLocal / renameLocal cases (the file variants are O7/O8/O10):
377
+ // these exercise the directory switch-cases and their success → state-applied paths.
378
+
379
+ it("O11: a remote directory deletion propagates to a local deleteLocalDirectory and converges", async () => {
380
+ const result = await runScenario({
381
+ name: "O11",
382
+ mode: "cloudToLocal",
383
+ initialRemote: { "/d": null },
384
+ steps: [
385
+ runCycle(),
386
+ remoteMutate(world => world.cloud.controls.trashPath("/d")),
387
+ runCycle(),
388
+ runCycle()
389
+ ]
390
+ })
391
+
392
+ expect(hasTransfer(result.cycles[1]!.messages, "deleteLocalDirectory", "success")).toBe(true)
393
+ expect(taskErrorCount(result.messages)).toBe(0)
394
+ expect(result.finalLocal["/d"]).toBeUndefined()
395
+ expect(result.finalLocal).toEqual(result.finalRemote)
396
+ })
397
+
398
+ it("O12: a remote directory rename propagates to a local renameLocalDirectory and converges", async () => {
399
+ const result = await runScenario({
400
+ name: "O12",
401
+ mode: "cloudToLocal",
402
+ initialRemote: { "/d": null },
403
+ steps: [
404
+ runCycle(),
405
+ remoteMutate(world => world.cloud.controls.movePath("/d", "/e")),
406
+ runCycle(),
407
+ runCycle()
408
+ ]
409
+ })
410
+
411
+ expect(hasTransfer(result.cycles[1]!.messages, "renameLocalDirectory", "success")).toBe(true)
412
+ expect(taskErrorCount(result.messages)).toBe(0)
413
+ expect(result.finalLocal["/e"]).toMatchObject({ type: "directory" })
414
+ expect(result.finalLocal["/d"]).toBeUndefined()
415
+ expect(result.finalLocal).toEqual(result.finalRemote)
416
+ })
417
+
418
+ // O13 (F1 regression): an upload that fails AFTER the file was already synced once must not poison the
419
+ // md5 dedup cache. The modify-branch upload (deltas.ts) writes localFileHashes[path] = newHash and only
420
+ // THEN calls uploadLocalFile; if the upload throws, the hash must be reverted (or written only on
421
+ // success). Otherwise, after the host clears the task error (resetTaskErrors — which does NOT clear
422
+ // localFileHashes), the next cycle recomputes the same newHash, finds it already equal to the poisoned
423
+ // cache entry, and SUPPRESSES the re-upload → the local edit is silently never pushed and the sides
424
+ // diverge permanently. This is the modify branch (file present on BOTH sides); a first upload goes
425
+ // through the additions branch which has no dedup, so only an edit-after-sync exposes it.
426
+ it("O13: an upload failure on a MODIFIED (already-synced) file does not suppress the retry (F1)", async () => {
427
+ const result = await runScenario({
428
+ name: "O13",
429
+ mode: "twoWay",
430
+ initialLocal: { "/local/a.txt": "v0-initial" },
431
+ steps: [
432
+ runCycle(),
433
+ // Edit the already-synced file: distinct size + newer whole-second mtime → modify branch, localWins.
434
+ localMutate(world => writeLocalAt(world, "a.txt", "v1-edited-longer-content", BASE_TIME + 5 * SECOND)),
435
+ control(world => world.cloud.controls.setError("uploadLocalFile", new Error("upload boom"))),
436
+ runCycle(),
437
+ // Host recovery: clear the injected fault and the task error. Crucially does NOT touch localFileHashes.
438
+ control(world => {
439
+ world.cloud.controls.clearError("uploadLocalFile")
440
+ world.worker.resetTaskErrors(world.syncPair.uuid)
441
+ world.triggerWatcher()
442
+ }),
443
+ runCycle(),
444
+ runCycle()
445
+ ]
446
+ })
447
+
448
+ const failCycle = result.cycles[1]!
449
+
450
+ // The first attempt surfaced an upload error and gated the cycle.
451
+ expect(hasTransfer(failCycle.messages, "upload", "error")).toBe(true)
452
+ expect(taskErrorCount(failCycle.messages)).toBeGreaterThan(0)
453
+ // After recovery the edit IS pushed and the sides converge on the new content (not the stale v0).
454
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v1-edited-longer-content".length })
455
+ expect(result.finalLocal).toEqual(result.finalRemote)
456
+ })
457
+
458
+ // O14 (F4 regression): a deleteLocalFile whose target already vanished must still evict the cache entry,
459
+ // otherwise that entry is persisted into the base tree as a PHANTOM. A later re-creation of the same path
460
+ // on the remote would then be read as a local deletion to propagate, and the engine would DELETE the
461
+ // re-created remote file instead of downloading it. The local unlink evicts the cache only AFTER a
462
+ // successful move/rm; when the source is already gone the move throws and the eviction is skipped — the
463
+ // fix makes unlink idempotent (evict when the source is confirmed gone). The remote unlink already does
464
+ // this via cleanItemEntry; this restores the symmetry on the local side.
465
+ it("O14: a deleteLocalFile on an already-vanished target evicts the cache, so a re-created remote file is not mis-deleted (F4)", async () => {
466
+ const result = await runScenario({
467
+ name: "O14",
468
+ mode: "twoWay",
469
+ initialLocal: { "/local/a.txt": "v0" },
470
+ steps: [
471
+ runCycle(),
472
+ // Remote trashes a.txt → next cycle mirrors the delete down to the local side.
473
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
474
+ // The local copy vanishes concurrently WITHOUT a watcher event (a control step does not trigger
475
+ // the watcher), so the cached local scan still lists a.txt and a deleteLocalFile is generated —
476
+ // but the file is already gone when the task runs (move → ENOENT).
477
+ control(world => rmLocal(world, "a.txt")),
478
+ runCycle(),
479
+ // Another device re-creates a.txt remotely (new uuid). A phantom base entry for the old local
480
+ // a.txt would make the engine read this as a local deletion to propagate, deleting the new file.
481
+ remoteMutate(world => world.cloud.controls.addFile("/a.txt", "v2-recreated", { mtimeMs: BASE_TIME + 9 * SECOND })),
482
+ control(world => world.triggerWatcher()),
483
+ runCycle(),
484
+ runCycle()
485
+ ]
486
+ })
487
+
488
+ expect(taskErrorCount(result.messages)).toBe(0)
489
+ // The re-created remote file must SURVIVE and be mirrored down — not deleted by a phantom base entry.
490
+ expect(result.finalRemote["/a.txt"], "re-created remote file must survive").toMatchObject({
491
+ type: "file",
492
+ size: "v2-recreated".length
493
+ })
494
+ expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: "v2-recreated".length })
495
+ expect(result.finalLocal).toEqual(result.finalRemote)
496
+ })
497
+ })