@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,304 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate, restart } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds, allOps } from "../harness/snapshot"
5
+ import { writeLocalAt, readLocal, existsLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category W — the FULL cloudToLocal operation matrix, the mirror image of Category U. cloudToLocal is a
9
+ * strict one-way MIRROR with the REMOTE authoritative; the local side is forced to match it every cycle:
10
+ * • remote adds / modifies / deletes / renames / moves / type-changes all propagate down to local;
11
+ * • a local-only item is mirror-DELETED (it is not on the remote);
12
+ * • a foreign LOCAL edit to a synced file is REVERTED — remote is re-downloaded over it (F6);
13
+ * • a remote change always wins, even against a newer-mtime foreign local edit (F5);
14
+ * • local is never pushed up to the remote.
15
+ * The worlds CONVERGE (finalLocal === finalRemote) once settled. Contrast with cloudBackup (Category X),
16
+ * which is additive: it never deletes the local copy and tolerates local edits.
17
+ */
18
+ const SECOND = 1000
19
+
20
+ describe("Category W — cloudToLocal (strict mirror remote→local)", () => {
21
+ it("W1: remote files and directories are downloaded to local", async () => {
22
+ const result = await runScenario({
23
+ name: "W1",
24
+ mode: "cloudToLocal",
25
+ initialRemote: { "/a.txt": "alpha", "/dir/b.txt": "bravo", "/dir/empty": null },
26
+ steps: [runCycle(), runCycle(), runCycle()]
27
+ })
28
+
29
+ expect(existsLocal(result.world, "a.txt")).toBe(true)
30
+ expect(readLocal(result.world, "dir/b.txt")).toBe("bravo")
31
+ expect(result.finalLocal["/dir/empty"]).toMatchObject({ type: "directory" })
32
+ expect(result.finalLocal).toEqual(result.finalRemote)
33
+ })
34
+
35
+ it("W2: a remote modification is pulled down to local", async () => {
36
+ const result = await runScenario({
37
+ name: "W2",
38
+ mode: "cloudToLocal",
39
+ initialRemote: { "/a.txt": "v1" },
40
+ steps: [
41
+ runCycle(),
42
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "v2-longer", { mtimeMs: BASE_TIME + 5 * SECOND })),
43
+ runCycle(),
44
+ runCycle()
45
+ ]
46
+ })
47
+
48
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
49
+ expect(readLocal(result.world, "a.txt")).toBe("v2-longer")
50
+ expect(result.finalLocal).toEqual(result.finalRemote)
51
+ })
52
+
53
+ it("W3: a remote file deletion is mirrored to local", async () => {
54
+ const result = await runScenario({
55
+ name: "W3",
56
+ mode: "cloudToLocal",
57
+ initialRemote: { "/a.txt": "data" },
58
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/a.txt")), runCycle(), runCycle()]
59
+ })
60
+
61
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteLocalFile")
62
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
63
+ expect(result.finalLocal).toEqual(result.finalRemote)
64
+ })
65
+
66
+ it("W4: a remote directory deletion mirrors as ONE collapsed local delete", async () => {
67
+ const result = await runScenario({
68
+ name: "W4",
69
+ mode: "cloudToLocal",
70
+ initialRemote: { "/dir/a.txt": "a", "/dir/sub/c.txt": "c" },
71
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/dir")), runCycle(), runCycle()]
72
+ })
73
+
74
+ const kinds = transferKinds(result.cycles[1]!.messages)
75
+
76
+ expect(kinds.filter(kind => kind === "deleteLocalDirectory")).toHaveLength(1)
77
+ expect(kinds).not.toContain("deleteLocalFile")
78
+ expect(result.finalLocal["/dir"]).toBeUndefined()
79
+ expect(result.finalLocal).toEqual(result.finalRemote)
80
+ })
81
+
82
+ it("W5: a remote file rename is mirrored as a local rename (no re-download)", async () => {
83
+ const result = await runScenario({
84
+ name: "W5",
85
+ mode: "cloudToLocal",
86
+ initialRemote: { "/a.txt": "data" },
87
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")), runCycle(), runCycle()]
88
+ })
89
+
90
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameLocalFile")
91
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("download")
92
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
93
+ expect(result.finalLocal["/b.txt"]).toMatchObject({ type: "file" })
94
+ expect(result.finalLocal).toEqual(result.finalRemote)
95
+ })
96
+
97
+ it("W6: a remote directory rename mirrors as ONE collapsed local rename", async () => {
98
+ const result = await runScenario({
99
+ name: "W6",
100
+ mode: "cloudToLocal",
101
+ initialRemote: { "/dir/a.txt": "a", "/dir/b.txt": "b" },
102
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.movePath("/dir", "/dir2")), runCycle(), runCycle()]
103
+ })
104
+
105
+ const kinds = transferKinds(result.cycles[1]!.messages)
106
+
107
+ expect(kinds.filter(kind => kind === "renameLocalDirectory")).toHaveLength(1)
108
+ expect(kinds).not.toContain("download")
109
+ expect(result.finalLocal["/dir"]).toBeUndefined()
110
+ expect(result.finalLocal["/dir2/a.txt"]).toMatchObject({ type: "file" })
111
+ expect(result.finalLocal).toEqual(result.finalRemote)
112
+ })
113
+
114
+ it("W7: a remote cross-directory move is mirrored to local", async () => {
115
+ const result = await runScenario({
116
+ name: "W7",
117
+ mode: "cloudToLocal",
118
+ initialRemote: { "/a.txt": "data", "/sub/keep.txt": "k" },
119
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/sub/a.txt")), runCycle(), runCycle()]
120
+ })
121
+
122
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameLocalFile")
123
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
124
+ expect(result.finalLocal["/sub/a.txt"]).toMatchObject({ type: "file" })
125
+ expect(result.finalLocal).toEqual(result.finalRemote)
126
+ })
127
+
128
+ it("W8: a remote directory→file type change replaces the local tree with the file", async () => {
129
+ const result = await runScenario({
130
+ name: "W8",
131
+ mode: "cloudToLocal",
132
+ initialRemote: { "/x/inner.txt": "inner" },
133
+ steps: [
134
+ runCycle(),
135
+ remoteMutate(world => {
136
+ world.cloud.controls.deletePath("/x")
137
+ world.cloud.controls.addFile("/x", "now-a-file")
138
+ }),
139
+ runCycle(),
140
+ runCycle()
141
+ ]
142
+ })
143
+
144
+ expect(result.finalLocal["/x"]).toMatchObject({ type: "file", size: "now-a-file".length })
145
+ expect(result.finalLocal["/x/inner.txt"]).toBeUndefined()
146
+ expect(result.finalLocal).toEqual(result.finalRemote)
147
+ })
148
+
149
+ it("W9: a local-only file is mirror-DELETED (not pushed, not kept)", async () => {
150
+ const result = await runScenario({
151
+ name: "W9",
152
+ mode: "cloudToLocal",
153
+ initialLocal: { "/local/local-only.txt": "mine" },
154
+ initialRemote: { "/r.txt": "remote" },
155
+ steps: [runCycle(), runCycle(), runCycle()]
156
+ })
157
+
158
+ expect(transferKinds(result.cycles[0]!.messages)).toContain("deleteLocalFile")
159
+ expect(result.finalLocal["/local-only.txt"]).toBeUndefined()
160
+ expect(result.finalLocal["/r.txt"]).toMatchObject({ type: "file" })
161
+ expect(result.finalLocal).toEqual(result.finalRemote)
162
+ })
163
+
164
+ it("W10: a local-only directory subtree is mirror-DELETED", async () => {
165
+ const result = await runScenario({
166
+ name: "W10",
167
+ mode: "cloudToLocal",
168
+ initialLocal: { "/local/localdir/x.txt": "x", "/local/localdir/sub/y.txt": "y" },
169
+ initialRemote: { "/r.txt": "remote" },
170
+ steps: [runCycle(), runCycle(), runCycle()]
171
+ })
172
+
173
+ expect(result.finalLocal["/localdir"]).toBeUndefined()
174
+ expect(result.finalLocal["/localdir/x.txt"]).toBeUndefined()
175
+ expect(result.finalLocal["/r.txt"]).toMatchObject({ type: "file" })
176
+ expect(result.finalLocal).toEqual(result.finalRemote)
177
+ })
178
+
179
+ it("W11: a foreign local edit to a synced file is REVERTED by re-downloading the remote (F6)", async () => {
180
+ const result = await runScenario({
181
+ name: "W11",
182
+ mode: "cloudToLocal",
183
+ initialRemote: { "/a.txt": "remote-content" },
184
+ steps: [
185
+ runCycle(),
186
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-EDIT", BASE_TIME + 9 * SECOND)),
187
+ runCycle(),
188
+ runCycle()
189
+ ]
190
+ })
191
+
192
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
193
+ expect(readLocal(result.world, "a.txt")).toBe("remote-content")
194
+ expect(result.finalLocal).toEqual(result.finalRemote)
195
+ })
196
+
197
+ it("W12: a remote edit wins over a newer-mtime foreign local edit (F5)", async () => {
198
+ const result = await runScenario({
199
+ name: "W12",
200
+ mode: "cloudToLocal",
201
+ initialRemote: { "/a.txt": "orig" },
202
+ steps: [
203
+ runCycle(),
204
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-EDIT", { mtimeMs: BASE_TIME + 3 * SECOND })),
205
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-NEWER", BASE_TIME + 9 * SECOND)),
206
+ runCycle(),
207
+ runCycle()
208
+ ]
209
+ })
210
+
211
+ expect(readLocal(result.world, "a.txt")).toBe("REMOTE-EDIT")
212
+ expect(result.finalLocal).toEqual(result.finalRemote)
213
+ })
214
+
215
+ it("W13: a 0-byte remote file is mirrored to local", async () => {
216
+ const result = await runScenario({
217
+ name: "W13",
218
+ mode: "cloudToLocal",
219
+ initialRemote: { "/empty.txt": "", "/nonempty.txt": "x" },
220
+ steps: [runCycle(), runCycle()]
221
+ })
222
+
223
+ expect(result.finalLocal["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
224
+ expect(result.finalLocal).toEqual(result.finalRemote)
225
+ })
226
+
227
+ it("W14: a local-only file is deleted WHILE a new remote file downloads in the same cycle", async () => {
228
+ const result = await runScenario({
229
+ name: "W14",
230
+ mode: "cloudToLocal",
231
+ initialLocal: { "/local/local-only.txt": "mine" },
232
+ initialRemote: { "/keep.txt": "keep" },
233
+ steps: [
234
+ runCycle(),
235
+ remoteMutate(world => world.cloud.controls.addFile("/added.txt", "added", { mtimeMs: BASE_TIME + 3 * SECOND })),
236
+ runCycle(),
237
+ runCycle()
238
+ ]
239
+ })
240
+
241
+ expect(result.finalLocal["/local-only.txt"]).toBeUndefined()
242
+ expect(result.finalLocal["/keep.txt"]).toMatchObject({ type: "file" })
243
+ expect(result.finalLocal["/added.txt"]).toMatchObject({ type: "file" })
244
+ expect(result.finalLocal).toEqual(result.finalRemote)
245
+ })
246
+
247
+ it("W15: a settled cloudToLocal is idempotent and survives a restart with no work", async () => {
248
+ const result = await runScenario({
249
+ name: "W15",
250
+ mode: "cloudToLocal",
251
+ initialRemote: { "/a.txt": "alpha", "/dir/b.txt": "bravo" },
252
+ steps: [runCycle(), runCycle(), restart(), runCycle(), runCycle()]
253
+ })
254
+
255
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
256
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
257
+ expect(result.finalLocal).toEqual(result.finalRemote)
258
+ })
259
+
260
+ it("W17: a local stray at a remote path (no base, newer mtime, differing size) is overwritten by the remote (F9)", async () => {
261
+ const result = await runScenario({
262
+ name: "W17",
263
+ mode: "cloudToLocal",
264
+ initialRemote: { "/a.txt": "authoritative-remote" },
265
+ steps: [
266
+ // A local stray appears at the same path BEFORE the first sync, with a NEWER mtime — so there
267
+ // is no base and the mtime tiebreak favors local. Only the size-divergence rule (F9) lets the
268
+ // authoritative remote win and pull its copy down over the stray.
269
+ localMutate(world => writeLocalAt(world, "a.txt", "X", BASE_TIME + 9 * SECOND)),
270
+ runCycle(),
271
+ runCycle()
272
+ ]
273
+ })
274
+
275
+ expect(readLocal(result.world, "a.txt")).toBe("authoritative-remote")
276
+ expect(result.finalLocal).toEqual(result.finalRemote)
277
+ })
278
+
279
+ it("W16: a long-lived run of varied remote edits stays converged each settle", async () => {
280
+ const result = await runScenario({
281
+ name: "W16",
282
+ mode: "cloudToLocal",
283
+ initialRemote: { "/a.txt": "a0", "/d/b.txt": "b0" },
284
+ steps: [
285
+ runCycle(),
286
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "a1-edit", { mtimeMs: BASE_TIME + 2 * SECOND })),
287
+ runCycle(),
288
+ remoteMutate(world => world.cloud.controls.movePath("/d/b.txt", "/d/b2.txt")),
289
+ runCycle(),
290
+ remoteMutate(world => world.cloud.controls.addFile("/d/c.txt", "c0", { mtimeMs: BASE_TIME + 3 * SECOND })),
291
+ runCycle(),
292
+ remoteMutate(world => world.cloud.controls.trashPath("/a.txt")),
293
+ runCycle(),
294
+ runCycle()
295
+ ]
296
+ })
297
+
298
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
299
+ expect(result.finalLocal["/d/b2.txt"]).toMatchObject({ type: "file" })
300
+ expect(result.finalLocal["/d/c.txt"]).toMatchObject({ type: "file" })
301
+ expect(result.finalLocal).toEqual(result.finalRemote)
302
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
303
+ })
304
+ })
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate, restart } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds, allOps } from "../harness/snapshot"
5
+ import { writeLocalAt, readLocal, existsLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category X — the FULL cloudBackup operation matrix, the mirror image of Category V. cloudBackup pulls
9
+ * the remote side down but is ADDITIVE, not a mirror: it NEVER deletes the local copy and deliberately
10
+ * TOLERATES foreign local edits. Contrast with cloudToLocal (Category W), which mirror-deletes and
11
+ * reverts foreign local edits.
12
+ * • remote adds / modifies / renames / moves / type-changes propagate down;
13
+ * • a remote DELETE does NOT propagate — the local copy is kept;
14
+ * • a local-only item SURVIVES (not deleted, not pushed);
15
+ * • a foreign local edit to a synced file is left ALONE (tolerated);
16
+ * • a remote change still always wins for the files it touches (F5).
17
+ */
18
+ const SECOND = 1000
19
+
20
+ describe("Category X — cloudBackup (additive pull remote→local)", () => {
21
+ it("X1: a new remote file is downloaded to local", async () => {
22
+ const result = await runScenario({
23
+ name: "X1",
24
+ mode: "cloudBackup",
25
+ initialRemote: { "/a.txt": "data", "/dir/b.txt": "b" },
26
+ steps: [runCycle(), runCycle()]
27
+ })
28
+
29
+ expect(readLocal(result.world, "a.txt")).toBe("data")
30
+ expect(existsLocal(result.world, "dir/b.txt")).toBe(true)
31
+ })
32
+
33
+ it("X2: a remote modification is pulled down to local", async () => {
34
+ const result = await runScenario({
35
+ name: "X2",
36
+ mode: "cloudBackup",
37
+ initialRemote: { "/a.txt": "v1" },
38
+ steps: [
39
+ runCycle(),
40
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "v2-longer", { mtimeMs: BASE_TIME + 5 * SECOND })),
41
+ runCycle(),
42
+ runCycle()
43
+ ]
44
+ })
45
+
46
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
47
+ expect(readLocal(result.world, "a.txt")).toBe("v2-longer")
48
+ })
49
+
50
+ it("X3: a remote file deletion does NOT propagate — the local copy is kept", async () => {
51
+ const result = await runScenario({
52
+ name: "X3",
53
+ mode: "cloudBackup",
54
+ initialRemote: { "/a.txt": "data" },
55
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/a.txt")), runCycle(), runCycle()]
56
+ })
57
+
58
+ expect(transferKinds(result.messages)).not.toContain("deleteLocalFile")
59
+ expect(existsLocal(result.world, "a.txt")).toBe(true)
60
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
61
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
62
+ })
63
+
64
+ it("X4: deleting a whole remote directory does NOT propagate — the local tree is kept", async () => {
65
+ const result = await runScenario({
66
+ name: "X4",
67
+ mode: "cloudBackup",
68
+ initialRemote: { "/dir/a.txt": "a", "/dir/sub/c.txt": "c" },
69
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.trashPath("/dir")), runCycle(), runCycle()]
70
+ })
71
+
72
+ expect(transferKinds(result.messages)).not.toContain("deleteLocalDirectory")
73
+ expect(transferKinds(result.messages)).not.toContain("deleteLocalFile")
74
+ expect(existsLocal(result.world, "dir/a.txt")).toBe(true)
75
+ expect(existsLocal(result.world, "dir/sub/c.txt")).toBe(true)
76
+ })
77
+
78
+ it("X5: a remote rename propagates as a local rename (the move follows; no data lost)", async () => {
79
+ const result = await runScenario({
80
+ name: "X5",
81
+ mode: "cloudBackup",
82
+ initialRemote: { "/a.txt": "data" },
83
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/b.txt")), runCycle(), runCycle()]
84
+ })
85
+
86
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameLocalFile")
87
+ expect(existsLocal(result.world, "b.txt")).toBe(true)
88
+ expect(existsLocal(result.world, "a.txt")).toBe(false)
89
+ })
90
+
91
+ it("X6: a remote cross-directory move propagates to local", async () => {
92
+ const result = await runScenario({
93
+ name: "X6",
94
+ mode: "cloudBackup",
95
+ initialRemote: { "/a.txt": "data", "/sub/keep.txt": "k" },
96
+ steps: [runCycle(), remoteMutate(world => world.cloud.controls.movePath("/a.txt", "/sub/a.txt")), runCycle(), runCycle()]
97
+ })
98
+
99
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameLocalFile")
100
+ expect(existsLocal(result.world, "sub/a.txt")).toBe(true)
101
+ expect(existsLocal(result.world, "a.txt")).toBe(false)
102
+ })
103
+
104
+ it("X7: a local-only file SURVIVES (additive — not deleted, not pushed)", async () => {
105
+ const result = await runScenario({
106
+ name: "X7",
107
+ mode: "cloudBackup",
108
+ initialLocal: { "/local/mine.txt": "mine" },
109
+ initialRemote: { "/r.txt": "remote" },
110
+ steps: [runCycle(), runCycle()]
111
+ })
112
+
113
+ expect(transferKinds(result.messages)).not.toContain("deleteLocalFile")
114
+ expect(existsLocal(result.world, "mine.txt")).toBe(true)
115
+ expect(existsLocal(result.world, "r.txt")).toBe(true)
116
+ // Not pushed to the remote.
117
+ expect(result.finalRemote["/mine.txt"]).toBeUndefined()
118
+ })
119
+
120
+ it("X8: a foreign local edit to a synced file is TOLERATED (not reverted)", async () => {
121
+ const result = await runScenario({
122
+ name: "X8",
123
+ mode: "cloudBackup",
124
+ initialRemote: { "/a.txt": "remote-content" },
125
+ steps: [
126
+ runCycle(),
127
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-EDIT", BASE_TIME + 9 * SECOND)),
128
+ runCycle(),
129
+ runCycle()
130
+ ]
131
+ })
132
+
133
+ // No download to revert it (the cycles after the foreign edit do nothing); the local copy keeps it.
134
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("download")
135
+ expect(transferKinds(result.cycles[2]!.messages)).not.toContain("download")
136
+ expect(readLocal(result.world, "a.txt")).toBe("LOCAL-EDIT")
137
+ })
138
+
139
+ it("X9: a remote edit wins over a newer-mtime foreign local edit (F5)", async () => {
140
+ const result = await runScenario({
141
+ name: "X9",
142
+ mode: "cloudBackup",
143
+ initialRemote: { "/a.txt": "orig" },
144
+ steps: [
145
+ runCycle(),
146
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-EDIT", { mtimeMs: BASE_TIME + 3 * SECOND })),
147
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-NEWER", BASE_TIME + 9 * SECOND)),
148
+ runCycle(),
149
+ runCycle()
150
+ ]
151
+ })
152
+
153
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
154
+ expect(readLocal(result.world, "a.txt")).toBe("REMOTE-EDIT")
155
+ })
156
+
157
+ it("X10: a remote file→directory type change replaces the local file with the directory", async () => {
158
+ const result = await runScenario({
159
+ name: "X10",
160
+ mode: "cloudBackup",
161
+ initialRemote: { "/x": "a-file" },
162
+ steps: [
163
+ runCycle(),
164
+ remoteMutate(world => {
165
+ world.cloud.controls.deletePath("/x")
166
+ world.cloud.controls.addFile("/x/inner.txt", "inner")
167
+ }),
168
+ runCycle(),
169
+ runCycle()
170
+ ]
171
+ })
172
+
173
+ expect(result.finalLocal["/x"]).toMatchObject({ type: "directory" })
174
+ expect(existsLocal(result.world, "x/inner.txt")).toBe(true)
175
+ })
176
+
177
+ it("X11: a 0-byte remote file is backed up to local", async () => {
178
+ const result = await runScenario({
179
+ name: "X11",
180
+ mode: "cloudBackup",
181
+ initialRemote: { "/empty.txt": "", "/nonempty.txt": "x" },
182
+ steps: [runCycle(), runCycle()]
183
+ })
184
+
185
+ expect(result.finalLocal["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
186
+ })
187
+
188
+ it("X12: a settled cloudBackup is idempotent across a restart", async () => {
189
+ const result = await runScenario({
190
+ name: "X12",
191
+ mode: "cloudBackup",
192
+ initialRemote: { "/a.txt": "alpha", "/dir/b.txt": "bravo" },
193
+ steps: [runCycle(), runCycle(), restart(), runCycle(), runCycle()]
194
+ })
195
+
196
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
197
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
198
+ expect(existsLocal(result.world, "a.txt")).toBe(true)
199
+ expect(existsLocal(result.world, "dir/b.txt")).toBe(true)
200
+ })
201
+ })