@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.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import pathModule from "path"
|
|
3
|
+
import { type Stats } from "fs-extra"
|
|
4
|
+
import { State } from "../../src/lib/state"
|
|
5
|
+
import { type LocalItem, type LocalTree } from "../../src/lib/filesystems/local"
|
|
6
|
+
import { type RemoteItem, type RemoteTree } from "../../src/lib/filesystems/remote"
|
|
7
|
+
import { type DoneTask } from "../../src/lib/tasks"
|
|
8
|
+
import { createVirtualFS, toPosixPath, type VirtualFS } from "../fakes/virtual-fs"
|
|
9
|
+
import type Sync from "../../src/lib/sync"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unit coverage for `src/lib/state.ts` — the durability layer that persists the engine's previous
|
|
13
|
+
* trees and `localFileHashes` as line-delimited JSON under `<dbPath>/state/v2/<pairUUID>/`.
|
|
14
|
+
*
|
|
15
|
+
* This complements the scenario-level Category J (`tests/scenarios/j-state-cache.test.ts`, which
|
|
16
|
+
* drives restart/round-trip/corruption through full cycles) by exercising the {@link State} class's
|
|
17
|
+
* own methods and branches directly: the path getters, the
|
|
18
|
+
* `writeLargeRecordSerializedAndAtomically`/`readLargeRecordFromLineStream` serializer pair (line
|
|
19
|
+
* format, streaming-per-entry, corrupt-line recovery), the save/initialize/clear lifecycle and its
|
|
20
|
+
* empty/missing/partial edge branches, and `applyDoneTasksToState` (dead code in the live cycle but
|
|
21
|
+
* a large, branch-dense part of the file that the scenarios never reach).
|
|
22
|
+
*
|
|
23
|
+
* {@link State} only touches a handful of `Sync` fields, so a minimal cast stand-in (mirroring the
|
|
24
|
+
* `Sync` stub used in `ipc-lock.test.ts`) keeps these tests at the unit level with no fake cloud,
|
|
25
|
+
* fake timers, or scheduling loop. Every expectation was verified against the real implementation.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DB_ROOT = "/db"
|
|
29
|
+
|
|
30
|
+
/** The exact subset of {@link Sync} that {@link State} reads or writes. */
|
|
31
|
+
type StateSyncStub = {
|
|
32
|
+
dbPath: string
|
|
33
|
+
syncPair: { uuid: string }
|
|
34
|
+
environment: { fs: VirtualFS["fs"]; globFs: VirtualFS["globFs"] }
|
|
35
|
+
localFileHashes: Record<string, string>
|
|
36
|
+
previousLocalTree: LocalTree
|
|
37
|
+
previousRemoteTree: RemoteTree
|
|
38
|
+
isPreviousSavedTreeStateEmpty: boolean
|
|
39
|
+
removed: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeSyncStub(vfs: VirtualFS, uuid: string): StateSyncStub {
|
|
43
|
+
return {
|
|
44
|
+
dbPath: DB_ROOT,
|
|
45
|
+
syncPair: { uuid },
|
|
46
|
+
environment: { fs: vfs.fs, globFs: vfs.globFs },
|
|
47
|
+
localFileHashes: {},
|
|
48
|
+
previousLocalTree: { tree: {}, inodes: {}, size: 0 },
|
|
49
|
+
previousRemoteTree: { tree: {}, uuids: {}, size: 0 },
|
|
50
|
+
isPreviousSavedTreeStateEmpty: true,
|
|
51
|
+
removed: false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeState(stub: StateSyncStub): State {
|
|
56
|
+
return new State(stub as unknown as Sync)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function localFile(path: string, inode: number, opts?: { size?: number; lastModified?: number; creation?: number }): LocalItem {
|
|
60
|
+
return {
|
|
61
|
+
type: "file",
|
|
62
|
+
path,
|
|
63
|
+
inode,
|
|
64
|
+
size: opts?.size ?? 100,
|
|
65
|
+
lastModified: opts?.lastModified ?? 1_700_000_000_000,
|
|
66
|
+
creation: opts?.creation ?? 1_690_000_000_000
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function localDir(path: string, inode: number): LocalItem {
|
|
71
|
+
return {
|
|
72
|
+
type: "directory",
|
|
73
|
+
path,
|
|
74
|
+
inode,
|
|
75
|
+
size: 0,
|
|
76
|
+
lastModified: 1_700_000_000_000,
|
|
77
|
+
creation: 1_690_000_000_000
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function remoteFile(path: string, uuid: string, name: string): RemoteItem {
|
|
82
|
+
return {
|
|
83
|
+
type: "file",
|
|
84
|
+
uuid,
|
|
85
|
+
name,
|
|
86
|
+
size: 100,
|
|
87
|
+
mime: "text/plain",
|
|
88
|
+
lastModified: 1_700_000_000_000,
|
|
89
|
+
version: 2,
|
|
90
|
+
chunks: 1,
|
|
91
|
+
key: `key-${uuid}`,
|
|
92
|
+
bucket: "bucket",
|
|
93
|
+
region: "region",
|
|
94
|
+
path
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function remoteDir(path: string, uuid: string, name: string): RemoteItem {
|
|
99
|
+
return {
|
|
100
|
+
type: "directory",
|
|
101
|
+
uuid,
|
|
102
|
+
name,
|
|
103
|
+
size: 0,
|
|
104
|
+
path
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A throwaway `Stats` carrying only the four fields the create/upload/download cases read. */
|
|
109
|
+
function makeStats(values: { mtimeMs: number; birthtimeMs: number; size: number; ino: number }): Stats {
|
|
110
|
+
return values as unknown as Stats
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `filePath` is built with the engine's platform `pathModule.join`, so on a Windows runner it carries
|
|
114
|
+
// backslashes / a drive letter. The engine persists through the wrapped fs (which posix-normalizes at
|
|
115
|
+
// the memfs boundary), so raw `ifs` access here must normalize too or it would miss the stored key.
|
|
116
|
+
|
|
117
|
+
/** Plant a raw file (creating parents) so loader/reader branches can be driven over exact bytes. */
|
|
118
|
+
function writeRaw(vfs: VirtualFS, filePath: string, content: string): void {
|
|
119
|
+
const posixPath = toPosixPath(filePath)
|
|
120
|
+
|
|
121
|
+
vfs.ifs.mkdirSync(pathModule.posix.dirname(posixPath), { recursive: true })
|
|
122
|
+
vfs.ifs.writeFileSync(posixPath, content)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read a written file back as text (the engine always writes utf-8). */
|
|
126
|
+
function readRaw(vfs: VirtualFS, filePath: string): string {
|
|
127
|
+
return vfs.ifs.readFileSync(toPosixPath(filePath), "utf-8") as string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Split a written line-delimited file into its non-empty JSON lines. */
|
|
131
|
+
function jsonLines(raw: string): string[] {
|
|
132
|
+
return raw.split("\n").filter(line => line.length > 0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("State — path getters", () => {
|
|
136
|
+
it("derives every persisted path from the documented state/v2/<uuid> layout", () => {
|
|
137
|
+
const uuid = "pair-uuid-123"
|
|
138
|
+
const state = makeState(makeSyncStub(createVirtualFS(), uuid))
|
|
139
|
+
const base = pathModule.join(DB_ROOT, "state", "v2", uuid)
|
|
140
|
+
|
|
141
|
+
expect(state.statePath).toBe(base)
|
|
142
|
+
expect(state.previousLocalTreePath).toBe(pathModule.join(base, "previousLocalTree"))
|
|
143
|
+
expect(state.previousLocalINodesPath).toBe(pathModule.join(base, "previousLocalINodes"))
|
|
144
|
+
expect(state.previousRemoteTreePath).toBe(pathModule.join(base, "previousRemoteTree"))
|
|
145
|
+
expect(state.previousRemoteUUIDsPath).toBe(pathModule.join(base, "previousRemoteUUIDs"))
|
|
146
|
+
expect(state.localFileHashesPath).toBe(pathModule.join(base, "localFileHashes"))
|
|
147
|
+
|
|
148
|
+
// The version segment is pinned at v2 (STATE_VERSION) between "state" and the pair uuid.
|
|
149
|
+
expect(state.statePath.includes(pathModule.join("state", "v2", uuid))).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe("State — line-delimited serializer/reader", () => {
|
|
154
|
+
it("writes one {prop,data} JSON line per entry and round-trips it back", async () => {
|
|
155
|
+
const vfs = createVirtualFS()
|
|
156
|
+
const state = makeState(makeSyncStub(vfs, "fmt-uuid"))
|
|
157
|
+
const record = { "/a.txt": "hash-a", "/b.txt": "hash-b" }
|
|
158
|
+
const dest = pathModule.join(state.statePath, "fmtRecord")
|
|
159
|
+
|
|
160
|
+
await state.writeLargeRecordSerializedAndAtomically(dest, record)
|
|
161
|
+
|
|
162
|
+
const lines = jsonLines(readRaw(vfs, dest))
|
|
163
|
+
|
|
164
|
+
expect(lines.length).toBe(2)
|
|
165
|
+
expect(JSON.parse(lines[0]!)).toEqual({ prop: "/a.txt", data: "hash-a" })
|
|
166
|
+
expect(JSON.parse(lines[1]!)).toEqual({ prop: "/b.txt", data: "hash-b" })
|
|
167
|
+
|
|
168
|
+
expect(await state.readLargeRecordFromLineStream<string>(dest)).toEqual(record)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("streams large maps entry-by-entry (one line each, surviving stream backpressure)", async () => {
|
|
172
|
+
const vfs = createVirtualFS()
|
|
173
|
+
const state = makeState(makeSyncStub(vfs, "large-uuid"))
|
|
174
|
+
const record: Record<string, string> = {}
|
|
175
|
+
|
|
176
|
+
// ~300KB total comfortably exceeds the write stream's highWaterMark, forcing the drain path.
|
|
177
|
+
for (let index = 0; index < 200; index++) {
|
|
178
|
+
record[`/path/file-${index}.txt`] = `${index}:${"x".repeat(1500)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const dest = pathModule.join(state.statePath, "largeRecord")
|
|
182
|
+
|
|
183
|
+
await state.writeLargeRecordSerializedAndAtomically(dest, record)
|
|
184
|
+
|
|
185
|
+
const lines = jsonLines(readRaw(vfs, dest))
|
|
186
|
+
|
|
187
|
+
// Exactly one self-contained JSON line per entry — proves it streams rather than emitting a
|
|
188
|
+
// single giant blob.
|
|
189
|
+
expect(lines.length).toBe(200)
|
|
190
|
+
|
|
191
|
+
for (const line of lines.slice(0, 3)) {
|
|
192
|
+
const parsed = JSON.parse(line)
|
|
193
|
+
|
|
194
|
+
expect(parsed).toHaveProperty("prop")
|
|
195
|
+
expect(parsed).toHaveProperty("data")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(await state.readLargeRecordFromLineStream<string>(dest)).toEqual(record)
|
|
199
|
+
|
|
200
|
+
// The atomic write left no temp file behind (the move consumed it).
|
|
201
|
+
expect(Object.keys(vfs.controls.toJSON()).filter(path => path.endsWith(".tmp"))).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("skips blank, unparseable and wrong-shaped lines, keeping only valid {prop,data} entries", async () => {
|
|
205
|
+
const vfs = createVirtualFS()
|
|
206
|
+
const state = makeState(makeSyncStub(vfs, "reader-uuid"))
|
|
207
|
+
const dest = pathModule.join(state.statePath, "mixed")
|
|
208
|
+
const content =
|
|
209
|
+
[
|
|
210
|
+
JSON.stringify({ prop: "/valid1", data: "v1" }),
|
|
211
|
+
"not json at all",
|
|
212
|
+
JSON.stringify({ prop: "/valid2", data: "v2" }),
|
|
213
|
+
"}{ broken line",
|
|
214
|
+
JSON.stringify({ noprop: true }),
|
|
215
|
+
"42",
|
|
216
|
+
"null",
|
|
217
|
+
"\"juststring\"",
|
|
218
|
+
JSON.stringify({ prop: "/valid3", data: "v3" }),
|
|
219
|
+
"",
|
|
220
|
+
" "
|
|
221
|
+
].join("\n") + "\n"
|
|
222
|
+
|
|
223
|
+
writeRaw(vfs, dest, content)
|
|
224
|
+
|
|
225
|
+
// Garbage / non-object / shapeless / empty lines are all silently dropped; the reader recovers
|
|
226
|
+
// the three well-formed entries without throwing.
|
|
227
|
+
expect(await state.readLargeRecordFromLineStream<string>(dest)).toEqual({
|
|
228
|
+
"/valid1": "v1",
|
|
229
|
+
"/valid2": "v2",
|
|
230
|
+
"/valid3": "v3"
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe("State — localFileHashes persistence", () => {
|
|
236
|
+
it("saves and reloads localFileHashes across a fresh State over the same vfs", async () => {
|
|
237
|
+
const vfs = createVirtualFS()
|
|
238
|
+
const source = makeSyncStub(vfs, "hash-uuid")
|
|
239
|
+
|
|
240
|
+
source.localFileHashes = { "/a.txt": "h-a", "/dir/b.txt": "h-b" }
|
|
241
|
+
|
|
242
|
+
await makeState(source).saveLocalFileHashes()
|
|
243
|
+
|
|
244
|
+
const reloaded = makeSyncStub(vfs, "hash-uuid")
|
|
245
|
+
|
|
246
|
+
await makeState(reloaded).loadLocalFileHashes()
|
|
247
|
+
|
|
248
|
+
expect(reloaded.localFileHashes).toEqual({ "/a.txt": "h-a", "/dir/b.txt": "h-b" })
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe("State — previous-trees round-trip", () => {
|
|
253
|
+
it("persists trees/inodes/uuids/hashes and reloads them deep-equal, with sizes derived from the tree", async () => {
|
|
254
|
+
const vfs = createVirtualFS()
|
|
255
|
+
const source = makeSyncStub(vfs, "round-uuid")
|
|
256
|
+
|
|
257
|
+
const fileA = localFile("/a.txt", 101)
|
|
258
|
+
const dir1 = localDir("/dir", 102)
|
|
259
|
+
const fileB = localFile("/dir/b.txt", 103)
|
|
260
|
+
const localTree: LocalTree = {
|
|
261
|
+
tree: { "/a.txt": fileA, "/dir": dir1, "/dir/b.txt": fileB },
|
|
262
|
+
inodes: { 101: fileA, 102: dir1, 103: fileB },
|
|
263
|
+
size: 3
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const rfileA = remoteFile("/a.txt", "u-a", "a.txt")
|
|
267
|
+
const rdir = remoteDir("/dir", "u-dir", "dir")
|
|
268
|
+
const rfileB = remoteFile("/dir/b.txt", "u-b", "b.txt")
|
|
269
|
+
const remoteTree: RemoteTree = {
|
|
270
|
+
tree: { "/a.txt": rfileA, "/dir": rdir, "/dir/b.txt": rfileB },
|
|
271
|
+
uuids: { "u-a": rfileA, "u-dir": rdir, "u-b": rfileB },
|
|
272
|
+
size: 3
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const hashes = { "/a.txt": "hash-a", "/dir/b.txt": "hash-b" }
|
|
276
|
+
|
|
277
|
+
source.previousLocalTree = localTree
|
|
278
|
+
source.previousRemoteTree = remoteTree
|
|
279
|
+
source.localFileHashes = hashes
|
|
280
|
+
|
|
281
|
+
const sourceState = makeState(source)
|
|
282
|
+
|
|
283
|
+
await sourceState.save()
|
|
284
|
+
|
|
285
|
+
// savePreviousTrees marks the live state as non-empty after writing.
|
|
286
|
+
expect(source.isPreviousSavedTreeStateEmpty).toBe(false)
|
|
287
|
+
|
|
288
|
+
const reloaded = makeSyncStub(vfs, "round-uuid")
|
|
289
|
+
const reloadedState = makeState(reloaded)
|
|
290
|
+
|
|
291
|
+
await reloadedState.initialize()
|
|
292
|
+
|
|
293
|
+
expect(reloaded.previousLocalTree.tree).toEqual(localTree.tree)
|
|
294
|
+
expect(reloaded.previousLocalTree.inodes).toEqual(localTree.inodes)
|
|
295
|
+
expect(reloaded.previousRemoteTree.tree).toEqual(remoteTree.tree)
|
|
296
|
+
expect(reloaded.previousRemoteTree.uuids).toEqual(remoteTree.uuids)
|
|
297
|
+
expect(reloaded.localFileHashes).toEqual(hashes)
|
|
298
|
+
expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(false)
|
|
299
|
+
|
|
300
|
+
// Sizes are recomputed from the loaded TREE key counts (they are not themselves persisted).
|
|
301
|
+
expect(reloaded.previousLocalTree.size).toBe(Object.keys(localTree.tree).length)
|
|
302
|
+
expect(reloaded.previousRemoteTree.size).toBe(Object.keys(remoteTree.tree).length)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("keys the four tree files by path/inode/uuid as documented", async () => {
|
|
306
|
+
const vfs = createVirtualFS()
|
|
307
|
+
const source = makeSyncStub(vfs, "layout-uuid")
|
|
308
|
+
|
|
309
|
+
const fileA = localFile("/a.txt", 101)
|
|
310
|
+
const dir1 = localDir("/dir", 102)
|
|
311
|
+
const rfileA = remoteFile("/a.txt", "u-a", "a.txt")
|
|
312
|
+
const rdir = remoteDir("/dir", "u-dir", "dir")
|
|
313
|
+
|
|
314
|
+
source.previousLocalTree = { tree: { "/a.txt": fileA, "/dir": dir1 }, inodes: { 101: fileA, 102: dir1 }, size: 2 }
|
|
315
|
+
source.previousRemoteTree = { tree: { "/a.txt": rfileA, "/dir": rdir }, uuids: { "u-a": rfileA, "u-dir": rdir }, size: 2 }
|
|
316
|
+
|
|
317
|
+
const state = makeState(source)
|
|
318
|
+
|
|
319
|
+
await state.savePreviousTrees()
|
|
320
|
+
|
|
321
|
+
const propsOf = (filePath: string): string[] => jsonLines(readRaw(vfs, filePath)).map(line => JSON.parse(line).prop).sort()
|
|
322
|
+
|
|
323
|
+
// previousLocalTree is keyed by relative path, previousLocalINodes by inode (as a string key).
|
|
324
|
+
expect(propsOf(state.previousLocalTreePath)).toEqual(["/a.txt", "/dir"])
|
|
325
|
+
expect(propsOf(state.previousLocalINodesPath)).toEqual(["101", "102"])
|
|
326
|
+
// previousRemoteTree is keyed by path, previousRemoteUUIDs by uuid.
|
|
327
|
+
expect(propsOf(state.previousRemoteTreePath)).toEqual(["/a.txt", "/dir"])
|
|
328
|
+
expect(propsOf(state.previousRemoteUUIDsPath)).toEqual(["u-a", "u-dir"])
|
|
329
|
+
|
|
330
|
+
// The per-line `data` is the verbatim item, so a single line rehydrates the whole RemoteItem.
|
|
331
|
+
const firstRemoteLine = jsonLines(readRaw(vfs, state.previousRemoteTreePath))[0]!
|
|
332
|
+
|
|
333
|
+
expect(JSON.parse(firstRemoteLine)).toEqual({ prop: "/a.txt", data: rfileA })
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe("State — loadPreviousTrees branches", () => {
|
|
338
|
+
it("treats a completely missing state directory as a fresh, empty previous state without throwing", async () => {
|
|
339
|
+
const vfs = createVirtualFS()
|
|
340
|
+
const stub = makeSyncStub(vfs, "missing-uuid")
|
|
341
|
+
|
|
342
|
+
await makeState(stub).initialize()
|
|
343
|
+
|
|
344
|
+
expect(stub.localFileHashes).toEqual({})
|
|
345
|
+
expect(stub.previousLocalTree.tree).toEqual({})
|
|
346
|
+
expect(stub.previousLocalTree.inodes).toEqual({})
|
|
347
|
+
expect(stub.previousRemoteTree.tree).toEqual({})
|
|
348
|
+
expect(stub.previousRemoteTree.uuids).toEqual({})
|
|
349
|
+
expect(stub.isPreviousSavedTreeStateEmpty).toBe(true)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it("treats a partial set of tree files as no saved state (short-circuits before reading)", async () => {
|
|
353
|
+
const vfs = createVirtualFS()
|
|
354
|
+
const stub = makeSyncStub(vfs, "partial-uuid")
|
|
355
|
+
const state = makeState(stub)
|
|
356
|
+
|
|
357
|
+
// Only the local tree exists; the missing remote tree trips the existence guard, so the loader
|
|
358
|
+
// returns "empty" without ever attempting to read the absent files.
|
|
359
|
+
writeRaw(vfs, state.previousLocalTreePath, JSON.stringify({ prop: "/x.txt", data: localFile("/x.txt", 1) }) + "\n")
|
|
360
|
+
|
|
361
|
+
await state.initialize()
|
|
362
|
+
|
|
363
|
+
expect(stub.isPreviousSavedTreeStateEmpty).toBe(true)
|
|
364
|
+
expect(stub.previousLocalTree.tree).toEqual({})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it("treats a saved state with a MISSING local-inodes file as no saved state, not a throw", async () => {
|
|
368
|
+
const vfs = createVirtualFS()
|
|
369
|
+
const source = makeSyncStub(vfs, "missing-inodes-uuid")
|
|
370
|
+
|
|
371
|
+
const fileA = localFile("/a.txt", 101)
|
|
372
|
+
const rfileA = remoteFile("/a.txt", "u-a", "a.txt")
|
|
373
|
+
|
|
374
|
+
source.previousLocalTree = { tree: { "/a.txt": fileA }, inodes: { 101: fileA }, size: 1 }
|
|
375
|
+
source.previousRemoteTree = { tree: { "/a.txt": rfileA }, uuids: { "u-a": rfileA }, size: 1 }
|
|
376
|
+
|
|
377
|
+
const sourceState = makeState(source)
|
|
378
|
+
|
|
379
|
+
await sourceState.save()
|
|
380
|
+
|
|
381
|
+
// The local-inodes file is the ONE file the completeness guard used to skip while still reading it
|
|
382
|
+
// unconditionally. Remove it to simulate a partial / interrupted-write on-disk state: the loader must
|
|
383
|
+
// degrade to "no saved state" (and re-derive next cycle), NOT throw ENOENT and brick the load.
|
|
384
|
+
vfs.ifs.rmSync(toPosixPath(sourceState.previousLocalINodesPath))
|
|
385
|
+
|
|
386
|
+
const reloaded = makeSyncStub(vfs, "missing-inodes-uuid")
|
|
387
|
+
|
|
388
|
+
await makeState(reloaded).initialize()
|
|
389
|
+
|
|
390
|
+
expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(true)
|
|
391
|
+
expect(reloaded.previousLocalTree.tree).toEqual({})
|
|
392
|
+
expect(reloaded.previousLocalTree.inodes).toEqual({})
|
|
393
|
+
expect(reloaded.previousRemoteTree.tree).toEqual({})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it("distinguishes a persisted-but-empty state (files present, empty) from no saved state", async () => {
|
|
397
|
+
const vfs = createVirtualFS()
|
|
398
|
+
|
|
399
|
+
// Saving empty trees writes empty files for all of them.
|
|
400
|
+
await makeState(makeSyncStub(vfs, "empty-uuid")).save()
|
|
401
|
+
|
|
402
|
+
const reloaded = makeSyncStub(vfs, "empty-uuid")
|
|
403
|
+
|
|
404
|
+
await makeState(reloaded).initialize()
|
|
405
|
+
|
|
406
|
+
expect(reloaded.previousLocalTree.tree).toEqual({})
|
|
407
|
+
expect(reloaded.previousLocalTree.size).toBe(0)
|
|
408
|
+
expect(reloaded.previousRemoteTree.tree).toEqual({})
|
|
409
|
+
expect(reloaded.previousRemoteTree.size).toBe(0)
|
|
410
|
+
expect(reloaded.localFileHashes).toEqual({})
|
|
411
|
+
// Files exist, so this is "an empty saved state", NOT "no saved state".
|
|
412
|
+
expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(false)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it("degrades a single corrupt tree file to empty while still loading the intact siblings", async () => {
|
|
416
|
+
const vfs = createVirtualFS()
|
|
417
|
+
const source = makeSyncStub(vfs, "corrupt-uuid")
|
|
418
|
+
|
|
419
|
+
const fileA = localFile("/a.txt", 101)
|
|
420
|
+
|
|
421
|
+
source.previousLocalTree = { tree: { "/a.txt": fileA }, inodes: { 101: fileA }, size: 1 }
|
|
422
|
+
source.previousRemoteTree = {
|
|
423
|
+
tree: { "/a.txt": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
424
|
+
uuids: { "u-a": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
425
|
+
size: 1
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const sourceState = makeState(source)
|
|
429
|
+
|
|
430
|
+
await sourceState.save()
|
|
431
|
+
|
|
432
|
+
// Corrupt ONLY the remote tree file; the loader reads each file independently and recovers
|
|
433
|
+
// per-line, so this one degrades to empty without affecting the still-valid local tree.
|
|
434
|
+
writeRaw(vfs, sourceState.previousRemoteTreePath, "}{ not json\n<<garbage>>\n")
|
|
435
|
+
|
|
436
|
+
const reloaded = makeSyncStub(vfs, "corrupt-uuid")
|
|
437
|
+
|
|
438
|
+
await makeState(reloaded).initialize()
|
|
439
|
+
|
|
440
|
+
expect(reloaded.previousRemoteTree.tree).toEqual({})
|
|
441
|
+
expect(reloaded.previousRemoteTree.size).toBe(0)
|
|
442
|
+
expect(reloaded.previousLocalTree.tree).toEqual({ "/a.txt": fileA })
|
|
443
|
+
expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(false)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("removes stray .tmp files from the state directory while leaving real files untouched", async () => {
|
|
447
|
+
const vfs = createVirtualFS()
|
|
448
|
+
const source = makeSyncStub(vfs, "tmp-uuid")
|
|
449
|
+
const fileA = localFile("/a.txt", 101)
|
|
450
|
+
|
|
451
|
+
source.previousLocalTree = { tree: { "/a.txt": fileA }, inodes: { 101: fileA }, size: 1 }
|
|
452
|
+
source.previousRemoteTree = {
|
|
453
|
+
tree: { "/a.txt": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
454
|
+
uuids: { "u-a": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
455
|
+
size: 1
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const sourceState = makeState(source)
|
|
459
|
+
|
|
460
|
+
await sourceState.save()
|
|
461
|
+
|
|
462
|
+
const strayTmp = pathModule.join(sourceState.statePath, "leftover.tmp")
|
|
463
|
+
|
|
464
|
+
writeRaw(vfs, strayTmp, "interrupted write")
|
|
465
|
+
|
|
466
|
+
expect(vfs.controls.exists(strayTmp)).toBe(true)
|
|
467
|
+
|
|
468
|
+
const reloaded = makeSyncStub(vfs, "tmp-uuid")
|
|
469
|
+
const reloadedState = makeState(reloaded)
|
|
470
|
+
|
|
471
|
+
await reloadedState.loadPreviousTrees()
|
|
472
|
+
|
|
473
|
+
// The stray temp file was swept; the real persisted state still loaded.
|
|
474
|
+
expect(vfs.controls.exists(strayTmp)).toBe(false)
|
|
475
|
+
expect(vfs.controls.exists(reloadedState.previousLocalTreePath)).toBe(true)
|
|
476
|
+
expect(reloaded.previousLocalTree.tree).toEqual({ "/a.txt": fileA })
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
describe("State — clear", () => {
|
|
481
|
+
it("removes all five persisted files, so a subsequent load sees no saved state", async () => {
|
|
482
|
+
const vfs = createVirtualFS()
|
|
483
|
+
const source = makeSyncStub(vfs, "clear-uuid")
|
|
484
|
+
const fileA = localFile("/a.txt", 101)
|
|
485
|
+
|
|
486
|
+
source.previousLocalTree = { tree: { "/a.txt": fileA }, inodes: { 101: fileA }, size: 1 }
|
|
487
|
+
source.previousRemoteTree = {
|
|
488
|
+
tree: { "/a.txt": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
489
|
+
uuids: { "u-a": remoteFile("/a.txt", "u-a", "a.txt") },
|
|
490
|
+
size: 1
|
|
491
|
+
}
|
|
492
|
+
source.localFileHashes = { "/a.txt": "hash-a" }
|
|
493
|
+
|
|
494
|
+
const state = makeState(source)
|
|
495
|
+
|
|
496
|
+
await state.save()
|
|
497
|
+
|
|
498
|
+
const files = [
|
|
499
|
+
state.previousLocalTreePath,
|
|
500
|
+
state.previousLocalINodesPath,
|
|
501
|
+
state.previousRemoteTreePath,
|
|
502
|
+
state.previousRemoteUUIDsPath,
|
|
503
|
+
state.localFileHashesPath
|
|
504
|
+
]
|
|
505
|
+
|
|
506
|
+
for (const filePath of files) {
|
|
507
|
+
expect(vfs.controls.exists(filePath)).toBe(true)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
await state.clear()
|
|
511
|
+
|
|
512
|
+
for (const filePath of files) {
|
|
513
|
+
expect(vfs.controls.exists(filePath)).toBe(false)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const reloaded = makeSyncStub(vfs, "clear-uuid")
|
|
517
|
+
|
|
518
|
+
await makeState(reloaded).initialize()
|
|
519
|
+
|
|
520
|
+
expect(reloaded.isPreviousSavedTreeStateEmpty).toBe(true)
|
|
521
|
+
expect(reloaded.previousLocalTree.tree).toEqual({})
|
|
522
|
+
expect(reloaded.localFileHashes).toEqual({})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it("is a no-op (force) when there is nothing to remove", async () => {
|
|
526
|
+
const vfs = createVirtualFS()
|
|
527
|
+
const state = makeState(makeSyncStub(vfs, "clear-empty-uuid"))
|
|
528
|
+
|
|
529
|
+
await expect(state.clear()).resolves.toBeUndefined()
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
describe("State — applyDoneTasksToState", () => {
|
|
534
|
+
it("returns the trees untouched (same references) when the sync was removed", () => {
|
|
535
|
+
const vfs = createVirtualFS()
|
|
536
|
+
const stub = makeSyncStub(vfs, "removed-uuid")
|
|
537
|
+
|
|
538
|
+
stub.removed = true
|
|
539
|
+
stub.localFileHashes = { "/f.txt": "h" }
|
|
540
|
+
|
|
541
|
+
const localTree: LocalTree = { tree: { "/f.txt": localFile("/f.txt", 1) }, inodes: { 1: localFile("/f.txt", 1) }, size: 1 }
|
|
542
|
+
const remoteTree: RemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
543
|
+
const localSnapshot = structuredClone(localTree)
|
|
544
|
+
const hashesSnapshot = structuredClone(stub.localFileHashes)
|
|
545
|
+
|
|
546
|
+
const result = makeState(stub).applyDoneTasksToState({
|
|
547
|
+
doneTasks: [{ path: "/f.txt", type: "deleteLocalFile" }],
|
|
548
|
+
currentLocalTree: localTree,
|
|
549
|
+
currentRemoteTree: remoteTree
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
expect(result.currentLocalTree).toBe(localTree)
|
|
553
|
+
expect(result.currentRemoteTree).toBe(remoteTree)
|
|
554
|
+
expect(localTree).toEqual(localSnapshot)
|
|
555
|
+
expect(stub.localFileHashes).toEqual(hashesSnapshot)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it("applies create/upload/download tasks to both trees with normalizeUTime-floored timestamps", () => {
|
|
559
|
+
const vfs = createVirtualFS()
|
|
560
|
+
const state = makeState(makeSyncStub(vfs, "create-uuid"))
|
|
561
|
+
const localTree: LocalTree = { tree: {}, inodes: {}, size: 0 }
|
|
562
|
+
const remoteTree: RemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
563
|
+
|
|
564
|
+
const crItem = remoteDir("/cr", "u-cr", "cr")
|
|
565
|
+
const upItem = remoteFile("/up.txt", "u-up", "up.txt")
|
|
566
|
+
const clItem = remoteDir("/cl", "u-cl", "cl")
|
|
567
|
+
const dlItem = remoteFile("/dl.txt", "u-dl", "dl.txt")
|
|
568
|
+
|
|
569
|
+
const tasks: DoneTask[] = [
|
|
570
|
+
{ path: "/cr", type: "createRemoteDirectory", item: crItem, stats: makeStats({ mtimeMs: 1000.9, birthtimeMs: 500.9, size: 4096, ino: 401 }) },
|
|
571
|
+
{ path: "/up.txt", type: "uploadFile", item: upItem, stats: makeStats({ mtimeMs: 2000.5, birthtimeMs: 1500.5, size: 123, ino: 402 }) },
|
|
572
|
+
{ path: "/cl", type: "createLocalDirectory", item: clItem, stats: makeStats({ mtimeMs: 3000.1, birthtimeMs: 2500.1, size: 777, ino: 403 }) },
|
|
573
|
+
{ path: "/dl.txt", type: "downloadFile", item: dlItem, stats: makeStats({ mtimeMs: 4000.8, birthtimeMs: 3500.8, size: 456, ino: 404 }) }
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
state.applyDoneTasksToState({ doneTasks: tasks, currentLocalTree: localTree, currentRemoteTree: remoteTree })
|
|
577
|
+
|
|
578
|
+
// Every task mirrors its remote item into the remote tree, keyed by both path and uuid.
|
|
579
|
+
expect(remoteTree.tree["/cr"]).toBe(crItem)
|
|
580
|
+
expect(remoteTree.uuids["u-cr"]).toBe(crItem)
|
|
581
|
+
expect(remoteTree.tree["/up.txt"]).toBe(upItem)
|
|
582
|
+
expect(remoteTree.uuids["u-up"]).toBe(upItem)
|
|
583
|
+
expect(remoteTree.tree["/cl"]).toBe(clItem)
|
|
584
|
+
expect(remoteTree.tree["/dl.txt"]).toBe(dlItem)
|
|
585
|
+
|
|
586
|
+
// Local items are synthesized from stats; float mtimes/creations are floored by normalizeUTime,
|
|
587
|
+
// and a createRemoteDirectory carries size 0 whereas a createLocalDirectory keeps the stat size.
|
|
588
|
+
expect(localTree.tree["/cr"]).toEqual({ type: "directory", path: "/cr", size: 0, inode: 401, lastModified: 1000, creation: 500 })
|
|
589
|
+
expect(localTree.inodes[401]).toBe(localTree.tree["/cr"])
|
|
590
|
+
expect(localTree.tree["/up.txt"]).toEqual({ type: "file", path: "/up.txt", size: 123, inode: 402, lastModified: 2000, creation: 1500 })
|
|
591
|
+
expect(localTree.tree["/cl"]).toEqual({ type: "directory", path: "/cl", size: 777, inode: 403, lastModified: 3000, creation: 2500 })
|
|
592
|
+
expect(localTree.tree["/dl.txt"]).toEqual({ type: "file", path: "/dl.txt", size: 456, inode: 404, lastModified: 4000, creation: 3500 })
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it("reparents a renamed local directory: tree, inodes and file hashes all follow the move", () => {
|
|
596
|
+
const vfs = createVirtualFS()
|
|
597
|
+
const stub = makeSyncStub(vfs, "rl-uuid")
|
|
598
|
+
|
|
599
|
+
stub.localFileHashes = { "/old": "hOld", "/old/child.txt": "hChild", "/unrelated.txt": "hUnrel" }
|
|
600
|
+
|
|
601
|
+
const oldDir = localDir("/old", 201)
|
|
602
|
+
const childFile = localFile("/old/child.txt", 202)
|
|
603
|
+
const unrelFile = localFile("/unrelated.txt", 203)
|
|
604
|
+
const localTree: LocalTree = {
|
|
605
|
+
tree: { "/old": oldDir, "/old/child.txt": childFile, "/unrelated.txt": unrelFile },
|
|
606
|
+
inodes: { 201: oldDir, 202: childFile, 203: unrelFile },
|
|
607
|
+
size: 3
|
|
608
|
+
}
|
|
609
|
+
const remoteTree: RemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
610
|
+
|
|
611
|
+
makeState(stub).applyDoneTasksToState({
|
|
612
|
+
doneTasks: [
|
|
613
|
+
{ path: "/new", type: "renameLocalDirectory", from: "/old", to: "/new", stats: makeStats({ mtimeMs: 1, birthtimeMs: 1, size: 0, ino: 201 }) }
|
|
614
|
+
],
|
|
615
|
+
currentLocalTree: localTree,
|
|
616
|
+
currentRemoteTree: remoteTree
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
expect(localTree.tree["/new"]).toMatchObject({ path: "/new", type: "directory", inode: 201 })
|
|
620
|
+
expect(localTree.tree["/new/child.txt"]).toMatchObject({ path: "/new/child.txt", inode: 202 })
|
|
621
|
+
expect(localTree.tree["/old"]).toBeUndefined()
|
|
622
|
+
expect(localTree.tree["/old/child.txt"]).toBeUndefined()
|
|
623
|
+
expect(localTree.tree["/unrelated.txt"]).toBe(unrelFile)
|
|
624
|
+
expect(localTree.inodes[201]!.path).toBe("/new")
|
|
625
|
+
expect(localTree.inodes[202]!.path).toBe("/new/child.txt")
|
|
626
|
+
expect(stub.localFileHashes).toEqual({ "/new": "hOld", "/new/child.txt": "hChild", "/unrelated.txt": "hUnrel" })
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it("reparents a renamed remote directory: tree, uuids and basenames all follow the move", () => {
|
|
630
|
+
const vfs = createVirtualFS()
|
|
631
|
+
const stub = makeSyncStub(vfs, "rr-uuid")
|
|
632
|
+
|
|
633
|
+
const oldDir = remoteDir("/rold", "u-old", "rold")
|
|
634
|
+
const childFile = remoteFile("/rold/c.txt", "u-child", "c.txt")
|
|
635
|
+
const otherFile = remoteFile("/other.txt", "u-other", "other.txt")
|
|
636
|
+
const localTree: LocalTree = { tree: {}, inodes: {}, size: 0 }
|
|
637
|
+
const remoteTree: RemoteTree = {
|
|
638
|
+
tree: { "/rold": oldDir, "/rold/c.txt": childFile, "/other.txt": otherFile },
|
|
639
|
+
uuids: { "u-old": oldDir, "u-child": childFile, "u-other": otherFile },
|
|
640
|
+
size: 3
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
makeState(stub).applyDoneTasksToState({
|
|
644
|
+
doneTasks: [{ path: "/rnew", type: "renameRemoteDirectory", from: "/rold", to: "/rnew" }],
|
|
645
|
+
currentLocalTree: localTree,
|
|
646
|
+
currentRemoteTree: remoteTree
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
expect(remoteTree.tree["/rnew"]).toMatchObject({ path: "/rnew", name: "rnew", uuid: "u-old", type: "directory" })
|
|
650
|
+
expect(remoteTree.uuids["u-old"]).toMatchObject({ path: "/rnew", name: "rnew" })
|
|
651
|
+
expect(remoteTree.tree["/rnew/c.txt"]).toMatchObject({ path: "/rnew/c.txt", name: "c.txt", uuid: "u-child" })
|
|
652
|
+
expect(remoteTree.uuids["u-child"]).toMatchObject({ path: "/rnew/c.txt", name: "c.txt" })
|
|
653
|
+
expect(remoteTree.tree["/rold"]).toBeUndefined()
|
|
654
|
+
expect(remoteTree.tree["/rold/c.txt"]).toBeUndefined()
|
|
655
|
+
expect(remoteTree.tree["/other.txt"]).toBe(otherFile)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it("prunes a deleted local directory's subtree from tree, inodes and hashes (and survives a stale inode)", () => {
|
|
659
|
+
const vfs = createVirtualFS()
|
|
660
|
+
const stub = makeSyncStub(vfs, "dl-uuid")
|
|
661
|
+
|
|
662
|
+
stub.localFileHashes = { "/del/sub.txt": "h1", "/keep.txt": "h2" }
|
|
663
|
+
|
|
664
|
+
const delDir = localDir("/del", 301)
|
|
665
|
+
const subFile = localFile("/del/sub.txt", 302)
|
|
666
|
+
const keepFile = localFile("/keep.txt", 303)
|
|
667
|
+
const localTree: LocalTree = {
|
|
668
|
+
tree: { "/del": delDir, "/del/sub.txt": subFile, "/keep.txt": keepFile },
|
|
669
|
+
// The 999 slot is intentionally a hole, exercising the `!currentItem` guard in the inode loop.
|
|
670
|
+
inodes: { 301: delDir, 302: subFile, 303: keepFile, 999: undefined as unknown as LocalItem },
|
|
671
|
+
size: 3
|
|
672
|
+
}
|
|
673
|
+
const remoteTree: RemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
674
|
+
|
|
675
|
+
makeState(stub).applyDoneTasksToState({
|
|
676
|
+
doneTasks: [{ path: "/del", type: "deleteLocalDirectory" }],
|
|
677
|
+
currentLocalTree: localTree,
|
|
678
|
+
currentRemoteTree: remoteTree
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
expect(localTree.tree["/del"]).toBeUndefined()
|
|
682
|
+
expect(localTree.tree["/del/sub.txt"]).toBeUndefined()
|
|
683
|
+
expect(localTree.tree["/keep.txt"]).toBe(keepFile)
|
|
684
|
+
expect(localTree.inodes[301]).toBeUndefined()
|
|
685
|
+
expect(localTree.inodes[302]).toBeUndefined()
|
|
686
|
+
expect(localTree.inodes[303]).toBe(keepFile)
|
|
687
|
+
expect(stub.localFileHashes).toEqual({ "/keep.txt": "h2" })
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it("prunes a deleted remote directory's subtree from tree and uuids (and survives a stale uuid)", () => {
|
|
691
|
+
const vfs = createVirtualFS()
|
|
692
|
+
const stub = makeSyncStub(vfs, "dr-uuid")
|
|
693
|
+
|
|
694
|
+
const delDir = remoteDir("/rdel", "u-d", "rdel")
|
|
695
|
+
const xFile = remoteFile("/rdel/x.txt", "u-x", "x.txt")
|
|
696
|
+
const keepFile = remoteFile("/rkeep.txt", "u-k", "rkeep.txt")
|
|
697
|
+
const localTree: LocalTree = { tree: {}, inodes: {}, size: 0 }
|
|
698
|
+
const remoteTree: RemoteTree = {
|
|
699
|
+
tree: { "/rdel": delDir, "/rdel/x.txt": xFile, "/rkeep.txt": keepFile },
|
|
700
|
+
// The hole exercises the `!currentItem` guard in the uuid loop.
|
|
701
|
+
uuids: { "u-d": delDir, "u-x": xFile, "u-k": keepFile, "u-undef": undefined as unknown as RemoteItem },
|
|
702
|
+
size: 3
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
makeState(stub).applyDoneTasksToState({
|
|
706
|
+
doneTasks: [{ path: "/rdel", type: "deleteRemoteDirectory" }],
|
|
707
|
+
currentLocalTree: localTree,
|
|
708
|
+
currentRemoteTree: remoteTree
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
expect(remoteTree.tree["/rdel"]).toBeUndefined()
|
|
712
|
+
expect(remoteTree.tree["/rdel/x.txt"]).toBeUndefined()
|
|
713
|
+
expect(remoteTree.tree["/rkeep.txt"]).toBe(keepFile)
|
|
714
|
+
expect(remoteTree.uuids["u-d"]).toBeUndefined()
|
|
715
|
+
expect(remoteTree.uuids["u-x"]).toBeUndefined()
|
|
716
|
+
expect(remoteTree.uuids["u-k"]).toBe(keepFile)
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it("deletes single local and remote files from their trees, inodes/uuids and hashes", () => {
|
|
720
|
+
const vfs = createVirtualFS()
|
|
721
|
+
const stub = makeSyncStub(vfs, "df-uuid")
|
|
722
|
+
|
|
723
|
+
stub.localFileHashes = { "/f.txt": "h" }
|
|
724
|
+
|
|
725
|
+
const lf = localFile("/f.txt", 501)
|
|
726
|
+
const rf = remoteFile("/rf.txt", "u-rf", "rf.txt")
|
|
727
|
+
const localTree: LocalTree = { tree: { "/f.txt": lf }, inodes: { 501: lf }, size: 1 }
|
|
728
|
+
const remoteTree: RemoteTree = { tree: { "/rf.txt": rf }, uuids: { "u-rf": rf }, size: 1 }
|
|
729
|
+
|
|
730
|
+
makeState(stub).applyDoneTasksToState({
|
|
731
|
+
doneTasks: [
|
|
732
|
+
{ path: "/f.txt", type: "deleteLocalFile" },
|
|
733
|
+
{ path: "/rf.txt", type: "deleteRemoteFile" }
|
|
734
|
+
],
|
|
735
|
+
currentLocalTree: localTree,
|
|
736
|
+
currentRemoteTree: remoteTree
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
expect(localTree.tree["/f.txt"]).toBeUndefined()
|
|
740
|
+
expect(localTree.inodes[501]).toBeUndefined()
|
|
741
|
+
expect(stub.localFileHashes).toEqual({})
|
|
742
|
+
expect(remoteTree.tree["/rf.txt"]).toBeUndefined()
|
|
743
|
+
expect(remoteTree.uuids["u-rf"]).toBeUndefined()
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it("relocates a single local and remote file rename, leaving a prefix-sibling untouched (no descendant scan)", () => {
|
|
747
|
+
const vfs = createVirtualFS()
|
|
748
|
+
const stub = makeSyncStub(vfs, "rf2-uuid")
|
|
749
|
+
|
|
750
|
+
stub.localFileHashes = { "/a.txt": "hA", "/a.txt.bak": "hBak" }
|
|
751
|
+
|
|
752
|
+
const lf = localFile("/a.txt", 601)
|
|
753
|
+
// Shares the "/a.txt" PREFIX but is NOT a descendant ("/a.txt/..."), so a file rename must leave it be.
|
|
754
|
+
const sibling = localFile("/a.txt.bak", 602)
|
|
755
|
+
const rf = remoteFile("/ra.txt", "u-ra", "ra.txt")
|
|
756
|
+
const rsibling = remoteFile("/ra.txt.bak", "u-rab", "ra.txt.bak")
|
|
757
|
+
const localTree: LocalTree = { tree: { "/a.txt": lf, "/a.txt.bak": sibling }, inodes: { 601: lf, 602: sibling }, size: 2 }
|
|
758
|
+
const remoteTree: RemoteTree = { tree: { "/ra.txt": rf, "/ra.txt.bak": rsibling }, uuids: { "u-ra": rf, "u-rab": rsibling }, size: 2 }
|
|
759
|
+
|
|
760
|
+
makeState(stub).applyDoneTasksToState({
|
|
761
|
+
doneTasks: [
|
|
762
|
+
{ path: "/b.txt", type: "renameLocalFile", from: "/a.txt", to: "/b.txt", stats: makeStats({ mtimeMs: 1, birthtimeMs: 1, size: 0, ino: 601 }) },
|
|
763
|
+
{ path: "/rb.txt", type: "renameRemoteFile", from: "/ra.txt", to: "/rb.txt" }
|
|
764
|
+
],
|
|
765
|
+
currentLocalTree: localTree,
|
|
766
|
+
currentRemoteTree: remoteTree
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
// The file moved (tree + inode + its own hash)...
|
|
770
|
+
expect(localTree.tree["/b.txt"]).toMatchObject({ path: "/b.txt", inode: 601 })
|
|
771
|
+
expect(localTree.tree["/a.txt"]).toBeUndefined()
|
|
772
|
+
expect(localTree.inodes[601]!.path).toBe("/b.txt")
|
|
773
|
+
expect(stub.localFileHashes["/b.txt"]).toBe("hA")
|
|
774
|
+
expect(stub.localFileHashes["/a.txt"]).toBeUndefined()
|
|
775
|
+
// ...and the prefix-sibling is completely untouched.
|
|
776
|
+
expect(localTree.tree["/a.txt.bak"]).toBe(sibling)
|
|
777
|
+
expect(localTree.inodes[602]).toBe(sibling)
|
|
778
|
+
expect(stub.localFileHashes["/a.txt.bak"]).toBe("hBak")
|
|
779
|
+
|
|
780
|
+
expect(remoteTree.tree["/rb.txt"]).toMatchObject({ path: "/rb.txt", name: "rb.txt", uuid: "u-ra" })
|
|
781
|
+
expect(remoteTree.tree["/ra.txt"]).toBeUndefined()
|
|
782
|
+
expect(remoteTree.uuids["u-ra"]!.path).toBe("/rb.txt")
|
|
783
|
+
expect(remoteTree.tree["/ra.txt.bak"]).toBe(rsibling)
|
|
784
|
+
expect(remoteTree.uuids["u-rab"]).toBe(rsibling)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it("is a safe no-op when a rename targets a path absent from the current trees", () => {
|
|
788
|
+
const vfs = createVirtualFS()
|
|
789
|
+
const stub = makeSyncStub(vfs, "rfm-uuid")
|
|
790
|
+
const localTree: LocalTree = { tree: {}, inodes: {}, size: 0 }
|
|
791
|
+
const remoteTree: RemoteTree = { tree: {}, uuids: {}, size: 0 }
|
|
792
|
+
|
|
793
|
+
makeState(stub).applyDoneTasksToState({
|
|
794
|
+
doneTasks: [
|
|
795
|
+
{ path: "/ghostB", type: "renameLocalFile", from: "/ghostA", to: "/ghostB", stats: makeStats({ mtimeMs: 1, birthtimeMs: 1, size: 0, ino: 1 }) },
|
|
796
|
+
{ path: "/rghostB", type: "renameRemoteFile", from: "/rghostA", to: "/rghostB" }
|
|
797
|
+
],
|
|
798
|
+
currentLocalTree: localTree,
|
|
799
|
+
currentRemoteTree: remoteTree
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
// No source item existed, so nothing is created or moved on either side.
|
|
803
|
+
expect(localTree.tree).toEqual({})
|
|
804
|
+
expect(localTree.inodes).toEqual({})
|
|
805
|
+
expect(remoteTree.tree).toEqual({})
|
|
806
|
+
expect(remoteTree.uuids).toEqual({})
|
|
807
|
+
expect(stub.localFileHashes).toEqual({})
|
|
808
|
+
})
|
|
809
|
+
})
|