@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,101 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { createWorld, type World } from "../harness/world"
3
+ import { type SyncDirTreeFetcher } from "../../src/lib/filesystems/dirTree"
4
+
5
+ /**
6
+ * Added regression tests for the order-independent remote tree build. The /v3/dir/tree API can return
7
+ * its files/folders arrays UNORDERED (no guarantee a parent precedes its children, nor that folders[0]
8
+ * is the sync root). The old builder built paths incrementally in array order and required
9
+ * folders[0].parent === "base", so an out-of-order response corrupted child paths or THREW. The fake
10
+ * cloud always emits BFS order, so the mocked suite never exercised this — these tests inject a
11
+ * hand-built response (ordered and shuffled) directly.
12
+ *
13
+ * On the OLD code the shuffled case throws "Invalid base folder parent" (the build rejects → empty
14
+ * changed:true result is never produced) — verified RED by stashing the rewrite. NEW FILE.
15
+ */
16
+
17
+ type Folder = [string, string, string]
18
+ type File = [string, string, string, number, string, string, number, number]
19
+
20
+ function folderMeta(name: string): string {
21
+ return JSON.stringify({ name })
22
+ }
23
+
24
+ function fileMeta(name: string): string {
25
+ return JSON.stringify({ name, size: 10, mime: "text/plain", key: "k", lastModified: 1_700_000_000_000, creation: 1_690_000_000_000 })
26
+ }
27
+
28
+ /**
29
+ * A small tree: root / a / b / c.txt , plus root / x.txt . Returns the folder+file tuples in BFS
30
+ * (parent-first) order; the caller can shuffle them.
31
+ */
32
+ function buildTuples(rootUUID: string): { folders: Folder[]; files: File[] } {
33
+ const folders: Folder[] = [
34
+ [rootUUID, folderMeta("Sync"), "base"],
35
+ ["uuid-a", folderMeta("a"), rootUUID],
36
+ ["uuid-b", folderMeta("b"), "uuid-a"]
37
+ ]
38
+ const files: File[] = [
39
+ ["uuid-c", "bucket", "region", 1, "uuid-b", fileMeta("c.txt"), 2, 1_700_000_000_000],
40
+ ["uuid-x", "bucket", "region", 1, rootUUID, fileMeta("x.txt"), 2, 1_700_000_000_000]
41
+ ]
42
+
43
+ return { folders, files }
44
+ }
45
+
46
+ function inject(world: World, folders: Folder[], files: File[]): void {
47
+ const fetcher: SyncDirTreeFetcher = async () => ({ files, folders, raw: "" }) as unknown as Awaited<ReturnType<SyncDirTreeFetcher>>
48
+
49
+ world.sync.environment.fetchDirTree = fetcher
50
+ }
51
+
52
+ describe("RemoteFileSystem.getDirectoryTree — order-independent build", () => {
53
+ it("builds the correct tree from an ORDERED response (baseline)", async () => {
54
+ const world = await createWorld({ mode: "twoWay" })
55
+ const { folders, files } = buildTuples(world.cloud.controls.rootUUID)
56
+
57
+ inject(world, folders, files)
58
+
59
+ const result = await world.sync.remoteFileSystem.getDirectoryTree(true)
60
+
61
+ expect(result.result.tree["/a"]).toMatchObject({ type: "directory", path: "/a" })
62
+ expect(result.result.tree["/a/b"]).toMatchObject({ type: "directory", path: "/a/b" })
63
+ expect(result.result.tree["/a/b/c.txt"]).toMatchObject({ type: "file", path: "/a/b/c.txt" })
64
+ expect(result.result.tree["/x.txt"]).toMatchObject({ type: "file", path: "/x.txt" })
65
+ expect(result.result.size).toBe(4)
66
+ })
67
+
68
+ it("builds the SAME tree from a SHUFFLED response (child before parent, root not first)", async () => {
69
+ const world = await createWorld({ mode: "twoWay" })
70
+ const { folders, files } = buildTuples(world.cloud.controls.rootUUID)
71
+
72
+ // Reverse the folders so a child (b) precedes its parent (a) and the root is LAST — the exact shape
73
+ // that made the old builder throw / mis-path.
74
+ const shuffledFolders: Folder[] = [folders[2]!, folders[1]!, folders[0]!]
75
+ const shuffledFiles: File[] = [files[1]!, files[0]!]
76
+
77
+ inject(world, shuffledFolders, shuffledFiles)
78
+
79
+ const result = await world.sync.remoteFileSystem.getDirectoryTree(true)
80
+
81
+ // Despite the scrambled input, every path resolves correctly via the parent-chain walk.
82
+ expect(result.result.tree["/a"]).toMatchObject({ type: "directory", path: "/a" })
83
+ expect(result.result.tree["/a/b"]).toMatchObject({ type: "directory", path: "/a/b" })
84
+ expect(result.result.tree["/a/b/c.txt"]).toMatchObject({ type: "file", path: "/a/b/c.txt" })
85
+ expect(result.result.tree["/x.txt"]).toMatchObject({ type: "file", path: "/x.txt" })
86
+ expect(result.result.size).toBe(4)
87
+ expect(result.result.uuids["uuid-c"]).toMatchObject({ path: "/a/b/c.txt" })
88
+ })
89
+
90
+ it("still throws on a malformed response with NO sync-root folder (not treated as empty)", async () => {
91
+ const world = await createWorld({ mode: "twoWay" })
92
+
93
+ // No folder has parent "base" — a malformed response. Must throw rather than yield an empty tree
94
+ // (which would look like every remote node was deleted).
95
+ const folders: Folder[] = [["uuid-a", folderMeta("a"), "uuid-missing"]]
96
+
97
+ inject(world, folders, [])
98
+
99
+ await expect(world.sync.remoteFileSystem.getDirectoryTree(true)).rejects.toThrow()
100
+ })
101
+ })
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { Semaphore } from "../../src/semaphore"
3
+
4
+ /**
5
+ * Added regression tests pinning the behavioral contract of {@link Semaphore} under LARGE fan-out, so the
6
+ * O(1)-dequeue optimization (finding 001: replacing the O(n) `Array.shift()` with a head-index queue)
7
+ * cannot silently change semantics. These assert the invariants the optimization must preserve — FIFO
8
+ * wake order, the concurrency cap, and purge accounting — at queue depths where a regression would show.
9
+ *
10
+ * NEW FILE — does not touch the existing Semaphore tests in tests/unit/n-unit.test.ts.
11
+ */
12
+
13
+ const tick = (): Promise<void> => new Promise<void>(resolve => setTimeout(resolve, 0))
14
+
15
+ describe("Semaphore — large fan-out invariants (perf optimization guard)", () => {
16
+ it("preserves strict FIFO wake order across thousands of queued waiters", async () => {
17
+ const sem = new Semaphore(1)
18
+
19
+ await sem.acquire() // hold the only slot so everything queues
20
+
21
+ const order: number[] = []
22
+ const count = 3_000
23
+ const waiters: Promise<void>[] = []
24
+
25
+ for (let i = 0; i < count; i++) {
26
+ waiters.push(
27
+ (async () => {
28
+ await sem.acquire()
29
+
30
+ order.push(i)
31
+
32
+ sem.release()
33
+ })()
34
+ )
35
+ }
36
+
37
+ await tick() // let all `count` waiters enqueue
38
+ sem.release() // release the holder → FIFO cascade drains the queue
39
+
40
+ await Promise.all(waiters)
41
+
42
+ expect(order.length).toBe(count)
43
+
44
+ for (let i = 0; i < count; i++) {
45
+ expect(order[i]).toBe(i)
46
+ }
47
+
48
+ expect(sem.count()).toBe(0)
49
+ })
50
+
51
+ it("never exceeds the concurrency cap and fully reaches it under heavy contention", async () => {
52
+ const width = 10
53
+ const sem = new Semaphore(width)
54
+ let running = 0
55
+ let maxRunning = 0
56
+
57
+ await Promise.all(
58
+ Array.from({ length: 5_000 }, async () => {
59
+ await sem.acquire()
60
+
61
+ running++
62
+ maxRunning = Math.max(maxRunning, running)
63
+
64
+ await tick()
65
+
66
+ running--
67
+
68
+ sem.release()
69
+ })
70
+ )
71
+
72
+ expect(maxRunning).toBe(width)
73
+ expect(sem.count()).toBe(0)
74
+ })
75
+
76
+ it("drains thousands of sequential width-1 contenders (head-index compaction path)", async () => {
77
+ const sem = new Semaphore(1)
78
+
79
+ await sem.acquire()
80
+
81
+ let done = 0
82
+ const count = 8_000
83
+ const waiters = Array.from({ length: count }, async () => {
84
+ await sem.acquire()
85
+
86
+ done++
87
+
88
+ sem.release()
89
+ })
90
+
91
+ await tick()
92
+ sem.release()
93
+
94
+ await Promise.all(waiters)
95
+
96
+ expect(done).toBe(count)
97
+ expect(sem.count()).toBe(0)
98
+ })
99
+
100
+ it("purge() after a large fan-out rejects every waiter, returns the count, and resets", async () => {
101
+ const sem = new Semaphore(1)
102
+
103
+ await sem.acquire()
104
+
105
+ const count = 1_500
106
+ const rejected: Promise<unknown>[] = []
107
+
108
+ for (let i = 0; i < count; i++) {
109
+ rejected.push(sem.acquire().then(() => "resolved", () => "rejected"))
110
+ }
111
+
112
+ await tick()
113
+
114
+ expect(sem.purge()).toBe(count)
115
+ expect(sem.count()).toBe(0)
116
+
117
+ const outcomes = await Promise.all(rejected)
118
+
119
+ expect(outcomes.every(o => o === "rejected")).toBe(true)
120
+ })
121
+
122
+ it("reuses a width-1 mutex correctly across many serial acquire/release cycles", async () => {
123
+ const sem = new Semaphore(1)
124
+ let concurrent = 0
125
+ let maxConcurrent = 0
126
+
127
+ for (let i = 0; i < 2_000; i++) {
128
+ await sem.acquire()
129
+
130
+ concurrent++
131
+ maxConcurrent = Math.max(maxConcurrent, concurrent)
132
+ concurrent--
133
+
134
+ sem.release()
135
+ }
136
+
137
+ expect(maxConcurrent).toBe(1)
138
+ expect(sem.count()).toBe(0)
139
+ })
140
+ })
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { State } from "../../src/lib/state"
3
+ import { type LocalItem, type LocalTree } from "../../src/lib/filesystems/local"
4
+ import { type RemoteItem, type RemoteTree } from "../../src/lib/filesystems/remote"
5
+ import { createVirtualFS, toPosixPath, type VirtualFS } from "../fakes/virtual-fs"
6
+ import type Sync from "../../src/lib/sync"
7
+
8
+ /**
9
+ * Added regression tests for the state index-file optimization: the previousLocalINodes /
10
+ * previousRemoteUUIDs files are now persisted as compact path-REFS (the in-memory indexes are rebuilt
11
+ * from the tree files on load), instead of duplicating every full item. This pins:
12
+ * (1) the on-disk index files are refs (data = path string), not full items;
13
+ * (2) a save -> load round-trip reconstructs the inode/uuid indexes exactly;
14
+ * (3) BACK-COMPAT: an OLD on-disk state whose index files still hold FULL items loads identically
15
+ * (the index files are ignored on load and rebuilt from the trees), so an upgrade is a no-op.
16
+ *
17
+ * NEW FILE — does not touch tests/unit/state.test.ts.
18
+ */
19
+
20
+ const DB_ROOT = "/db"
21
+
22
+ type StateSyncStub = {
23
+ dbPath: string
24
+ syncPair: { uuid: string }
25
+ environment: { fs: VirtualFS["fs"]; globFs: VirtualFS["globFs"] }
26
+ localFileHashes: Record<string, string>
27
+ previousLocalTree: LocalTree
28
+ previousRemoteTree: RemoteTree
29
+ isPreviousSavedTreeStateEmpty: boolean
30
+ removed: boolean
31
+ }
32
+
33
+ function makeSyncStub(vfs: VirtualFS, uuid: string): StateSyncStub {
34
+ return {
35
+ dbPath: DB_ROOT,
36
+ syncPair: { uuid },
37
+ environment: { fs: vfs.fs, globFs: vfs.globFs },
38
+ localFileHashes: {},
39
+ previousLocalTree: { tree: {}, inodes: {}, size: 0 },
40
+ previousRemoteTree: { tree: {}, uuids: {}, size: 0 },
41
+ isPreviousSavedTreeStateEmpty: true,
42
+ removed: false
43
+ }
44
+ }
45
+
46
+ function makeState(stub: StateSyncStub): State {
47
+ return new State(stub as unknown as Sync)
48
+ }
49
+
50
+ function localFile(path: string, inode: number): LocalItem {
51
+ return { type: "file", path, inode, size: 100, lastModified: 1_700_000_000_000, creation: 1_690_000_000_000 }
52
+ }
53
+
54
+ function localDir(path: string, inode: number): LocalItem {
55
+ return { type: "directory", path, inode, size: 0, lastModified: 1_700_000_000_000, creation: 1_690_000_000_000 }
56
+ }
57
+
58
+ function remoteFile(path: string, uuid: string, name: string): RemoteItem {
59
+ return { type: "file", uuid, name, size: 100, mime: "text/plain", lastModified: 1_700_000_000_000, version: 2, chunks: 1, key: `key-${uuid}`, bucket: "b", region: "r", path }
60
+ }
61
+
62
+ function remoteDir(path: string, uuid: string, name: string): RemoteItem {
63
+ return { type: "directory", uuid, name, size: 0, path }
64
+ }
65
+
66
+ function jsonLines(vfs: VirtualFS, filePath: string): { prop: string; data: unknown }[] {
67
+ return (vfs.ifs.readFileSync(toPosixPath(filePath), "utf-8") as string)
68
+ .split("\n")
69
+ .filter(line => line.length > 0)
70
+ .map(line => JSON.parse(line))
71
+ }
72
+
73
+ function seedTrees(stub: StateSyncStub): { localTree: LocalTree; remoteTree: RemoteTree } {
74
+ const fileA = localFile("/a.txt", 101)
75
+ const dir1 = localDir("/dir", 102)
76
+ const fileB = localFile("/dir/b.txt", 103)
77
+ const localTree: LocalTree = {
78
+ tree: { "/a.txt": fileA, "/dir": dir1, "/dir/b.txt": fileB },
79
+ inodes: { 101: fileA, 102: dir1, 103: fileB },
80
+ size: 3
81
+ }
82
+
83
+ const rfileA = remoteFile("/a.txt", "u-a", "a.txt")
84
+ const rdir = remoteDir("/dir", "u-dir", "dir")
85
+ const rfileB = remoteFile("/dir/b.txt", "u-b", "b.txt")
86
+ const remoteTree: RemoteTree = {
87
+ tree: { "/a.txt": rfileA, "/dir": rdir, "/dir/b.txt": rfileB },
88
+ uuids: { "u-a": rfileA, "u-dir": rdir, "u-b": rfileB },
89
+ size: 3
90
+ }
91
+
92
+ stub.previousLocalTree = localTree
93
+ stub.previousRemoteTree = remoteTree
94
+
95
+ return { localTree, remoteTree }
96
+ }
97
+
98
+ describe("State — index files persisted as path-refs (perf guard)", () => {
99
+ it("writes the inode/uuid index files as path-refs, not full items", async () => {
100
+ const vfs = createVirtualFS()
101
+ const stub = makeSyncStub(vfs, "ref-uuid")
102
+ const state = makeState(stub)
103
+
104
+ seedTrees(stub)
105
+
106
+ await state.savePreviousTrees()
107
+
108
+ // Each line's `data` is the item's path (a string), not the full item object.
109
+ for (const line of jsonLines(vfs, state.previousLocalINodesPath)) {
110
+ expect(typeof line.data).toBe("string")
111
+ }
112
+
113
+ for (const line of jsonLines(vfs, state.previousRemoteUUIDsPath)) {
114
+ expect(typeof line.data).toBe("string")
115
+ }
116
+
117
+ // Props are still the inode / uuid keys (the completeness gate + key layout are preserved).
118
+ expect(jsonLines(vfs, state.previousLocalINodesPath).map(l => l.prop).sort()).toEqual(["101", "102", "103"])
119
+ expect(jsonLines(vfs, state.previousRemoteUUIDsPath).map(l => l.prop).sort()).toEqual(["u-a", "u-b", "u-dir"])
120
+
121
+ // The tree files are unchanged: their `data` is the verbatim full item.
122
+ const firstTreeLine = jsonLines(vfs, state.previousLocalTreePath)[0]!
123
+
124
+ expect(typeof firstTreeLine.data).toBe("object")
125
+ })
126
+
127
+ it("round-trips: save (ref index) then load rebuilds the inode/uuid indexes exactly", async () => {
128
+ const vfs = createVirtualFS()
129
+ const source = makeSyncStub(vfs, "rt-uuid")
130
+ const { localTree, remoteTree } = seedTrees(source)
131
+
132
+ await makeState(source).save()
133
+
134
+ const reloaded = makeSyncStub(vfs, "rt-uuid")
135
+
136
+ await makeState(reloaded).initialize()
137
+
138
+ expect(reloaded.previousLocalTree.tree).toEqual(localTree.tree)
139
+ expect(reloaded.previousLocalTree.inodes).toEqual(localTree.inodes)
140
+ expect(reloaded.previousRemoteTree.tree).toEqual(remoteTree.tree)
141
+ expect(reloaded.previousRemoteTree.uuids).toEqual(remoteTree.uuids)
142
+ // The rebuilt index points at the SAME item objects held in the loaded tree.
143
+ expect(reloaded.previousLocalTree.inodes[101]).toBe(reloaded.previousLocalTree.tree["/a.txt"])
144
+ expect(reloaded.previousRemoteTree.uuids["u-a"]).toBe(reloaded.previousRemoteTree.tree["/a.txt"])
145
+ })
146
+
147
+ it("BACK-COMPAT: an old on-disk state with FULL-item index files loads identically", async () => {
148
+ const vfs = createVirtualFS()
149
+ const source = makeSyncStub(vfs, "legacy-uuid")
150
+ const { localTree, remoteTree } = seedTrees(source)
151
+ const state = makeState(source)
152
+
153
+ // Write the trees normally...
154
+ await state.writeLargeRecordSerializedAndAtomically(state.previousLocalTreePath, localTree.tree)
155
+ await state.writeLargeRecordSerializedAndAtomically(state.previousRemoteTreePath, remoteTree.tree)
156
+ // ...but write the index files in the OLD format: FULL items as data (the pre-optimization layout).
157
+ await state.writeLargeRecordSerializedAndAtomically(
158
+ state.previousLocalINodesPath,
159
+ localTree.inodes as unknown as Record<string, LocalItem>
160
+ )
161
+ await state.writeLargeRecordSerializedAndAtomically(
162
+ state.previousRemoteUUIDsPath,
163
+ remoteTree.uuids as unknown as Record<string, RemoteItem>
164
+ )
165
+ await state.saveLocalFileHashes()
166
+
167
+ // Confirm the on-disk index really is the legacy full-item format.
168
+ expect(typeof jsonLines(vfs, state.previousLocalINodesPath)[0]!.data).toBe("object")
169
+
170
+ const reloaded = makeSyncStub(vfs, "legacy-uuid")
171
+
172
+ await makeState(reloaded).loadPreviousTrees()
173
+
174
+ // New loader ignores the legacy index files and rebuilds from the trees → identical base.
175
+ expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(false)
176
+ expect(reloaded.previousLocalTree.inodes).toEqual(localTree.inodes)
177
+ expect(reloaded.previousRemoteTree.uuids).toEqual(remoteTree.uuids)
178
+ expect(reloaded.previousLocalTree.size).toBe(3)
179
+ expect(reloaded.previousRemoteTree.size).toBe(3)
180
+ })
181
+
182
+ it("still degrades to no-saved-state when an index file is MISSING (gate preserved)", async () => {
183
+ const vfs = createVirtualFS()
184
+ const source = makeSyncStub(vfs, "gate-uuid")
185
+
186
+ seedTrees(source)
187
+
188
+ const state = makeState(source)
189
+
190
+ await state.save()
191
+
192
+ // Remove the (now ref-encoded) inodes file: the completeness gate must still trip.
193
+ vfs.ifs.rmSync(toPosixPath(state.previousLocalINodesPath))
194
+
195
+ const reloaded = makeSyncStub(vfs, "gate-uuid")
196
+
197
+ await makeState(reloaded).loadPreviousTrees()
198
+
199
+ expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(true)
200
+ expect(reloaded.previousLocalTree.tree).toEqual({})
201
+ expect(reloaded.previousLocalTree.inodes).toEqual({})
202
+ })
203
+
204
+ it("rebuild handles two paths sharing an inode (hardlink): last in tree order wins, as the scan did", async () => {
205
+ const vfs = createVirtualFS()
206
+ const source = makeSyncStub(vfs, "hardlink-uuid")
207
+
208
+ // Two files share inode 700. The scan inserts tree + inodes in the same order, so the index ends up
209
+ // pointing at whichever comes LAST. Persisting the tree preserves that order; the rebuild must match.
210
+ const first = localFile("/first.txt", 700)
211
+ const second = localFile("/second.txt", 700)
212
+
213
+ source.previousLocalTree = { tree: { "/first.txt": first, "/second.txt": second }, inodes: { 700: second }, size: 2 }
214
+ source.previousRemoteTree = { tree: {}, uuids: {}, size: 0 }
215
+
216
+ await makeState(source).save()
217
+
218
+ const reloaded = makeSyncStub(vfs, "hardlink-uuid")
219
+
220
+ await makeState(reloaded).loadPreviousTrees()
221
+
222
+ expect(reloaded.previousLocalTree.inodes[700]!.path).toBe("/second.txt")
223
+ })
224
+ })