@filen/sync 0.2.1 → 0.3.1

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,130 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, control } from "../harness/runner"
3
+ import { renameLocal } from "../harness/mutations"
4
+ import { messagesOfType } from "../harness/snapshot"
5
+ import { type SyncMessage } from "../../src/types"
6
+
7
+ /**
8
+ * Category ZE — moving an item INTO a newly-created nested directory.
9
+ *
10
+ * remote.mkdir() must be able to create a directory whose missing parent is the sync ROOT. The
11
+ * intermediate-directory loop used to skip any level whose parent was the root (the `if (!parentItem)
12
+ * continue` fired before the `parentIsBase` branch that supplies remoteParentUUID), so mkdir('/x/y')
13
+ * threw whenever '/x' did not already exist. A cross-parent rename builds its destination parent inline
14
+ * (BEFORE the createRemoteDirectory tasks run), so moving a file into a NEW >=2-level folder made the
15
+ * rename throw; for a TOP-LEVEL source that throw was then swallowed by fileExists() (which returned
16
+ * false for every top-level file because tree['/'] is never a cache key) -> zero task errors -> a skewed
17
+ * base was persisted -> the file was silently DUPLICATED (original resurrected + a copy at the new path).
18
+ */
19
+
20
+ function taskErrorCount(messages: SyncMessage[]): number {
21
+ return messagesOfType(messages, "taskErrors").reduce((sum, m) => sum + m.data.errors.length, 0)
22
+ }
23
+
24
+ describe("Category ZE — move into a newly-created nested directory", () => {
25
+ it("ZE1: twoWay — move a top-level file into a NEW 2-level dir converges (no duplication)", async () => {
26
+ const result = await runScenario({
27
+ name: "ZE1",
28
+ mode: "twoWay",
29
+ initialLocal: { "/local/a.txt": "hello", "/local/keep.txt": "k" },
30
+ steps: [
31
+ runCycle(),
32
+ localMutate(world => renameLocal(world, "a.txt", "x/y/a.txt")),
33
+ runCycle(),
34
+ runCycle(),
35
+ runCycle()
36
+ ]
37
+ })
38
+
39
+ expect(result.finalRemote["/a.txt"], "the moved-away original must NOT survive").toBeUndefined()
40
+ expect(result.finalLocal["/a.txt"]).toBeUndefined()
41
+ expect(result.finalRemote["/x/y/a.txt"]).toMatchObject({ type: "file", size: "hello".length })
42
+ expect(result.finalLocal).toEqual(result.finalRemote)
43
+ })
44
+
45
+ it("ZE2: twoWay — move a top-level file into a NEW 3-level dir converges", async () => {
46
+ const result = await runScenario({
47
+ name: "ZE2",
48
+ mode: "twoWay",
49
+ initialLocal: { "/local/a.txt": "deep", "/local/keep.txt": "k" },
50
+ steps: [
51
+ runCycle(),
52
+ localMutate(world => renameLocal(world, "a.txt", "p/q/r/a.txt")),
53
+ runCycle(),
54
+ runCycle(),
55
+ runCycle()
56
+ ]
57
+ })
58
+
59
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
60
+ expect(result.finalRemote["/p/q/r/a.txt"]).toMatchObject({ type: "file" })
61
+ expect(result.finalLocal).toEqual(result.finalRemote)
62
+ })
63
+
64
+ it("ZE3: twoWay — move a top-level DIRECTORY into a NEW 2-level dir converges", async () => {
65
+ const result = await runScenario({
66
+ name: "ZE3",
67
+ mode: "twoWay",
68
+ initialLocal: { "/local/dir/child.txt": "c", "/local/keep.txt": "k" },
69
+ steps: [
70
+ runCycle(),
71
+ localMutate(world => renameLocal(world, "dir", "x/y/dir")),
72
+ runCycle(),
73
+ runCycle(),
74
+ runCycle()
75
+ ]
76
+ })
77
+
78
+ expect(result.finalRemote["/dir"]).toBeUndefined()
79
+ expect(result.finalRemote["/x/y/dir/child.txt"]).toMatchObject({ type: "file" })
80
+ expect(result.finalLocal).toEqual(result.finalRemote)
81
+ })
82
+
83
+ it("ZE4: localToCloud — move a top-level file into a NEW 2-level dir converges", async () => {
84
+ const result = await runScenario({
85
+ name: "ZE4",
86
+ mode: "localToCloud",
87
+ initialLocal: { "/local/a.txt": "mirror", "/local/keep.txt": "k" },
88
+ steps: [
89
+ runCycle(),
90
+ localMutate(world => renameLocal(world, "a.txt", "x/y/a.txt")),
91
+ runCycle(),
92
+ runCycle(),
93
+ runCycle()
94
+ ]
95
+ })
96
+
97
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
98
+ expect(result.finalRemote["/x/y/a.txt"]).toMatchObject({ type: "file" })
99
+ expect(result.finalLocal).toEqual(result.finalRemote)
100
+ })
101
+
102
+ it("ZE5: a TRANSIENT error on a top-level remote file op SURFACES (not swallowed) and retries to convergence", async () => {
103
+ const result = await runScenario({
104
+ name: "ZE5",
105
+ mode: "twoWay",
106
+ initialLocal: { "/local/a.txt": "x", "/local/keep.txt": "k" },
107
+ steps: [
108
+ runCycle(),
109
+ localMutate(world => renameLocal(world, "a.txt", "b.txt")),
110
+ control(world => world.cloud.controls.setError("renameFile", new Error("transient rename failure"))),
111
+ runCycle(),
112
+ control(world => {
113
+ world.cloud.controls.clearError("renameFile")
114
+ world.worker.resetTaskErrors(world.syncPair.uuid)
115
+ world.triggerWatcher()
116
+ }),
117
+ runCycle(),
118
+ runCycle()
119
+ ]
120
+ })
121
+
122
+ // The failing cycle must REPORT a task error (the top-level rename failure must not be swallowed as
123
+ // "target vanished" — fileExists has to see the still-present top-level file).
124
+ expect(taskErrorCount(result.cycles[1]!.messages)).toBeGreaterThan(0)
125
+ // After recovery: the rename completed, no resurrection of the old name, both sides converge.
126
+ expect(result.finalRemote["/b.txt"]).toMatchObject({ type: "file" })
127
+ expect(result.finalRemote["/a.txt"]).toBeUndefined()
128
+ expect(result.finalLocal).toEqual(result.finalRemote)
129
+ })
130
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, remoteMutate, localMutate } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { transferKinds } from "../harness/snapshot"
5
+ import { readLocal, writeLocalAt } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category ZF — a genuine REMOTE content change must be pulled in twoWay even when the remote's mtime is
9
+ * not strictly newer than the local copy, AS LONG AS the local copy is itself unchanged since the base.
10
+ *
11
+ * The download gate's `remoteWins` term only resolved a CONFLICT (newer mtime wins), but it applied the
12
+ * newer-mtime tiebreak unconditionally — so when only the remote changed (no conflict, local untouched) a
13
+ * remote edit whose mtime equalled or trailed the local mtime was silently dropped and never re-pulled. The
14
+ * remote re-upload mints a new uuid, so the change is unambiguously detectable; the tiebreak belongs to the
15
+ * both-changed case only. The fix makes `remoteWins` also true when the local copy is unchanged vs the base.
16
+ */
17
+ const SECOND = 1000
18
+
19
+ describe("Category ZF — remote change vs unchanged local (twoWay, mtime tiebreak)", () => {
20
+ it("ZF1: a remote edit with an EQUAL mtime is pulled when local is unchanged", async () => {
21
+ const result = await runScenario({
22
+ name: "ZF1",
23
+ mode: "twoWay",
24
+ initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
25
+ steps: [
26
+ runCycle(),
27
+ // Remote re-uploads new content (new uuid) but stamps the SAME whole-second mtime as local.
28
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "v2-remote-edit", { mtimeMs: BASE_TIME })),
29
+ runCycle(),
30
+ runCycle(),
31
+ runCycle()
32
+ ]
33
+ })
34
+
35
+ // The change must actually be downloaded (not just "eventually equal" by some other path).
36
+ expect(transferKinds(result.cycles[1]!.messages)).toContain("download")
37
+ expect(readLocal(result.world, "a.txt")).toBe("v2-remote-edit")
38
+ expect(result.finalLocal).toEqual(result.finalRemote)
39
+ })
40
+
41
+ it("ZF2: a remote edit with an OLDER mtime is pulled when local is unchanged", async () => {
42
+ const result = await runScenario({
43
+ name: "ZF2",
44
+ mode: "twoWay",
45
+ initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME + 10 * SECOND }, "/local/keep.txt": "k" },
46
+ steps: [
47
+ runCycle(),
48
+ // Remote edit lands with an mtime BEHIND the local copy (e.g. an out-of-sync clock on the
49
+ // editing device). It still changed (new uuid) and local is untouched, so it must win.
50
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "older-but-real", { mtimeMs: BASE_TIME })),
51
+ runCycle(),
52
+ runCycle(),
53
+ runCycle()
54
+ ]
55
+ })
56
+
57
+ expect(readLocal(result.world, "a.txt")).toBe("older-but-real")
58
+ expect(result.finalLocal).toEqual(result.finalRemote)
59
+ })
60
+
61
+ it("ZF3: a real CONFLICT (both edited) still resolves by newer mtime — local newer wins, not the older remote", async () => {
62
+ const result = await runScenario({
63
+ name: "ZF3",
64
+ mode: "twoWay",
65
+ initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
66
+ steps: [
67
+ runCycle(),
68
+ // BOTH sides edit: local becomes strictly newer than the remote edit → local must win the
69
+ // conflict (the fix must NOT turn every remote change into an unconditional pull).
70
+ remoteMutate(world => world.cloud.controls.updateFile("/a.txt", "remote-older", { mtimeMs: BASE_TIME + 1 * SECOND })),
71
+ localMutate(world => writeLocalAt(world, "a.txt", "local-newer", BASE_TIME + 5 * SECOND)),
72
+ runCycle(),
73
+ runCycle(),
74
+ runCycle()
75
+ ]
76
+ })
77
+
78
+ expect(readLocal(result.world, "a.txt")).toBe("local-newer")
79
+ expect(result.finalLocal).toEqual(result.finalRemote)
80
+ })
81
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { runScenario, runCycle, control } from "../harness/runner"
3
+ import { BASE_TIME } from "../harness/world"
4
+ import { writeLocalAt, readLocal } from "../harness/mutations"
5
+
6
+ /**
7
+ * Category ZG — a file edited WHILE the local scan is running must not be lost.
8
+ *
9
+ * getDirectoryTree() skips a rescan while `lastDirectoryChangeTimestamp < cache.timestamp`. The cache used
10
+ * to be stamped when the walk ENDED, so a file changed after the engine lstat'd it (but before the walk
11
+ * returned) recorded a change time EARLIER than the stamp — the next cycle then judged the cache fresh and
12
+ * never re-read the edit. Stamping at the walk's START closes that window: the change time is >= the stamp,
13
+ * the strict `<` fails, and the next cycle rescans. This reproduces the race deterministically by editing
14
+ * the file from inside the lstat hook (after the engine read it) and advancing the fake clock so that an
15
+ * end-of-walk stamp WOULD have beaten the edit.
16
+ */
17
+ const SECOND = 1000
18
+
19
+ describe("Category ZG — an edit during the local scan is not lost", () => {
20
+ it("ZG1: a file edited mid-scan is re-detected and uploaded on the next cycle (twoWay)", async () => {
21
+ let fired = false
22
+
23
+ const result = await runScenario({
24
+ name: "ZG1",
25
+ mode: "twoWay",
26
+ initialLocal: { "/local/a.txt": { content: "v1", mtimeMs: BASE_TIME }, "/local/keep.txt": "k" },
27
+ steps: [
28
+ // Cycle 0: settle the initial state — a.txt="v1" is uploaded and becomes the base.
29
+ runCycle(),
30
+ // Force the next cycle to actually scan (bump the change clock) and arm a one-shot hook that,
31
+ // the moment the engine lstats a.txt, simulates the user overwriting it AFTER it was read, then
32
+ // advances the clock so an end-of-walk stamp would land after the edit's change time.
33
+ control(world => {
34
+ world.triggerWatcher()
35
+
36
+ world.vfs.controls.onStat(posixPath => {
37
+ if (fired || !posixPath.endsWith("/a.txt")) {
38
+ return
39
+ }
40
+
41
+ fired = true
42
+
43
+ const editAt = Date.now()
44
+
45
+ writeLocalAt(world, "a.txt", "v2-edited-mid-scan", editAt)
46
+ world.triggerWatcher()
47
+ vi.setSystemTime(editAt + 30 * SECOND)
48
+ world.vfs.controls.clearStatHook()
49
+ })
50
+ }),
51
+ // Cycle 1: the racy scan reads "v1"; the hook overwrites to v2, bumps the change clock, and
52
+ // jumps the clock forward. With the bug the cache is stamped AFTER the edit's change time.
53
+ runCycle(),
54
+ // Cycle 2: must rescan (change time >= scan-start stamp) and upload v2. The bug skips it as a
55
+ // "fresh" cache, so the edit never reaches the remote.
56
+ runCycle(),
57
+ runCycle()
58
+ ]
59
+ })
60
+
61
+ // The hook edited the local file in BOTH the fixed and buggy worlds, so the discriminating signal is
62
+ // whether the edit reached the REMOTE: convergence holds only if the mid-scan edit was re-detected.
63
+ expect(fired, "the mid-scan lstat hook must have fired").toBe(true)
64
+ expect(readLocal(result.world, "a.txt")).toBe("v2-edited-mid-scan")
65
+ expect(result.finalRemote["/a.txt"]).toMatchObject({ type: "file", size: "v2-edited-mid-scan".length })
66
+ expect(result.finalLocal).toEqual(result.finalRemote)
67
+ })
68
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, localMutate, remoteMutate } from "../harness/runner"
3
+ import { writeLocal, rmLocal } from "../harness/mutations"
4
+
5
+ /**
6
+ * Category ZH — a directory deletion must not cascade over live content the OTHER side did not delete.
7
+ *
8
+ * When one side removes a directory while the other adds (or keeps a modified) child inside it in the same
9
+ * cycle, the raw delta set holds both `deleteXDirectory <dir>` and the child's own add. The dir-delete used
10
+ * to win — collapse subsumed the child's sibling deletes under it and the dir-delete then wiped the
11
+ * brand-new file at execution time, before it was ever propagated (silent data loss). Newer content beats a
12
+ * delete (the same rule the per-file passes apply), so the directory survives: its delete is dropped and
13
+ * the surviving child's own add re-creates it. A child that is merely RENAMED out of the directory does NOT
14
+ * keep it alive (that is a normal dir rename, which deletes the now-empty old directory).
15
+ */
16
+ describe("Category ZH — directory delete vs a live child", () => {
17
+ it("ZH1: remote deletes a dir while local adds a new child — the child survives, the dir is kept (twoWay)", async () => {
18
+ const result = await runScenario({
19
+ name: "ZH1",
20
+ mode: "twoWay",
21
+ initialLocal: { "/local/dir/keep.txt": "keep", "/local/other.txt": "o" },
22
+ steps: [
23
+ runCycle(),
24
+ remoteMutate(world => world.cloud.controls.trashPath("/dir")),
25
+ localMutate(world => writeLocal(world, "dir/new.txt", "new-child")),
26
+ runCycle(),
27
+ runCycle(),
28
+ runCycle()
29
+ ]
30
+ })
31
+
32
+ // The new child survives and is uploaded; the dir is re-asserted; the unmodified base child the
33
+ // remote deleted is gone; both sides converge.
34
+ expect(result.finalLocal["/dir/new.txt"]).toMatchObject({ type: "file", size: "new-child".length })
35
+ expect(result.finalRemote["/dir/new.txt"]).toMatchObject({ type: "file" })
36
+ expect(result.finalLocal["/dir/keep.txt"]).toBeUndefined()
37
+ expect(result.finalLocal).toEqual(result.finalRemote)
38
+ })
39
+
40
+ it("ZH2: local deletes a dir while remote adds a new child — the child survives (symmetric)", async () => {
41
+ const result = await runScenario({
42
+ name: "ZH2",
43
+ mode: "twoWay",
44
+ initialLocal: { "/local/dir/keep.txt": "keep", "/local/other.txt": "o" },
45
+ steps: [
46
+ runCycle(),
47
+ localMutate(world => rmLocal(world, "dir")),
48
+ remoteMutate(world => world.cloud.controls.addFile("/dir/new.txt", "remote-new")),
49
+ runCycle(),
50
+ runCycle(),
51
+ runCycle()
52
+ ]
53
+ })
54
+
55
+ expect(result.finalRemote["/dir/new.txt"]).toMatchObject({ type: "file", size: "remote-new".length })
56
+ expect(result.finalLocal["/dir/new.txt"]).toMatchObject({ type: "file" })
57
+ expect(result.finalRemote["/dir/keep.txt"]).toBeUndefined()
58
+ expect(result.finalLocal).toEqual(result.finalRemote)
59
+ })
60
+
61
+ it("ZH3: remote deletes a dir while local MODIFIES a base child — the modified child wins, dir kept", async () => {
62
+ const result = await runScenario({
63
+ name: "ZH3",
64
+ mode: "twoWay",
65
+ initialLocal: { "/local/dir/keep.txt": "v1", "/local/other.txt": "o" },
66
+ steps: [
67
+ runCycle(),
68
+ remoteMutate(world => world.cloud.controls.trashPath("/dir")),
69
+ localMutate(world => writeLocal(world, "dir/keep.txt", "v2-modified-bigger")),
70
+ runCycle(),
71
+ runCycle(),
72
+ runCycle()
73
+ ]
74
+ })
75
+
76
+ // Newer-modify-wins: the locally-modified child is resurrected (re-uploaded), keeping its directory.
77
+ expect(result.finalRemote["/dir/keep.txt"]).toMatchObject({ type: "file", size: "v2-modified-bigger".length })
78
+ expect(result.finalLocal["/dir/keep.txt"]).toMatchObject({ type: "file" })
79
+ expect(result.finalLocal).toEqual(result.finalRemote)
80
+ })
81
+
82
+ it("ZH4: deleting a dir whose ONLY child is moved out still deletes the dir (no false-keep on rename)", async () => {
83
+ const result = await runScenario({
84
+ name: "ZH4",
85
+ mode: "twoWay",
86
+ initialLocal: { "/local/dir/only.txt": "x", "/local/other.txt": "o" },
87
+ steps: [
88
+ runCycle(),
89
+ localMutate(world => {
90
+ // Move the only child OUT to the root, then remove the now-empty directory.
91
+ writeLocal(world, "moved.txt", "x")
92
+ rmLocal(world, "dir")
93
+ }),
94
+ runCycle(),
95
+ runCycle(),
96
+ runCycle()
97
+ ]
98
+ })
99
+
100
+ expect(result.finalRemote["/dir"]).toBeUndefined()
101
+ expect(result.finalRemote["/moved.txt"]).toMatchObject({ type: "file" })
102
+ expect(result.finalLocal).toEqual(result.finalRemote)
103
+ })
104
+ })
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { SYNC_INTERVAL } from "../../src/constants"
3
+ import { createWorld, BASE_TIME, type CreateWorldOptions, type World } from "../harness/world"
4
+ import { messagesOfType } from "../harness/snapshot"
5
+ import { makeErrnoError } from "../fakes/virtual-fs"
6
+
7
+ /**
8
+ * Category ZI — the local smoke test must not hold the account lock during a local outage (H7).
9
+ *
10
+ * The smoke test retries every SYNC_INTERVAL for as long as the local sync root is unavailable (an
11
+ * unmounted drive, a disconnected network filesystem). It used to run AFTER the lock was acquired, so the
12
+ * whole outage held the account lock and starved every other device. Running it BEFORE the lock means an
13
+ * outage stalls only this pair's cycle. (The retry loop was also de-recursed so a long outage no longer
14
+ * grows the stack by one suspended frame per retry.)
15
+ */
16
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
17
+
18
+ async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
19
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
20
+ vi.setSystemTime(BASE_TIME)
21
+
22
+ try {
23
+ const world = await createWorld(options)
24
+
25
+ await body(world)
26
+ } finally {
27
+ vi.useRealTimers()
28
+ }
29
+ }
30
+
31
+ const countOf = (world: World, type: Parameters<typeof messagesOfType>[1]): number => messagesOfType(world.messages, type).length
32
+
33
+ describe("Category ZI — smoke test does not hold the lock during a local outage", () => {
34
+ it("ZI1: a local outage retries the smoke test WITHOUT acquiring the lock, then proceeds on recovery", async () => {
35
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
36
+ // Settle once for a clean base.
37
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
38
+ await world.sync.runCycle()
39
+
40
+ const lockDoneBaseline = countOf(world, "cycleAcquiringLockDone")
41
+
42
+ // Simulate a local outage: the sync root is no longer accessible.
43
+ world.vfs.controls.setError("/local", makeErrnoError("EACCES", "local sync root unavailable"))
44
+
45
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
46
+
47
+ let settled = false
48
+ const cyclePromise = world.sync.runCycle().finally(() => {
49
+ settled = true
50
+ })
51
+
52
+ // Pump several retry intervals while the outage persists.
53
+ for (let i = 0; i < 3 && !settled; i++) {
54
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
55
+ }
56
+
57
+ // The smoke test failed and kept failing — but the lock was NEVER acquired (before the fix it
58
+ // would have been, because the smoke test ran under the lock), and the cycle has not settled.
59
+ expect(countOf(world, "cycleLocalSmokeTestFailed")).toBeGreaterThan(0)
60
+ expect(countOf(world, "cycleAcquiringLockDone"), "the lock must not be acquired during the outage").toBe(lockDoneBaseline)
61
+ expect(settled).toBe(false)
62
+
63
+ // Recover: the path is accessible again. The next retry succeeds and the cycle runs to completion.
64
+ world.vfs.controls.clearError("/local")
65
+
66
+ for (let i = 0; i < 10 && !settled; i++) {
67
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
68
+ }
69
+
70
+ await cyclePromise
71
+
72
+ expect(settled).toBe(true)
73
+ expect(countOf(world, "cycleAcquiringLockDone"), "after recovery the lock is acquired and the cycle proceeds").toBeGreaterThan(
74
+ lockDoneBaseline
75
+ )
76
+ })
77
+ })
78
+ })
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import pathModule from "path"
3
+ import { createWorld, BASE_TIME, LOCAL_ROOT, type CreateWorldOptions, type World } from "../harness/world"
4
+ import { toPosixPath } from "../fakes/virtual-fs"
5
+ import { LOCAL_TRASH_NAME } from "../../src/constants"
6
+
7
+ /**
8
+ * Category ZJ — the local-trash eviction sweep must age out trashed DIRECTORIES, and the sweep's timer
9
+ * must be torn down with the pair (M2 + M3).
10
+ *
11
+ * The local trash holds whatever a delete moved aside — and a deleted directory is moved in wholesale by
12
+ * its basename, so it lands as a top-level DIRECTORY inside the trash. The 30-day eviction sweep globbed
13
+ * with `onlyFiles:true`, which skipped every trashed directory (and, with `deep:0`, their contents too),
14
+ * so trashed directories accumulated on disk forever (M2). Separately, the sweep's `setInterval` handle
15
+ * was discarded, so a removed pair's timer kept firing every 5 minutes forever, pinning the whole Sync
16
+ * (and its in-memory trees) and burning a periodic glob on a dead pair (M3).
17
+ *
18
+ * These are LOCAL-only concerns (a folder on the local disk + a client-side timer); the backend plays no
19
+ * part, so there is no e2e counterpart — see the boundary note in tests/e2e/regressions.e2e.test.ts.
20
+ */
21
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
22
+ const CLEANUP_INTERVAL_MS = 300000
23
+ const DAY_MS = 86400000
24
+ const TRASH_ROOT = pathModule.posix.join(LOCAL_ROOT, LOCAL_TRASH_NAME)
25
+
26
+ async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
27
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
28
+ vi.setSystemTime(BASE_TIME)
29
+
30
+ try {
31
+ const world = await createWorld(options)
32
+
33
+ await body(world)
34
+ } finally {
35
+ vi.useRealTimers()
36
+ }
37
+ }
38
+
39
+ // Drop a file or a directory (with one child) straight into the on-disk trash via memfs, then backdate its
40
+ // access time so the eviction gate (`atime + 30d < now`) decides it on the next sweep.
41
+ function seedTrashEntry(world: World, name: string, kind: "file" | "dir", ageDays: number): string {
42
+ const ifs = world.vfs.ifs
43
+ const entryPath = pathModule.posix.join(TRASH_ROOT, name)
44
+
45
+ if (kind === "dir") {
46
+ ifs.mkdirSync(toPosixPath(entryPath), { recursive: true })
47
+ ifs.writeFileSync(toPosixPath(pathModule.posix.join(entryPath, "inner.txt")), "inner")
48
+ } else {
49
+ ifs.mkdirSync(toPosixPath(TRASH_ROOT), { recursive: true })
50
+ ifs.writeFileSync(toPosixPath(entryPath), "leaf")
51
+ }
52
+
53
+ // utimesSync takes seconds; set atime to `ageDays` before the (fake) current time.
54
+ const atimeSeconds = (Date.now() - ageDays * DAY_MS) / 1000
55
+
56
+ ifs.utimesSync(toPosixPath(entryPath), atimeSeconds, atimeSeconds)
57
+
58
+ return entryPath
59
+ }
60
+
61
+ const existsInTrash = (world: World, name: string): boolean => world.vfs.ifs.existsSync(toPosixPath(pathModule.posix.join(TRASH_ROOT, name)))
62
+
63
+ describe("Category ZJ — local trash cleanup evicts directories and tears down its timer", () => {
64
+ it("ZJ1: an old trashed DIRECTORY is evicted by the sweep (not just old files)", async () => {
65
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
66
+ const oldDir = seedTrashEntry(world, "old-dir", "dir", 40)
67
+ const oldFile = seedTrashEntry(world, "old-file.txt", "file", 40)
68
+
69
+ expect(existsInTrash(world, "old-dir")).toBe(true)
70
+ expect(existsInTrash(world, "old-file.txt")).toBe(true)
71
+
72
+ await world.sync.cleanupLocalTrashOnce()
73
+
74
+ // Both the aged directory (with onlyFiles:true it was skipped and leaked forever) and the aged file
75
+ // are gone.
76
+ expect(world.vfs.ifs.existsSync(toPosixPath(oldDir)), "an aged trashed directory must be removed").toBe(false)
77
+ expect(world.vfs.ifs.existsSync(toPosixPath(oldFile))).toBe(false)
78
+ })
79
+ })
80
+
81
+ it("ZJ2: a RECENT trashed directory is kept — the 30-day gate still applies to directories", async () => {
82
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
83
+ seedTrashEntry(world, "recent-dir", "dir", 5)
84
+ seedTrashEntry(world, "old-dir", "dir", 40)
85
+
86
+ await world.sync.cleanupLocalTrashOnce()
87
+
88
+ expect(existsInTrash(world, "recent-dir"), "a directory trashed 5 days ago is still within the retention window").toBe(true)
89
+ expect(existsInTrash(world, "old-dir")).toBe(false)
90
+ })
91
+ })
92
+
93
+ it("ZJ3: cleanup() tears down the sweep timer so a removed pair stops sweeping", async () => {
94
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
95
+ // The constructor armed the periodic sweep and kept its handle.
96
+ expect(world.sync.cleanupLocalTrashInterval, "the sweep timer must be retained so it can be cleared").toBeDefined()
97
+
98
+ await world.sync.cleanup({})
99
+
100
+ expect(world.sync.cleanupLocalTrashInterval, "cleanup() must clear the sweep timer handle").toBeUndefined()
101
+
102
+ // An aged directory appears in the trash AFTER the pair was removed. If the timer were still alive
103
+ // it would fire within one interval and delete it; because it was cleared, the entry survives.
104
+ seedTrashEntry(world, "post-removal-old-dir", "dir", 40)
105
+
106
+ await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS * 3 + 1)
107
+
108
+ expect(existsInTrash(world, "post-removal-old-dir"), "a removed pair must not keep sweeping the trash").toBe(true)
109
+ })
110
+ })
111
+
112
+ it("ZJ4: while the pair is live the timer invokes the sweep once per period", async () => {
113
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "a" } }, async world => {
114
+ // Assert the timer WIRING (it calls the sweep on schedule) rather than the sweep's async filesystem
115
+ // effect — FastGlob's stream completion leans on the real event loop, which a floating timer
116
+ // callback can't deterministically drain under fake timers (ZJ1/ZJ2 cover the eviction effect when
117
+ // the sweep is awaited directly).
118
+ const sweep = vi.spyOn(world.sync, "cleanupLocalTrashOnce").mockResolvedValue()
119
+
120
+ expect(sweep).not.toHaveBeenCalled()
121
+
122
+ await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS + 1)
123
+
124
+ expect(sweep, "the armed interval must invoke the sweep once per period").toHaveBeenCalledTimes(1)
125
+
126
+ await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS)
127
+
128
+ expect(sweep, "and again on the next period").toHaveBeenCalledTimes(2)
129
+
130
+ sweep.mockRestore()
131
+ })
132
+ })
133
+ })