@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,347 @@
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 { writeLocal, writeLocalAt, rmLocal, renameLocal, readLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category U — the FULL localToCloud operation matrix. localToCloud is a strict one-way MIRROR: the
9
+ * local side is authoritative and the remote is forced to match it every cycle. So:
10
+ * • local adds / modifies / deletes / renames / moves / type-changes all propagate to the remote;
11
+ * • a remote-only item (added by another device) is mirror-DELETED (it is not in local);
12
+ * • a foreign edit to a synced file is REVERTED — local re-asserts its bytes (F6);
13
+ * • a local change always wins, even against a newer-mtime foreign remote edit (F5);
14
+ * • the remote is never pulled down to local.
15
+ * Because it is a true mirror, the worlds CONVERGE (finalLocal === finalRemote) once settled. Contrast
16
+ * with localBackup (Category V), which is additive: it never deletes the remote and tolerates foreign
17
+ * edits.
18
+ */
19
+ const SECOND = 1000
20
+
21
+ describe("Category U — localToCloud (strict mirror local→remote)", () => {
22
+ it("U1: local files and directories are created on the remote", async () => {
23
+ const result = await runScenario({
24
+ name: "U1",
25
+ mode: "localToCloud",
26
+ initialLocal: { "/local/a.txt": "alpha", "/local/dir/b.txt": "bravo", "/local/dir/empty": null },
27
+ steps: [runCycle(), runCycle(), runCycle()]
28
+ })
29
+
30
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "alpha".length })
31
+ expect(result.finalRemote["/dir"]).toMatchObject({ type: "directory" })
32
+ expect(result.finalRemote["/dir/b.txt"]).toMatchObject({ type: "file" })
33
+ expect(result.finalRemote["/dir/empty"]).toMatchObject({ type: "directory" })
34
+ expect(result.finalLocal).toEqual(result.finalRemote)
35
+ })
36
+
37
+ it("U2: a local modification is pushed to the remote", async () => {
38
+ const result = await runScenario({
39
+ name: "U2",
40
+ mode: "localToCloud",
41
+ initialLocal: { "/local/a.txt": "v1" },
42
+ steps: [runCycle(), localMutate(world => writeLocalAt(world, "a.txt", "v2-longer", BASE_TIME + 5 * SECOND)), runCycle(), runCycle()]
43
+ })
44
+
45
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
46
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v2-longer".length })
47
+ expect(result.finalLocal).toEqual(result.finalRemote)
48
+ })
49
+
50
+ it("U3: a local file deletion is mirrored to the remote", async () => {
51
+ const result = await runScenario({
52
+ name: "U3",
53
+ mode: "localToCloud",
54
+ initialLocal: { "/local/a.txt": "data" },
55
+ steps: [runCycle(), localMutate(world => rmLocal(world, "a.txt")), runCycle(), runCycle()]
56
+ })
57
+
58
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("deleteRemoteFile")
59
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
60
+ expect(result.finalLocal).toEqual(result.finalRemote)
61
+ })
62
+
63
+ it("U4: a local directory deletion mirrors as ONE collapsed remote delete", async () => {
64
+ const result = await runScenario({
65
+ name: "U4",
66
+ mode: "localToCloud",
67
+ initialLocal: { "/local/dir/a.txt": "a", "/local/dir/sub/c.txt": "c" },
68
+ steps: [runCycle(), localMutate(world => rmLocal(world, "dir")), runCycle(), runCycle()]
69
+ })
70
+
71
+ const kinds = transferKinds(result.cycles[1]!.messages)
72
+
73
+ expect(kinds.filter(kind => kind === "deleteRemoteDirectory")).toHaveLength(1)
74
+ expect(kinds).not.toContain("deleteRemoteFile")
75
+ expect(result.finalRemote["/dir"]).toBeUndefined()
76
+ expect(result.finalLocal).toEqual(result.finalRemote)
77
+ })
78
+
79
+ it("U5: a local file rename is mirrored as a remote rename (identity preserved)", async () => {
80
+ let originalUUID: string | undefined
81
+
82
+ const result = await runScenario({
83
+ name: "U5",
84
+ mode: "localToCloud",
85
+ initialLocal: { "/local/a.txt": "data" },
86
+ steps: [
87
+ runCycle(),
88
+ localMutate(world => {
89
+ originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
90
+ renameLocal(world, "a.txt", "b.txt")
91
+ }),
92
+ runCycle(),
93
+ runCycle()
94
+ ]
95
+ })
96
+
97
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
98
+ // The rename cycle does NOT re-upload (identity is preserved, not re-created).
99
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
100
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
101
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file" })
102
+ expect(result.world.cloud.controls.getByPath("/b.txt")?.uuid).toBe(originalUUID)
103
+ expect(result.finalLocal).toEqual(result.finalRemote)
104
+ })
105
+
106
+ it("U6: a local directory rename mirrors as ONE collapsed remote rename", async () => {
107
+ const result = await runScenario({
108
+ name: "U6",
109
+ mode: "localToCloud",
110
+ initialLocal: { "/local/dir/a.txt": "a", "/local/dir/b.txt": "b" },
111
+ steps: [runCycle(), localMutate(world => renameLocal(world, "dir", "dir2")), runCycle(), runCycle()]
112
+ })
113
+
114
+ const kinds = transferKinds(result.cycles[1]!.messages)
115
+
116
+ expect(kinds.filter(kind => kind === "renameRemoteDirectory")).toHaveLength(1)
117
+ expect(kinds).not.toContain("upload")
118
+ expect(result.finalRemote["/dir"]).toBeUndefined()
119
+ expect(result.finalRemote["/dir2/a.txt"]).toMatchObject({ type: "file" })
120
+ expect(result.finalLocal).toEqual(result.finalRemote)
121
+ })
122
+
123
+ it("U7: a local cross-directory move is mirrored to the remote (identity preserved)", async () => {
124
+ let originalUUID: string | undefined
125
+
126
+ const result = await runScenario({
127
+ name: "U7",
128
+ mode: "localToCloud",
129
+ initialLocal: { "/local/a.txt": "data", "/local/sub/keep.txt": "k" },
130
+ steps: [
131
+ runCycle(),
132
+ localMutate(world => {
133
+ originalUUID = world.cloud.controls.getByPath("/a.txt")?.uuid
134
+ renameLocal(world, "a.txt", "sub/a.txt")
135
+ }),
136
+ runCycle(),
137
+ runCycle()
138
+ ]
139
+ })
140
+
141
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
142
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
143
+ expect(result.finalRemote["/sub/a.txt"]).toMatchObject({ type: "file" })
144
+ expect(result.world.cloud.controls.getByPath("/sub/a.txt")?.uuid).toBe(originalUUID)
145
+ expect(result.finalLocal).toEqual(result.finalRemote)
146
+ })
147
+
148
+ it("U8: a local directory→file type change replaces the remote tree with the file", async () => {
149
+ const result = await runScenario({
150
+ name: "U8",
151
+ mode: "localToCloud",
152
+ initialLocal: { "/local/x/inner.txt": "inner" },
153
+ steps: [
154
+ runCycle(),
155
+ localMutate(world => {
156
+ rmLocal(world, "x")
157
+ writeLocal(world, "x", "now-a-file")
158
+ }),
159
+ runCycle(),
160
+ runCycle()
161
+ ]
162
+ })
163
+
164
+ expect(result.finalRemote["/x"]).toMatchObject({ type: "file", size: "now-a-file".length })
165
+ expect(result.finalRemote["/x/inner.txt"]).toBeUndefined()
166
+ expect(result.finalLocal).toEqual(result.finalRemote)
167
+ })
168
+
169
+ it("U9: a remote-only file is mirror-DELETED (not pulled, not kept)", async () => {
170
+ const result = await runScenario({
171
+ name: "U9",
172
+ mode: "localToCloud",
173
+ initialLocal: { "/local/mine.txt": "mine" },
174
+ initialRemote: { "/foreign.txt": "theirs" },
175
+ steps: [runCycle(), runCycle(), runCycle()]
176
+ })
177
+
178
+ expect(transferKinds(result.cycles[0]!.messages)).toContain("deleteRemoteFile")
179
+ expect(result.finalRemote["/foreign.txt"]).toBeUndefined()
180
+ expect(result.finalRemote["/mine.txt"]).toMatchObject({ type: "file" })
181
+ expect(result.finalLocal).toEqual(result.finalRemote)
182
+ })
183
+
184
+ it("U10: a remote-only directory subtree is mirror-DELETED", async () => {
185
+ const result = await runScenario({
186
+ name: "U10",
187
+ mode: "localToCloud",
188
+ initialLocal: { "/local/mine.txt": "mine" },
189
+ initialRemote: { "/foreigndir/x.txt": "x", "/foreigndir/sub/y.txt": "y" },
190
+ steps: [runCycle(), runCycle(), runCycle()]
191
+ })
192
+
193
+ expect(result.finalRemote["/foreigndir"]).toBeUndefined()
194
+ expect(result.finalRemote["/foreigndir/x.txt"]).toBeUndefined()
195
+ expect(result.finalRemote["/mine.txt"]).toMatchObject({ type: "file" })
196
+ expect(result.finalLocal).toEqual(result.finalRemote)
197
+ })
198
+
199
+ it("U11: a foreign remote edit to a synced file is REVERTED to the local content (F6)", async () => {
200
+ const result = await runScenario({
201
+ name: "U11",
202
+ mode: "localToCloud",
203
+ initialLocal: { "/local/a.txt": "local-content" },
204
+ steps: [
205
+ runCycle(),
206
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "FOREIGN-EDIT", { mtimeMs: BASE_TIME + 9 * SECOND })),
207
+ runCycle(),
208
+ runCycle()
209
+ ]
210
+ })
211
+
212
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
213
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "local-content".length })
214
+ expect(readLocal(result.world, "a.txt")).toBe("local-content")
215
+ expect(result.finalLocal).toEqual(result.finalRemote)
216
+ })
217
+
218
+ it("U12: a local edit wins over a newer-mtime foreign remote edit (F5)", async () => {
219
+ const result = await runScenario({
220
+ name: "U12",
221
+ mode: "localToCloud",
222
+ initialLocal: { "/local/a.txt": "orig" },
223
+ steps: [
224
+ runCycle(),
225
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-EDIT", BASE_TIME + 3 * SECOND)),
226
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-NEWER", { mtimeMs: BASE_TIME + 9 * SECOND })),
227
+ runCycle(),
228
+ runCycle()
229
+ ]
230
+ })
231
+
232
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "LOCAL-EDIT".length })
233
+ expect(readLocal(result.world, "a.txt")).toBe("LOCAL-EDIT")
234
+ expect(result.finalLocal).toEqual(result.finalRemote)
235
+ })
236
+
237
+ it("U13: a 0-byte local file is mirrored to the remote", async () => {
238
+ const result = await runScenario({
239
+ name: "U13",
240
+ mode: "localToCloud",
241
+ initialLocal: { "/local/empty.txt": "" },
242
+ steps: [runCycle(), runCycle()]
243
+ })
244
+
245
+ expect(result.finalRemote["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
246
+ expect(result.finalLocal).toEqual(result.finalRemote)
247
+ })
248
+
249
+ it("U14: a rename + in-place modify in one cycle keeps the new name AND new content (F1)", async () => {
250
+ const result = await runScenario({
251
+ name: "U14",
252
+ mode: "localToCloud",
253
+ initialLocal: { "/local/a.txt": "original-content" },
254
+ steps: [
255
+ runCycle(),
256
+ localMutate(world => {
257
+ renameLocal(world, "a.txt", "b.txt")
258
+ writeLocalAt(world, "b.txt", "BRAND-NEW-CONTENT", BASE_TIME + 5 * SECOND)
259
+ }),
260
+ runCycle(),
261
+ runCycle()
262
+ ]
263
+ })
264
+
265
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
266
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file", size: "BRAND-NEW-CONTENT".length })
267
+ expect(result.finalLocal).toEqual(result.finalRemote)
268
+ })
269
+
270
+ it("U15: a settled localToCloud is idempotent and survives a restart with no work", async () => {
271
+ const result = await runScenario({
272
+ name: "U15",
273
+ mode: "localToCloud",
274
+ initialLocal: { "/local/a.txt": "alpha", "/local/dir/b.txt": "bravo" },
275
+ steps: [runCycle(), runCycle(), restart(), runCycle(), runCycle()]
276
+ })
277
+
278
+ // The two post-restart cycles do no file transfers.
279
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
280
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
281
+ expect(result.finalLocal).toEqual(result.finalRemote)
282
+ })
283
+
284
+ it("U16: a remote-only file is deleted WHILE a new local file uploads in the same cycle", async () => {
285
+ const result = await runScenario({
286
+ name: "U16",
287
+ mode: "localToCloud",
288
+ initialLocal: { "/local/keep.txt": "keep" },
289
+ initialRemote: { "/foreign.txt": "theirs" },
290
+ steps: [
291
+ runCycle(),
292
+ localMutate(world => writeLocal(world, "added.txt", "added")),
293
+ runCycle(),
294
+ runCycle()
295
+ ]
296
+ })
297
+
298
+ expect(result.finalRemote["/foreign.txt"]).toBeUndefined()
299
+ expect(result.finalRemote["/keep.txt"]).toMatchObject({ type: "file" })
300
+ expect(result.finalRemote["/added.txt"]).toMatchObject({ type: "file" })
301
+ expect(result.finalLocal).toEqual(result.finalRemote)
302
+ })
303
+
304
+ it("U18: a remote stray at a local path (no base, newer mtime, differing size) is overwritten by local (F9)", async () => {
305
+ const result = await runScenario({
306
+ name: "U18",
307
+ mode: "localToCloud",
308
+ initialLocal: { "/local/a.txt": "authoritative-local" },
309
+ steps: [
310
+ // A remote stray appears at the same path BEFORE the first sync, NEWER mtime, different size.
311
+ // Only the size-divergence rule (F9) lets the authoritative local win and push over the stray.
312
+ remoteMutate(world => world.cloud.controls.addFile("/a.txt", "X", { mtimeMs: BASE_TIME + 9 * SECOND })),
313
+ runCycle(),
314
+ runCycle()
315
+ ]
316
+ })
317
+
318
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "authoritative-local".length })
319
+ expect(result.finalLocal).toEqual(result.finalRemote)
320
+ })
321
+
322
+ it("U17: a long-lived run of varied local edits stays converged each settle", async () => {
323
+ const result = await runScenario({
324
+ name: "U17",
325
+ mode: "localToCloud",
326
+ initialLocal: { "/local/a.txt": "a0", "/local/d/b.txt": "b0" },
327
+ steps: [
328
+ runCycle(),
329
+ localMutate(world => writeLocalAt(world, "a.txt", "a1-edit", BASE_TIME + 2 * SECOND)),
330
+ runCycle(),
331
+ localMutate(world => renameLocal(world, "d/b.txt", "d/b2.txt")),
332
+ runCycle(),
333
+ localMutate(world => writeLocal(world, "d/c.txt", "c0")),
334
+ runCycle(),
335
+ localMutate(world => rmLocal(world, "a.txt")),
336
+ runCycle(),
337
+ runCycle()
338
+ ]
339
+ })
340
+
341
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
342
+ expect(result.finalRemote["/d/b2.txt"]).toMatchObject({ type: "file" })
343
+ expect(result.finalRemote["/d/c.txt"]).toMatchObject({ type: "file" })
344
+ expect(result.finalLocal).toEqual(result.finalRemote)
345
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
346
+ })
347
+ })
@@ -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 { writeLocal, writeLocalAt, rmLocal, renameLocal, readLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category V — the FULL localBackup operation matrix. localBackup pushes the local side up but is
9
+ * ADDITIVE, not a mirror: it NEVER deletes on the remote and deliberately TOLERATES foreign remote edits.
10
+ * Contrast with localToCloud (Category U), which mirror-deletes and reverts foreign edits.
11
+ * • local adds / modifies / renames / moves / type-changes propagate up;
12
+ * • a local DELETE does NOT propagate — the remote keeps the file (the whole point of a backup);
13
+ * • a remote-only item SURVIVES (not deleted, not pulled);
14
+ * • a foreign remote edit to a synced file is left ALONE (tolerated);
15
+ * • a local change still always wins for the files it touches (F5), so a backup can't be silently
16
+ * suppressed by a newer-mtime foreign remote edit.
17
+ * Because deletes don't propagate, the worlds do NOT generally converge — assertions target the
18
+ * specific additive behavior.
19
+ */
20
+ const SECOND = 1000
21
+
22
+ describe("Category V — localBackup (additive push local→remote)", () => {
23
+ it("V1: a new local file is uploaded to the remote", async () => {
24
+ const result = await runScenario({
25
+ name: "V1",
26
+ mode: "localBackup",
27
+ initialLocal: { "/local/a.txt": "data", "/local/dir/b.txt": "b" },
28
+ steps: [runCycle(), runCycle()]
29
+ })
30
+
31
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "data".length })
32
+ expect(result.finalRemote["/dir/b.txt"]).toMatchObject({ type: "file" })
33
+ })
34
+
35
+ it("V2: a local modification is pushed to the remote", async () => {
36
+ const result = await runScenario({
37
+ name: "V2",
38
+ mode: "localBackup",
39
+ initialLocal: { "/local/a.txt": "v1" },
40
+ steps: [runCycle(), localMutate(world => writeLocalAt(world, "a.txt", "v2-longer", BASE_TIME + 5 * SECOND)), runCycle(), runCycle()]
41
+ })
42
+
43
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
44
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v2-longer".length })
45
+ })
46
+
47
+ it("V3: a local file deletion does NOT propagate — the remote keeps the backup", async () => {
48
+ const result = await runScenario({
49
+ name: "V3",
50
+ mode: "localBackup",
51
+ initialLocal: { "/local/a.txt": "data" },
52
+ steps: [runCycle(), localMutate(world => rmLocal(world, "a.txt")), runCycle(), runCycle()]
53
+ })
54
+
55
+ expect(transferKinds(result.messages)).not.toContain("deleteRemoteFile")
56
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "data".length })
57
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
58
+ // And it is idempotent: the last cycle does no work despite the local/remote difference.
59
+ expect(allOps(result.cycles[result.cycles.length - 1]!.messages)).toEqual([])
60
+ })
61
+
62
+ it("V4: deleting a whole local directory does NOT propagate — the remote keeps the tree", async () => {
63
+ const result = await runScenario({
64
+ name: "V4",
65
+ mode: "localBackup",
66
+ initialLocal: { "/local/dir/a.txt": "a", "/local/dir/sub/c.txt": "c" },
67
+ steps: [runCycle(), localMutate(world => rmLocal(world, "dir")), runCycle(), runCycle()]
68
+ })
69
+
70
+ expect(transferKinds(result.messages)).not.toContain("deleteRemoteDirectory")
71
+ expect(transferKinds(result.messages)).not.toContain("deleteRemoteFile")
72
+ expect(result.finalRemote["/dir/a.txt"]).toMatchObject({ type: "file" })
73
+ expect(result.finalRemote["/dir/sub/c.txt"]).toMatchObject({ type: "file" })
74
+ })
75
+
76
+ it("V5: a local rename propagates as a remote rename (the move follows; no data lost)", async () => {
77
+ const result = await runScenario({
78
+ name: "V5",
79
+ mode: "localBackup",
80
+ initialLocal: { "/local/a.txt": "data" },
81
+ steps: [runCycle(), localMutate(world => renameLocal(world, "a.txt", "b.txt")), runCycle(), runCycle()]
82
+ })
83
+
84
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
85
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file" })
86
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
87
+ })
88
+
89
+ it("V6: a local cross-directory move propagates to the remote", async () => {
90
+ const result = await runScenario({
91
+ name: "V6",
92
+ mode: "localBackup",
93
+ initialLocal: { "/local/a.txt": "data", "/local/sub/keep.txt": "k" },
94
+ steps: [runCycle(), localMutate(world => renameLocal(world, "a.txt", "sub/a.txt")), runCycle(), runCycle()]
95
+ })
96
+
97
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("renameRemoteFile")
98
+ expect(result.finalRemote["/sub/a.txt"]).toMatchObject({ type: "file" })
99
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
100
+ })
101
+
102
+ it("V7: a remote-only file SURVIVES (additive — not deleted, not pulled)", async () => {
103
+ const result = await runScenario({
104
+ name: "V7",
105
+ mode: "localBackup",
106
+ initialLocal: { "/local/mine.txt": "mine" },
107
+ initialRemote: { "/foreign.txt": "theirs" },
108
+ steps: [runCycle(), runCycle()]
109
+ })
110
+
111
+ expect(transferKinds(result.messages)).not.toContain("deleteRemoteFile")
112
+ expect(result.finalRemote["/foreign.txt"]).toMatchObject({ type: "file" })
113
+ expect(result.finalRemote["/mine.txt"]).toMatchObject({ type: "file" })
114
+ // Not pulled to local.
115
+ expect(result.finalLocal["/foreign.txt"]).toBeUndefined()
116
+ })
117
+
118
+ it("V8: a foreign remote edit to a synced file is TOLERATED (not reverted)", async () => {
119
+ const result = await runScenario({
120
+ name: "V8",
121
+ mode: "localBackup",
122
+ initialLocal: { "/local/a.txt": "local-content" },
123
+ steps: [
124
+ runCycle(),
125
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "FOREIGN-EDIT", { mtimeMs: BASE_TIME + 9 * SECOND })),
126
+ runCycle(),
127
+ runCycle()
128
+ ]
129
+ })
130
+
131
+ // No upload to revert it (the cycles after the foreign edit do nothing); the remote keeps the
132
+ // foreign edit and the local copy is untouched.
133
+ expect(transferKinds(result.cycles[1]!.messages)).not.toContain("upload")
134
+ expect(transferKinds(result.cycles[2]!.messages)).not.toContain("upload")
135
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "FOREIGN-EDIT".length })
136
+ expect(readLocal(result.world, "a.txt")).toBe("local-content")
137
+ })
138
+
139
+ it("V9: a local edit wins over a newer-mtime foreign remote edit (F5)", async () => {
140
+ const result = await runScenario({
141
+ name: "V9",
142
+ mode: "localBackup",
143
+ initialLocal: { "/local/a.txt": "orig" },
144
+ steps: [
145
+ runCycle(),
146
+ localMutate(world => writeLocalAt(world, "a.txt", "LOCAL-EDIT", BASE_TIME + 3 * SECOND)),
147
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "REMOTE-NEWER", { mtimeMs: BASE_TIME + 9 * SECOND })),
148
+ runCycle(),
149
+ runCycle()
150
+ ]
151
+ })
152
+
153
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
154
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "LOCAL-EDIT".length })
155
+ })
156
+
157
+ it("V10: a local file→directory type change replaces the remote file with the directory", async () => {
158
+ const result = await runScenario({
159
+ name: "V10",
160
+ mode: "localBackup",
161
+ initialLocal: { "/local/x": "a-file" },
162
+ steps: [
163
+ runCycle(),
164
+ localMutate(world => {
165
+ rmLocal(world, "x")
166
+ writeLocal(world, "x/inner.txt", "inner")
167
+ }),
168
+ runCycle(),
169
+ runCycle()
170
+ ]
171
+ })
172
+
173
+ expect(result.finalRemote["/x"]).toMatchObject({ type: "directory" })
174
+ expect(result.finalRemote["/x/inner.txt"]).toMatchObject({ type: "file" })
175
+ })
176
+
177
+ it("V11: a 0-byte local file is backed up", async () => {
178
+ const result = await runScenario({
179
+ name: "V11",
180
+ mode: "localBackup",
181
+ initialLocal: { "/local/empty.txt": "" },
182
+ steps: [runCycle(), runCycle()]
183
+ })
184
+
185
+ expect(result.finalRemote["/empty.txt"]).toMatchObject({ type: "file", size: 0 })
186
+ })
187
+
188
+ it("V12: a settled localBackup is idempotent across a restart", async () => {
189
+ const result = await runScenario({
190
+ name: "V12",
191
+ mode: "localBackup",
192
+ initialLocal: { "/local/a.txt": "alpha", "/local/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(result.finalRemote["/a.txt"]).toMatchObject({ type: "file" })
199
+ expect(result.finalRemote["/dir/b.txt"]).toMatchObject({ type: "file" })
200
+ })
201
+ })