@effect-vfs/core 0.7.0 → 0.8.0
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/README.md +15 -238
- package/dist/LiveVolume.d.ts.map +1 -1
- package/dist/LiveVolume.js +9 -9
- package/dist/Search.d.ts +730 -0
- package/dist/Search.d.ts.map +1 -0
- package/dist/Search.js +568 -0
- package/dist/SnapshotDelta.d.ts +234 -0
- package/dist/SnapshotDelta.d.ts.map +1 -1
- package/dist/SnapshotDelta.js +107 -0
- package/dist/VirtualFileSystem.d.ts +53 -1
- package/dist/VirtualFileSystem.d.ts.map +1 -1
- package/dist/VirtualFileSystem.js +59 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/internal/canonicalBase64.js +3 -3
- package/dist/internal/fixture.js +3 -3
- package/dist/internal/glob.d.ts +66 -0
- package/dist/internal/glob.d.ts.map +1 -0
- package/dist/internal/glob.js +248 -0
- package/dist/internal/image.d.ts +7 -1
- package/dist/internal/image.d.ts.map +1 -1
- package/dist/internal/image.js +9 -7
- package/dist/internal/liveImage.d.ts +1 -1
- package/dist/internal/liveImage.d.ts.map +1 -1
- package/dist/internal/liveImage.js +2 -10
- package/dist/internal/overlayDiff.js +2 -2
- package/dist/internal/path.d.ts.map +1 -1
- package/dist/internal/path.js +3 -3
- package/dist/internal/resolution.d.ts +135 -0
- package/dist/internal/resolution.d.ts.map +1 -0
- package/dist/internal/resolution.js +277 -0
- package/dist/internal/search.d.ts +24 -0
- package/dist/internal/search.d.ts.map +1 -0
- package/dist/internal/search.js +78 -0
- package/dist/internal/searchContent.d.ts +77 -0
- package/dist/internal/searchContent.d.ts.map +1 -0
- package/dist/internal/searchContent.js +223 -0
- package/dist/internal/searchModel.d.ts +83 -0
- package/dist/internal/searchModel.d.ts.map +1 -0
- package/dist/internal/searchModel.js +23 -0
- package/dist/internal/snapshotDelta.d.ts +470 -1
- package/dist/internal/snapshotDelta.d.ts.map +1 -1
- package/dist/internal/snapshotDelta.js +60 -26
- package/dist/internal/snapshotMerge.d.ts +54 -0
- package/dist/internal/snapshotMerge.d.ts.map +1 -0
- package/dist/internal/snapshotMerge.js +959 -0
- package/dist/internal/tokenRegistry.d.ts +77 -0
- package/dist/internal/tokenRegistry.d.ts.map +1 -0
- package/dist/internal/tokenRegistry.js +128 -0
- package/dist/internal/tree.js +2 -2
- package/dist/internal/virtualFileSystem.d.ts +32 -17
- package/dist/internal/virtualFileSystem.d.ts.map +1 -1
- package/dist/internal/virtualFileSystem.js +640 -993
- package/dist/internal/volumeLimits.d.ts +53 -0
- package/dist/internal/volumeLimits.d.ts.map +1 -0
- package/dist/internal/volumeLimits.js +63 -0
- package/dist/internal/volumeState.d.ts +2 -0
- package/dist/internal/volumeState.d.ts.map +1 -1
- package/dist/internal/volumeState.js +9 -0
- package/package.json +10 -6
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Order from "effect/Order";
|
|
5
|
+
import * as Result from "effect/Result";
|
|
6
|
+
import { MergeSideChange } from "../SnapshotDelta.js";
|
|
7
|
+
import { getBytes, make as makeBytePath } from "./bytePath.js";
|
|
8
|
+
import { bytesOrder, sameBytes } from "./bytes.js";
|
|
9
|
+
import { CanonicalBase64 } from "./canonicalBase64.js";
|
|
10
|
+
import { argumentFailure, imageFailure } from "./errors.js";
|
|
11
|
+
import * as Image from "./image.js";
|
|
12
|
+
import * as Merkle from "./merkle.js";
|
|
13
|
+
import * as Delta from "./snapshotDelta.js";
|
|
14
|
+
import * as SnapshotDeltaModel from "./snapshotDeltaModel.js";
|
|
15
|
+
import { UNCHECKED } from "./tree.js";
|
|
16
|
+
import { getNode, payloadOf, storedMetadata } from "./volumeState.js";
|
|
17
|
+
const OPERATION = "mergeSnapshotDeltas";
|
|
18
|
+
const SIDES = ["ours", "theirs"];
|
|
19
|
+
const other = (side) => (side === "ours" ? "theirs" : "ours");
|
|
20
|
+
// Path keys are the hex of the raw path bytes, so their lexicographic order is the byte order of the paths and every
|
|
21
|
+
// path beneath a directory shares the directory's key followed by the hex of "/".
|
|
22
|
+
const SLASH_HEX = "2f";
|
|
23
|
+
const ROOT_KEY = SLASH_HEX;
|
|
24
|
+
const AFTER_SLASH_HEX = "2g";
|
|
25
|
+
const METADATA_FIELDS = ["mode", "uid", "gid"];
|
|
26
|
+
const TIME_FIELDS = ["atimeNs", "mtimeNs", "ctimeNs", "birthtimeNs"];
|
|
27
|
+
const isTimeField = (field) => Delta.timestampFields.has(field);
|
|
28
|
+
const resolve = (node) => ({
|
|
29
|
+
kind: node.kind,
|
|
30
|
+
metadata: storedMetadata(node.metadata),
|
|
31
|
+
payload: payloadOf(node)
|
|
32
|
+
});
|
|
33
|
+
const sameMetadata = (left, right) => METADATA_FIELDS.every((field) => left.metadata[field] === right.metadata[field]);
|
|
34
|
+
// Equal apart from timestamps, which never dispute anything.
|
|
35
|
+
const sameNode = (left, right) => left.kind === right.kind && sameMetadata(left, right) && sameBytes(left.payload, right.payload);
|
|
36
|
+
const maxTime = (left, right) => (left > right ? left : right);
|
|
37
|
+
const Decision = Data.taggedEnum();
|
|
38
|
+
const { Unchanged, Added, Removed, Updated } = MergeSideChange.cases;
|
|
39
|
+
const sideChange = (change) => {
|
|
40
|
+
if (change === undefined)
|
|
41
|
+
return Unchanged.make({}, UNCHECKED);
|
|
42
|
+
return Delta.Change.match(change, {
|
|
43
|
+
Added: ({ kind }) => Added.make({ kind }, UNCHECKED),
|
|
44
|
+
Removed: ({ kind }) => Removed.make({ kind }, UNCHECKED),
|
|
45
|
+
Updated: ({ beforeKind, afterKind, differences }) => {
|
|
46
|
+
const meaningful = differences.filter((field) => !isTimeField(field));
|
|
47
|
+
return meaningful.length === 0
|
|
48
|
+
? Unchanged.make({}, UNCHECKED)
|
|
49
|
+
: Updated.make({ beforeKind, afterKind, differences: meaningful }, UNCHECKED);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const isTimestampsOnly = (change) => Delta.Change.guards.Updated(change) && change.differences.every(isTimeField);
|
|
54
|
+
const hasKindChange = (change) => Delta.Change.guards.Updated(change) && change.differences.includes("kind");
|
|
55
|
+
const bisect = (sorted, key) => {
|
|
56
|
+
let low = 0;
|
|
57
|
+
let high = sorted.length;
|
|
58
|
+
while (low < high) {
|
|
59
|
+
const middle = (low + high) >>> 1;
|
|
60
|
+
if (sorted[middle] < key)
|
|
61
|
+
low = middle + 1;
|
|
62
|
+
else
|
|
63
|
+
high = middle;
|
|
64
|
+
}
|
|
65
|
+
return low;
|
|
66
|
+
};
|
|
67
|
+
// The keys strictly beneath a directory, as a contiguous slice of a sorted key list.
|
|
68
|
+
const beneath = (sorted, root) => {
|
|
69
|
+
if (root === ROOT_KEY)
|
|
70
|
+
return sorted.filter((key) => key !== ROOT_KEY);
|
|
71
|
+
const prefix = root + SLASH_HEX;
|
|
72
|
+
return sorted.slice(bisect(sorted, prefix), bisect(sorted, root + AFTER_SLASH_HEX));
|
|
73
|
+
};
|
|
74
|
+
const isBeneath = (pathKey, root) => root === ROOT_KEY ? pathKey !== ROOT_KEY : pathKey.startsWith(root + SLASH_HEX);
|
|
75
|
+
const lookupNode = (value, path) => Delta.lookup((ino) => getNode(value, ino), Delta.valueEntries, path)?.node;
|
|
76
|
+
const indexSide = (side, document, state, base) => {
|
|
77
|
+
const changes = new Map();
|
|
78
|
+
const paths = new Map();
|
|
79
|
+
const inoAt = new Map();
|
|
80
|
+
const members = new Map();
|
|
81
|
+
const eliminated = [];
|
|
82
|
+
for (const change of document.changes) {
|
|
83
|
+
const path = CanonicalBase64.toBytes(change.path);
|
|
84
|
+
const pathKey = Delta.key(path);
|
|
85
|
+
changes.set(pathKey, change);
|
|
86
|
+
paths.set(pathKey, path);
|
|
87
|
+
const before = lookupNode(base, path);
|
|
88
|
+
const after = Delta.Change.guards.Removed(change) ? undefined : lookupNode(state, path);
|
|
89
|
+
if (before?.kind === "directory" && after?.kind !== "directory")
|
|
90
|
+
eliminated.push(pathKey);
|
|
91
|
+
if (after === undefined || after.kind === "directory")
|
|
92
|
+
continue;
|
|
93
|
+
inoAt.set(pathKey, after.ino);
|
|
94
|
+
const group = members.get(after.ino) ?? { names: [], node: after };
|
|
95
|
+
group.names.push(pathKey);
|
|
96
|
+
members.set(after.ino, group);
|
|
97
|
+
}
|
|
98
|
+
const sorted = Arr.sort(paths.keys(), Order.String);
|
|
99
|
+
const classes = new Map();
|
|
100
|
+
for (const [ino, group] of members) {
|
|
101
|
+
classes.set(ino, { ino, names: Arr.sort(group.names, Order.String), node: resolve(group.node) });
|
|
102
|
+
}
|
|
103
|
+
return { side, state, changes, paths, sorted, inoAt, classes, eliminated: Arr.sort(eliminated, Order.String) };
|
|
104
|
+
};
|
|
105
|
+
const indexBase = (value, identity, inputs) => {
|
|
106
|
+
const nodes = new Map();
|
|
107
|
+
const paths = new Map();
|
|
108
|
+
for (const input of inputs)
|
|
109
|
+
for (const [pathKey, path] of input.paths)
|
|
110
|
+
paths.set(pathKey, path);
|
|
111
|
+
for (const group of identity.groups.values())
|
|
112
|
+
for (const path of group)
|
|
113
|
+
paths.set(Delta.key(path), path);
|
|
114
|
+
const nodeAt = (pathKey) => {
|
|
115
|
+
if (nodes.has(pathKey))
|
|
116
|
+
return nodes.get(pathKey);
|
|
117
|
+
const path = paths.get(pathKey);
|
|
118
|
+
const node = path === undefined ? undefined : lookupNode(value, path);
|
|
119
|
+
nodes.set(pathKey, node);
|
|
120
|
+
return node;
|
|
121
|
+
};
|
|
122
|
+
const namesOf = new Map();
|
|
123
|
+
for (const [ino, group] of identity.groups)
|
|
124
|
+
namesOf.set(ino, Arr.sort(group.map(Delta.key), Order.String));
|
|
125
|
+
for (const input of inputs) {
|
|
126
|
+
for (const pathKey of input.sorted) {
|
|
127
|
+
const node = nodeAt(pathKey);
|
|
128
|
+
if (node !== undefined && node.kind !== "directory" && !namesOf.has(node.ino))
|
|
129
|
+
namesOf.set(node.ino, [pathKey]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { value, identity, side: Delta.side(value, identity), paths, nodeAt, namesOf };
|
|
133
|
+
};
|
|
134
|
+
// Combine what two sides made of one base node, field by field. A field both sides set to different values is a
|
|
135
|
+
// clash; timestamps never clash and take the later value. A side that left the node alone passes the base.
|
|
136
|
+
const combine = (before, ours, theirs) => {
|
|
137
|
+
const clashes = [];
|
|
138
|
+
const metadata = { ...before.metadata };
|
|
139
|
+
for (const field of METADATA_FIELDS) {
|
|
140
|
+
const inO = ours.metadata[field] !== before.metadata[field];
|
|
141
|
+
const inT = theirs.metadata[field] !== before.metadata[field];
|
|
142
|
+
if (inO && inT && ours.metadata[field] !== theirs.metadata[field])
|
|
143
|
+
clashes.push(field);
|
|
144
|
+
metadata[field] = inO ? ours.metadata[field] : inT ? theirs.metadata[field] : before.metadata[field];
|
|
145
|
+
}
|
|
146
|
+
for (const field of TIME_FIELDS) {
|
|
147
|
+
const inO = ours.metadata[field] !== before.metadata[field];
|
|
148
|
+
const inT = theirs.metadata[field] !== before.metadata[field];
|
|
149
|
+
metadata[field] = inO && inT
|
|
150
|
+
? maxTime(ours.metadata[field], theirs.metadata[field])
|
|
151
|
+
: inO
|
|
152
|
+
? ours.metadata[field]
|
|
153
|
+
: inT
|
|
154
|
+
? theirs.metadata[field]
|
|
155
|
+
: before.metadata[field];
|
|
156
|
+
}
|
|
157
|
+
const inO = !sameBytes(ours.payload, before.payload);
|
|
158
|
+
const inT = !sameBytes(theirs.payload, before.payload);
|
|
159
|
+
if (inO && inT && !sameBytes(ours.payload, theirs.payload))
|
|
160
|
+
clashes.push(Delta.payloadDifference(before.kind));
|
|
161
|
+
const payload = inO ? ours.payload : inT ? theirs.payload : before.payload;
|
|
162
|
+
return { node: { kind: before.kind, metadata, payload }, clashes };
|
|
163
|
+
};
|
|
164
|
+
// Two nodes both sides wrote afresh agree when everything but their timestamps matches.
|
|
165
|
+
const combineFresh = (ours, theirs) => {
|
|
166
|
+
if (!sameNode(ours, theirs))
|
|
167
|
+
return undefined;
|
|
168
|
+
const metadata = { ...ours.metadata };
|
|
169
|
+
for (const field of TIME_FIELDS)
|
|
170
|
+
metadata[field] = maxTime(ours.metadata[field], theirs.metadata[field]);
|
|
171
|
+
return { ...ours, metadata };
|
|
172
|
+
};
|
|
173
|
+
const View = Data.taggedEnum();
|
|
174
|
+
const sameNames = (left, right) => left.length === right.length && left.every((name, index) => name === right[index]);
|
|
175
|
+
const isSubset = (left, right) => left.every((name) => right.has(name));
|
|
176
|
+
const mergeNames = (base, ours, theirs) => {
|
|
177
|
+
if (sameNames(ours, base))
|
|
178
|
+
return Result.succeed(theirs);
|
|
179
|
+
if (sameNames(theirs, base) || sameNames(ours, theirs))
|
|
180
|
+
return Result.succeed(ours);
|
|
181
|
+
const baseSet = new Set(base);
|
|
182
|
+
// Both sides only unlinked names; the removals add up.
|
|
183
|
+
return isSubset(ours, baseSet) && isSubset(theirs, baseSet)
|
|
184
|
+
? Result.succeed(ours.filter((name) => theirs.includes(name)))
|
|
185
|
+
: Result.fail("HardLinkGroupDiverged");
|
|
186
|
+
};
|
|
187
|
+
// Union-find over base inodes that any side's class ties together.
|
|
188
|
+
const components = (base, inputs, touched) => {
|
|
189
|
+
const parent = new Map();
|
|
190
|
+
const find = (ino) => {
|
|
191
|
+
let root = ino;
|
|
192
|
+
while (parent.get(root) !== undefined && parent.get(root) !== root)
|
|
193
|
+
root = parent.get(root);
|
|
194
|
+
return root;
|
|
195
|
+
};
|
|
196
|
+
const union = (left, right) => {
|
|
197
|
+
const a = find(left);
|
|
198
|
+
const b = find(right);
|
|
199
|
+
if (a !== b)
|
|
200
|
+
parent.set(a, b);
|
|
201
|
+
};
|
|
202
|
+
for (const ino of touched)
|
|
203
|
+
parent.set(ino, ino);
|
|
204
|
+
// Every name a class holds ties that class's base nodes together, and a name two sides give to different nodes
|
|
205
|
+
// ties those nodes too, so a conflict on any of them keeps all of them at base.
|
|
206
|
+
const byName = new Map();
|
|
207
|
+
for (const input of inputs) {
|
|
208
|
+
for (const cls of input.classes.values()) {
|
|
209
|
+
const inodes = cls.names.flatMap((name) => {
|
|
210
|
+
const before = base.nodeAt(name);
|
|
211
|
+
return before === undefined || before.kind === "directory" ? [] : [before.ino];
|
|
212
|
+
});
|
|
213
|
+
if (!Arr.isArrayNonEmpty(inodes))
|
|
214
|
+
continue;
|
|
215
|
+
const first = inodes[0];
|
|
216
|
+
for (const ino of inodes)
|
|
217
|
+
union(first, ino);
|
|
218
|
+
for (const name of cls.names) {
|
|
219
|
+
const known = byName.get(name);
|
|
220
|
+
if (known === undefined)
|
|
221
|
+
byName.set(name, first);
|
|
222
|
+
else
|
|
223
|
+
union(known, first);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return find;
|
|
228
|
+
};
|
|
229
|
+
const classify = (base, ours, theirs) => {
|
|
230
|
+
const inputs = { ours, theirs };
|
|
231
|
+
const paths = new Map([...ours.paths, ...theirs.paths]);
|
|
232
|
+
const union = Arr.sort(paths.keys(), Order.String);
|
|
233
|
+
const decisions = new Map();
|
|
234
|
+
const scopes = [];
|
|
235
|
+
const changeAt = (side, pathKey) => inputs[side].changes.get(pathKey);
|
|
236
|
+
const open = (affected, records) => {
|
|
237
|
+
scopes.push({
|
|
238
|
+
paths: new Set(affected),
|
|
239
|
+
records: [...records],
|
|
240
|
+
recorded: new Set(records.map((record) => record.pathKey))
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
// Base inodes either side touched, and how each side left them.
|
|
244
|
+
const touched = new Set();
|
|
245
|
+
for (const input of [ours, theirs]) {
|
|
246
|
+
for (const pathKey of input.sorted) {
|
|
247
|
+
const before = base.nodeAt(pathKey);
|
|
248
|
+
if (before !== undefined && before.kind !== "directory")
|
|
249
|
+
touched.add(before.ino);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Which base node a side's class is: the node whose name still carries its bytes, else the only node whose kept
|
|
253
|
+
// names it holds. A class holding kept names of several nodes and none of their bytes is contested.
|
|
254
|
+
const CONTESTED = Symbol("contested");
|
|
255
|
+
const ownerOf = new Map();
|
|
256
|
+
for (const input of [ours, theirs]) {
|
|
257
|
+
const owners = new Map();
|
|
258
|
+
const kepts = new Map();
|
|
259
|
+
for (const cls of input.classes.values()) {
|
|
260
|
+
const keptOf = new Set();
|
|
261
|
+
const inheriting = new Set();
|
|
262
|
+
for (const name of cls.names) {
|
|
263
|
+
const before = base.nodeAt(name);
|
|
264
|
+
const change = input.changes.get(name);
|
|
265
|
+
if (before === undefined || before.kind === "directory" || change === undefined || hasKindChange(change)) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
keptOf.add(before.ino);
|
|
269
|
+
if (sameBytes(payloadOf(before), cls.node.payload))
|
|
270
|
+
inheriting.add(before.ino);
|
|
271
|
+
}
|
|
272
|
+
owners.set(cls.ino, inheriting.size === 1
|
|
273
|
+
? [...inheriting][0]
|
|
274
|
+
: keptOf.size === 1
|
|
275
|
+
? [...keptOf][0]
|
|
276
|
+
: keptOf.size === 0
|
|
277
|
+
? undefined
|
|
278
|
+
: CONTESTED);
|
|
279
|
+
kepts.set(cls.ino, keptOf);
|
|
280
|
+
}
|
|
281
|
+
// A contested class whose other candidate nodes are already owned by sibling classes belongs to the one left.
|
|
282
|
+
const owned = new Set(owners.values());
|
|
283
|
+
owned.delete(CONTESTED);
|
|
284
|
+
owned.delete(undefined);
|
|
285
|
+
for (const [ino, owner] of owners) {
|
|
286
|
+
if (owner !== CONTESTED)
|
|
287
|
+
continue;
|
|
288
|
+
const free = [...kepts.get(ino)].filter((candidate) => !owned.has(candidate));
|
|
289
|
+
if (free.length === 1)
|
|
290
|
+
owners.set(ino, free[0]);
|
|
291
|
+
}
|
|
292
|
+
ownerOf.set(input, owners);
|
|
293
|
+
}
|
|
294
|
+
const view = (input, ino) => {
|
|
295
|
+
const names = base.namesOf.get(ino);
|
|
296
|
+
const untouched = names.filter((name) => !input.changes.has(name));
|
|
297
|
+
if (untouched.length === names.length)
|
|
298
|
+
return View.Untouched();
|
|
299
|
+
const kept = names.filter((name) => {
|
|
300
|
+
const change = input.changes.get(name);
|
|
301
|
+
return change !== undefined && !Delta.Change.guards.Removed(change) && !hasKindChange(change);
|
|
302
|
+
});
|
|
303
|
+
// A side that touched this node lists every name of it, so names it left alone still hold the base node.
|
|
304
|
+
if (untouched.length > 0) {
|
|
305
|
+
if (kept.length > 0)
|
|
306
|
+
return View.Ambiguous();
|
|
307
|
+
const node = lookupNode(input.state, base.paths.get(untouched[0]));
|
|
308
|
+
return View.Kept({ class: { ino: node.ino, names: untouched, node: resolve(node) } });
|
|
309
|
+
}
|
|
310
|
+
if (kept.length === 0)
|
|
311
|
+
return View.Removed();
|
|
312
|
+
const candidates = new Map();
|
|
313
|
+
let contested = false;
|
|
314
|
+
for (const name of kept) {
|
|
315
|
+
const cls = input.classes.get(input.inoAt.get(name));
|
|
316
|
+
const owner = ownerOf.get(input).get(cls.ino);
|
|
317
|
+
if (owner === CONTESTED)
|
|
318
|
+
contested = true;
|
|
319
|
+
else if (owner === ino)
|
|
320
|
+
candidates.set(cls.ino, cls);
|
|
321
|
+
}
|
|
322
|
+
if (contested)
|
|
323
|
+
return View.Ambiguous();
|
|
324
|
+
if (candidates.size > 1) {
|
|
325
|
+
// Several classes carry this node's names; the one still holding its bytes is the node, the rest are replacements.
|
|
326
|
+
const inheriting = [...candidates.values()].filter((cls) => cls.names.some((name) => kept.includes(name) && sameBytes(payloadOf(base.nodeAt(name)), cls.node.payload)));
|
|
327
|
+
return inheriting.length === 1 ? View.Kept({ class: inheriting[0] }) : View.Ambiguous();
|
|
328
|
+
}
|
|
329
|
+
return candidates.size === 1 ? View.Kept({ class: [...candidates.values()][0] }) : View.Removed();
|
|
330
|
+
};
|
|
331
|
+
// Two sides that left a node in exactly the same classes agree, however those classes relate to the node.
|
|
332
|
+
const identicalClasses = (ino) => base.namesOf.get(ino).every((name) => {
|
|
333
|
+
const o = ours.inoAt.get(name);
|
|
334
|
+
const t = theirs.inoAt.get(name);
|
|
335
|
+
if (o === undefined || t === undefined)
|
|
336
|
+
return o === t && ours.changes.has(name) === theirs.changes.has(name);
|
|
337
|
+
const cO = ours.classes.get(o);
|
|
338
|
+
const cT = theirs.classes.get(t);
|
|
339
|
+
return sameNames(cO.names, cT.names) && sameNode(cO.node, cT.node);
|
|
340
|
+
});
|
|
341
|
+
const find = components(base, [ours, theirs], touched);
|
|
342
|
+
const componentPaths = new Map();
|
|
343
|
+
const componentOf = new Map();
|
|
344
|
+
const place = (root, pathKey) => {
|
|
345
|
+
const set = componentPaths.get(root) ?? new Set();
|
|
346
|
+
set.add(pathKey);
|
|
347
|
+
componentPaths.set(root, set);
|
|
348
|
+
componentOf.set(pathKey, root);
|
|
349
|
+
};
|
|
350
|
+
const classesAt = new Map();
|
|
351
|
+
for (const input of [ours, theirs]) {
|
|
352
|
+
for (const cls of input.classes.values()) {
|
|
353
|
+
const seen = new Set();
|
|
354
|
+
for (const name of cls.names) {
|
|
355
|
+
const before = base.nodeAt(name);
|
|
356
|
+
if (before === undefined || before.kind === "directory" || seen.has(before.ino))
|
|
357
|
+
continue;
|
|
358
|
+
seen.add(before.ino);
|
|
359
|
+
const list = classesAt.get(before.ino) ?? [];
|
|
360
|
+
list.push(cls);
|
|
361
|
+
classesAt.set(before.ino, list);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const ino of touched) {
|
|
366
|
+
const root = find(ino);
|
|
367
|
+
for (const name of base.namesOf.get(ino))
|
|
368
|
+
place(root, name);
|
|
369
|
+
for (const cls of classesAt.get(ino) ?? [])
|
|
370
|
+
for (const name of cls.names)
|
|
371
|
+
place(root, name);
|
|
372
|
+
}
|
|
373
|
+
const eliminatedAbove = (side, pathKey) => inputs[side].eliminated.find((root) => root !== pathKey && isBeneath(pathKey, root));
|
|
374
|
+
// How a side that no longer holds a name gave it up: with the directory above it, by changing its kind, or alone.
|
|
375
|
+
const removalRecord = (removing, pathKey, fallback) => {
|
|
376
|
+
const change = inputs[removing].changes.get(pathKey);
|
|
377
|
+
if (change !== undefined && hasKindChange(change))
|
|
378
|
+
return { pathKey, reason: "KindDiverged" };
|
|
379
|
+
const root = eliminatedAbove(removing, pathKey);
|
|
380
|
+
if (root !== undefined && (change === undefined || Delta.Change.guards.Removed(change))) {
|
|
381
|
+
return { pathKey, reason: "ParentRemoved", sides: { [removing]: inputs[removing].changes.get(root) } };
|
|
382
|
+
}
|
|
383
|
+
return { pathKey, reason: fallback };
|
|
384
|
+
};
|
|
385
|
+
// Which classes the inode decisions claimed; the rest are fresh nodes a side wrote.
|
|
386
|
+
const claimed = { ours: new Set(), theirs: new Set() };
|
|
387
|
+
const inodeRecords = (names, reason) => names.map((pathKey) => ({ pathKey, reason }));
|
|
388
|
+
// Base names a node no longer holds; they are removed unless another node or a fresh node takes the name.
|
|
389
|
+
const vacated = new Set();
|
|
390
|
+
for (const ino of touched) {
|
|
391
|
+
const names = base.namesOf.get(ino);
|
|
392
|
+
const before = resolve(base.nodeAt(names[0]));
|
|
393
|
+
const vO = view(ours, ino);
|
|
394
|
+
const vT = view(theirs, ino);
|
|
395
|
+
if (View.$is("Kept")(vO))
|
|
396
|
+
claimed.ours.add(vO.class.ino);
|
|
397
|
+
if (View.$is("Kept")(vT))
|
|
398
|
+
claimed.theirs.add(vT.class.ino);
|
|
399
|
+
if (View.$is("Untouched")(vO) && View.$is("Untouched")(vT))
|
|
400
|
+
continue;
|
|
401
|
+
const component = componentPaths.get(find(ino));
|
|
402
|
+
const lineage = `m:${ino}`;
|
|
403
|
+
// A name two nodes both end up holding was re-pointed differently by the two sides.
|
|
404
|
+
const settle = (finalNames, node) => {
|
|
405
|
+
const contested = finalNames.filter((name) => {
|
|
406
|
+
const existing = decisions.get(name);
|
|
407
|
+
return existing !== undefined && Decision.$is("Node")(existing) && existing.lineage !== lineage;
|
|
408
|
+
});
|
|
409
|
+
if (contested.length > 0) {
|
|
410
|
+
open(component, contested.map((name) => {
|
|
411
|
+
const reason = base.nodeAt(name) === undefined ? "BothAddedDifferent" : "BothChanged";
|
|
412
|
+
return { pathKey: name, reason };
|
|
413
|
+
}));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const name of finalNames)
|
|
417
|
+
decisions.set(name, Decision.Node({ lineage, node }));
|
|
418
|
+
for (const name of names)
|
|
419
|
+
if (!finalNames.includes(name))
|
|
420
|
+
vacated.add(name);
|
|
421
|
+
};
|
|
422
|
+
if (View.$is("Ambiguous")(vO) || View.$is("Ambiguous")(vT)) {
|
|
423
|
+
const unopposed = View.$is("Untouched")(vO) || View.$is("Untouched")(vT) || identicalClasses(ino);
|
|
424
|
+
if (!unopposed) {
|
|
425
|
+
open(component, inodeRecords(Arr.sort(component, Order.String), "HardLinkGroupDiverged"));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
// A side split the node in ways no class can claim and nothing opposes it: its classes stand as fresh nodes.
|
|
429
|
+
for (const name of names)
|
|
430
|
+
vacated.add(name);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const sideOf = (v) => View.$is("Kept")(v) ? v.class : undefined;
|
|
434
|
+
const cO = sideOf(vO);
|
|
435
|
+
const cT = sideOf(vT);
|
|
436
|
+
if (cO === undefined && cT === undefined) {
|
|
437
|
+
settle([], before);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (cO === undefined || cT === undefined) {
|
|
441
|
+
const kept = (cO ?? cT);
|
|
442
|
+
const removing = cO === undefined ? "ours" : "theirs";
|
|
443
|
+
const removedView = removing === "ours" ? vO : vT;
|
|
444
|
+
if (View.$is("Untouched")(removedView)) {
|
|
445
|
+
settle(kept.names, kept.node);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const added = kept.names.filter((name) => !names.includes(name));
|
|
449
|
+
const nodeChanged = !sameNode(before, kept.node);
|
|
450
|
+
if (added.length > 0) {
|
|
451
|
+
open(component, inodeRecords(added, "ChangedRemoved"));
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (nodeChanged) {
|
|
455
|
+
open(component, kept.names.map((name) => removalRecord(removing, name, "ChangedRemoved")));
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// The other side only unlinked some names; removing them all agrees with that.
|
|
459
|
+
settle([], before);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const mergedNames = mergeNames(names, cO.names, cT.names);
|
|
463
|
+
if (Result.isFailure(mergedNames)) {
|
|
464
|
+
open(component, inodeRecords(Arr.sort(component, Order.String), mergedNames.failure));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const finalNames = mergedNames.success;
|
|
468
|
+
const { clashes, node } = combine(before, cO.node, cT.node);
|
|
469
|
+
if (clashes.length > 0) {
|
|
470
|
+
open(component, inodeRecords(finalNames, "BothChanged"));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
settle(finalNames, node);
|
|
474
|
+
}
|
|
475
|
+
// Fresh nodes: classes no inode decision claimed, decided name by name against the other side.
|
|
476
|
+
const fresh = (input) => [...input.classes.values()].filter((cls) => !claimed[input.side].has(cls.ino));
|
|
477
|
+
const freshOurs = fresh(ours);
|
|
478
|
+
const freshTheirs = fresh(theirs);
|
|
479
|
+
const freshClassAt = new Map([["ours", new Map()], ["theirs", new Map()]]);
|
|
480
|
+
for (const cls of freshOurs)
|
|
481
|
+
for (const name of cls.names)
|
|
482
|
+
freshClassAt.get("ours").set(name, cls);
|
|
483
|
+
for (const cls of freshTheirs)
|
|
484
|
+
for (const name of cls.names)
|
|
485
|
+
freshClassAt.get("theirs").set(name, cls);
|
|
486
|
+
const isDirectoryPath = (pathKey) => base.nodeAt(pathKey)?.kind === "directory" ||
|
|
487
|
+
SIDES.some((side) => {
|
|
488
|
+
const change = inputs[side].changes.get(pathKey);
|
|
489
|
+
return change !== undefined && !Delta.Change.guards.Removed(change) && Delta.kindOf(change) === "directory";
|
|
490
|
+
});
|
|
491
|
+
const freshNames = new Set([...freshClassAt.get("ours").keys(), ...freshClassAt.get("theirs").keys()]);
|
|
492
|
+
const freshDecided = new Set();
|
|
493
|
+
// Names both sides wrote afresh first, so a shared node gathers every name either side gave it.
|
|
494
|
+
for (const name of freshNames) {
|
|
495
|
+
const cO = freshClassAt.get("ours").get(name);
|
|
496
|
+
const cT = freshClassAt.get("theirs").get(name);
|
|
497
|
+
if (cO === undefined || cT === undefined || freshDecided.has(name) || isDirectoryPath(name))
|
|
498
|
+
continue;
|
|
499
|
+
const shared = new Set([...cO.names, ...cT.names]);
|
|
500
|
+
for (const member of shared)
|
|
501
|
+
freshDecided.add(member);
|
|
502
|
+
const combined = combineFresh(cO.node, cT.node);
|
|
503
|
+
if (combined === undefined) {
|
|
504
|
+
const reason = base.nodeAt(name) === undefined ? "BothAddedDifferent" : "KindDiverged";
|
|
505
|
+
open(shared, [{ pathKey: name, reason }]);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const lineage = `m:${Arr.sort(shared, Order.String)[0]}`;
|
|
509
|
+
for (const member of shared)
|
|
510
|
+
decisions.set(member, Decision.Node({ lineage, node: combined }));
|
|
511
|
+
}
|
|
512
|
+
for (const [side, classAt] of freshClassAt) {
|
|
513
|
+
for (const [name, cls] of classAt) {
|
|
514
|
+
if (freshDecided.has(name) || isDirectoryPath(name))
|
|
515
|
+
continue;
|
|
516
|
+
freshDecided.add(name);
|
|
517
|
+
const existing = decisions.get(name);
|
|
518
|
+
// A fresh node at a name the other side's node still holds: two different nodes for one name.
|
|
519
|
+
if (existing !== undefined && Decision.$is("Node")(existing)) {
|
|
520
|
+
const root = componentOf.get(name);
|
|
521
|
+
const affected = new Set([...cls.names, ...(root === undefined ? [] : componentPaths.get(root))]);
|
|
522
|
+
const before = base.nodeAt(name);
|
|
523
|
+
const reason = before === undefined
|
|
524
|
+
? "BothAddedDifferent"
|
|
525
|
+
: before.kind !== cls.node.kind
|
|
526
|
+
? "KindDiverged"
|
|
527
|
+
: "BothChanged";
|
|
528
|
+
open(affected, [{ pathKey: name, reason }]);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
decisions.set(name, Decision.Node({ lineage: `${side}:${cls.ino}`, node: cls.node }));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
for (const name of vacated)
|
|
535
|
+
if (!decisions.has(name))
|
|
536
|
+
decisions.set(name, Decision.Removed());
|
|
537
|
+
// Directories, top down. A change beneath a removed directory disputes the removal unless it is a removal too, a
|
|
538
|
+
// timestamp-only touch, or a name the node decisions already gave up.
|
|
539
|
+
const survivesBeneath = (side, root) => beneath(inputs[side].sorted, root).filter((candidate) => {
|
|
540
|
+
const change = inputs[side].changes.get(candidate);
|
|
541
|
+
const decision = decisions.get(candidate);
|
|
542
|
+
return !Delta.Change.guards.Removed(change) && !isTimestampsOnly(change) &&
|
|
543
|
+
!(decision !== undefined && Decision.$is("Removed")(decision));
|
|
544
|
+
});
|
|
545
|
+
// A node decision from the node pass stands; a vacated name is free for what the directory pass finds there.
|
|
546
|
+
const holdsNode = (pathKey) => {
|
|
547
|
+
const existing = decisions.get(pathKey);
|
|
548
|
+
return existing !== undefined && Decision.$is("Node")(existing);
|
|
549
|
+
};
|
|
550
|
+
const sideLineage = (side, pathKey) => {
|
|
551
|
+
const ino = inputs[side].inoAt.get(pathKey);
|
|
552
|
+
return ino === undefined ? `${side}:dir:${pathKey}` : `${side}:${ino}`;
|
|
553
|
+
};
|
|
554
|
+
const eliminate = (side, pathKey, change) => {
|
|
555
|
+
const disputed = survivesBeneath(other(side), pathKey);
|
|
556
|
+
if (disputed.length > 0) {
|
|
557
|
+
const affected = [pathKey, ...beneath(union, pathKey)];
|
|
558
|
+
open(affected, disputed.map((candidate) => ({
|
|
559
|
+
pathKey: candidate,
|
|
560
|
+
reason: "ParentRemoved",
|
|
561
|
+
sides: { [side]: change }
|
|
562
|
+
})));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (holdsNode(pathKey))
|
|
566
|
+
return;
|
|
567
|
+
const after = lookupNode(inputs[side].state, paths.get(pathKey));
|
|
568
|
+
decisions.set(pathKey, after === undefined
|
|
569
|
+
? Decision.Removed()
|
|
570
|
+
: Decision.Node({ lineage: sideLineage(side, pathKey), node: resolve(after) }));
|
|
571
|
+
};
|
|
572
|
+
const directoryNode = (side, pathKey) => {
|
|
573
|
+
const node = lookupNode(inputs[side].state, paths.get(pathKey));
|
|
574
|
+
return node === undefined ? undefined : resolve(node);
|
|
575
|
+
};
|
|
576
|
+
for (const pathKey of union) {
|
|
577
|
+
if (!isDirectoryPath(pathKey))
|
|
578
|
+
continue;
|
|
579
|
+
const o = ours.changes.get(pathKey);
|
|
580
|
+
const t = theirs.changes.get(pathKey);
|
|
581
|
+
const before = base.nodeAt(pathKey);
|
|
582
|
+
const oneSide = (side, change) => {
|
|
583
|
+
if (before?.kind === "directory" && (Delta.Change.guards.Removed(change) || hasKindChange(change))) {
|
|
584
|
+
eliminate(side, pathKey, change);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (holdsNode(pathKey))
|
|
588
|
+
return;
|
|
589
|
+
const node = directoryNode(side, pathKey);
|
|
590
|
+
decisions.set(pathKey, Decision.Node({ lineage: sideLineage(side, pathKey), node }));
|
|
591
|
+
};
|
|
592
|
+
if (o === undefined || t === undefined) {
|
|
593
|
+
const side = o === undefined ? "theirs" : "ours";
|
|
594
|
+
oneSide(side, (o ?? t));
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const oReal = !isTimestampsOnly(o);
|
|
598
|
+
const tReal = !isTimestampsOnly(t);
|
|
599
|
+
if (Delta.Change.guards.Removed(o) && Delta.Change.guards.Removed(t)) {
|
|
600
|
+
decisions.set(pathKey, Decision.Removed());
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!oReal || !tReal) {
|
|
604
|
+
const real = oReal ? o : t;
|
|
605
|
+
const side = oReal ? "ours" : "theirs";
|
|
606
|
+
if (Delta.Change.guards.Removed(real) || hasKindChange(real)) {
|
|
607
|
+
oneSide(side, real);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (Delta.Change.guards.Removed(o) || Delta.Change.guards.Removed(t)) {
|
|
612
|
+
const removing = Delta.Change.guards.Removed(o) ? "ours" : "theirs";
|
|
613
|
+
const change = changeAt(other(removing), pathKey);
|
|
614
|
+
const fallback = hasKindChange(change) ? "KindDiverged" : "ChangedRemoved";
|
|
615
|
+
open([pathKey, ...beneath(union, pathKey)], [removalRecord(removing, pathKey, fallback)]);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const nO = directoryNode("ours", pathKey);
|
|
619
|
+
const nT = directoryNode("theirs", pathKey);
|
|
620
|
+
if (before === undefined || hasKindChange(o) || hasKindChange(t)) {
|
|
621
|
+
// Both sides wrote a directory here, or at least one replaced what was here.
|
|
622
|
+
const bothWrote = before === undefined || (hasKindChange(o) && hasKindChange(t));
|
|
623
|
+
const combined = bothWrote ? combineFresh(nO, nT) : undefined;
|
|
624
|
+
if (combined === undefined) {
|
|
625
|
+
const reason = before === undefined ? "BothAddedDifferent" : "KindDiverged";
|
|
626
|
+
open([pathKey, ...beneath(union, pathKey)], [{ pathKey, reason }]);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (!holdsNode(pathKey)) {
|
|
630
|
+
decisions.set(pathKey, Decision.Node({ lineage: `m:dir:${pathKey}`, node: combined }));
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const { clashes, node } = combine(resolve(before), nO, nT);
|
|
635
|
+
if (clashes.length > 0) {
|
|
636
|
+
open([pathKey], [{ pathKey, reason: "BothChanged" }]);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (!holdsNode(pathKey))
|
|
640
|
+
decisions.set(pathKey, Decision.Node({ lineage: `m:dir:${pathKey}`, node }));
|
|
641
|
+
}
|
|
642
|
+
// A conflict keeps its paths at base, and base must stay consistent: every name of a node tied to a conflicted
|
|
643
|
+
// path stays too, and so does the subtree of any directory a side removed above one.
|
|
644
|
+
const eliminatedAround = (pathKey) => SIDES.flatMap((side) => inputs[side].eliminated.filter((root) => root === pathKey || isBeneath(pathKey, root)));
|
|
645
|
+
const subtreeOf = new Map();
|
|
646
|
+
const subtree = (dir) => {
|
|
647
|
+
const known = subtreeOf.get(dir);
|
|
648
|
+
if (known !== undefined)
|
|
649
|
+
return known;
|
|
650
|
+
const members = [dir, ...beneath(union, dir)];
|
|
651
|
+
subtreeOf.set(dir, members);
|
|
652
|
+
return members;
|
|
653
|
+
};
|
|
654
|
+
// Names that joined a scope only because they share a node with a conflicted name.
|
|
655
|
+
const pulledByNode = new Set();
|
|
656
|
+
let grew = true;
|
|
657
|
+
let expanded = false;
|
|
658
|
+
// Scopes first grow by node and by the directories removed above their paths and merge, so each removed directory's
|
|
659
|
+
// subtree is added once per merged scope; a subtree that brings new nodes in starts the round again.
|
|
660
|
+
while (grew || !expanded) {
|
|
661
|
+
if (!grew) {
|
|
662
|
+
expanded = true;
|
|
663
|
+
for (const scope of scopes) {
|
|
664
|
+
for (const dir of Array.from(scope.paths)) {
|
|
665
|
+
if (!eliminatedAround(dir).includes(dir))
|
|
666
|
+
continue;
|
|
667
|
+
for (const candidate of subtree(dir)) {
|
|
668
|
+
if (!scope.paths.has(candidate)) {
|
|
669
|
+
scope.paths.add(candidate);
|
|
670
|
+
grew = true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (!grew)
|
|
676
|
+
break;
|
|
677
|
+
expanded = false;
|
|
678
|
+
}
|
|
679
|
+
grew = false;
|
|
680
|
+
for (const scope of scopes) {
|
|
681
|
+
const current = Array.from(scope.paths);
|
|
682
|
+
const absorbed = new Set();
|
|
683
|
+
for (const pathKey of current) {
|
|
684
|
+
const root = componentOf.get(pathKey);
|
|
685
|
+
const shared = root === undefined ? [] : [...componentPaths.get(root)];
|
|
686
|
+
const beneathDirs = [];
|
|
687
|
+
for (const dir of eliminatedAround(pathKey)) {
|
|
688
|
+
if (absorbed.has(dir))
|
|
689
|
+
continue;
|
|
690
|
+
absorbed.add(dir);
|
|
691
|
+
beneathDirs.push(dir);
|
|
692
|
+
}
|
|
693
|
+
for (const candidate of shared) {
|
|
694
|
+
if (!scope.paths.has(candidate)) {
|
|
695
|
+
scope.paths.add(candidate);
|
|
696
|
+
pulledByNode.add(candidate);
|
|
697
|
+
grew = true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
for (const candidate of beneathDirs) {
|
|
701
|
+
if (!scope.paths.has(candidate)) {
|
|
702
|
+
scope.paths.add(candidate);
|
|
703
|
+
grew = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Overlapping scopes resolve as one.
|
|
709
|
+
const owner = new Map();
|
|
710
|
+
const merged = [];
|
|
711
|
+
const dead = new Set();
|
|
712
|
+
const absorb = (target, source) => {
|
|
713
|
+
for (const pathKey of source.paths) {
|
|
714
|
+
target.paths.add(pathKey);
|
|
715
|
+
owner.set(pathKey, target);
|
|
716
|
+
}
|
|
717
|
+
for (const record of source.records) {
|
|
718
|
+
if (target.recorded.has(record.pathKey))
|
|
719
|
+
continue;
|
|
720
|
+
target.recorded.add(record.pathKey);
|
|
721
|
+
target.records.push(record);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
for (const scope of scopes) {
|
|
725
|
+
let target;
|
|
726
|
+
for (const pathKey of scope.paths) {
|
|
727
|
+
const existing = owner.get(pathKey);
|
|
728
|
+
if (existing === undefined || existing === target)
|
|
729
|
+
continue;
|
|
730
|
+
if (target === undefined)
|
|
731
|
+
target = existing;
|
|
732
|
+
else {
|
|
733
|
+
absorb(target, existing);
|
|
734
|
+
dead.add(existing);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (target === undefined) {
|
|
738
|
+
merged.push(scope);
|
|
739
|
+
for (const pathKey of scope.paths)
|
|
740
|
+
owner.set(pathKey, scope);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
absorb(target, scope);
|
|
744
|
+
grew = true;
|
|
745
|
+
}
|
|
746
|
+
scopes.splice(0, scopes.length, ...merged.filter((scope) => !dead.has(scope)));
|
|
747
|
+
}
|
|
748
|
+
// A changed name that stays at base only because it shares a node with a conflicted name is reported too,
|
|
749
|
+
// otherwise its change would vanish without a record. Paths beneath a conflicted directory are not: the
|
|
750
|
+
// directory's record covers them.
|
|
751
|
+
for (const scope of scopes) {
|
|
752
|
+
const known = new Set(scope.records.map((record) => record.pathKey));
|
|
753
|
+
for (const pathKey of scope.paths) {
|
|
754
|
+
const decision = decisions.get(pathKey);
|
|
755
|
+
if (known.has(pathKey) || !pulledByNode.has(pathKey) || decision === undefined)
|
|
756
|
+
continue;
|
|
757
|
+
if (Decision.$is("Node")(decision) && SIDES.some((side) => inputs[side].changes.has(pathKey))) {
|
|
758
|
+
scope.records.push({ pathKey, reason: "HardLinkGroupDiverged" });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
for (const scope of scopes)
|
|
763
|
+
for (const pathKey of scope.paths)
|
|
764
|
+
decisions.delete(pathKey);
|
|
765
|
+
return { decisions, scopes };
|
|
766
|
+
};
|
|
767
|
+
// Apply resolutions: a scope taken from one side holds exactly what that side left at each of its paths.
|
|
768
|
+
const settle = Effect.fnUntraced(function* (classified, inputs, resolutions) {
|
|
769
|
+
const takes = new Map();
|
|
770
|
+
for (const resolution of resolutions) {
|
|
771
|
+
const bytes = getBytes(resolution.path);
|
|
772
|
+
if (bytes === undefined)
|
|
773
|
+
return yield* argumentFailure(OPERATION, "resolutions");
|
|
774
|
+
const pathKey = Delta.key(bytes);
|
|
775
|
+
if (takes.has(pathKey) && takes.get(pathKey) !== resolution.take) {
|
|
776
|
+
return yield* argumentFailure(OPERATION, "resolutions");
|
|
777
|
+
}
|
|
778
|
+
takes.set(pathKey, resolution.take);
|
|
779
|
+
}
|
|
780
|
+
const reported = new Set(classified.scopes.flatMap((scope) => scope.records.map((record) => record.pathKey)));
|
|
781
|
+
for (const pathKey of takes.keys()) {
|
|
782
|
+
if (!reported.has(pathKey))
|
|
783
|
+
return yield* argumentFailure(OPERATION, "resolutions");
|
|
784
|
+
}
|
|
785
|
+
const decisions = new Map(classified.decisions);
|
|
786
|
+
const conflicts = [];
|
|
787
|
+
const paths = new Map([...inputs.ours.paths, ...inputs.theirs.paths]);
|
|
788
|
+
for (const scope of classified.scopes) {
|
|
789
|
+
// A scope takes one side as a whole: every reported path resolved the same way, or none of them.
|
|
790
|
+
const chosen = new Set(scope.records.map((record) => takes.get(record.pathKey)));
|
|
791
|
+
if (chosen.size > 1)
|
|
792
|
+
return yield* argumentFailure(OPERATION, "resolutions");
|
|
793
|
+
const [take] = chosen;
|
|
794
|
+
if (take === undefined) {
|
|
795
|
+
for (const record of scope.records) {
|
|
796
|
+
conflicts.push({
|
|
797
|
+
path: makeBytePath(paths.get(record.pathKey)),
|
|
798
|
+
reason: record.reason,
|
|
799
|
+
ours: sideChange(record.sides?.ours ?? inputs.ours.changes.get(record.pathKey)),
|
|
800
|
+
theirs: sideChange(record.sides?.theirs ?? inputs.theirs.changes.get(record.pathKey))
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (take === "base")
|
|
806
|
+
continue;
|
|
807
|
+
const input = inputs[take];
|
|
808
|
+
for (const pathKey of scope.paths) {
|
|
809
|
+
const change = input.changes.get(pathKey);
|
|
810
|
+
if (change === undefined)
|
|
811
|
+
continue;
|
|
812
|
+
if (Delta.Change.guards.Removed(change)) {
|
|
813
|
+
decisions.set(pathKey, Decision.Removed());
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const ino = input.inoAt.get(pathKey);
|
|
817
|
+
if (ino !== undefined) {
|
|
818
|
+
decisions.set(pathKey, Decision.Node({ lineage: `${take}:${ino}`, node: input.classes.get(ino).node }));
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
const node = lookupNode(input.state, paths.get(pathKey));
|
|
822
|
+
decisions.set(pathKey, Decision.Node({ lineage: `${take}:dir:${pathKey}`, node: resolve(node) }));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
decisions,
|
|
827
|
+
conflicts: Arr.sortWith(conflicts, (conflict) => getBytes(conflict.path), bytesOrder)
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
const differencesBetween = (before, after, hardLinks) => Delta.differenceOrder.filter((field) => {
|
|
831
|
+
if (field === "kind")
|
|
832
|
+
return before.kind !== after.kind;
|
|
833
|
+
if (field === "content" || field === "target") {
|
|
834
|
+
return before.kind === after.kind && after.kind !== "directory" &&
|
|
835
|
+
Delta.payloadDifference(after.kind) === field &&
|
|
836
|
+
!sameBytes(payloadOf(before), after.payload);
|
|
837
|
+
}
|
|
838
|
+
if (field === "hardLinks")
|
|
839
|
+
return hardLinks;
|
|
840
|
+
return before.metadata[field] !== after.metadata[field];
|
|
841
|
+
});
|
|
842
|
+
// Lay the decisions out as one change list: removals as they are, and every lineage with its first name in byte order
|
|
843
|
+
// carrying the node and the other names linking to it.
|
|
844
|
+
const emit = (base, decisions, paths) => {
|
|
845
|
+
const changes = [];
|
|
846
|
+
const encode = CanonicalBase64.encode;
|
|
847
|
+
const lineages = new Map();
|
|
848
|
+
const pathOf = (pathKey) => paths.get(pathKey);
|
|
849
|
+
for (const [pathKey, decision] of decisions) {
|
|
850
|
+
Decision.$match(decision, {
|
|
851
|
+
Removed: () => {
|
|
852
|
+
const before = base.nodeAt(pathKey);
|
|
853
|
+
changes.push({
|
|
854
|
+
path: pathOf(pathKey),
|
|
855
|
+
change: Delta.Change.cases.Removed.make({ path: encode(pathOf(pathKey)), kind: before.kind }, UNCHECKED)
|
|
856
|
+
});
|
|
857
|
+
},
|
|
858
|
+
Node: ({ lineage, node }) => {
|
|
859
|
+
const group = lineages.get(lineage) ?? { names: [], node };
|
|
860
|
+
group.names.push(pathKey);
|
|
861
|
+
lineages.set(lineage, group);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
for (const { names, node } of lineages.values()) {
|
|
866
|
+
const sorted = Arr.sort(names, Order.String);
|
|
867
|
+
const headKey = sorted[0];
|
|
868
|
+
const groupKey = sorted.length > 1 ? sorted.join("/") : undefined;
|
|
869
|
+
const headPath = encode(pathOf(headKey));
|
|
870
|
+
const deltaNode = (pathKey, withPayload) => {
|
|
871
|
+
if (pathKey !== headKey)
|
|
872
|
+
return Delta.DeltaNode.cases.link.make({ to: headPath }, UNCHECKED);
|
|
873
|
+
if (node.kind === "directory")
|
|
874
|
+
return Delta.DeltaNode.cases.directory.make({ metadata: node.metadata }, UNCHECKED);
|
|
875
|
+
if (node.kind === "file") {
|
|
876
|
+
return Delta.DeltaNode.cases.file.make(withPayload
|
|
877
|
+
? {
|
|
878
|
+
metadata: node.metadata,
|
|
879
|
+
content: Delta.InlineContent.make({ bytes: encode(node.payload) }, UNCHECKED)
|
|
880
|
+
}
|
|
881
|
+
: { metadata: node.metadata }, UNCHECKED);
|
|
882
|
+
}
|
|
883
|
+
return Delta.DeltaNode.cases.symlink.make(withPayload ? { metadata: node.metadata, target: encode(node.payload) } : { metadata: node.metadata }, UNCHECKED);
|
|
884
|
+
};
|
|
885
|
+
const emitted = [];
|
|
886
|
+
let headEmitted = false;
|
|
887
|
+
for (const pathKey of sorted) {
|
|
888
|
+
const path = pathOf(pathKey);
|
|
889
|
+
const before = base.nodeAt(pathKey);
|
|
890
|
+
const baseGroup = base.side.groupOf.get(pathKey) ?? pathKey;
|
|
891
|
+
const hardLinks = (groupKey ?? pathKey) !== baseGroup;
|
|
892
|
+
if (before === undefined) {
|
|
893
|
+
headEmitted ||= pathKey === headKey;
|
|
894
|
+
emitted.push({
|
|
895
|
+
path,
|
|
896
|
+
change: Delta.Change.cases.Added.make({ path: encode(path), kind: node.kind, node: deltaNode(pathKey, true) }, UNCHECKED)
|
|
897
|
+
});
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const differences = differencesBetween(before, node, hardLinks);
|
|
901
|
+
if (differences.length === 0)
|
|
902
|
+
continue;
|
|
903
|
+
const withPayload = differences.includes("kind") || differences.includes(Delta.payloadDifference(node.kind));
|
|
904
|
+
headEmitted ||= pathKey === headKey;
|
|
905
|
+
emitted.push({
|
|
906
|
+
path,
|
|
907
|
+
change: Delta.Change.cases.Updated.make({
|
|
908
|
+
path: encode(path),
|
|
909
|
+
beforeKind: before.kind,
|
|
910
|
+
afterKind: node.kind,
|
|
911
|
+
differences,
|
|
912
|
+
node: deltaNode(pathKey, withPayload)
|
|
913
|
+
}, UNCHECKED)
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
// Every name of a lineage shares its node and group, so a member with differences implies the head has them too.
|
|
917
|
+
if (emitted.length > 0 && !headEmitted)
|
|
918
|
+
return Result.fail("a hard-link group was emitted without its head");
|
|
919
|
+
changes.push(...emitted);
|
|
920
|
+
}
|
|
921
|
+
return Result.succeed(Arr.sortWith(changes, (entry) => entry.path, bytesOrder).map(({ change }) => change));
|
|
922
|
+
};
|
|
923
|
+
/** @internal */
|
|
924
|
+
export const mergeSnapshotDeltas = Effect.fnUntraced(function* (base, ours, theirs, resolutions, limits) {
|
|
925
|
+
const documents = { ours: yield* Delta.getDocument(ours), theirs: yield* Delta.getDocument(theirs) };
|
|
926
|
+
const value = yield* Image.valueOf(base);
|
|
927
|
+
const identity = yield* Merkle.identify(value, Delta.baseBudget(limits));
|
|
928
|
+
const states = {
|
|
929
|
+
ours: yield* Delta.verifyWith(value, identity, documents.ours, limits),
|
|
930
|
+
theirs: yield* Delta.verifyWith(value, identity, documents.theirs, limits)
|
|
931
|
+
};
|
|
932
|
+
const inputs = {
|
|
933
|
+
ours: indexSide("ours", documents.ours, states.ours, value),
|
|
934
|
+
theirs: indexSide("theirs", documents.theirs, states.theirs, value)
|
|
935
|
+
};
|
|
936
|
+
const indexed = indexBase(value, identity, [inputs.ours, inputs.theirs]);
|
|
937
|
+
const classified = classify(indexed, inputs.ours, inputs.theirs);
|
|
938
|
+
const settled = yield* settle(classified, inputs, resolutions);
|
|
939
|
+
const changes = yield* Result.match(emit(indexed, settled.decisions, indexed.paths), {
|
|
940
|
+
onFailure: (reason) => Effect.die(new Error(`mergeSnapshotDeltas: ${reason}`)),
|
|
941
|
+
onSuccess: Effect.succeed
|
|
942
|
+
});
|
|
943
|
+
if (changes.length > limits.records) {
|
|
944
|
+
return yield* imageFailure(OPERATION, "LimitExceeded", { field: "deltaRecords" });
|
|
945
|
+
}
|
|
946
|
+
const draft = {
|
|
947
|
+
format: Delta.FORMAT,
|
|
948
|
+
version: 1,
|
|
949
|
+
base: CanonicalBase64.encode(identity.digest),
|
|
950
|
+
target: CanonicalBase64.encode(identity.digest),
|
|
951
|
+
changes
|
|
952
|
+
};
|
|
953
|
+
const folded = yield* Effect.fromResult(Delta.fold(value, identity, draft));
|
|
954
|
+
yield* Effect.fromResult(Delta.outputIssue(limits, folded));
|
|
955
|
+
const target = yield* Delta.foldedIdentity(value, folded, identity, limits);
|
|
956
|
+
const document = { ...draft, target: CanonicalBase64.encode(target) };
|
|
957
|
+
yield* Effect.fromResult(Delta.validate(document, limits));
|
|
958
|
+
return { delta: SnapshotDeltaModel.make(document), conflicts: settled.conflicts };
|
|
959
|
+
});
|