@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.
- 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,202 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, control } from "../harness/runner"
|
|
3
|
+
import { transferKinds } from "../harness/snapshot"
|
|
4
|
+
import { writeLocal, rmLocal, renameLocal } from "../harness/mutations"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Category K — scale / stress (behavioral spec §K, §2). These exercise the perf-relevant bulk paths:
|
|
8
|
+
* a wide single directory, a deep nesting chain, a large-subtree rename collapsing to one op, and a
|
|
9
|
+
* heterogeneous bulk (adds + deletes + rename) in a single cycle.
|
|
10
|
+
*
|
|
11
|
+
* The spec aspires to much larger sizes (10k files wide, ~1k deep). CI uses a smaller but
|
|
12
|
+
* representative scale that drives the identical code paths while keeping the whole file to a few
|
|
13
|
+
* seconds. Every scenario asserts the §2 meta-invariants that apply: twoWay convergence
|
|
14
|
+
* (`finalLocal` ≡ `finalRemote`) and idempotence (a fully settled trailing cycle emits no ops —
|
|
15
|
+
* an empty `transferKinds`, which subsumes the empty-`transferOps` file-transfer check).
|
|
16
|
+
*/
|
|
17
|
+
describe("Category K — scale / stress", () => {
|
|
18
|
+
it("K1: a wide tree of many files in one directory all upload and converge (twoWay)", async () => {
|
|
19
|
+
// Spec §K aspires to ~10k files in one directory; CI uses a representative width.
|
|
20
|
+
const fileCount = 400
|
|
21
|
+
|
|
22
|
+
const result = await runScenario({
|
|
23
|
+
name: "K1",
|
|
24
|
+
mode: "twoWay",
|
|
25
|
+
steps: [
|
|
26
|
+
runCycle(),
|
|
27
|
+
localMutate(world => {
|
|
28
|
+
for (let index = 0; index < fileCount; index++) {
|
|
29
|
+
writeLocal(world, `wide/file-${index}.txt`, `content-${index}`)
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
runCycle(),
|
|
33
|
+
runCycle()
|
|
34
|
+
]
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// The bulk add uploaded on the op cycle.
|
|
38
|
+
expect(transferKinds(result.cycles[1]!.messages)).toContain("upload")
|
|
39
|
+
|
|
40
|
+
// Every file landed on the remote, and nothing extra was created under /wide.
|
|
41
|
+
for (let index = 0; index < fileCount; index++) {
|
|
42
|
+
expect(result.finalRemote[`/wide/file-${index}.txt`]).toMatchObject({ type: "file" })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect(Object.keys(result.finalRemote).filter(path => path.startsWith("/wide/")).length).toBe(fileCount)
|
|
46
|
+
|
|
47
|
+
// Convergence + idempotence: the worlds match and the settled cycle does no work.
|
|
48
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
49
|
+
expect(transferKinds(result.cycles[2]!.messages)).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("K2: a deeply nested chain creates every intermediate directory and converges (twoWay)", async () => {
|
|
53
|
+
// Spec §K aspires to ~1000 levels of nesting; CI uses a representative depth.
|
|
54
|
+
const depth = 30
|
|
55
|
+
const segments: string[] = []
|
|
56
|
+
|
|
57
|
+
for (let level = 0; level < depth; level++) {
|
|
58
|
+
segments.push(`d${level}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const leafPath = `${segments.join("/")}/leaf.txt`
|
|
62
|
+
|
|
63
|
+
const result = await runScenario({
|
|
64
|
+
name: "K2",
|
|
65
|
+
mode: "twoWay",
|
|
66
|
+
steps: [runCycle(), localMutate(world => writeLocal(world, leafPath, "deep")), runCycle(), runCycle()]
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// The leaf file exists at the bottom of the chain…
|
|
70
|
+
expect(result.finalRemote[`/${leafPath}`]).toMatchObject({ type: "file", size: 4 })
|
|
71
|
+
|
|
72
|
+
// …and every intermediate directory along the way was created remotely.
|
|
73
|
+
let prefix = ""
|
|
74
|
+
|
|
75
|
+
for (const segment of segments) {
|
|
76
|
+
prefix += `/${segment}`
|
|
77
|
+
|
|
78
|
+
expect(result.finalRemote[prefix]).toMatchObject({ type: "directory" })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
82
|
+
expect(transferKinds(result.cycles[2]!.messages)).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("K3: renaming the top directory of a big subtree is ONE op (children collapsed)", async () => {
|
|
86
|
+
const dirCount = 4
|
|
87
|
+
const filesPerDir = 5
|
|
88
|
+
const initialLocal: Record<string, string> = {}
|
|
89
|
+
|
|
90
|
+
for (let directory = 0; directory < dirCount; directory++) {
|
|
91
|
+
for (let file = 0; file < filesPerDir; file++) {
|
|
92
|
+
initialLocal[`/local/big/dir-${directory}/file-${file}.txt`] = `content-${directory}-${file}`
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let childUUID: string | undefined
|
|
97
|
+
|
|
98
|
+
const result = await runScenario({
|
|
99
|
+
name: "K3",
|
|
100
|
+
mode: "twoWay",
|
|
101
|
+
initialLocal,
|
|
102
|
+
steps: [
|
|
103
|
+
runCycle(),
|
|
104
|
+
control(world => {
|
|
105
|
+
childUUID = world.cloud.controls.getByPath("/big/dir-0/file-0.txt")?.uuid
|
|
106
|
+
}),
|
|
107
|
+
localMutate(world => renameLocal(world, "big", "big2")),
|
|
108
|
+
runCycle(),
|
|
109
|
+
runCycle()
|
|
110
|
+
]
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
114
|
+
|
|
115
|
+
// Exactly one parent rename carries the whole subtree — no per-child rename, no re-upload.
|
|
116
|
+
expect(kinds.filter(kind => kind === "renameRemoteDirectory")).toHaveLength(1)
|
|
117
|
+
expect(kinds).not.toContain("renameRemoteFile")
|
|
118
|
+
expect(kinds).not.toContain("upload")
|
|
119
|
+
|
|
120
|
+
// All content now lives under /big2; the old root is gone.
|
|
121
|
+
expect(result.finalRemote["/big"]).toBeUndefined()
|
|
122
|
+
|
|
123
|
+
for (let directory = 0; directory < dirCount; directory++) {
|
|
124
|
+
for (let file = 0; file < filesPerDir; file++) {
|
|
125
|
+
expect(result.finalRemote[`/big2/dir-${directory}/file-${file}.txt`]).toMatchObject({ type: "file" })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The child kept its remote identity → it was carried by the parent rename, not re-created.
|
|
130
|
+
expect(childUUID).toBeDefined()
|
|
131
|
+
expect(result.world.cloud.controls.getByPath("/big2/dir-0/file-0.txt")?.uuid).toBe(childUUID)
|
|
132
|
+
|
|
133
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
134
|
+
expect(transferKinds(result.cycles[2]!.messages)).toEqual([])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("K4: a mixed bulk (adds + deletes + dir rename) in one cycle all takes effect and converges", async () => {
|
|
138
|
+
const result = await runScenario({
|
|
139
|
+
name: "K4",
|
|
140
|
+
mode: "twoWay",
|
|
141
|
+
initialLocal: {
|
|
142
|
+
"/local/docs/a.txt": "a",
|
|
143
|
+
"/local/docs/b.txt": "b",
|
|
144
|
+
"/local/docs/c.txt": "c",
|
|
145
|
+
"/local/legacy/x.txt": "x",
|
|
146
|
+
"/local/legacy/y.txt": "y",
|
|
147
|
+
"/local/legacy/z.txt": "z",
|
|
148
|
+
"/local/oldname/m.txt": "m",
|
|
149
|
+
"/local/oldname/n.txt": "n",
|
|
150
|
+
"/local/stay.txt": "stay"
|
|
151
|
+
},
|
|
152
|
+
steps: [
|
|
153
|
+
runCycle(),
|
|
154
|
+
localMutate(world => {
|
|
155
|
+
// Heterogeneous deltas in a single cycle: adds (one into a brand-new directory),
|
|
156
|
+
// deletes (across two directories), and a directory rename.
|
|
157
|
+
writeLocal(world, "docs/d.txt", "d")
|
|
158
|
+
writeLocal(world, "docs/e.txt", "e")
|
|
159
|
+
writeLocal(world, "fresh/new.txt", "new")
|
|
160
|
+
rmLocal(world, "docs/c.txt")
|
|
161
|
+
rmLocal(world, "legacy/x.txt")
|
|
162
|
+
rmLocal(world, "legacy/y.txt")
|
|
163
|
+
renameLocal(world, "oldname", "newname")
|
|
164
|
+
}),
|
|
165
|
+
runCycle(),
|
|
166
|
+
runCycle()
|
|
167
|
+
]
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const kinds = transferKinds(result.cycles[1]!.messages)
|
|
171
|
+
|
|
172
|
+
// All three delta kinds were processed together in the one op cycle…
|
|
173
|
+
expect(kinds).toContain("upload")
|
|
174
|
+
expect(kinds.filter(kind => kind === "deleteRemoteFile")).toHaveLength(3)
|
|
175
|
+
expect(kinds.filter(kind => kind === "renameRemoteDirectory")).toHaveLength(1)
|
|
176
|
+
// …and the renamed directory's children were collapsed (carried, not re-renamed).
|
|
177
|
+
expect(kinds).not.toContain("renameRemoteFile")
|
|
178
|
+
|
|
179
|
+
// Adds took effect (including the new directory).
|
|
180
|
+
expect(result.finalRemote["/docs/d.txt"]).toMatchObject({ type: "file" })
|
|
181
|
+
expect(result.finalRemote["/docs/e.txt"]).toMatchObject({ type: "file" })
|
|
182
|
+
expect(result.finalRemote["/fresh/new.txt"]).toMatchObject({ type: "file" })
|
|
183
|
+
|
|
184
|
+
// Deletes took effect.
|
|
185
|
+
expect(result.finalRemote["/docs/c.txt"]).toBeUndefined()
|
|
186
|
+
expect(result.finalRemote["/legacy/x.txt"]).toBeUndefined()
|
|
187
|
+
expect(result.finalRemote["/legacy/y.txt"]).toBeUndefined()
|
|
188
|
+
|
|
189
|
+
// The rename took effect; the old directory name is gone, the new one holds both files.
|
|
190
|
+
expect(result.finalRemote["/oldname"]).toBeUndefined()
|
|
191
|
+
expect(result.finalRemote["/newname/m.txt"]).toMatchObject({ type: "file" })
|
|
192
|
+
expect(result.finalRemote["/newname/n.txt"]).toMatchObject({ type: "file" })
|
|
193
|
+
|
|
194
|
+
// Untouched paths survived unchanged.
|
|
195
|
+
expect(result.finalRemote["/docs/a.txt"]).toMatchObject({ type: "file" })
|
|
196
|
+
expect(result.finalRemote["/legacy/z.txt"]).toMatchObject({ type: "file" })
|
|
197
|
+
expect(result.finalRemote["/stay.txt"]).toMatchObject({ type: "file" })
|
|
198
|
+
|
|
199
|
+
expect(result.finalLocal).toEqual(result.finalRemote)
|
|
200
|
+
expect(transferKinds(result.cycles[2]!.messages)).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate, type Step } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { allOps } from "../harness/snapshot"
|
|
5
|
+
import { writeLocalAt, rmLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category L — cross-mode property tests (behavioral spec §L, §2). Randomized but WELL-BEHAVED
|
|
9
|
+
* histories of local/remote add/modify/delete operations, asserting the §2 meta-invariants:
|
|
10
|
+
* convergence (the worlds end identical), idempotence (a fully settled cycle does no work), and — via
|
|
11
|
+
* convergence over content hashes — no silent data loss.
|
|
12
|
+
*
|
|
13
|
+
* "Well-behaved" deliberately keeps every history unambiguous so a failure means a REAL bug, not a
|
|
14
|
+
* known gap: every write gets a strictly-increasing whole-second mtime (so latest-mtime-wins never
|
|
15
|
+
* hits the equal-second tie — which now resolves to local-wins, §C6 — nor the same-second same-size
|
|
16
|
+
* content swap that has no detectable signal, §C11), each path is touched at most once per round (so
|
|
17
|
+
* there is no same-path delete-vs-modify ambiguity within a single cycle), content is never empty
|
|
18
|
+
* (BUG-002), and names are case-distinct (no §F11 collisions). Renames-under-concurrency are covered
|
|
19
|
+
* structurally by Category E and the BUG-004 note, so they are not generated here. Each case is
|
|
20
|
+
* seeded, so any failure reproduces deterministically from its seed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Deterministic PRNG (mulberry32) — same seed yields the same history. */
|
|
24
|
+
function mulberry32(seed: number): () => number {
|
|
25
|
+
let state = seed >>> 0
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
29
|
+
|
|
30
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
|
31
|
+
|
|
32
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
33
|
+
|
|
34
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FILE_POOL = ["f0.txt", "f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"]
|
|
39
|
+
const ROUNDS = 8
|
|
40
|
+
const MAX_MUTATIONS_PER_ROUND = 3
|
|
41
|
+
const SETTLE_CYCLES = 4
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a randomized scenario from a seed: a sequence of rounds (each a few one-sided add/modify/delete
|
|
45
|
+
* mutations on distinct paths, followed by a cycle), then several quiet settling cycles. Returns the
|
|
46
|
+
* step list plus a count of how many mutations were emitted (for a sanity check that work happened).
|
|
47
|
+
*/
|
|
48
|
+
function buildHistory(seed: number): { steps: Step[]; mutationCount: number } {
|
|
49
|
+
const random = mulberry32(seed)
|
|
50
|
+
const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
|
51
|
+
const steps: Step[] = []
|
|
52
|
+
const exists = new Set<string>()
|
|
53
|
+
let clock = BASE_TIME + 1000
|
|
54
|
+
let mutationCount = 0
|
|
55
|
+
|
|
56
|
+
// Seed the world with an initial file so deletes/modifies have something to act on early. Capture
|
|
57
|
+
// the mtime as a const — closures must NOT close over the mutable `clock` (it advances during
|
|
58
|
+
// generation, so a by-reference capture would stamp every write with the final, largest mtime).
|
|
59
|
+
clock += 1000
|
|
60
|
+
const seedMtime = clock
|
|
61
|
+
|
|
62
|
+
steps.push(localMutate(world => writeLocalAt(world, "f0.txt", "seed-0", seedMtime)))
|
|
63
|
+
exists.add("f0.txt")
|
|
64
|
+
|
|
65
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
66
|
+
const touched = new Set<string>()
|
|
67
|
+
const mutations = 1 + Math.floor(random() * MAX_MUTATIONS_PER_ROUND)
|
|
68
|
+
|
|
69
|
+
for (let m = 0; m < mutations; m++) {
|
|
70
|
+
const path = pick(FILE_POOL)
|
|
71
|
+
|
|
72
|
+
if (touched.has(path)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
touched.add(path)
|
|
77
|
+
|
|
78
|
+
const side = random() < 0.5 ? "local" : "remote"
|
|
79
|
+
// Only delete a path that currently exists; otherwise write (add or modify).
|
|
80
|
+
const doDelete = exists.has(path) && random() < 0.35
|
|
81
|
+
|
|
82
|
+
clock += 1000
|
|
83
|
+
const mtime = clock
|
|
84
|
+
const content = `s${seed}-r${round}-m${m}-${Math.floor(random() * 1_000_000)}`
|
|
85
|
+
|
|
86
|
+
if (doDelete) {
|
|
87
|
+
exists.delete(path)
|
|
88
|
+
|
|
89
|
+
if (side === "local") {
|
|
90
|
+
steps.push(localMutate(world => rmLocal(world, path)))
|
|
91
|
+
} else {
|
|
92
|
+
steps.push(remoteMutate(world => world.cloud.controls.trashPath(`/${path}`)))
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
const isAdd = !exists.has(path)
|
|
96
|
+
|
|
97
|
+
exists.add(path)
|
|
98
|
+
|
|
99
|
+
if (side === "local") {
|
|
100
|
+
steps.push(localMutate(world => writeLocalAt(world, path, content, mtime)))
|
|
101
|
+
} else if (isAdd) {
|
|
102
|
+
steps.push(remoteMutate(world => world.cloud.controls.addFile(`/${path}`, content, { mtimeMs: mtime })))
|
|
103
|
+
} else {
|
|
104
|
+
steps.push(remoteMutate(world => world.cloud.controls.updateFile(`/${path}`, content, { mtimeMs: mtime })))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
mutationCount++
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
steps.push(runCycle())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Quiet settling cycles: with no further changes the engine must reach a fixed point.
|
|
115
|
+
for (let cycle = 0; cycle < SETTLE_CYCLES; cycle++) {
|
|
116
|
+
steps.push(runCycle())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { steps, mutationCount }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("Category L — property tests (twoWay meta-invariants)", () => {
|
|
123
|
+
const ITERATIONS = 24
|
|
124
|
+
|
|
125
|
+
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
|
126
|
+
const seed = 0x1234 + iteration * 0x9e37
|
|
127
|
+
|
|
128
|
+
it(`L-prop seed=${seed}: a random well-behaved history converges and is idempotent`, async () => {
|
|
129
|
+
const { steps, mutationCount } = buildHistory(seed)
|
|
130
|
+
|
|
131
|
+
const result = await runScenario({ name: `L-${seed}`, mode: "twoWay", steps })
|
|
132
|
+
|
|
133
|
+
// Convergence (§2.3) — the normalized worlds are identical (content hashes included).
|
|
134
|
+
expect(result.finalLocal, `seed=${seed} did not converge`).toEqual(result.finalRemote)
|
|
135
|
+
|
|
136
|
+
// Idempotence (§2.2) — the final settled cycle performed no file transfers.
|
|
137
|
+
const lastCycle = result.cycles[result.cycles.length - 1]!
|
|
138
|
+
|
|
139
|
+
expect(allOps(lastCycle.messages), `seed=${seed} was not idempotent`).toEqual([])
|
|
140
|
+
|
|
141
|
+
// Sanity: the history actually exercised the engine.
|
|
142
|
+
expect(mutationCount).toBeGreaterThan(0)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
})
|