@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,296 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds } from "../harness/snapshot"
5
+ import { renameLocal, writeLocalAt, rmLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Cross-side directory rename + concurrent child change (BUG-A / BUG-B). A directory renamed on ONE side
9
+ * while a descendant is changed on the OTHER side is the hardest reconciliation case: the rename relocates
10
+ * the whole subtree, but the per-descendant passes compare current-vs-base by PATH, so a child still sitting
11
+ * at the pre-rename path on the other side is mis-attributed. Before the rename-aware rebase, this silently
12
+ * destroyed the other-side modification (BUG-A) or resurrected an other-side deletion (BUG-B). Every case
13
+ * must converge (finalLocal === finalRemote) with the NEWER change winning and no data loss.
14
+ *
15
+ * Category E covers same-side rename mechanics; Z covers same-side multi-op; Y covers same-path conflicts.
16
+ * This file is specifically the cross-side directory-subtree race.
17
+ */
18
+ const SECOND = 1000
19
+
20
+ describe("Cross-side directory rename + concurrent child change (BUG-A / BUG-B)", () => {
21
+ it("ZB1: local dir rename + remote child MODIFY → the remote edit survives (BUG-A)", async () => {
22
+ const result = await runScenario({
23
+ name: "ZB1",
24
+ mode: "twoWay",
25
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
26
+ steps: [
27
+ runCycle(),
28
+ localMutate(world => renameLocal(world, "dir", "dir2")),
29
+ remoteMutate(world =>
30
+ world.cloud.controls.updateFile("/dir/child.txt", "REMOTE-EDITED-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
31
+ ),
32
+ runCycle(),
33
+ runCycle(),
34
+ runCycle()
35
+ ]
36
+ })
37
+
38
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "REMOTE-EDITED-NEW-CONTENT".length })
39
+ expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
40
+ expect(result.finalRemote["/dir/child.txt"]).toBeUndefined()
41
+ expect(result.finalLocal).toEqual(result.finalRemote)
42
+ })
43
+
44
+ it("ZB2: remote dir rename + local child MODIFY → the local edit survives (BUG-A symmetric)", async () => {
45
+ const result = await runScenario({
46
+ name: "ZB2",
47
+ mode: "twoWay",
48
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
49
+ steps: [
50
+ runCycle(),
51
+ remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
52
+ localMutate(world => writeLocalAt(world, "dir/child.txt", "LOCAL-EDITED-NEW-CONTENT", BASE_TIME + 10 * SECOND)),
53
+ runCycle(),
54
+ runCycle(),
55
+ runCycle()
56
+ ]
57
+ })
58
+
59
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "LOCAL-EDITED-NEW-CONTENT".length })
60
+ expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
61
+ expect(result.finalLocal).toEqual(result.finalRemote)
62
+ })
63
+
64
+ it("ZB3: local dir rename + remote child DELETE → the child is deleted, not resurrected (BUG-B)", async () => {
65
+ const result = await runScenario({
66
+ name: "ZB3",
67
+ mode: "twoWay",
68
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/keep.txt": "k" },
69
+ steps: [
70
+ runCycle(),
71
+ localMutate(world => renameLocal(world, "dir", "dir2")),
72
+ remoteMutate(world => world.cloud.controls.trashPath("/dir/child.txt")),
73
+ runCycle(),
74
+ runCycle(),
75
+ runCycle()
76
+ ]
77
+ })
78
+
79
+ // The renamed directory survives with its un-touched child; the remotely-deleted child stays deleted.
80
+ expect(result.finalRemote["/dir2/keep.txt"]).toMatchObject({ size: "k".length })
81
+ expect(result.finalRemote["/dir2/child.txt"]).toBeUndefined()
82
+ expect(result.finalLocal["/dir2/child.txt"]).toBeUndefined()
83
+ expect(result.finalLocal).toEqual(result.finalRemote)
84
+ })
85
+
86
+ it("ZB4: remote dir rename + local child DELETE → the child is deleted, not resurrected (BUG-B symmetric)", async () => {
87
+ const result = await runScenario({
88
+ name: "ZB4",
89
+ mode: "twoWay",
90
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/keep.txt": "k" },
91
+ steps: [
92
+ runCycle(),
93
+ remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
94
+ // The user deletes a file inside a folder another device just renamed. Without the rebase the
95
+ // remote copy (now at /dir2/child.txt) would be resurrected back down instead of deleted.
96
+ localMutate(world => rmLocal(world, "dir/child.txt")),
97
+ runCycle(),
98
+ runCycle(),
99
+ runCycle()
100
+ ]
101
+ })
102
+
103
+ expect(result.finalRemote["/dir2/keep.txt"]).toMatchObject({ size: "k".length })
104
+ expect(result.finalRemote["/dir2/child.txt"]).toBeUndefined()
105
+ expect(result.finalLocal["/dir2/child.txt"]).toBeUndefined()
106
+ expect(result.finalLocal).toEqual(result.finalRemote)
107
+ })
108
+
109
+ it("ZB5: NESTED — rename a top dir + remote modify of a DEEPLY nested child → edit survives at the new path", async () => {
110
+ const result = await runScenario({
111
+ name: "ZB5",
112
+ mode: "twoWay",
113
+ initialLocal: { "/local/top/mid/deep/child.txt": "old", "/local/top/other.txt": "o" },
114
+ steps: [
115
+ runCycle(),
116
+ localMutate(world => renameLocal(world, "top", "top2")),
117
+ remoteMutate(world =>
118
+ world.cloud.controls.updateFile("/top/mid/deep/child.txt", "DEEPLY-NESTED-NEW-CONTENT", {
119
+ mtimeMs: BASE_TIME + 10 * SECOND
120
+ })
121
+ ),
122
+ runCycle(),
123
+ runCycle(),
124
+ runCycle()
125
+ ]
126
+ })
127
+
128
+ expect(result.finalRemote["/top2/mid/deep/child.txt"]).toMatchObject({ size: "DEEPLY-NESTED-NEW-CONTENT".length })
129
+ expect(result.finalRemote["/top2/other.txt"]).toMatchObject({ size: "o".length })
130
+ expect(result.finalRemote["/top/mid/deep/child.txt"]).toBeUndefined()
131
+ expect(result.finalLocal).toEqual(result.finalRemote)
132
+ })
133
+
134
+ it("ZB6: MULTIPLE children — rename dir; remote modifies one, deletes one, leaves one", async () => {
135
+ const result = await runScenario({
136
+ name: "ZB6",
137
+ mode: "twoWay",
138
+ initialLocal: { "/local/dir/a.txt": "a-old", "/local/dir/b.txt": "b-old", "/local/dir/c.txt": "c-old" },
139
+ steps: [
140
+ runCycle(),
141
+ localMutate(world => renameLocal(world, "dir", "dir2")),
142
+ remoteMutate(world => {
143
+ world.cloud.controls.updateFile("/dir/a.txt", "A-REMOTE-EDITED-NEW", { mtimeMs: BASE_TIME + 10 * SECOND })
144
+ world.cloud.controls.trashPath("/dir/b.txt")
145
+ }),
146
+ runCycle(),
147
+ runCycle(),
148
+ runCycle()
149
+ ]
150
+ })
151
+
152
+ expect(result.finalRemote["/dir2/a.txt"]).toMatchObject({ size: "A-REMOTE-EDITED-NEW".length })
153
+ expect(result.finalRemote["/dir2/b.txt"]).toBeUndefined()
154
+ expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ size: "c-old".length })
155
+ expect(result.finalLocal).toEqual(result.finalRemote)
156
+ })
157
+
158
+ it("ZB7: MOVE a dir into another dir + remote child modify → edit survives at the moved-into path", async () => {
159
+ const result = await runScenario({
160
+ name: "ZB7",
161
+ mode: "twoWay",
162
+ initialLocal: { "/local/src/data.txt": "old", "/local/dest/keep.txt": "k" },
163
+ steps: [
164
+ runCycle(),
165
+ localMutate(world => renameLocal(world, "src", "dest/src")),
166
+ remoteMutate(world =>
167
+ world.cloud.controls.updateFile("/src/data.txt", "MOVED-DIR-CHILD-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
168
+ ),
169
+ runCycle(),
170
+ runCycle(),
171
+ runCycle()
172
+ ]
173
+ })
174
+
175
+ expect(result.finalRemote["/dest/src/data.txt"]).toMatchObject({ size: "MOVED-DIR-CHILD-NEW-CONTENT".length })
176
+ expect(result.finalRemote["/dest/keep.txt"]).toMatchObject({ size: "k".length })
177
+ expect(result.finalRemote["/src/data.txt"]).toBeUndefined()
178
+ expect(result.finalLocal).toEqual(result.finalRemote)
179
+ })
180
+
181
+ it("ZB8: local dir rename + remote ADDS a new child under the old path → the new child lands in the renamed dir", async () => {
182
+ const result = await runScenario({
183
+ name: "ZB8",
184
+ mode: "twoWay",
185
+ initialLocal: { "/local/dir/child.txt": "old" },
186
+ steps: [
187
+ runCycle(),
188
+ localMutate(world => renameLocal(world, "dir", "dir2")),
189
+ remoteMutate(world => world.cloud.controls.addFile("/dir/new.txt", "REMOTE-ADDED-CHILD", { mtimeMs: BASE_TIME + 10 * SECOND })),
190
+ runCycle(),
191
+ runCycle(),
192
+ runCycle()
193
+ ]
194
+ })
195
+
196
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
197
+ expect(result.finalRemote["/dir2/new.txt"]).toMatchObject({ size: "REMOTE-ADDED-CHILD".length })
198
+ expect(result.finalLocal).toEqual(result.finalRemote)
199
+ })
200
+
201
+ it("ZB9: a SIMPLE dir rename (no child change) converges with NO file transfer (regression guard)", async () => {
202
+ const result = await runScenario({
203
+ name: "ZB9",
204
+ mode: "twoWay",
205
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
206
+ steps: [
207
+ runCycle(),
208
+ localMutate(world => renameLocal(world, "dir", "dir2")),
209
+ runCycle(),
210
+ runCycle()
211
+ ]
212
+ })
213
+
214
+ // The rename must NOT degrade into a re-upload/re-download of the unchanged children.
215
+ const renameCycleKinds = transferKinds(result.cycles[1]!.messages)
216
+ expect(renameCycleKinds).not.toContain("upload")
217
+ expect(renameCycleKinds).not.toContain("download")
218
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
219
+ expect(result.finalRemote["/dir2/sibling.txt"]).toMatchObject({ size: "sib".length })
220
+ expect(result.finalLocal).toEqual(result.finalRemote)
221
+ })
222
+
223
+ it("ZB10: after a cross-side rename+modify converges, an extra cycle is a no-op (multi-cycle stability)", async () => {
224
+ const result = await runScenario({
225
+ name: "ZB10",
226
+ mode: "twoWay",
227
+ initialLocal: { "/local/dir/child.txt": "old" },
228
+ steps: [
229
+ runCycle(),
230
+ localMutate(world => renameLocal(world, "dir", "dir2")),
231
+ remoteMutate(world =>
232
+ world.cloud.controls.updateFile("/dir/child.txt", "REMOTE-EDITED-NEW-CONTENT", { mtimeMs: BASE_TIME + 10 * SECOND })
233
+ ),
234
+ runCycle(),
235
+ runCycle(),
236
+ runCycle(),
237
+ // A final settled cycle must produce no transfers.
238
+ runCycle()
239
+ ]
240
+ })
241
+
242
+ const lastCycleKinds = transferKinds(result.cycles[result.cycles.length - 1]!.messages)
243
+ expect(lastCycleKinds).not.toContain("upload")
244
+ expect(lastCycleKinds).not.toContain("download")
245
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "REMOTE-EDITED-NEW-CONTENT".length })
246
+ expect(result.finalLocal).toEqual(result.finalRemote)
247
+ })
248
+
249
+ // Mirror-mode coverage: BUG-A's DATA LOSS is twoWay-specific (a mirror's authoritative side always wins
250
+ // correctly), but the rename-aware rebase must still keep mirror modes CONVERGENT and the authoritative
251
+ // side's content winning at the renamed path.
252
+
253
+ it("ZB11: localToCloud — local dir rename + a foreign remote child edit → local content wins, converges", async () => {
254
+ const result = await runScenario({
255
+ name: "ZB11",
256
+ mode: "localToCloud",
257
+ initialLocal: { "/local/dir/child.txt": "old", "/local/dir/sibling.txt": "sib" },
258
+ steps: [
259
+ runCycle(),
260
+ localMutate(world => renameLocal(world, "dir", "dir2")),
261
+ remoteMutate(world =>
262
+ world.cloud.controls.updateFile("/dir/child.txt", "FOREIGN-REMOTE-EDIT", { mtimeMs: BASE_TIME + 10 * SECOND })
263
+ ),
264
+ runCycle(),
265
+ runCycle(),
266
+ runCycle()
267
+ ]
268
+ })
269
+
270
+ // Local authoritative: the foreign remote edit is reverted to the local content at the renamed path.
271
+ expect(result.finalRemote["/dir2/child.txt"]).toMatchObject({ size: "old".length })
272
+ expect(result.finalRemote["/dir/child.txt"]).toBeUndefined()
273
+ expect(result.finalLocal).toEqual(result.finalRemote)
274
+ })
275
+
276
+ it("ZB12: cloudToLocal — remote dir rename + a foreign local child edit → remote content wins, converges", async () => {
277
+ const result = await runScenario({
278
+ name: "ZB12",
279
+ mode: "cloudToLocal",
280
+ initialRemote: { "/dir/child.txt": "old", "/dir/sibling.txt": "sib" },
281
+ steps: [
282
+ runCycle(),
283
+ remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")),
284
+ localMutate(world => writeLocalAt(world, "dir/child.txt", "FOREIGN-LOCAL-EDIT", BASE_TIME + 10 * SECOND)),
285
+ runCycle(),
286
+ runCycle(),
287
+ runCycle()
288
+ ]
289
+ })
290
+
291
+ // Remote authoritative: the foreign local edit is reverted to the remote content at the renamed path.
292
+ expect(result.finalLocal["/dir2/child.txt"]).toMatchObject({ size: "old".length })
293
+ expect(result.finalLocal["/dir/child.txt"]).toBeUndefined()
294
+ expect(result.finalLocal).toEqual(result.finalRemote)
295
+ })
296
+ })
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, restart, localMutate, remoteMutate, control, type Step } from "../harness/runner"
3
+ import { writeLocalAt, renameLocal, rmLocal } from "../harness/mutations"
4
+ import { BASE_TIME } from "../harness/world"
5
+
6
+ const SECOND = 1000
7
+
8
+ /**
9
+ * Category ZC — crash / stop mid-run recovery.
10
+ *
11
+ * The engine has no write-ahead log; instead it relies on a single invariant: the persisted base
12
+ * (`previousLocalTree`/`previousRemoteTree`) is advanced ONLY at the end of a cycle that had ZERO task
13
+ * errors (`sync.ts` — `if (this.taskErrors.length === 0) { ... state.save() }`). So if the process is
14
+ * killed — or the user stops the sync — mid-cycle, the on-disk base stays at the LAST CLEAN cycle while
15
+ * the real filesystem / remote may already be partially advanced. On the next boot a fresh engine loads
16
+ * that stale base, re-scans both sides, and re-derives whatever work is still outstanding. The recovery
17
+ * is "at-least-once": an already-applied transfer may be re-derived, but it converges with no data loss
18
+ * and no duplication.
19
+ *
20
+ * A crash is modelled faithfully by (1) running a cycle in which one task fails — which gates the cycle
21
+ * and SKIPS the state save while OTHER tasks in the same cycle have already hit the fake cloud — then
22
+ * (2) `restart()`, which rebuilds the engine over the SAME virtual fs + cloud and reloads the (stale)
23
+ * persisted base, discarding all in-memory cycle progress. That on-disk outcome (base behind reality,
24
+ * in-memory state gone) is identical to a hard `kill -9` between task application and the state save.
25
+ *
26
+ * Distinct from Category S (restart between SETTLED cycles, where base == reality and the first cycle is
27
+ * a trivial no-op): here the base is deliberately BEHIND reality, exercising the self-heal path.
28
+ */
29
+
30
+ function settle(): Step[] {
31
+ return [runCycle(), runCycle()]
32
+ }
33
+
34
+ describe("Category ZC — crash / stop mid-run recovery", () => {
35
+ it("ZC1: a crash after a partially-applied cycle (upload landed, rename did not, state NOT saved) heals on restart", async () => {
36
+ const result = await runScenario({
37
+ name: "ZC1",
38
+ mode: "twoWay",
39
+ initialLocal: { "/local/a.txt": "a", "/local/dir/c.txt": "c" },
40
+ steps: [
41
+ ...settle(),
42
+ // Two INDEPENDENT local changes in one cycle: add new.txt (its upload lands on the fake
43
+ // remote) and rename dir -> dir2 (the remote rename is forced to fail = the "crash" point).
44
+ localMutate(world => writeLocalAt(world, "new.txt", "fresh", BASE_TIME + 10 * SECOND)),
45
+ localMutate(world => renameLocal(world, "dir", "dir2")),
46
+ control(world => world.cloud.controls.setError("renameDirectory", new Error("crash: rename never reached the server"))),
47
+ // new.txt uploads; the dir rename throws -> taskErrors > 0 -> state.save() is SKIPPED.
48
+ runCycle(),
49
+ // Process dies here: in-memory progress is discarded and the on-disk base is still PRE-cycle.
50
+ restart(),
51
+ control(world => {
52
+ world.cloud.controls.clearError("renameDirectory")
53
+ world.triggerWatcher()
54
+ }),
55
+ runCycle(),
56
+ runCycle(),
57
+ runCycle()
58
+ ]
59
+ })
60
+
61
+ // Guard the premise: the crash cycle (index 2, after the two settle cycles) genuinely left PARTIAL
62
+ // state — new.txt's upload reached the remote, but the rename did not. Without this the test could
63
+ // silently degrade into "everything just syncs after a restart".
64
+ const crashCycle = result.cycles[2]!
65
+
66
+ expect(crashCycle.remote["/new.txt"]).toMatchObject({ type: "file" })
67
+ expect(crashCycle.remote["/dir/c.txt"]).toMatchObject({ type: "file" })
68
+ expect(crashCycle.remote["/dir2"]).toBeUndefined()
69
+
70
+ // No loss, no duplication: the already-uploaded file survives exactly once, the lost rename
71
+ // completes, and the stale base is fully reconciled.
72
+ expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file", size: "fresh".length })
73
+ expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ type: "file" })
74
+ expect(result.finalRemote["/dir/c.txt"]).toBeUndefined()
75
+ expect(result.finalRemote["/dir"]).toBeUndefined()
76
+ expect(result.finalLocal).toEqual(result.finalRemote)
77
+ })
78
+
79
+ it("ZC2: a crash with un-synced changes on BOTH sides (base behind reality both ways) converges on restart", async () => {
80
+ const result = await runScenario({
81
+ name: "ZC2",
82
+ mode: "twoWay",
83
+ initialLocal: { "/local/base.txt": "base" },
84
+ steps: [
85
+ ...settle(),
86
+ // Both sides change, but the engine dies before ANY cycle syncs them: the on-disk base only
87
+ // knows base.txt, yet local has local-only.txt and the remote has remote-only.txt.
88
+ localMutate(world => writeLocalAt(world, "local-only.txt", "L", BASE_TIME + 10 * SECOND)),
89
+ remoteMutate(world => world.cloud.controls.addFile("/remote-only.txt", "R", { mtimeMs: BASE_TIME + 10 * SECOND })),
90
+ restart(),
91
+ control(world => world.triggerWatcher()),
92
+ runCycle(),
93
+ runCycle(),
94
+ runCycle()
95
+ ]
96
+ })
97
+
98
+ // Both un-synced additions are picked up from the real fs/remote despite the stale base — neither
99
+ // is lost, and the deleted-detection gate does NOT mistake "present but not in base" for a deletion.
100
+ expect(result.finalLocal["/local-only.txt"]).toMatchObject({ type: "file" })
101
+ expect(result.finalLocal["/remote-only.txt"]).toMatchObject({ type: "file" })
102
+ expect(result.finalLocal["/base.txt"]).toMatchObject({ type: "file" })
103
+ expect(result.finalRemote["/local-only.txt"]).toMatchObject({ type: "file" })
104
+ expect(result.finalRemote["/remote-only.txt"]).toMatchObject({ type: "file" })
105
+ expect(result.finalLocal).toEqual(result.finalRemote)
106
+ })
107
+
108
+ it("ZC3: a crash mid-deletion (one delete applied, one failed, state NOT saved) does NOT resurrect on restart", async () => {
109
+ const result = await runScenario({
110
+ name: "ZC3",
111
+ mode: "twoWay",
112
+ initialLocal: {
113
+ "/local/gone.txt": "g",
114
+ "/local/keepdir/k.txt": "k",
115
+ "/local/deldir/d.txt": "d"
116
+ },
117
+ steps: [
118
+ ...settle(),
119
+ // Delete a file AND a directory locally in one cycle; the remote DIR-trash fails (crash),
120
+ // but the remote FILE-trash lands — so gone.txt is already removed remotely when we die.
121
+ localMutate(world => rmLocal(world, "gone.txt")),
122
+ localMutate(world => rmLocal(world, "deldir")),
123
+ control(world => world.cloud.controls.setError("trashDirectory", new Error("crash: dir delete never reached the server"))),
124
+ runCycle(),
125
+ // Reload the stale base: it still lists gone.txt AND deldir as present on BOTH sides.
126
+ restart(),
127
+ control(world => {
128
+ world.cloud.controls.clearError("trashDirectory")
129
+ world.triggerWatcher()
130
+ }),
131
+ runCycle(),
132
+ runCycle(),
133
+ runCycle()
134
+ ]
135
+ })
136
+
137
+ // Guard the premise: the crash cycle genuinely left a HALF-APPLIED deletion — gone.txt was already
138
+ // trashed remotely, but deldir was not.
139
+ const crashCycle = result.cycles[2]!
140
+
141
+ expect(crashCycle.remote["/gone.txt"]).toBeUndefined()
142
+ expect(crashCycle.remote["/deldir/d.txt"]).toMatchObject({ type: "file" })
143
+
144
+ // The already-applied file deletion must NOT be resurrected from the stale base (both sides
145
+ // genuinely removed it); the pending dir deletion completes; untouched data is preserved.
146
+ expect(result.finalRemote["/gone.txt"]).toBeUndefined()
147
+ expect(result.finalLocal["/gone.txt"]).toBeUndefined()
148
+ expect(result.finalRemote["/deldir"]).toBeUndefined()
149
+ expect(result.finalRemote["/deldir/d.txt"]).toBeUndefined()
150
+ expect(result.finalRemote["/keepdir/k.txt"]).toMatchObject({ type: "file" })
151
+ expect(result.finalLocal).toEqual(result.finalRemote)
152
+ })
153
+
154
+ it("ZC4: localToCloud — a crash after a partially-applied cycle heals on restart (mirror still converges)", async () => {
155
+ const result = await runScenario({
156
+ name: "ZC4",
157
+ mode: "localToCloud",
158
+ initialLocal: { "/local/a.txt": "a", "/local/dir/c.txt": "c" },
159
+ steps: [
160
+ ...settle(),
161
+ localMutate(world => writeLocalAt(world, "new.txt", "fresh", BASE_TIME + 10 * SECOND)),
162
+ localMutate(world => renameLocal(world, "dir", "dir2")),
163
+ control(world => world.cloud.controls.setError("renameDirectory", new Error("crash"))),
164
+ runCycle(),
165
+ restart(),
166
+ control(world => {
167
+ world.cloud.controls.clearError("renameDirectory")
168
+ world.triggerWatcher()
169
+ }),
170
+ runCycle(),
171
+ runCycle(),
172
+ runCycle()
173
+ ]
174
+ })
175
+
176
+ // Guard the premise: the crash cycle left partial state (upload landed, rename did not).
177
+ const crashCycle = result.cycles[2]!
178
+
179
+ expect(crashCycle.remote["/new.txt"]).toMatchObject({ type: "file" })
180
+ expect(crashCycle.remote["/dir2"]).toBeUndefined()
181
+
182
+ // The local mirror is authoritative: after the crash the remote is brought back into line with it,
183
+ // the dropped rename completes, and the partially-uploaded file is present exactly once.
184
+ expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file", size: "fresh".length })
185
+ expect(result.finalRemote["/dir2/c.txt"]).toMatchObject({ type: "file" })
186
+ expect(result.finalRemote["/dir"]).toBeUndefined()
187
+ expect(result.finalLocal).toEqual(result.finalRemote)
188
+ })
189
+ })
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, control, type Step } from "../harness/runner"
3
+ import { writeLocalAt, rmLocal, renameLocal } from "../harness/mutations"
4
+ import { transferKinds } from "../harness/snapshot"
5
+ import { BASE_TIME } from "../harness/world"
6
+
7
+ const SECOND = 1000
8
+
9
+ /**
10
+ * Category ZD — inode reuse must not be misread as a rename.
11
+ *
12
+ * The local rename pass keys on inode: an inode that sat at path P in the base but now sits at path Q is
13
+ * treated as "P renamed to Q". But the OS RECYCLES inode numbers — ext4 hands a just-freed inode to the
14
+ * very next created file — so "delete a.txt + create c.txt" can put c.txt on a.txt's old inode and be
15
+ * misread as "rename a.txt -> c.txt". That phantom rename propagates as a REMOTE rename and deletes the
16
+ * original: silent data loss in modes that keep deletions (localBackup), and invisible in twoWay only
17
+ * because the stale path was going to be deleted anyway. This is exactly why the live `lifecycle.e2e` I6
18
+ * test flaked on Linux (ext4) while passing on macOS/Windows and in this fake-fs suite — memfs does not
19
+ * surface the reuse on its own, so these tests force it with the `setInode` control.
20
+ *
21
+ * The fix additionally requires the creation/birthtime to match: a genuine rename (even a rename+modify)
22
+ * preserves birthtime, whereas a reused inode belongs to a freshly-created file with a newer one.
23
+ */
24
+
25
+ function settle(): Step[] {
26
+ return [runCycle(), runCycle()]
27
+ }
28
+
29
+ describe("Category ZD — inode reuse is not a rename", () => {
30
+ it("ZD1: localBackup — a NEW file on a deleted file's recycled inode does NOT delete the original (no phantom rename)", async () => {
31
+ let reusedInode = 0
32
+
33
+ const result = await runScenario({
34
+ name: "ZD1",
35
+ mode: "localBackup",
36
+ initialLocal: { "/local/a.txt": "a", "/local/keep.txt": "k" },
37
+ steps: [
38
+ ...settle(),
39
+ // Capture a.txt's inode, delete it (freeing the inode), create a brand-new c.txt, then force
40
+ // c.txt onto a.txt's freed inode — exactly what ext4 does and memfs will not do on its own.
41
+ control(world => {
42
+ reusedInode = world.vfs.controls.getInode("/local/a.txt")!
43
+ }),
44
+ localMutate(world => rmLocal(world, "a.txt")),
45
+ localMutate(world => writeLocalAt(world, "c.txt", "c", BASE_TIME + 30 * SECOND)),
46
+ control(world => world.vfs.controls.setInode("/local/c.txt", reusedInode)),
47
+ runCycle(),
48
+ runCycle()
49
+ ]
50
+ })
51
+
52
+ const reuseCycle = result.cycles[2]!
53
+
54
+ // The reuse must NOT be propagated as a rename of the remote original.
55
+ expect(transferKinds(reuseCycle.messages)).not.toContain("renameRemoteFile")
56
+ // localBackup keeps remote-only files: the original survives, and the genuinely new file is uploaded.
57
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: 1 })
58
+ expect(result.finalRemote["/c.txt"]).toMatchObject({ type: "file", size: 1 })
59
+ expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
60
+ })
61
+
62
+ it("ZD2: twoWay — inode reuse is a delete+add, never a phantom rename", async () => {
63
+ let reusedInode = 0
64
+
65
+ const result = await runScenario({
66
+ name: "ZD2",
67
+ mode: "twoWay",
68
+ initialLocal: { "/local/a.txt": "aaaa", "/local/keep.txt": "k" },
69
+ steps: [
70
+ ...settle(),
71
+ control(world => {
72
+ reusedInode = world.vfs.controls.getInode("/local/a.txt")!
73
+ }),
74
+ localMutate(world => rmLocal(world, "a.txt")),
75
+ localMutate(world => writeLocalAt(world, "c.txt", "cc", BASE_TIME + 30 * SECOND)),
76
+ control(world => world.vfs.controls.setInode("/local/c.txt", reusedInode)),
77
+ runCycle(),
78
+ runCycle()
79
+ ]
80
+ })
81
+
82
+ const reuseCycle = result.cycles[2]!
83
+
84
+ // twoWay would also end up with a.txt gone and c.txt present even WITH the phantom rename (the rename
85
+ // + a follow-up content upload coincidentally lands the same end state), so the meaningful guarantee
86
+ // is the EMITTED intent: a delete + an add, never a rename of the unrelated original.
87
+ expect(transferKinds(reuseCycle.messages)).not.toContain("renameRemoteFile")
88
+ // The deletion genuinely propagated and the new file carries ITS OWN content ("cc" = 2 bytes), not
89
+ // the original's ("aaaa" = 4 bytes).
90
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
91
+ expect(result.finalRemote["/c.txt"]).toMatchObject({ type: "file", size: 2 })
92
+ expect(result.finalLocal).toEqual(result.finalRemote)
93
+ })
94
+
95
+ it("ZD3: a genuine rename (inode AND birthtime preserved) is still detected as a rename, not a re-upload", async () => {
96
+ const result = await runScenario({
97
+ name: "ZD3",
98
+ mode: "twoWay",
99
+ initialLocal: { "/local/old.txt": "data", "/local/keep.txt": "k" },
100
+ steps: [
101
+ ...settle(),
102
+ localMutate(world => renameLocal(world, "old.txt", "new.txt")),
103
+ runCycle(),
104
+ runCycle()
105
+ ]
106
+ })
107
+
108
+ const renameCycle = result.cycles[2]!
109
+
110
+ // The creation-match guard must NOT over-correct: a real move (memfs rename preserves inode AND
111
+ // birthtime, like a real fs) still propagates as a rename — no content re-upload.
112
+ expect(transferKinds(renameCycle.messages)).toContain("renameRemoteFile")
113
+ expect(transferKinds(renameCycle.messages)).not.toContain("upload")
114
+ expect(result.finalRemote["/new.txt"]).toMatchObject({ type: "file" })
115
+ expect(result.finalRemote["/old.txt"]).toBeUndefined()
116
+ expect(result.finalLocal).toEqual(result.finalRemote)
117
+ })
118
+ })