@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,187 @@
1
+ import SyncWorker from "../../src/index"
2
+ import Sync from "../../src/lib/sync"
3
+ import { type SyncPair, type SyncMode, type SyncMessage } from "../../src/types"
4
+ import { type SyncEnvironment, type SyncWatcher } from "../../src/lib/environment"
5
+ import { createVirtualFS, type VfsSpec, type VirtualFS } from "../fakes/virtual-fs"
6
+ import { createFakeCloud, type CloudSpec, type FakeCloud } from "../fakes/fake-cloud"
7
+ import { v4 as uuidv4 } from "uuid"
8
+
9
+ /**
10
+ * A fixed, realistic base time so file mtimes are large ms values (the engine's
11
+ * `convertTimestampToMs` treats them as already-ms) and the fake clock is deterministic.
12
+ */
13
+ export const BASE_TIME = new Date("2024-06-01T00:00:00.000Z").getTime()
14
+
15
+ export const LOCAL_ROOT = "/local"
16
+ export const DB_ROOT = "/db"
17
+
18
+ export type CreateWorldOptions = {
19
+ mode: SyncMode
20
+ initialLocal?: VfsSpec
21
+ initialRemote?: CloudSpec
22
+ excludeDotFiles?: boolean
23
+ localTrashDisabled?: boolean
24
+ requireConfirmationOnLargeDeletion?: boolean
25
+ paused?: boolean
26
+ uuid?: string
27
+ filenIgnore?: string
28
+ }
29
+
30
+ export type World = {
31
+ worker: SyncWorker
32
+ sync: Sync
33
+ vfs: VirtualFS
34
+ cloud: FakeCloud
35
+ messages: SyncMessage[]
36
+ syncPair: SyncPair
37
+ localPath: string
38
+ /** Simulate the directory watcher firing (a local change was observed). */
39
+ triggerWatcher: () => void
40
+ }
41
+
42
+ /**
43
+ * A manual watcher whose `onChange` callbacks are invoked on demand, so tests deterministically
44
+ * tell the engine "the local directory changed" instead of relying on a real fs watcher.
45
+ */
46
+ function createManualWatcher(): { factory: SyncEnvironment["createWatcher"]; trigger: () => void } {
47
+ const callbacks = new Set<() => void>()
48
+
49
+ return {
50
+ factory: async (_path: string, onChange: () => void): Promise<SyncWatcher> => {
51
+ callbacks.add(onChange)
52
+
53
+ return {
54
+ close: async (): Promise<void> => {
55
+ callbacks.delete(onChange)
56
+ }
57
+ }
58
+ },
59
+ trigger: (): void => {
60
+ for (const callback of callbacks) {
61
+ callback()
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Build a fully wired, in-memory sync world: a virtual filesystem + a stateful fake cloud injected
69
+ * into a real {@link SyncWorker}/{@link Sync}, with all engine I/O routed through the fakes and the
70
+ * scheduling loop bypassed (the runner drives {@link Sync.runCycle} directly).
71
+ *
72
+ * Must be called with vitest fake timers already installed and the system time set, so memfs and the
73
+ * engine share a deterministic clock.
74
+ */
75
+ export async function createWorld(options: CreateWorldOptions): Promise<World> {
76
+ const localSpec: VfsSpec = { [LOCAL_ROOT]: null, [DB_ROOT]: null }
77
+
78
+ for (const [path, value] of Object.entries(options.initialLocal ?? {})) {
79
+ localSpec[path] = value
80
+ }
81
+
82
+ if (typeof options.filenIgnore === "string") {
83
+ localSpec[`${LOCAL_ROOT}/.filenignore`] = options.filenIgnore
84
+ }
85
+
86
+ const vfs = createVirtualFS(localSpec)
87
+ const cloud = createFakeCloud(options.initialRemote ?? {}, { localFs: vfs.fs })
88
+ const watcher = createManualWatcher()
89
+
90
+ const environment: SyncEnvironment = {
91
+ fs: vfs.fs,
92
+ globFs: vfs.globFs,
93
+ writeFileAtomic: async (filename, data, writeOptions): Promise<void> => {
94
+ await vfs.fs.writeFile(filename, data, writeOptions)
95
+ },
96
+ createWatcher: watcher.factory,
97
+ // Route the tree fetch back through the fake cloud's SDK so every scenario exercises the real
98
+ // engine logic without HTTP/msgpack (the production msgpack path is unit-tested separately).
99
+ fetchDirTree: (_sdk, request) => cloud.sdk.api(3).dir().tree(request)
100
+ }
101
+
102
+ const messages: SyncMessage[] = []
103
+ const syncPair: SyncPair = {
104
+ name: "test-pair",
105
+ uuid: options.uuid ?? uuidv4(),
106
+ localPath: LOCAL_ROOT,
107
+ remotePath: "/",
108
+ remoteParentUUID: cloud.controls.rootUUID,
109
+ mode: options.mode,
110
+ excludeDotFiles: options.excludeDotFiles ?? false,
111
+ paused: options.paused ?? false,
112
+ localTrashDisabled: options.localTrashDisabled ?? false,
113
+ requireConfirmationOnLargeDeletion: options.requireConfirmationOnLargeDeletion ?? false
114
+ }
115
+
116
+ const worker = new SyncWorker({
117
+ syncPairs: [syncPair],
118
+ dbPath: DB_ROOT,
119
+ sdk: cloud.sdk,
120
+ onMessage: message => {
121
+ messages.push(message)
122
+ },
123
+ disableLogging: true,
124
+ environment
125
+ })
126
+
127
+ const sync = new Sync({ syncPair, worker })
128
+
129
+ worker.syncs[syncPair.uuid] = sync
130
+
131
+ // The engine's initialize() would also call run() (the self-scheduling loop); we skip it and
132
+ // load persisted state directly so the runner can drive discrete cycles.
133
+ await sync.state.initialize()
134
+ await sync.ignorer.initialize()
135
+
136
+ return {
137
+ worker,
138
+ sync,
139
+ vfs,
140
+ cloud,
141
+ messages,
142
+ syncPair,
143
+ localPath: LOCAL_ROOT,
144
+ triggerWatcher: watcher.trigger
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Simulate a process restart: rebuild the worker/sync over the SAME virtual filesystem and cloud,
150
+ * so persisted state (previous trees, deviceId, ignorer) is reloaded from disk. Mutates `world` in
151
+ * place (new worker/sync/watcher) and keeps appending to the same message stream.
152
+ */
153
+ export async function restartSync(world: World): Promise<void> {
154
+ const watcher = createManualWatcher()
155
+
156
+ const environment: SyncEnvironment = {
157
+ fs: world.vfs.fs,
158
+ globFs: world.vfs.globFs,
159
+ writeFileAtomic: async (filename, data, writeOptions): Promise<void> => {
160
+ await world.vfs.fs.writeFile(filename, data, writeOptions)
161
+ },
162
+ createWatcher: watcher.factory,
163
+ fetchDirTree: (_sdk, request) => world.cloud.sdk.api(3).dir().tree(request)
164
+ }
165
+
166
+ const worker = new SyncWorker({
167
+ syncPairs: [world.syncPair],
168
+ dbPath: DB_ROOT,
169
+ sdk: world.cloud.sdk,
170
+ onMessage: message => {
171
+ world.messages.push(message)
172
+ },
173
+ disableLogging: true,
174
+ environment
175
+ })
176
+
177
+ const sync = new Sync({ syncPair: world.syncPair, worker })
178
+
179
+ worker.syncs[world.syncPair.uuid] = sync
180
+
181
+ await sync.state.initialize()
182
+ await sync.ignorer.initialize()
183
+
184
+ world.worker = worker
185
+ world.sync = sync
186
+ world.triggerWatcher = watcher.trigger
187
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { runScenario, runCycle, restart } from "../harness/runner"
3
+ import { countMessages, transferKinds, allOps, hadTransfers } from "../harness/snapshot"
4
+
5
+ /**
6
+ * Category A — baseline & convergence (behavioral spec §A).
7
+ *
8
+ * The exact cycle in which the steady-state `cycleNoChanges` message appears depends on the
9
+ * deviceId-cache / cache-reset settling mechanics, so these assert the robust invariants: the
10
+ * expected transfer happens on the first cycle, NO transfers happen afterwards, the worlds converge
11
+ * to equality, and steady state is eventually reported.
12
+ */
13
+ describe("Category A — baseline & convergence", () => {
14
+ it("A1: empty ↔ empty converges with no operations", async () => {
15
+ const result = await runScenario({
16
+ name: "A1",
17
+ mode: "twoWay",
18
+ steps: [runCycle(), runCycle()]
19
+ })
20
+
21
+ expect(hadTransfers(result.messages)).toBe(false)
22
+ expect(countMessages(result.messages, "cycleNoChanges")).toBeGreaterThanOrEqual(1)
23
+ expect(result.finalLocal).toEqual({})
24
+ expect(result.finalRemote).toEqual({})
25
+ })
26
+
27
+ it("A2: a local-only file uploads on the first cycle, then converges (twoWay)", async () => {
28
+ const result = await runScenario({
29
+ name: "A2",
30
+ mode: "twoWay",
31
+ initialLocal: { "/local/a.txt": "0123456789" },
32
+ steps: [runCycle(), runCycle(), runCycle()]
33
+ })
34
+
35
+ expect(result.cycles[0]!.remote["/a.txt"]).toMatchObject({ type: "file", size: 10 })
36
+ expect(transferKinds(result.cycles[0]!.messages)).toContain("upload")
37
+ expect(allOps(result.cycles[1]!.messages)).toEqual([])
38
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
39
+ expect(countMessages(result.messages, "cycleNoChanges")).toBeGreaterThanOrEqual(1)
40
+ expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 10 })
41
+ expect(result.finalLocal).toEqual(result.finalRemote)
42
+ })
43
+
44
+ it("A3: a remote-only file downloads on the first cycle, then converges (twoWay)", async () => {
45
+ const result = await runScenario({
46
+ name: "A3",
47
+ mode: "twoWay",
48
+ initialRemote: { "/a.txt": "hello" },
49
+ steps: [runCycle(), runCycle(), runCycle()]
50
+ })
51
+
52
+ expect(result.cycles[0]!.local["/a.txt"]).toMatchObject({ type: "file", size: 5 })
53
+ expect(transferKinds(result.cycles[0]!.messages)).toContain("download")
54
+ expect(allOps(result.cycles[1]!.messages)).toEqual([])
55
+ expect(allOps(result.cycles[2]!.messages)).toEqual([])
56
+ expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 5 })
57
+ expect(result.finalLocal).toEqual(result.finalRemote)
58
+ })
59
+
60
+ it("A4: identical content on both sides converges without any transfer", async () => {
61
+ const result = await runScenario({
62
+ name: "A4",
63
+ mode: "twoWay",
64
+ initialLocal: { "/local/same.txt": "identical" },
65
+ initialRemote: { "/same.txt": "identical" },
66
+ steps: [runCycle(), runCycle(), runCycle()]
67
+ })
68
+
69
+ expect(hadTransfers(result.messages)).toBe(false)
70
+ expect(result.finalLocal["/same.txt"]).toMatchObject({ type: "file", size: 9 })
71
+ expect(result.finalLocal).toEqual(result.finalRemote)
72
+ })
73
+
74
+ it("A5: a converged world reports no operations after a restart (state persistence)", async () => {
75
+ const result = await runScenario({
76
+ name: "A5",
77
+ mode: "twoWay",
78
+ initialLocal: { "/local/a.txt": "persisted" },
79
+ steps: [runCycle(), runCycle(), runCycle(), restart(), runCycle(), runCycle()]
80
+ })
81
+
82
+ // The two cycles after the restart (indices 3 and 4) perform no transfers.
83
+ expect(allOps(result.cycles[3]!.messages)).toEqual([])
84
+ expect(allOps(result.cycles[4]!.messages)).toEqual([])
85
+ expect(countMessages(result.messages, "cycleError")).toBe(0)
86
+ expect(result.finalLocal["/a.txt"]).toMatchObject({ type: "file", size: 9 })
87
+ expect(result.finalLocal).toEqual(result.finalRemote)
88
+ })
89
+
90
+ it("A6: a deeply nested directory tree converges (twoWay)", async () => {
91
+ const result = await runScenario({
92
+ name: "A6",
93
+ mode: "twoWay",
94
+ initialLocal: {
95
+ "/local/a/b/c/d/e/deep.txt": "deep",
96
+ "/local/a/b/sibling.txt": "sibling",
97
+ "/local/a/top.txt": "top"
98
+ },
99
+ steps: [runCycle(), runCycle(), runCycle(), runCycle()]
100
+ })
101
+
102
+ expect(result.finalRemote["/a/b/c/d/e/deep.txt"]).toMatchObject({ type: "file", size: 4 })
103
+ expect(result.finalRemote["/a/b/c/d/e"]).toMatchObject({ type: "directory" })
104
+ expect(result.finalRemote["/a/top.txt"]).toMatchObject({ type: "file", size: 3 })
105
+ expect(result.finalLocal).toEqual(result.finalRemote)
106
+ })
107
+ })
@@ -0,0 +1,258 @@
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 { snapshotLocal, snapshotRemote, messagesOfType } from "../harness/snapshot"
5
+ import { writeLocal, rmLocal } from "../harness/mutations"
6
+
7
+ /**
8
+ * Category AA — concurrency / race conditions and the large-deletion confirmation across modes. The
9
+ * lock cases prove a cycle survives another device holding the remote resource lock (it reports a
10
+ * cycleError and recovers when released, never corrupting state). The confirmation cases extend the
11
+ * Category G prompt — which only covered twoWay — to the directional modes: the prompt fires for the
12
+ * side a mode is allowed to delete (localToCloud→local, cloudToLocal→remote) and NEVER fires for the
13
+ * additive backup modes (which never delete the target).
14
+ *
15
+ * Cycles that block on a confirmation prompt are driven manually (a timer pump delivers the decision).
16
+ */
17
+ const FAKE_TIMERS = ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"] as const
18
+
19
+ async function withWorld(options: CreateWorldOptions, body: (world: World) => Promise<void>): Promise<void> {
20
+ vi.useFakeTimers({ toFake: [...FAKE_TIMERS] })
21
+ vi.setSystemTime(BASE_TIME)
22
+
23
+ try {
24
+ const world = await createWorld(options)
25
+
26
+ await body(world)
27
+ } finally {
28
+ vi.useRealTimers()
29
+ }
30
+ }
31
+
32
+ async function plainCycle(world: World): Promise<void> {
33
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
34
+
35
+ await world.sync.runCycle()
36
+ }
37
+
38
+ async function cycleWithDecision(world: World, decision: "delete" | "restart"): Promise<void> {
39
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
40
+
41
+ let settled = false
42
+ const cyclePromise = world.sync.runCycle().finally(() => {
43
+ settled = true
44
+ })
45
+
46
+ for (let tick = 0; tick < 30 && !settled; tick++) {
47
+ world.worker.confirmDeletion(world.syncPair.uuid, decision)
48
+
49
+ await vi.advanceTimersByTimeAsync(1000)
50
+ }
51
+
52
+ await cyclePromise
53
+ }
54
+
55
+ /**
56
+ * Drive one cycle that begins while another device holds the remote lock. With maxTries:Infinity the
57
+ * acquire BLOCKS (retrying every second) instead of failing, so the cycle does no work and reports no
58
+ * cycleError until the lock frees. Mirrors cycleWithDecision's timer pump so the blocked acquire and the
59
+ * rest of the (now unblocked) cycle both drain deterministically. `assertWhileBlocked` runs at the point
60
+ * the cycle is provably still waiting on the lock, before it is released.
61
+ */
62
+ async function cycleBlockedByContendedLock(world: World, assertWhileBlocked: () => void): Promise<void> {
63
+ await vi.advanceTimersByTimeAsync(SYNC_INTERVAL + 1)
64
+
65
+ let settled = false
66
+ const cyclePromise = world.sync.runCycle().finally(() => {
67
+ settled = true
68
+ })
69
+
70
+ // Spin several retry windows (past the 3s "acquiring lock" notice); the cycle stays blocked.
71
+ await vi.advanceTimersByTimeAsync(5000)
72
+
73
+ expect(settled, "the cycle must block on the contended lock, not fail").toBe(false)
74
+
75
+ assertWhileBlocked()
76
+
77
+ // Another device releases the lock; the acquire succeeds on its next retry and the cycle finishes.
78
+ world.cloud.controls.releaseLockContention(lockResource(world))
79
+
80
+ for (let tick = 0; tick < 30 && !settled; tick++) {
81
+ await vi.advanceTimersByTimeAsync(1000)
82
+ }
83
+
84
+ await cyclePromise
85
+ }
86
+
87
+ function lockResource(world: World): string {
88
+ return `sync-remoteParentUUID-${world.cloud.controls.rootUUID}`
89
+ }
90
+
91
+ function confirmDeletionCount(world: World): number {
92
+ return messagesOfType(world.messages, "confirmDeletion").length
93
+ }
94
+
95
+ describe("Category AA — races & cross-mode deletion confirmation", () => {
96
+ it("AA1: a contended remote lock makes the cycle wait, then it recovers when released", async () => {
97
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "data" } }, async world => {
98
+ world.cloud.controls.contendLock(lockResource(world))
99
+
100
+ await cycleBlockedByContendedLock(world, () => {
101
+ // Blocked on the lock (the engine acquires with maxTries:Infinity): it announced it is waiting,
102
+ // reported NO error, and did NO work (no corruption) — the previous fake threw here instead.
103
+ expect(messagesOfType(world.messages, "cycleAcquiringLockStarted").length).toBeGreaterThan(0)
104
+ expect(messagesOfType(world.messages, "cycleError").length).toBe(0)
105
+ expect(snapshotRemote(world)["/a.txt"]).toBeUndefined()
106
+ })
107
+
108
+ // The now-unblocked cycle (plus a follow-up to settle both sides) syncs to convergence.
109
+ await plainCycle(world)
110
+
111
+ expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
112
+ expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
113
+ })
114
+ })
115
+
116
+ it("AA2: a cycle blocked by lock contention applies ALL pending adds and deletes once released", async () => {
117
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/keep.txt": "k", "/local/remove.txt": "r" } }, async world => {
118
+ await plainCycle(world)
119
+ await plainCycle(world)
120
+
121
+ // Queue an add and a delete, then block the next cycle on a contended lock.
122
+ writeLocal(world, "added.txt", "added")
123
+ rmLocal(world, "remove.txt")
124
+ world.triggerWatcher()
125
+ world.cloud.controls.contendLock(lockResource(world))
126
+
127
+ await cycleBlockedByContendedLock(world, () => {
128
+ // Nothing applied while the cycle is blocked on the lock.
129
+ expect(snapshotRemote(world)["/added.txt"]).toBeUndefined()
130
+ expect(snapshotRemote(world)["/remove.txt"]).toMatchObject({ type: "file" })
131
+ })
132
+
133
+ // After release the queued work all lands and the worlds converge.
134
+ await plainCycle(world)
135
+
136
+ expect(snapshotRemote(world)["/added.txt"]).toMatchObject({ type: "file" })
137
+ expect(snapshotRemote(world)["/remove.txt"]).toBeUndefined()
138
+ expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
139
+ })
140
+ })
141
+
142
+ it("AA3: localToCloud — emptying the local side prompts a local-deletion confirmation", async () => {
143
+ await withWorld(
144
+ { mode: "localToCloud", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
145
+ async world => {
146
+ await plainCycle(world)
147
+
148
+ rmLocal(world, "a.txt")
149
+ rmLocal(world, "b.txt")
150
+ world.triggerWatcher()
151
+
152
+ await cycleWithDecision(world, "delete")
153
+
154
+ expect(confirmDeletionCount(world)).toBeGreaterThan(0)
155
+ expect(messagesOfType(world.messages, "confirmDeletion")[0]!.data.where).toBe("local")
156
+ // "delete" confirmed → the remote mirror is emptied.
157
+ expect(snapshotRemote(world)).toEqual({})
158
+ }
159
+ )
160
+ })
161
+
162
+ it("AA4: cloudToLocal — emptying the remote side prompts a remote-deletion confirmation", async () => {
163
+ await withWorld(
164
+ { mode: "cloudToLocal", requireConfirmationOnLargeDeletion: true, initialRemote: { "/a.txt": "a", "/b.txt": "b" } },
165
+ async world => {
166
+ await plainCycle(world)
167
+
168
+ world.cloud.controls.trashPath("/a.txt")
169
+ world.cloud.controls.trashPath("/b.txt")
170
+
171
+ await cycleWithDecision(world, "delete")
172
+
173
+ expect(confirmDeletionCount(world)).toBeGreaterThan(0)
174
+ expect(messagesOfType(world.messages, "confirmDeletion")[0]!.data.where).toBe("remote")
175
+ // "delete" confirmed → the local mirror is emptied.
176
+ expect(snapshotLocal(world)).toEqual({})
177
+ }
178
+ )
179
+ })
180
+
181
+ it("AA5: localToCloud — answering 'restart' to the prompt skips the deletions", async () => {
182
+ await withWorld(
183
+ { mode: "localToCloud", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
184
+ async world => {
185
+ await plainCycle(world)
186
+
187
+ rmLocal(world, "a.txt")
188
+ rmLocal(world, "b.txt")
189
+ world.triggerWatcher()
190
+
191
+ await cycleWithDecision(world, "restart")
192
+
193
+ expect(confirmDeletionCount(world)).toBeGreaterThan(0)
194
+ // "restart" → the remote still holds both files (deletions not applied).
195
+ expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
196
+ expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
197
+ }
198
+ )
199
+ })
200
+
201
+ it("AA6: localBackup — emptying the local side NEVER prompts (backups don't delete the target)", async () => {
202
+ await withWorld(
203
+ { mode: "localBackup", requireConfirmationOnLargeDeletion: true, initialLocal: { "/local/a.txt": "a", "/local/b.txt": "b" } },
204
+ async world => {
205
+ await plainCycle(world)
206
+
207
+ rmLocal(world, "a.txt")
208
+ rmLocal(world, "b.txt")
209
+ world.triggerWatcher()
210
+
211
+ await plainCycle(world)
212
+
213
+ expect(confirmDeletionCount(world)).toBe(0)
214
+ // The remote backup is untouched.
215
+ expect(snapshotRemote(world)["/a.txt"]).toMatchObject({ type: "file" })
216
+ expect(snapshotRemote(world)["/b.txt"]).toMatchObject({ type: "file" })
217
+ }
218
+ )
219
+ })
220
+
221
+ it("AA7: cloudBackup — emptying the remote side NEVER prompts (backups don't delete the target)", async () => {
222
+ await withWorld(
223
+ { mode: "cloudBackup", requireConfirmationOnLargeDeletion: true, initialRemote: { "/a.txt": "a", "/b.txt": "b" } },
224
+ async world => {
225
+ await plainCycle(world)
226
+
227
+ world.cloud.controls.trashPath("/a.txt")
228
+ world.cloud.controls.trashPath("/b.txt")
229
+
230
+ await plainCycle(world)
231
+
232
+ expect(confirmDeletionCount(world)).toBe(0)
233
+ // The local backup is untouched.
234
+ expect(snapshotLocal(world)["/a.txt"]).toMatchObject({ type: "file" })
235
+ expect(snapshotLocal(world)["/b.txt"]).toMatchObject({ type: "file" })
236
+ }
237
+ )
238
+ })
239
+
240
+ it("AA8: a file replaced (vanished + re-created) mid-flight is handled without error and converges", async () => {
241
+ await withWorld({ mode: "twoWay", initialLocal: { "/local/a.txt": "v1" } }, async world => {
242
+ await plainCycle(world)
243
+
244
+ // Remote deletes a.txt while local re-creates it with new content in the same beat — the delete
245
+ // task re-checks existence and the engine still settles on the surviving local content.
246
+ world.cloud.controls.trashPath("/a.txt")
247
+ writeLocal(world, "a.txt", "v2-local-recreated")
248
+ world.triggerWatcher()
249
+
250
+ await plainCycle(world)
251
+ await plainCycle(world)
252
+ await plainCycle(world)
253
+
254
+ expect(messagesOfType(world.messages, "taskErrors").some(message => message.data.errors.length > 0)).toBe(false)
255
+ expect(snapshotLocal(world)).toEqual(snapshotRemote(world))
256
+ })
257
+ })
258
+ })