@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,111 @@
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, resetIdentity } from "./harness/trees"
5
+ import { SYNC_INTERVAL } from "../../src/constants"
6
+ import { LOCAL_ROOT, type World } from "../harness/world"
7
+
8
+ /**
9
+ * End-to-end cycle benchmark — ties the whole pipeline together (scan + deltas + tasks + state.save)
10
+ * through a real in-memory world. The headline real-world cost is the INCREMENTAL cycle: on a large
11
+ * synced tree, changing ONE file still forces a full-tree rescan + full delta pass + full state.save,
12
+ * all O(N). This is what a desktop user pays on every small edit inside a big folder.
13
+ *
14
+ * Real timers; the debounce is bypassed by ageing lastDirectoryChangeTimestamp, and the uncontended
15
+ * in-memory lock acquires instantly, so a cycle never awaits a scheduled timer.
16
+ */
17
+
18
+ function ageDebounce(world: World): void {
19
+ world.sync.localFileSystem.lastDirectoryChangeTimestamp = Date.now() - SYNC_INTERVAL - 1_000
20
+ }
21
+
22
+ async function runCycle(world: World): Promise<void> {
23
+ ageDebounce(world)
24
+
25
+ await world.sync.runCycle()
26
+ }
27
+
28
+ async function syncToConvergence(world: World): Promise<void> {
29
+ // A few cycles settle an initial sync (uploads, then a no-change confirmation).
30
+ for (let i = 0; i < 3; i++) {
31
+ await runCycle(world)
32
+ }
33
+ }
34
+
35
+ describe("full runCycle", () => {
36
+ it("initial sync (upload everything)", async () => {
37
+ for (const nodes of [2_000, 10_000]) {
38
+ resetIdentity()
39
+
40
+ const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
41
+
42
+ await bench({
43
+ group: "runCycle / initial sync (twoWay)",
44
+ name: `${scene.length} nodes`,
45
+ n: scene.length,
46
+ iterations: 3,
47
+ // Fresh world each iteration: initial sync mutates cloud + state irreversibly.
48
+ setup: () => makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) }),
49
+ run: async world => {
50
+ await runCycle(world)
51
+
52
+ return world.sync.remoteFileSystem.getDirectoryTreeCache.size
53
+ }
54
+ })
55
+ }
56
+ })
57
+
58
+ it("no-change cycle (steady state — engine fixed overhead, network excluded)", async () => {
59
+ resetIdentity()
60
+
61
+ const scene = genWideScene(50, 100)
62
+ const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
63
+
64
+ await syncToConvergence(world)
65
+
66
+ await bench({
67
+ group: "runCycle / no-change steady state",
68
+ name: `${scene.length} nodes synced`,
69
+ n: scene.length,
70
+ iterations: 10,
71
+ setup: () => world,
72
+ run: w => w.sync.runCycle()
73
+ })
74
+ })
75
+
76
+ it("incremental: ONE file changed in a large synced tree (the real per-edit cost)", async () => {
77
+ // Baseline capped (initial sync uses the O(N²) transfers semaphore); bumped after the fix.
78
+ for (const nodes of [10_000, 20_000]) {
79
+ resetIdentity()
80
+
81
+ const scene = genWideScene(Math.max(1, Math.round(nodes / 101)), 100)
82
+ const world = await makeScaleWorld({ mode: "twoWay", initialLocal: sceneToVfsSpec(scene) })
83
+
84
+ await syncToConvergence(world)
85
+
86
+ const targetPath = `${LOCAL_ROOT}/dir-0/file-0.txt`
87
+ let counter = 0
88
+
89
+ await bench({
90
+ group: "runCycle / incremental 1-file change (twoWay)",
91
+ name: `1 of ${scene.length} nodes`,
92
+ n: scene.length,
93
+ iterations: 5,
94
+ setup: async () => {
95
+ // A growing payload guarantees a size change → detected as a real modify each iteration.
96
+ counter++
97
+
98
+ await world.vfs.fs.writeFile(targetPath, "y".repeat(64 + counter), { encoding: "utf-8" })
99
+
100
+ forceLocalRescan(world)
101
+
102
+ return world
103
+ },
104
+ run: w => runCycle(w)
105
+ })
106
+
107
+ // Sanity: still converged (the one change propagated, nothing else churned).
108
+ expect(world.sync.remoteFileSystem.getDirectoryTreeCache.size).toBe(scene.length)
109
+ }
110
+ })
111
+ })
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { bench } from "./harness/measure"
3
+ import { makeDeltas } from "./harness/fake-sync"
4
+ import {
5
+ genWideScene,
6
+ buildLocalTree,
7
+ buildRemoteTree,
8
+ addFiles,
9
+ deleteFiles,
10
+ modifyFiles,
11
+ renameTopDir,
12
+ resetIdentity,
13
+ type Scene
14
+ } from "./harness/trees"
15
+ import { type SyncMode } from "../../src/types"
16
+ import { type LocalTree } from "../../src/lib/filesystems/local"
17
+ import { type RemoteTree } from "../../src/lib/filesystems/remote"
18
+
19
+ /**
20
+ * Delta-engine benchmarks — the CPU hot path. `deltas.process` runs every cycle that has changes and is
21
+ * O(N) over the tree with ~20 passes. We measure it as pure in-memory CPU (synthetic trees, fake Sync),
22
+ * across the change scenarios the real suite exercises (add / delete / modify / rename / mixed / no-op)
23
+ * and across all 5 sync modes. Trees are prebuilt once per scenario (process() never mutates its inputs)
24
+ * so only the algorithm is timed.
25
+ */
26
+
27
+ const FILES_PER_DIR = 100
28
+
29
+ function sceneOf(nodes: number): Scene {
30
+ resetIdentity()
31
+
32
+ return genWideScene(Math.max(1, Math.round(nodes / (FILES_PER_DIR + 1))), FILES_PER_DIR)
33
+ }
34
+
35
+ type Trees = {
36
+ prevLocal: LocalTree
37
+ prevRemote: RemoteTree
38
+ curLocal: LocalTree
39
+ curRemote: RemoteTree
40
+ }
41
+
42
+ async function runProcess(mode: SyncMode, t: Trees): Promise<number> {
43
+ const deltas = makeDeltas(mode)
44
+ const result = await deltas.process({
45
+ currentLocalTree: t.curLocal,
46
+ currentRemoteTree: t.curRemote,
47
+ previousLocalTree: t.prevLocal,
48
+ previousRemoteTree: t.prevRemote,
49
+ currentLocalTreeErrors: [],
50
+ currentLocalTreeIgnored: []
51
+ })
52
+
53
+ return result.deltas.length
54
+ }
55
+
56
+ describe("deltas.process — CPU hot path", () => {
57
+ it("size sweep: bulk local add (twoWay) — the common incremental case", async () => {
58
+ for (const nodes of [10_000, 50_000, 200_000, 500_000]) {
59
+ const base = sceneOf(nodes)
60
+ const prevLocal = buildLocalTree(base)
61
+ const prevRemote = buildRemoteTree(base)
62
+ const addCount = Math.max(1, Math.round(nodes * 0.01))
63
+ const curLocal = buildLocalTree(addFiles(base, addCount))
64
+ const t: Trees = { prevLocal, prevRemote, curLocal, curRemote: prevRemote }
65
+
66
+ // Correctness sanity: exactly `addCount` uploadFile deltas (plus nothing else).
67
+ const count = await runProcess("twoWay", t)
68
+
69
+ expect(count).toBe(addCount)
70
+
71
+ await bench({
72
+ group: "deltas.process / size sweep (twoWay add 1%)",
73
+ name: `add ${addCount} into ${nodes} nodes`,
74
+ n: nodes,
75
+ iterations: nodes >= 200_000 ? 3 : 6,
76
+ setup: () => t,
77
+ run: tr => runProcess("twoWay", tr),
78
+ extra: () => ({ deltas: addCount })
79
+ })
80
+ }
81
+ })
82
+
83
+ it("scenario matrix at 200k nodes (twoWay)", async () => {
84
+ const nodes = 200_000
85
+ const base = sceneOf(nodes)
86
+ const prevLocal = buildLocalTree(base)
87
+ const prevRemote = buildRemoteTree(base)
88
+ const k = 2_000
89
+
90
+ const scenarios: { name: string; build: () => Trees }[] = [
91
+ {
92
+ name: "no-op (huge tree, 0 deltas)",
93
+ build: () => ({ prevLocal, prevRemote, curLocal: prevLocal, curRemote: prevRemote })
94
+ },
95
+ {
96
+ name: "local add 1%",
97
+ build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(addFiles(base, k)), curRemote: prevRemote })
98
+ },
99
+ {
100
+ name: "local delete 1%",
101
+ build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(deleteFiles(base, k)), curRemote: prevRemote })
102
+ },
103
+ {
104
+ name: "local modify 1%",
105
+ build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(modifyFiles(base, k, "local")), curRemote: prevRemote })
106
+ },
107
+ {
108
+ name: "remote modify 1%",
109
+ build: () => ({ prevLocal, prevRemote, curLocal: prevLocal, curRemote: buildRemoteTree(modifyFiles(base, k, "remote")) })
110
+ },
111
+ {
112
+ name: "rename top dir (subtree collapse)",
113
+ build: () => ({ prevLocal, prevRemote, curLocal: buildLocalTree(renameTopDir(base, "/dir-0", "/dir-renamed")), curRemote: prevRemote })
114
+ }
115
+ ]
116
+
117
+ for (const scenario of scenarios) {
118
+ const t = scenario.build()
119
+
120
+ await bench({
121
+ group: "deltas.process / scenario matrix @200k (twoWay)",
122
+ name: scenario.name,
123
+ n: nodes,
124
+ iterations: 4,
125
+ setup: () => t,
126
+ run: tr => runProcess("twoWay", tr)
127
+ })
128
+ }
129
+ })
130
+
131
+ it("all 5 modes: local add 1% @100k", async () => {
132
+ const nodes = 100_000
133
+ const base = sceneOf(nodes)
134
+ const prevLocal = buildLocalTree(base)
135
+ const prevRemote = buildRemoteTree(base)
136
+ const curLocal = buildLocalTree(addFiles(base, 1_000))
137
+ const t: Trees = { prevLocal, prevRemote, curLocal, curRemote: prevRemote }
138
+ const modes: SyncMode[] = ["twoWay", "localToCloud", "localBackup", "cloudToLocal", "cloudBackup"]
139
+
140
+ for (const mode of modes) {
141
+ await bench({
142
+ group: "deltas.process / all modes (local add 1% @100k)",
143
+ name: mode,
144
+ n: nodes,
145
+ iterations: 5,
146
+ setup: () => t,
147
+ run: tr => runProcess(mode, tr)
148
+ })
149
+ }
150
+ })
151
+ })
@@ -0,0 +1,32 @@
1
+ import type Sync from "../../../src/lib/sync"
2
+ import Deltas from "../../../src/lib/deltas"
3
+ import { type SyncMode } from "../../../src/types"
4
+
5
+ /**
6
+ * The minimal slice of `Sync` that `Deltas.process` actually reads: mode, removed, the cached upload
7
+ * hashes, a `createFileHash` (only hit on a same-size-mtime-touch modify), the ignorer, and the
8
+ * dot-file flag. Building this directly avoids constructing a whole memfs world + fake cloud + timers
9
+ * for what is a pure in-memory CPU benchmark, so tree construction (the thing we are NOT measuring) is
10
+ * the only setup cost.
11
+ */
12
+ export function makeDeltaSync(
13
+ mode: SyncMode,
14
+ overrides?: { localFileHashes?: Record<string, string>; createFileHash?: () => Promise<string>; ignores?: (path: string) => boolean }
15
+ ): Sync {
16
+ return {
17
+ mode,
18
+ removed: false,
19
+ excludeDotFiles: false,
20
+ localFileHashes: overrides?.localFileHashes ?? {},
21
+ localFileSystem: {
22
+ createFileHash: overrides?.createFileHash ?? (async (): Promise<string> => "benchhash")
23
+ },
24
+ ignorer: {
25
+ ignores: overrides?.ignores ?? ((): boolean => false)
26
+ }
27
+ } as unknown as Sync
28
+ }
29
+
30
+ export function makeDeltas(mode: SyncMode, overrides?: Parameters<typeof makeDeltaSync>[1]): Deltas {
31
+ return new Deltas(makeDeltaSync(mode, overrides))
32
+ }
@@ -0,0 +1,276 @@
1
+ import fs from "fs"
2
+ import os from "os"
3
+ import pathModule from "path"
4
+
5
+ /**
6
+ * A single benchmark measurement. Captures the three axes the goal cares about — wall time, CPU time,
7
+ * and memory — plus the input scale so results are comparable across sizes.
8
+ */
9
+ export type BenchResult = {
10
+ group: string
11
+ name: string
12
+ /** Input scale (e.g. node count) so ms/op-per-node can be derived. */
13
+ n: number
14
+ iterations: number
15
+ /** Wall-clock ms per iteration (mean / min). */
16
+ msMean: number
17
+ msMin: number
18
+ /** CPU ms per iteration (user+system), via process.cpuUsage. */
19
+ cpuMsMean: number
20
+ /** Heap still alive AFTER the run but BEFORE gc (retained result + transient not-yet-collected). MB. */
21
+ heapLiveMB: number
22
+ /** Heap retained AFTER gc (the result the run keeps referenced). MB. */
23
+ heapRetainedMB: number
24
+ /** Peak RSS growth observed by sampling during the timed loop, relative to pre-run RSS. MB. */
25
+ peakRssMB: number
26
+ /** ns per node (msMean / n * 1e6) — the headline efficiency number. */
27
+ nsPerNode: number
28
+ extra?: Record<string, number | string>
29
+ }
30
+
31
+ const results: BenchResult[] = []
32
+
33
+ function mean(xs: number[]): number {
34
+ return xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length
35
+ }
36
+
37
+ function min(xs: number[]): number {
38
+ return xs.length === 0 ? 0 : Math.min(...xs)
39
+ }
40
+
41
+ function gc(): void {
42
+ globalThis.gc?.()
43
+ }
44
+
45
+ const MB = 1024 * 1024
46
+
47
+ function fmt(n: number, digits = 2): string {
48
+ if (!Number.isFinite(n)) {
49
+ return "—"
50
+ }
51
+
52
+ return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })
53
+ }
54
+
55
+ /**
56
+ * Run one benchmark. `setup` builds a fresh input per iteration (NOT timed — so tree construction never
57
+ * pollutes the measurement); `run` is the timed body. Time + CPU are averaged over `iterations`; peak RSS
58
+ * is sampled across the whole timed loop; retained/live heap is measured on a single dedicated run so the
59
+ * accumulated allocations of the time loop don't inflate it.
60
+ */
61
+ export async function bench<T>(options: {
62
+ group: string
63
+ name: string
64
+ n: number
65
+ iterations?: number
66
+ warmup?: number
67
+ setup: () => T | Promise<T>
68
+ run: (input: T) => unknown | Promise<unknown>
69
+ /** Optional extra columns derived from one input (e.g. delta count). */
70
+ extra?: (input: T) => Record<string, number | string>
71
+ }): Promise<BenchResult> {
72
+ const iterations = options.iterations ?? 5
73
+ const warmup = options.warmup ?? 1
74
+
75
+ for (let i = 0; i < warmup; i++) {
76
+ const input = await options.setup()
77
+
78
+ await options.run(input)
79
+ }
80
+
81
+ gc()
82
+
83
+ const times: number[] = []
84
+ let cpuTotalMs = 0
85
+ let peakRss = 0
86
+ const rssBase = process.memoryUsage().rss
87
+ const sampler = setInterval(() => {
88
+ const rss = process.memoryUsage().rss - rssBase
89
+
90
+ if (rss > peakRss) {
91
+ peakRss = rss
92
+ }
93
+ }, 2)
94
+
95
+ // Keep the sampler from holding the event loop open / keeping the process alive.
96
+ if (typeof sampler === "object" && sampler && "unref" in sampler) {
97
+ ;(sampler as { unref: () => void }).unref()
98
+ }
99
+
100
+ let extra: Record<string, number | string> | undefined
101
+
102
+ for (let i = 0; i < iterations; i++) {
103
+ const input = await options.setup()
104
+
105
+ if (i === 0 && options.extra) {
106
+ extra = options.extra(input)
107
+ }
108
+
109
+ const cpu0 = process.cpuUsage()
110
+ const t0 = performance.now()
111
+
112
+ await options.run(input)
113
+
114
+ const elapsed = performance.now() - t0
115
+ const cpu = process.cpuUsage(cpu0)
116
+
117
+ times.push(elapsed)
118
+ cpuTotalMs += (cpu.user + cpu.system) / 1000
119
+ }
120
+
121
+ clearInterval(sampler)
122
+
123
+ // Dedicated retained-memory run.
124
+ gc()
125
+
126
+ const heap0 = process.memoryUsage().heapUsed
127
+ const memInput = await options.setup()
128
+ const out = await options.run(memInput)
129
+ const heapLive = process.memoryUsage().heapUsed - heap0
130
+
131
+ gc()
132
+
133
+ const heapRetained = process.memoryUsage().heapUsed - heap0
134
+
135
+ // Hold a reference so the optimizer / gc can't drop the result before we read retained heap.
136
+ void out
137
+ void memInput
138
+
139
+ const msMean = mean(times)
140
+ const result: BenchResult = {
141
+ group: options.group,
142
+ name: options.name,
143
+ n: options.n,
144
+ iterations,
145
+ msMean,
146
+ msMin: min(times),
147
+ cpuMsMean: cpuTotalMs / iterations,
148
+ heapLiveMB: heapLive / MB,
149
+ heapRetainedMB: heapRetained / MB,
150
+ peakRssMB: peakRss / MB,
151
+ nsPerNode: options.n > 0 ? (msMean / options.n) * 1e6 : 0,
152
+ ...(extra ? { extra } : {})
153
+ }
154
+
155
+ results.push(result)
156
+ appendResult(result)
157
+
158
+ return result
159
+ }
160
+
161
+ /** JSONL path that EVERY bench process appends to (cleared once at the start of a run by the runner). */
162
+ function jsonlPath(): string {
163
+ return process.env["BENCH_JSONL"] ?? pathModule.join(process.cwd(), "docs/perf/benchmarks/_results.jsonl")
164
+ }
165
+
166
+ /**
167
+ * Persist + print one result immediately. Incremental append (not an exit-time flush) so partial runs
168
+ * still produce data and results survive across the separate worker processes vitest spawns per file —
169
+ * vitest workers do not reliably fire `process.on("exit")`.
170
+ */
171
+ function appendResult(r: BenchResult): void {
172
+ const extraStr = r.extra
173
+ ? " | " +
174
+ Object.entries(r.extra)
175
+ .map(([k, v]) => `${k}=${typeof v === "number" ? fmt(v, 0) : v}`)
176
+ .join(" ")
177
+ : ""
178
+
179
+ // process.stdout.write bypasses vitest's console grouping so the line is always visible in the run log.
180
+ process.stdout.write(
181
+ `[bench] ${r.group} :: ${r.name} | n=${fmt(r.n, 0)} | ${fmt(r.msMean)}ms (min ${fmt(r.msMin)}) | cpu ${fmt(
182
+ r.cpuMsMean
183
+ )}ms | ns/node ${fmt(r.nsPerNode, 1)} | heapRet ${fmt(r.heapRetainedMB)}MB live ${fmt(r.heapLiveMB)}MB | peakRSS ${fmt(
184
+ r.peakRssMB
185
+ )}MB${extraStr}\n`
186
+ )
187
+
188
+ try {
189
+ const path = jsonlPath()
190
+
191
+ fs.mkdirSync(pathModule.dirname(path), { recursive: true })
192
+ fs.appendFileSync(path, JSON.stringify(r) + "\n", "utf-8")
193
+ } catch {
194
+ // Best-effort persistence; the stdout line above is the fallback record.
195
+ }
196
+ }
197
+
198
+ export function getResults(): BenchResult[] {
199
+ return results
200
+ }
201
+
202
+ /**
203
+ * Record a fully-computed result (for benchmarks like long-run leak detection that measure their own
204
+ * bespoke metrics rather than a single timed `run`). Missing numeric fields default to 0.
205
+ */
206
+ export function recordCustom(
207
+ partial: { group: string; name: string; n: number } & Partial<Omit<BenchResult, "group" | "name" | "n">>
208
+ ): void {
209
+ const result: BenchResult = {
210
+ iterations: 1,
211
+ msMean: 0,
212
+ msMin: 0,
213
+ cpuMsMean: 0,
214
+ heapLiveMB: 0,
215
+ heapRetainedMB: 0,
216
+ peakRssMB: 0,
217
+ nsPerNode: 0,
218
+ ...partial
219
+ }
220
+
221
+ results.push(result)
222
+ appendResult(result)
223
+ }
224
+
225
+ /**
226
+ * Render a JSONL results file (produced incrementally by {@link bench}) into a grouped markdown report.
227
+ * Run as a standalone step after the vitest bench run (see docs/perf/02-benchmarks.md).
228
+ */
229
+ export function renderReport(inJsonl: string, outMd: string, label: string): void {
230
+ if (!fs.existsSync(inJsonl)) {
231
+ throw new Error(`No results file at ${inJsonl}`)
232
+ }
233
+
234
+ const rows: BenchResult[] = fs
235
+ .readFileSync(inJsonl, "utf-8")
236
+ .split("\n")
237
+ .filter(line => line.trim().length > 0)
238
+ .map(line => JSON.parse(line) as BenchResult)
239
+
240
+ const lines: string[] = []
241
+
242
+ lines.push(`# Benchmark results — ${label}`)
243
+ lines.push("")
244
+ lines.push(`Generated: ${new Date().toISOString()}`)
245
+ lines.push(`Node: ${process.version} | platform: ${process.platform} | cpus: ${os.cpus().length}`)
246
+ lines.push("")
247
+
248
+ for (const group of [...new Set(rows.map(r => r.group))]) {
249
+ lines.push(`## ${group}`)
250
+ lines.push("")
251
+ lines.push("| scenario | n | ms mean | ms min | cpu ms | ns/node | heap ret MB | heap live MB | peak RSS MB | extra |")
252
+ lines.push("|---|---:|---:|---:|---:|---:|---:|---:|---:|---|")
253
+
254
+ for (const r of rows.filter(x => x.group === group)) {
255
+ const extraStr = r.extra
256
+ ? Object.entries(r.extra)
257
+ .map(([k, v]) => `${k}=${typeof v === "number" ? fmt(v as number, 0) : v}`)
258
+ .join(", ")
259
+ : ""
260
+
261
+ lines.push(
262
+ `| ${r.name} | ${fmt(r.n, 0)} | ${fmt(r.msMean)} | ${fmt(r.msMin)} | ${fmt(r.cpuMsMean)} | ${fmt(
263
+ r.nsPerNode,
264
+ 1
265
+ )} | ${fmt(r.heapRetainedMB)} | ${fmt(r.heapLiveMB)} | ${fmt(r.peakRssMB)} | ${extraStr} |`
266
+ )
267
+ }
268
+
269
+ lines.push("")
270
+ }
271
+
272
+ fs.mkdirSync(pathModule.dirname(outMd), { recursive: true })
273
+ fs.writeFileSync(outMd, lines.join("\n"), "utf-8")
274
+
275
+ process.stdout.write(`\n[bench] rendered ${rows.length} results -> ${outMd}\n`)
276
+ }