@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,160 @@
1
+ import { createWorld, type World, LOCAL_ROOT } from "../../harness/world"
2
+ import { type SyncMode } from "../../../src/types"
3
+ import { type VfsSpec } from "../../fakes/virtual-fs"
4
+ import { type CloudSpec } from "../../fakes/fake-cloud"
5
+ import { type SyncDirTreeFetcher, type DirTreeResponse } from "../../../src/lib/filesystems/dirTree"
6
+ import { type Scene } from "./trees"
7
+
8
+ /**
9
+ * Build a memfs {@link VfsSpec} (keys under the local root) from a scene. Files get tiny deterministic
10
+ * content; directories are explicit nulls so empty dirs still materialise.
11
+ */
12
+ export function sceneToVfsSpec(scene: Scene, contentByte = "x"): VfsSpec {
13
+ const spec: VfsSpec = {}
14
+
15
+ for (const node of scene) {
16
+ if (node.type === "directory") {
17
+ spec[`${LOCAL_ROOT}${node.path}`] = null
18
+ } else {
19
+ spec[`${LOCAL_ROOT}${node.path}`] = contentByte.repeat(node.size)
20
+ }
21
+ }
22
+
23
+ return spec
24
+ }
25
+
26
+ /** Build a fake-cloud {@link CloudSpec} (keys are sync-root-absolute paths) from a scene. */
27
+ export function sceneToCloudSpec(scene: Scene, contentByte = "x"): CloudSpec {
28
+ const spec: CloudSpec = {}
29
+
30
+ for (const node of scene) {
31
+ if (node.type === "directory") {
32
+ spec[node.path] = null
33
+ } else {
34
+ spec[node.path] = contentByte.repeat(node.size)
35
+ }
36
+ }
37
+
38
+ return spec
39
+ }
40
+
41
+ /**
42
+ * Create a wired in-memory world at scale and neutralise the background trash-cleanup interval so it
43
+ * cannot fire mid-benchmark. Runs under REAL timers (the bench measures wall time) — safe because the
44
+ * methods we benchmark (getDirectoryTree, runCycle with the debounce/timestamp pre-set) never await a
45
+ * scheduled timer in the uncontended in-memory path.
46
+ */
47
+ export async function makeScaleWorld(options: {
48
+ mode: SyncMode
49
+ initialLocal?: VfsSpec
50
+ initialRemote?: CloudSpec
51
+ }): Promise<World> {
52
+ const world = await createWorld(options)
53
+
54
+ if (world.sync.cleanupLocalTrashInterval) {
55
+ clearInterval(world.sync.cleanupLocalTrashInterval)
56
+
57
+ world.sync.cleanupLocalTrashInterval = undefined
58
+ }
59
+
60
+ return world
61
+ }
62
+
63
+ /** Reset the local directory-tree cache so the next getDirectoryTree() does a full rescan. */
64
+ export function forceLocalRescan(world: World): void {
65
+ world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now()
66
+ world.sync.localFileSystem.getDirectoryTreeCache = {
67
+ timestamp: 0,
68
+ tree: {},
69
+ inodes: {},
70
+ ignored: [],
71
+ errors: [],
72
+ size: 0
73
+ }
74
+ }
75
+
76
+ function basename(path: string): string {
77
+ const i = path.lastIndexOf("/")
78
+
79
+ return i === -1 ? path : path.slice(i + 1)
80
+ }
81
+
82
+ function parentPath(path: string): string {
83
+ const i = path.lastIndexOf("/")
84
+
85
+ return i <= 0 ? "" : path.slice(0, i)
86
+ }
87
+
88
+ /**
89
+ * Synthesize the exact `/v3/dir/tree` response (files/folders tuple arrays with JSON metadata — the fake
90
+ * cloud's decrypt is JSON.parse) directly from a scene, so the remote-build benchmark can feed it into
91
+ * the ENGINE's tree-build loop WITHOUT going through the fake cloud's O(N²) `buildFullTree`. Ordered
92
+ * parents-before-children (root first) by default — matching the order the engine's loop relies on.
93
+ * `shuffle` produces an out-of-order response to exercise the order-independence target T2.
94
+ */
95
+ export function sceneToDirTreeResponse(scene: Scene, rootUUID: string, options?: { shuffle?: () => void; rootName?: string }): DirTreeResponse {
96
+ const uuidByPath = new Map<string, string>()
97
+
98
+ for (const node of scene) {
99
+ if (node.type === "directory") {
100
+ uuidByPath.set(node.path, node.uuid)
101
+ }
102
+ }
103
+
104
+ const parentUUIDOf = (path: string): string => {
105
+ const parent = parentPath(path)
106
+
107
+ return parent.length === 0 ? rootUUID : uuidByPath.get(parent) ?? rootUUID
108
+ }
109
+
110
+ const folders: unknown[] = [[rootUUID, JSON.stringify({ name: options?.rootName ?? "Sync" }), "base"]]
111
+ const files: unknown[] = []
112
+
113
+ for (const node of scene) {
114
+ if (node.type === "directory") {
115
+ folders.push([node.uuid, JSON.stringify({ name: basename(node.path) }), parentUUIDOf(node.path)])
116
+ } else {
117
+ files.push([
118
+ node.uuid,
119
+ "filen-1",
120
+ "de-1",
121
+ 1,
122
+ parentUUIDOf(node.path),
123
+ JSON.stringify({
124
+ name: basename(node.path),
125
+ size: node.size,
126
+ mime: "application/octet-stream",
127
+ key: "k",
128
+ lastModified: node.mtime,
129
+ creation: node.creation,
130
+ hash: undefined
131
+ }),
132
+ 2,
133
+ node.mtime
134
+ ])
135
+ }
136
+ }
137
+
138
+ return { files, folders, raw: "" } as unknown as DirTreeResponse
139
+ }
140
+
141
+ /**
142
+ * Replace a world's remote tree fetcher with one that returns a fixed, pre-built response — so the timed
143
+ * `getDirectoryTree` measures ONLY the engine's build loop, not the fake cloud's response construction.
144
+ */
145
+ export function injectDirTreeResponse(world: World, response: DirTreeResponse): void {
146
+ const fetcher: SyncDirTreeFetcher = async () => response
147
+
148
+ world.sync.environment.fetchDirTree = fetcher
149
+ }
150
+
151
+ /** Reset the remote directory-tree cache so the next getDirectoryTree() rebuilds fully. */
152
+ export function forceRemoteRebuild(world: World): void {
153
+ world.sync.remoteFileSystem.getDirectoryTreeCache = {
154
+ timestamp: 0,
155
+ tree: {},
156
+ uuids: {},
157
+ ignored: [],
158
+ size: 0
159
+ }
160
+ }
@@ -0,0 +1,275 @@
1
+ import { type LocalTree, type LocalItem } from "../../../src/lib/filesystems/local"
2
+ import { type RemoteTree, type RemoteItem } from "../../../src/lib/filesystems/remote"
3
+
4
+ /**
5
+ * A scene is an ordered (parents-before-children) list of nodes WITH stable identity (inode + uuid),
6
+ * from which a matching {@link LocalTree} and {@link RemoteTree} can be built. Identity is what lets a
7
+ * "current" scene derived from a "previous" one be recognised by the delta engine as a rename/modify
8
+ * (same inode/uuid, changed path/size) rather than an unrelated add+delete.
9
+ *
10
+ * Local identity (inode) and remote identity (uuid) are independent per node, exactly as the engine
11
+ * treats them — there is no cross-requirement between a path's inode and its uuid.
12
+ */
13
+ export type GenNode = {
14
+ path: string
15
+ type: "file" | "directory"
16
+ inode: number
17
+ uuid: string
18
+ size: number
19
+ mtime: number
20
+ creation: number
21
+ }
22
+
23
+ export type Scene = GenNode[]
24
+
25
+ const BASE_MTIME = new Date("2024-06-01T00:00:00.000Z").getTime()
26
+
27
+ let inodeCounter = 1
28
+ let uuidCounter = 1
29
+
30
+ function nextInode(): number {
31
+ return inodeCounter++
32
+ }
33
+
34
+ function nextUUID(): string {
35
+ // Deterministic, cheap, uuid-shaped enough for the engine (it only uses uuids as map keys / identity).
36
+ const n = uuidCounter++
37
+
38
+ return `uuid-${n.toString(16).padStart(12, "0")}`
39
+ }
40
+
41
+ /** Reset the identity counters so independent benchmarks start from a clean, deterministic space. */
42
+ export function resetIdentity(): void {
43
+ inodeCounter = 1
44
+ uuidCounter = 1
45
+ }
46
+
47
+ function fileNode(path: string, size = 64): GenNode {
48
+ return { path, type: "file", inode: nextInode(), uuid: nextUUID(), size, mtime: BASE_MTIME, creation: BASE_MTIME }
49
+ }
50
+
51
+ function dirNode(path: string): GenNode {
52
+ return { path, type: "directory", inode: nextInode(), uuid: nextUUID(), size: 0, mtime: BASE_MTIME, creation: BASE_MTIME }
53
+ }
54
+
55
+ // ---- shape generators (return ordered, parents-first scenes) -------------------------------------
56
+
57
+ /** All `fileCount` files in the sync root (worst case for one flat directory). */
58
+ export function genFlatScene(fileCount: number): Scene {
59
+ const scene: Scene = []
60
+
61
+ for (let i = 0; i < fileCount; i++) {
62
+ scene.push(fileNode(`/file-${i}.txt`))
63
+ }
64
+
65
+ return scene
66
+ }
67
+
68
+ /** `dirCount` directories each holding `filesPerDir` files (a wide, shallow tree). */
69
+ export function genWideScene(dirCount: number, filesPerDir: number): Scene {
70
+ const scene: Scene = []
71
+
72
+ for (let d = 0; d < dirCount; d++) {
73
+ const dirPath = `/dir-${d}`
74
+
75
+ scene.push(dirNode(dirPath))
76
+
77
+ for (let f = 0; f < filesPerDir; f++) {
78
+ scene.push(fileNode(`${dirPath}/file-${f}.txt`))
79
+ }
80
+ }
81
+
82
+ return scene
83
+ }
84
+
85
+ /** A single chain `/d0/d1/.../d{depth-1}/leaf.txt` (deep nesting). */
86
+ export function genDeepScene(depth: number): Scene {
87
+ const scene: Scene = []
88
+ let prefix = ""
89
+
90
+ for (let level = 0; level < depth; level++) {
91
+ prefix += `/d${level}`
92
+
93
+ scene.push(dirNode(prefix))
94
+ }
95
+
96
+ scene.push(fileNode(`${prefix}/leaf.txt`))
97
+
98
+ return scene
99
+ }
100
+
101
+ /**
102
+ * A balanced tree: each directory has `fanout` subdirectories down to `depth`, and every directory
103
+ * holds `filesPerDir` files. Realistic mix of files and directories. Ordered parents-first (BFS).
104
+ */
105
+ export function genBalancedScene(options: { fanout: number; depth: number; filesPerDir: number }): Scene {
106
+ const { fanout, depth, filesPerDir } = options
107
+ const scene: Scene = []
108
+ const queue: { path: string; level: number }[] = [{ path: "", level: 0 }]
109
+
110
+ while (queue.length > 0) {
111
+ const { path, level } = queue.shift()!
112
+
113
+ for (let f = 0; f < filesPerDir; f++) {
114
+ scene.push(fileNode(`${path}/file-${f}.txt`))
115
+ }
116
+
117
+ if (level < depth) {
118
+ for (let c = 0; c < fanout; c++) {
119
+ const childPath = `${path}/dir-${level}-${c}`
120
+
121
+ scene.push(dirNode(childPath))
122
+ queue.push({ path: childPath, level: level + 1 })
123
+ }
124
+ }
125
+ }
126
+
127
+ // Drop a possible leading "/file..." with empty path → ensure all paths start with "/".
128
+ return scene
129
+ }
130
+
131
+ /** Generate a wide scene that targets roughly `targetNodes` total nodes. */
132
+ export function genSceneOfSize(targetNodes: number, filesPerDir = 100): Scene {
133
+ const dirCount = Math.max(1, Math.round(targetNodes / (filesPerDir + 1)))
134
+
135
+ return genWideScene(dirCount, filesPerDir)
136
+ }
137
+
138
+ // ---- scene → engine tree builders ----------------------------------------------------------------
139
+
140
+ export function buildLocalTree(scene: Scene): LocalTree {
141
+ const tree: Record<string, LocalItem> = {}
142
+ const inodes: Record<number, LocalItem> = {}
143
+
144
+ for (const node of scene) {
145
+ const item: LocalItem = {
146
+ lastModified: node.mtime,
147
+ type: node.type,
148
+ path: node.path,
149
+ size: node.size,
150
+ creation: node.creation,
151
+ inode: node.inode
152
+ }
153
+
154
+ tree[node.path] = item
155
+ inodes[node.inode] = item
156
+ }
157
+
158
+ return { tree, inodes, size: scene.length }
159
+ }
160
+
161
+ export function buildRemoteTree(scene: Scene): RemoteTree {
162
+ const tree: Record<string, RemoteItem> = {}
163
+ const uuids: Record<string, RemoteItem> = {}
164
+
165
+ for (const node of scene) {
166
+ const item: RemoteItem =
167
+ node.type === "directory"
168
+ ? { type: "directory", uuid: node.uuid, name: basename(node.path), size: 0, path: node.path }
169
+ : {
170
+ type: "file",
171
+ uuid: node.uuid,
172
+ name: basename(node.path),
173
+ size: node.size,
174
+ mime: "application/octet-stream",
175
+ lastModified: node.mtime,
176
+ version: 2,
177
+ chunks: 1,
178
+ key: "k",
179
+ bucket: "filen-1",
180
+ region: "de-1",
181
+ creation: node.creation,
182
+ path: node.path
183
+ }
184
+
185
+ tree[node.path] = item
186
+ uuids[node.uuid] = item
187
+ }
188
+
189
+ return { tree, uuids, size: scene.length }
190
+ }
191
+
192
+ function basename(path: string): string {
193
+ const i = path.lastIndexOf("/")
194
+
195
+ return i === -1 ? path : path.slice(i + 1)
196
+ }
197
+
198
+ // ---- mutators (return a NEW scene = the "current" side; never mutate the input) -------------------
199
+
200
+ export function cloneScene(scene: Scene): Scene {
201
+ return scene.map(n => ({ ...n }))
202
+ }
203
+
204
+ /** Append `k` brand-new files (fresh identity) under `parent` (default sync root). */
205
+ export function addFiles(scene: Scene, k: number, parent = ""): Scene {
206
+ const next = cloneScene(scene)
207
+
208
+ for (let i = 0; i < k; i++) {
209
+ next.push(fileNode(`${parent}/added-${i}-${nextInode()}.txt`))
210
+ }
211
+
212
+ return next
213
+ }
214
+
215
+ /** Remove the last `k` FILE nodes (keeps directories so the tree stays valid). */
216
+ export function deleteFiles(scene: Scene, k: number): Scene {
217
+ const next = cloneScene(scene)
218
+ let removed = 0
219
+
220
+ for (let i = next.length - 1; i >= 0 && removed < k; i--) {
221
+ if (next[i]!.type === "file") {
222
+ next.splice(i, 1)
223
+
224
+ removed++
225
+ }
226
+ }
227
+
228
+ return next
229
+ }
230
+
231
+ /**
232
+ * Modify the first `k` files. `side: "local"` keeps inode (a real local edit), `side: "remote"` mints a
233
+ * new uuid (a real remote re-upload). Both bump size + mtime so the change is detected against the base.
234
+ */
235
+ export function modifyFiles(scene: Scene, k: number, side: "local" | "remote"): Scene {
236
+ const next = cloneScene(scene)
237
+ let changed = 0
238
+
239
+ for (const node of next) {
240
+ if (changed >= k) {
241
+ break
242
+ }
243
+
244
+ if (node.type === "file") {
245
+ node.size += 1000
246
+ node.mtime += 60_000
247
+
248
+ if (side === "remote") {
249
+ node.uuid = nextUUID()
250
+ }
251
+
252
+ changed++
253
+ }
254
+ }
255
+
256
+ return next
257
+ }
258
+
259
+ /**
260
+ * Rename a top-level directory (and rebase every descendant) while preserving identity, so the delta
261
+ * engine sees a single directory rename carrying the subtree.
262
+ */
263
+ export function renameTopDir(scene: Scene, fromPath: string, toPath: string): Scene {
264
+ const next = cloneScene(scene)
265
+
266
+ for (const node of next) {
267
+ if (node.path === fromPath) {
268
+ node.path = toPath
269
+ } else if (node.path.startsWith(`${fromPath}/`)) {
270
+ node.path = toPath + node.path.slice(fromPath.length)
271
+ }
272
+ }
273
+
274
+ return next
275
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
4
+ import { genWideScene, genBalancedScene, resetIdentity, type Scene } from "./harness/trees"
5
+ import { type World } from "../harness/world"
6
+
7
+ /**
8
+ * Local tree-scan benchmark (targets T5, T10). `localFileSystem.getDirectoryTree` runs whenever the local
9
+ * side changed: fast-glob walks the whole tree (materialising an N-string array), then per entry does
10
+ * lstat + access(R_OK) (two fs ops) and builds the LocalItem maps. memfs backs the fs so we measure the
11
+ * walk + per-entry CPU + allocation, not real disk latency (the relative cost of fast-glob vs the loop
12
+ * vs the double-stat is what transfers to real disk).
13
+ */
14
+
15
+ async function buildWorld(scene: Scene): Promise<World> {
16
+ return await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
17
+ }
18
+
19
+ describe("localFileSystem.getDirectoryTree", () => {
20
+ it("wide tree size sweep", async () => {
21
+ for (const nodes of [10_000, 50_000, 100_000]) {
22
+ resetIdentity()
23
+
24
+ const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
25
+ const world = await buildWorld(scene)
26
+
27
+ // Correctness: the scan finds exactly the scene's nodes.
28
+ forceLocalRescan(world)
29
+
30
+ const first = await world.sync.localFileSystem.getDirectoryTree()
31
+
32
+ expect(first.result.size).toBe(scene.length)
33
+
34
+ await bench({
35
+ group: "localFileSystem.getDirectoryTree / wide",
36
+ name: `${nodes} nodes`,
37
+ n: scene.length,
38
+ iterations: 4,
39
+ setup: () => {
40
+ forceLocalRescan(world)
41
+
42
+ return world
43
+ },
44
+ run: w => w.sync.localFileSystem.getDirectoryTree()
45
+ })
46
+ }
47
+ })
48
+
49
+ it("balanced (directory-heavy) tree", async () => {
50
+ resetIdentity()
51
+
52
+ const scene = genBalancedScene({ fanout: 4, depth: 6, filesPerDir: 8 })
53
+ const world = await buildWorld(scene)
54
+
55
+ forceLocalRescan(world)
56
+
57
+ const first = await world.sync.localFileSystem.getDirectoryTree()
58
+
59
+ expect(first.result.size).toBe(scene.length)
60
+
61
+ await bench({
62
+ group: "localFileSystem.getDirectoryTree / balanced",
63
+ name: `fanout4 depth6 (${scene.length} nodes)`,
64
+ n: scene.length,
65
+ iterations: 4,
66
+ setup: () => {
67
+ forceLocalRescan(world)
68
+
69
+ return world
70
+ },
71
+ run: w => w.sync.localFileSystem.getDirectoryTree()
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { recordCustom } from "./harness/measure"
3
+ import { makeScaleWorld, sceneToVfsSpec, forceLocalRescan } from "./harness/scale-world"
4
+ import { genWideScene, resetIdentity } from "./harness/trees"
5
+ import { SYNC_INTERVAL } from "../../src/constants"
6
+ import { LOCAL_ROOT, type World } from "../harness/world"
7
+
8
+ /**
9
+ * Long-running benchmark — simulates many "fake hours" of a desktop sync (hundreds of cycles, a small
10
+ * change each cycle) and watches for two failure modes a long-lived process must not have:
11
+ * 1. memory leak — heapUsed trending up cycle over cycle (accumulating signals, caches, hashes…).
12
+ * 2. perf degradation — later cycles getting slower than earlier ones (growing structures, GC churn).
13
+ * Reports the heap slope (KB/cycle) and the late-vs-early cycle-time ratio.
14
+ */
15
+
16
+ function ageDebounce(world: World): void {
17
+ world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
18
+ }
19
+
20
+ function gc(): void {
21
+ globalThis.gc?.()
22
+ }
23
+
24
+ /** Least-squares slope of y over x (MB per cycle here). */
25
+ function slope(xs: number[], ys: number[]): number {
26
+ const n = xs.length
27
+ const meanX = xs.reduce((a, b) => a + b, 0) / n
28
+ const meanY = ys.reduce((a, b) => a + b, 0) / n
29
+ let num = 0
30
+ let den = 0
31
+
32
+ for (let i = 0; i < n; i++) {
33
+ num += (xs[i]! - meanX) * (ys[i]! - meanY)
34
+ den += (xs[i]! - meanX) ** 2
35
+ }
36
+
37
+ return den === 0 ? 0 : num / den
38
+ }
39
+
40
+ describe("long-running sync (leak + degradation)", () => {
41
+ it("hundreds of incremental cycles stay flat in memory and time", async () => {
42
+ const CYCLES = 300
43
+ const SAMPLE_EVERY = 25
44
+
45
+ resetIdentity()
46
+
47
+ const scene = genWideScene(50, 100) // ~5,050 nodes
48
+ const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
49
+
50
+ // Settle the initial sync.
51
+ for (let i = 0; i < 3; i++) {
52
+ ageDebounce(world)
53
+
54
+ await world.sync.runCycle()
55
+
56
+ world.messages.length = 0
57
+ }
58
+
59
+ const targetPath = `${LOCAL_ROOT}/dir-0/file-0.txt`
60
+ const cycleTimes: number[] = []
61
+ const sampleCycles: number[] = []
62
+ const sampleHeapMB: number[] = []
63
+
64
+ for (let c = 0; c < CYCLES; c++) {
65
+ await world.vfs.fs.writeFile(targetPath, "y".repeat(64 + (c % 500)), { encoding: "utf-8" })
66
+
67
+ forceLocalRescan(world)
68
+ ageDebounce(world)
69
+
70
+ const t0 = performance.now()
71
+
72
+ await world.sync.runCycle()
73
+
74
+ cycleTimes.push(performance.now() - t0)
75
+
76
+ // Clear the harness message log so we measure ENGINE memory, not the test's growing array.
77
+ world.messages.length = 0
78
+
79
+ if (c % SAMPLE_EVERY === 0) {
80
+ gc()
81
+
82
+ sampleCycles.push(c)
83
+ sampleHeapMB.push(process.memoryUsage().heapUsed / (1024 * 1024))
84
+ }
85
+ }
86
+
87
+ gc()
88
+
89
+ sampleCycles.push(CYCLES)
90
+ sampleHeapMB.push(process.memoryUsage().heapUsed / (1024 * 1024))
91
+
92
+ const heapSlopeMBPerCycle = slope(sampleCycles, sampleHeapMB)
93
+ const heapSlopeKBPerCycle = heapSlopeMBPerCycle * 1024
94
+
95
+ const half = Math.floor(cycleTimes.length / 2)
96
+ const earlyAvg = cycleTimes.slice(0, half).reduce((a, b) => a + b, 0) / half
97
+ const lateAvg = cycleTimes.slice(half).reduce((a, b) => a + b, 0) / (cycleTimes.length - half)
98
+ const degradationRatio = lateAvg / earlyAvg
99
+
100
+ recordCustom({
101
+ group: "long-run / leak + degradation",
102
+ name: `${CYCLES} incremental cycles, ${scene.length} nodes`,
103
+ n: CYCLES,
104
+ msMean: cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length,
105
+ msMin: Math.min(...cycleTimes),
106
+ heapRetainedMB: sampleHeapMB[sampleHeapMB.length - 1]!,
107
+ extra: {
108
+ heapStartMB: Number(sampleHeapMB[0]!.toFixed(1)),
109
+ heapEndMB: Number(sampleHeapMB[sampleHeapMB.length - 1]!.toFixed(1)),
110
+ heapSlopeKBPerCycle: Number(heapSlopeKBPerCycle.toFixed(2)),
111
+ earlyCycleMs: Number(earlyAvg.toFixed(2)),
112
+ lateCycleMs: Number(lateAvg.toFixed(2)),
113
+ degradationRatio: Number(degradationRatio.toFixed(3))
114
+ }
115
+ })
116
+
117
+ process.stdout.write(
118
+ `[longrun] heap ${sampleHeapMB[0]!.toFixed(1)}→${sampleHeapMB[sampleHeapMB.length - 1]!.toFixed(
119
+ 1
120
+ )}MB (slope ${heapSlopeKBPerCycle.toFixed(2)} KB/cycle) | cycle early ${earlyAvg.toFixed(2)}ms late ${lateAvg.toFixed(
121
+ 2
122
+ )}ms (x${degradationRatio.toFixed(3)})\n`
123
+ )
124
+
125
+ // Leak guard: a flat engine should not trend up more than a few KB/cycle (well under a MB over 300).
126
+ expect(heapSlopeKBPerCycle).toBeLessThan(200)
127
+ // Degradation guard: late cycles within 2x of early ones (generous — absorbs gc noise).
128
+ expect(degradationRatio).toBeLessThan(2)
129
+ })
130
+ })