@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,90 @@
1
+ /**
2
+ * Phase profiler for ONE incremental cycle (1 file changed in a large synced tree) — tells us exactly
3
+ * where the per-edit cost goes before we optimize. Not a *.bench.ts, so the bench suite ignores it.
4
+ *
5
+ * NODE_OPTIONS='--expose-gc' tsx tests/bench/profile-incremental.ts [nodes]
6
+ */
7
+ import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
8
+ import { genWideScene, resetIdentity } from "./harness/trees"
9
+ import { SYNC_INTERVAL } from "../../src/constants"
10
+ import { LOCAL_ROOT } from "../harness/world"
11
+
12
+ const nodes = Number(process.argv[2] ?? 20_000)
13
+
14
+ function wrap<T extends object, K extends keyof T>(obj: T, key: K, label: string, acc: Record<string, number>): void {
15
+ const original = obj[key] as unknown as (...args: unknown[]) => unknown
16
+
17
+ obj[key] = (async (...args: unknown[]) => {
18
+ const t0 = performance.now()
19
+ const result = await original.apply(obj, args)
20
+
21
+ acc[label] = (acc[label] ?? 0) + (performance.now() - t0)
22
+
23
+ return result
24
+ }) as unknown as T[K]
25
+ }
26
+
27
+ async function main(): Promise<void> {
28
+ resetIdentity()
29
+
30
+ const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
31
+ const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
32
+
33
+ // Settle the initial sync.
34
+ for (let i = 0; i < 3; i++) {
35
+ world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
36
+
37
+ await world.sync.runCycle()
38
+
39
+ world.messages.length = 0
40
+ }
41
+
42
+ const acc: Record<string, number> = {}
43
+
44
+ wrap(world.sync.ignorer, "initialize", "ignorer.initialize", acc)
45
+ wrap(world.sync.localFileSystem, "getDirectoryTree", "local.getDirectoryTree", acc)
46
+ wrap(world.sync.remoteFileSystem, "getDirectoryTree", "remote.getDirectoryTree", acc)
47
+ wrap(world.sync.deltas, "process", "deltas.process", acc)
48
+ wrap(world.sync.tasks, "process", "tasks.process", acc)
49
+ wrap(world.sync.state, "save", "state.save", acc)
50
+ wrap(world.sync.lock, "acquire", "lock.acquire", acc)
51
+ wrap(world.sync.lock, "release", "lock.release", acc)
52
+
53
+ // One measured incremental cycle: change a single file, force a rescan, run.
54
+ await world.vfs.fs.writeFile(`${LOCAL_ROOT}/dir-0/file-0.txt`, "y".repeat(99), { encoding: "utf-8" })
55
+
56
+ forceLocalRescan(world)
57
+
58
+ world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
59
+
60
+ const t0 = performance.now()
61
+
62
+ await world.sync.runCycle()
63
+
64
+ const total = performance.now() - t0
65
+
66
+ process.stdout.write(`\nIncremental cycle profile — ${scene.length} nodes, 1 file changed:\n`)
67
+
68
+ const sorted = Object.entries(acc).sort((a, b) => b[1] - a[1])
69
+ let accounted = 0
70
+
71
+ for (const [label, ms] of sorted) {
72
+ accounted += ms
73
+ process.stdout.write(` ${label.padEnd(26)} ${ms.toFixed(1).padStart(8)} ms (${((ms / total) * 100).toFixed(1)}%)\n`)
74
+ }
75
+
76
+ process.stdout.write(` ${"[unaccounted/other]".padEnd(26)} ${(total - accounted).toFixed(1).padStart(8)} ms\n`)
77
+ process.stdout.write(` ${"TOTAL".padEnd(26)} ${total.toFixed(1).padStart(8)} ms\n`)
78
+
79
+ if (world.sync.cleanupLocalTrashInterval) {
80
+ clearInterval(world.sync.cleanupLocalTrashInterval)
81
+ }
82
+ }
83
+
84
+ main().then(
85
+ () => process.exit(0),
86
+ e => {
87
+ console.error(e)
88
+ process.exit(1)
89
+ }
90
+ )
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import { makeScaleWorld, sceneToDirTreeResponse, injectDirTreeResponse, forceRemoteRebuild } from "./harness/scale-world"
4
+ import { genWideScene, genBalancedScene, resetIdentity, type Scene } from "./harness/trees"
5
+ import { type World } from "../harness/world"
6
+
7
+ /**
8
+ * Remote tree-build benchmark (target T2). `remoteFileSystem.getDirectoryTree` decrypts every folder
9
+ * (SEQUENTIALLY, to build parent paths) and every file (eagerly mapping N promises through a 1024-wide
10
+ * semaphore), then builds the tree+uuids maps.
11
+ *
12
+ * We feed the engine a PRE-BUILT dir-tree response (synthesised from a scene, JSON metadata) so the
13
+ * timed work is ONLY the engine's build loop — NOT the fake cloud's `buildFullTree`, which is O(N²)
14
+ * (test infra: it scans all nodes per directory) and would dominate + mask the engine cost. The fake
15
+ * cloud's decrypt (JSON.parse) is what runs per item, isolating the engine's per-node CPU + the
16
+ * eager-promise/closure memory T2 targets (real SDK crypto is a separate, SDK-owned cost).
17
+ */
18
+
19
+ async function worldFor(scene: Scene): Promise<World> {
20
+ // Empty remote — we inject the response directly; the world only supplies sdk.crypto + environment.
21
+ const world = await makeScaleWorld({ mode: "twoWay" })
22
+
23
+ injectDirTreeResponse(world, sceneToDirTreeResponse(scene, world.cloud.controls.rootUUID))
24
+
25
+ return world
26
+ }
27
+
28
+ describe("remoteFileSystem.getDirectoryTree (engine build loop, isolated)", () => {
29
+ it("wide tree size sweep", async () => {
30
+ // Post-fix: linear (finding 001 + 004), so the sweep goes to 500k where the baseline capped at
31
+ // 200k (which took ~22s under the old O(N²) build).
32
+ for (const nodes of [10_000, 50_000, 200_000, 500_000]) {
33
+ resetIdentity()
34
+
35
+ const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
36
+ const world = await worldFor(scene)
37
+
38
+ const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
39
+
40
+ expect(first.result.size).toBe(scene.length)
41
+
42
+ await bench({
43
+ group: "remoteFileSystem.getDirectoryTree / wide",
44
+ name: `${nodes} nodes`,
45
+ n: scene.length,
46
+ iterations: nodes >= 200_000 ? 3 : 5,
47
+ setup: () => {
48
+ forceRemoteRebuild(world)
49
+
50
+ return world
51
+ },
52
+ run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
53
+ })
54
+ }
55
+ })
56
+
57
+ it("directory-heavy tree (sequential folder-decrypt path)", async () => {
58
+ resetIdentity()
59
+
60
+ const scene = genWideScene(10_000, 2)
61
+ const world = await worldFor(scene)
62
+
63
+ const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
64
+
65
+ expect(first.result.size).toBe(scene.length)
66
+
67
+ await bench({
68
+ group: "remoteFileSystem.getDirectoryTree / directory-heavy",
69
+ name: `50k dirs x2 files (${scene.length} nodes)`,
70
+ n: scene.length,
71
+ iterations: 4,
72
+ setup: () => {
73
+ forceRemoteRebuild(world)
74
+
75
+ return world
76
+ },
77
+ run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
78
+ })
79
+ })
80
+
81
+ it("balanced tree", async () => {
82
+ resetIdentity()
83
+
84
+ const scene = genBalancedScene({ fanout: 4, depth: 5, filesPerDir: 8 })
85
+ const world = await worldFor(scene)
86
+
87
+ const first = await world.sync.remoteFileSystem.getDirectoryTree(true)
88
+
89
+ expect(first.result.size).toBe(scene.length)
90
+
91
+ await bench({
92
+ group: "remoteFileSystem.getDirectoryTree / balanced",
93
+ name: `fanout4 depth5 (${scene.length} nodes)`,
94
+ n: scene.length,
95
+ iterations: 4,
96
+ setup: () => {
97
+ forceRemoteRebuild(world)
98
+
99
+ return world
100
+ },
101
+ run: w => w.sync.remoteFileSystem.getDirectoryTree(true)
102
+ })
103
+ })
104
+ })
@@ -0,0 +1,14 @@
1
+ import pathModule from "path"
2
+ import { renderReport } from "./harness/measure"
3
+
4
+ /**
5
+ * Standalone report renderer: reads the JSONL the bench run appended and writes a grouped markdown
6
+ * table. Decoupled from the vitest lifecycle (vitest workers don't reliably fire exit hooks).
7
+ *
8
+ * tsx tests/bench/render.ts [label] [inJsonl] [outMd]
9
+ */
10
+ const label = process.argv[2] ?? process.env["BENCH_LABEL"] ?? "run"
11
+ const inJsonl = process.argv[3] ?? process.env["BENCH_JSONL"] ?? pathModule.join(process.cwd(), "docs/perf/benchmarks/_results.jsonl")
12
+ const outMd = process.argv[4] ?? process.env["BENCH_OUT"] ?? pathModule.join(process.cwd(), `docs/perf/benchmarks/${label}.md`)
13
+
14
+ renderReport(inJsonl, outMd, label)
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import { Semaphore } from "../../src/semaphore"
4
+
5
+ /**
6
+ * Semaphore throughput benchmark (finding 001). When N tasks contend on a semaphore of width `maxCount`,
7
+ * N−maxCount of them queue. The dequeue cost per release determines whether bulk fan-out (remote tree
8
+ * build, bulk transfers) is O(N) or O(N²). This drives a realistic fan-out: N tasks each acquire, yield a
9
+ * microtask, then release — exactly the shape of `dir.files.map(async () => { await sem.acquire(); … })`.
10
+ */
11
+
12
+ async function drain(maxCount: number, tasks: number): Promise<void> {
13
+ const sem = new Semaphore(maxCount)
14
+
15
+ await Promise.all(
16
+ Array.from({ length: tasks }, async () => {
17
+ await sem.acquire()
18
+
19
+ try {
20
+ // Yield once so the semaphore actually queues waiters (mirrors an awaited decrypt / IO step).
21
+ await Promise.resolve()
22
+ } finally {
23
+ sem.release()
24
+ }
25
+ })
26
+ )
27
+ }
28
+
29
+ describe("Semaphore fan-out throughput", () => {
30
+ it("width 1024 (remote listSemaphore shape)", async () => {
31
+ // Post-fix sizes — the O(1) head-index dequeue is linear, so these complete fast where the old
32
+ // O(N²) shift took seconds-to-minutes. (Baseline 00-baseline.md used 5k–40k to show the quadratic.)
33
+ for (const tasks of [50_000, 200_000, 1_000_000]) {
34
+ await bench({
35
+ group: "Semaphore.drain / width 1024",
36
+ name: `${tasks} tasks`,
37
+ n: tasks,
38
+ iterations: 4,
39
+ setup: () => ({ maxCount: 1024, tasks }),
40
+ run: ({ maxCount, tasks: t }) => drain(maxCount, t)
41
+ })
42
+ }
43
+ })
44
+
45
+ it("width 10 (transfers semaphore shape — bulk upload/download)", async () => {
46
+ for (const tasks of [50_000, 200_000, 1_000_000]) {
47
+ await bench({
48
+ group: "Semaphore.drain / width 10",
49
+ name: `${tasks} tasks`,
50
+ n: tasks,
51
+ iterations: 4,
52
+ setup: () => ({ maxCount: 10, tasks }),
53
+ run: ({ maxCount, tasks: t }) => drain(maxCount, t)
54
+ })
55
+ }
56
+ })
57
+
58
+ it("FIFO order preserved", async () => {
59
+ // Correctness guard the optimization must keep: waiters resume in acquire order.
60
+ const sem = new Semaphore(1)
61
+ const order: number[] = []
62
+
63
+ await sem.acquire() // hold the only slot
64
+
65
+ const waiters = [0, 1, 2, 3, 4].map(async i => {
66
+ await sem.acquire()
67
+ order.push(i)
68
+ sem.release()
69
+ })
70
+
71
+ // Let them all queue, then release to drain in FIFO order.
72
+ await Promise.resolve()
73
+ sem.release()
74
+
75
+ await Promise.all(waiters)
76
+
77
+ expect(order).toEqual([0, 1, 2, 3, 4])
78
+ })
79
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import State from "../../src/lib/state"
4
+ import type Sync from "../../src/lib/sync"
5
+ import { createVirtualFS } from "../fakes/virtual-fs"
6
+ import { genWideScene, buildLocalTree, buildRemoteTree, resetIdentity } from "./harness/trees"
7
+
8
+ /**
9
+ * State persistence benchmark (target T4). Every change-cycle persists 4 line-delimited-JSON files
10
+ * (previousLocalTree, previousLocalINodes, previousRemoteTree, previousRemoteUUIDs) and reloads them on
11
+ * restart. The tree and the inodes/uuids files hold the SAME item objects keyed differently, so each
12
+ * item is serialized TWICE — this measures the real cost and the duplication T4 targets. memfs backs the
13
+ * writes so we measure serialization + stream + parse CPU, not real disk latency.
14
+ */
15
+
16
+ function makeState(scene: ReturnType<typeof genWideScene>): { state: State; sync: Sync } {
17
+ const vfs = createVirtualFS({ "/db": null })
18
+ const localTree = buildLocalTree(scene)
19
+ const remoteTree = buildRemoteTree(scene)
20
+
21
+ const sync = {
22
+ dbPath: "/db",
23
+ syncPair: { uuid: "bench-uuid" },
24
+ environment: { fs: vfs.fs, globFs: vfs.globFs },
25
+ localFileHashes: {},
26
+ previousLocalTree: { tree: localTree.tree, inodes: localTree.inodes, size: localTree.size },
27
+ previousRemoteTree: { tree: remoteTree.tree, uuids: remoteTree.uuids, size: remoteTree.size },
28
+ isPreviousSavedTreeStateEmpty: true
29
+ } as unknown as Sync
30
+
31
+ return { state: new State(sync), sync }
32
+ }
33
+
34
+ function sceneOf(nodes: number): ReturnType<typeof genWideScene> {
35
+ resetIdentity()
36
+
37
+ return genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
38
+ }
39
+
40
+ describe("state persistence", () => {
41
+ it("savePreviousTrees (4 files, tree+inodes duplicated)", async () => {
42
+ for (const nodes of [10_000, 100_000, 300_000]) {
43
+ await bench({
44
+ group: "state.savePreviousTrees",
45
+ name: `${nodes} nodes`,
46
+ n: nodes,
47
+ iterations: 4,
48
+ setup: () => makeState(sceneOf(nodes)),
49
+ run: ({ state }) => state.savePreviousTrees()
50
+ })
51
+ }
52
+ })
53
+
54
+ it("loadPreviousTrees (round-trip read of the 4 files)", async () => {
55
+ for (const nodes of [10_000, 100_000, 300_000]) {
56
+ const scene = sceneOf(nodes)
57
+ const expectedSize = scene.length
58
+
59
+ await bench({
60
+ group: "state.loadPreviousTrees",
61
+ name: `${nodes} nodes`,
62
+ n: nodes,
63
+ iterations: 4,
64
+ setup: async () => {
65
+ const made = makeState(scene)
66
+
67
+ await made.state.savePreviousTrees()
68
+
69
+ // Reset the in-memory trees so the load actually re-reads + rebuilds from disk.
70
+ made.sync.previousLocalTree = { tree: {}, inodes: {}, size: 0 }
71
+ made.sync.previousRemoteTree = { tree: {}, uuids: {}, size: 0 }
72
+
73
+ return made
74
+ },
75
+ run: async ({ state, sync }) => {
76
+ await state.loadPreviousTrees()
77
+
78
+ expect(sync.previousLocalTree.size).toBe(expectedSize)
79
+
80
+ return sync.previousLocalTree.size
81
+ }
82
+ })
83
+ }
84
+ })
85
+ })
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import { type Delta } from "../../src/lib/deltas"
4
+
5
+ /**
6
+ * Task-dispatch benchmark (target T1). `tasks.process` dispatches deltas in 12 type passes by scanning
7
+ * the WHOLE deltasSorted array 10 times (`for…if(type!==X)continue`) plus 2 `.filter()` passes for the
8
+ * up/down transfers — 12·O(D) just to order the work. This isolates that dispatch overhead (with a
9
+ * no-op task body) and compares it to a single bucketing pass, so we know whether T1 is worth doing
10
+ * before touching the real executor.
11
+ */
12
+
13
+ const DISPATCH_ORDER = [
14
+ "renameLocalDirectory",
15
+ "renameLocalFile",
16
+ "renameRemoteDirectory",
17
+ "renameRemoteFile",
18
+ "deleteLocalDirectory",
19
+ "deleteLocalFile",
20
+ "deleteRemoteDirectory",
21
+ "deleteRemoteFile",
22
+ "createLocalDirectory",
23
+ "createRemoteDirectory"
24
+ ] as const
25
+
26
+ const noopProcess = async (): Promise<void> => {}
27
+
28
+ /** Current strategy: 10 full filter-scans (sequential) + 2 filter passes for transfers. */
29
+ async function dispatchCurrent(deltas: Delta[]): Promise<void> {
30
+ for (const type of DISPATCH_ORDER) {
31
+ for (const delta of deltas) {
32
+ if (delta.type !== type) {
33
+ continue
34
+ }
35
+
36
+ await noopProcess()
37
+ }
38
+ }
39
+
40
+ await Promise.all(deltas.filter(d => d.type === "uploadFile").map(noopProcess))
41
+ await Promise.all(deltas.filter(d => d.type === "downloadFile").map(noopProcess))
42
+ }
43
+
44
+ /** Bucketed strategy: one pass to partition, then drain each bucket in the same order. */
45
+ async function dispatchBucketed(deltas: Delta[]): Promise<void> {
46
+ const buckets = new Map<string, Delta[]>()
47
+
48
+ for (const delta of deltas) {
49
+ let bucket = buckets.get(delta.type)
50
+
51
+ if (!bucket) {
52
+ bucket = []
53
+
54
+ buckets.set(delta.type, bucket)
55
+ }
56
+
57
+ bucket.push(delta)
58
+ }
59
+
60
+ for (const type of DISPATCH_ORDER) {
61
+ const bucket = buckets.get(type)
62
+
63
+ if (bucket) {
64
+ for (let i = 0; i < bucket.length; i++) {
65
+ await noopProcess()
66
+ }
67
+ }
68
+ }
69
+
70
+ await Promise.all((buckets.get("uploadFile") ?? []).map(noopProcess))
71
+ await Promise.all((buckets.get("downloadFile") ?? []).map(noopProcess))
72
+ }
73
+
74
+ function genUploads(d: number): Delta[] {
75
+ const deltas: Delta[] = []
76
+
77
+ for (let i = 0; i < d; i++) {
78
+ deltas.push({ type: "uploadFile", path: `/dir-${i % 1000}/file-${i}.txt`, size: 64 })
79
+ }
80
+
81
+ return deltas
82
+ }
83
+
84
+ function genMixed(d: number): Delta[] {
85
+ const deltas: Delta[] = []
86
+
87
+ for (let i = 0; i < d; i++) {
88
+ const r = i % 5
89
+
90
+ if (r === 0) {
91
+ deltas.push({ type: "uploadFile", path: `/u-${i}.txt`, size: 64 })
92
+ } else if (r === 1) {
93
+ deltas.push({ type: "downloadFile", path: `/d-${i}.txt`, size: 64 })
94
+ } else if (r === 2) {
95
+ deltas.push({ type: "deleteRemoteFile", path: `/x-${i}.txt` })
96
+ } else if (r === 3) {
97
+ deltas.push({ type: "createRemoteDirectory", path: `/c-${i}` })
98
+ } else {
99
+ deltas.push({ type: "renameRemoteFile", path: `/r-${i}.txt`, from: `/r-${i}.txt`, to: `/r2-${i}.txt` })
100
+ }
101
+ }
102
+
103
+ return deltas
104
+ }
105
+
106
+ describe("tasks dispatch — 12-pass vs bucketed (T1)", () => {
107
+ it("all-uploads (bulk add — worst case for the 10 no-op rename/delete passes)", async () => {
108
+ for (const d of [10_000, 100_000, 1_000_000]) {
109
+ const deltas = genUploads(d)
110
+
111
+ await bench({
112
+ group: "tasks dispatch / all uploads",
113
+ name: `current 12-pass — D=${d}`,
114
+ n: d,
115
+ iterations: 5,
116
+ setup: () => deltas,
117
+ run: ds => dispatchCurrent(ds)
118
+ })
119
+
120
+ await bench({
121
+ group: "tasks dispatch / all uploads",
122
+ name: `bucketed — D=${d}`,
123
+ n: d,
124
+ iterations: 5,
125
+ setup: () => deltas,
126
+ run: ds => dispatchBucketed(ds)
127
+ })
128
+ }
129
+ })
130
+
131
+ it("mixed delta types", async () => {
132
+ for (const d of [100_000, 1_000_000]) {
133
+ const deltas = genMixed(d)
134
+
135
+ expect(deltas.length).toBe(d)
136
+
137
+ await bench({
138
+ group: "tasks dispatch / mixed types",
139
+ name: `current 12-pass — D=${d}`,
140
+ n: d,
141
+ iterations: 5,
142
+ setup: () => deltas,
143
+ run: ds => dispatchCurrent(ds)
144
+ })
145
+
146
+ await bench({
147
+ group: "tasks dispatch / mixed types",
148
+ name: `bucketed — D=${d}`,
149
+ n: d,
150
+ iterations: 5,
151
+ setup: () => deltas,
152
+ run: ds => dispatchBucketed(ds)
153
+ })
154
+ }
155
+ })
156
+ })