@milaboratories/pl-tree 1.14.4 → 1.15.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/dist/delta_sync.cjs +114 -0
- package/dist/delta_sync.cjs.map +1 -0
- package/dist/delta_sync.js +114 -0
- package/dist/delta_sync.js.map +1 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/state.cjs +55 -9
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +55 -9
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +47 -13
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +42 -7
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +45 -14
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +46 -3
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +28 -1
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +47 -4
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +4 -4
- package/src/algorithm_equivalence.test.ts +197 -0
- package/src/delta_benchmark.test.ts +260 -0
- package/src/delta_sync.test.ts +490 -0
- package/src/delta_sync.ts +221 -0
- package/src/delta_sync_live.test.ts +259 -0
- package/src/state.test.ts +157 -0
- package/src/state.ts +98 -16
- package/src/sync.test.ts +105 -1
- package/src/sync.ts +110 -29
- package/src/synchronized_tree.ts +88 -6
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { NullSignedResourceId } from "@milaboratories/pl-client";
|
|
3
|
+
import { loadDeltaTreeState } from "./delta_sync";
|
|
4
|
+
import { TreeStateUpdateError } from "./state";
|
|
5
|
+
import { initialTreeLoadingStat } from "./sync";
|
|
6
|
+
import type { TreeLoadingRequest, TreeLoadingStat } from "./sync";
|
|
7
|
+
|
|
8
|
+
type Frame = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
/** A `"resource"` frame, which under a token is the only kind a delta walk produces. */
|
|
11
|
+
function frame(id: string, opts: { fields?: Frame[]; error?: string; kv?: Frame[] } = {}): Frame {
|
|
12
|
+
return {
|
|
13
|
+
frameKind: "resource",
|
|
14
|
+
id,
|
|
15
|
+
type: { name: "Projects", version: "1" },
|
|
16
|
+
kind: "Structural",
|
|
17
|
+
data: undefined,
|
|
18
|
+
resourceReady: false,
|
|
19
|
+
error: opts.error ?? NullSignedResourceId,
|
|
20
|
+
originalResourceId: NullSignedResourceId,
|
|
21
|
+
final: false,
|
|
22
|
+
inputsLocked: false,
|
|
23
|
+
outputsLocked: false,
|
|
24
|
+
fields: opts.fields ?? [],
|
|
25
|
+
kv: opts.kv ?? [],
|
|
26
|
+
traverseWasStopped: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function field(name: string, value: string): Frame {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
type: "Dynamic",
|
|
34
|
+
value,
|
|
35
|
+
error: NullSignedResourceId,
|
|
36
|
+
status: "Resolved",
|
|
37
|
+
valueIsFinal: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Call = { seeds: string[]; opts: Record<string, unknown> };
|
|
42
|
+
|
|
43
|
+
/** A tx whose `resourceTree` replays one canned response per call, recording what it was
|
|
44
|
+
* asked for. */
|
|
45
|
+
function txReturning(responses: Frame[][]) {
|
|
46
|
+
const calls: Call[] = [];
|
|
47
|
+
const tx = {
|
|
48
|
+
resourceTree: (seeds: string[], opts: Record<string, unknown>) => {
|
|
49
|
+
calls.push({ seeds, opts });
|
|
50
|
+
const batch = responses[calls.length - 1] ?? [];
|
|
51
|
+
return (async function* () {
|
|
52
|
+
for (const f of batch) yield f;
|
|
53
|
+
})();
|
|
54
|
+
},
|
|
55
|
+
} as unknown as Parameters<typeof loadDeltaTreeState>[0];
|
|
56
|
+
return { tx, calls };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function request(over: Partial<Record<keyof TreeLoadingRequest, unknown>> = {}) {
|
|
60
|
+
return {
|
|
61
|
+
seedResources: [],
|
|
62
|
+
finalResources: new Set<string>(),
|
|
63
|
+
roots: ["NG:root"],
|
|
64
|
+
knownResources: new Set<string>(),
|
|
65
|
+
changedSinceToken: new Uint8Array([7]),
|
|
66
|
+
...over,
|
|
67
|
+
} as unknown as TreeLoadingRequest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("seeding", () => {
|
|
71
|
+
test("seeds the non-final frontier, not the roots, and passes the token", async () => {
|
|
72
|
+
const { tx, calls } = txReturning([[]]);
|
|
73
|
+
|
|
74
|
+
await loadDeltaTreeState(
|
|
75
|
+
tx,
|
|
76
|
+
request({
|
|
77
|
+
seedResources: ["NG:0x1", "NG:0x2"],
|
|
78
|
+
roots: ["NG:root"],
|
|
79
|
+
knownResources: new Set(["NG:0x1", "NG:0x2", "NG:root"]),
|
|
80
|
+
fieldFilter: { marker: "field" },
|
|
81
|
+
// constructTreeLoadingRequest still populates this and the ML passes one, so the
|
|
82
|
+
// request genuinely carries stop rules here: the assertion below has to see the
|
|
83
|
+
// delta path drop them rather than never having had them.
|
|
84
|
+
traverseStopRules: { marker: "stop" },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(calls).toHaveLength(1);
|
|
89
|
+
// Every non-final resource is a seed, never the roots alone.
|
|
90
|
+
expect(calls[0]?.seeds).toEqual(["NG:0x1", "NG:0x2"]);
|
|
91
|
+
expect(calls[0]?.opts.includeKv).toBe(true);
|
|
92
|
+
expect(calls[0]?.opts.changedSinceToken).toEqual(new Uint8Array([7]));
|
|
93
|
+
expect(calls[0]?.opts.fieldFilter).toEqual({ marker: "field" });
|
|
94
|
+
// Stop rules are inert under a token, so the delta walk must not send them.
|
|
95
|
+
expect(calls[0]?.opts.traverseStopRules).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("falls back to the roots when every resource is final", async () => {
|
|
99
|
+
const { tx, calls } = txReturning([[]]);
|
|
100
|
+
|
|
101
|
+
await loadDeltaTreeState(tx, request({ seedResources: [], roots: ["NG:root"] }));
|
|
102
|
+
|
|
103
|
+
expect(calls[0]?.seeds).toEqual(["NG:root"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("does no round trip at all when there is nothing to seed", async () => {
|
|
107
|
+
const { tx, calls } = txReturning([[]]);
|
|
108
|
+
|
|
109
|
+
const result = await loadDeltaTreeState(tx, request({ seedResources: [], roots: [] }));
|
|
110
|
+
|
|
111
|
+
expect(calls).toHaveLength(0);
|
|
112
|
+
expect(result).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("delta response", () => {
|
|
117
|
+
test("an all-unchanged poll yields nothing and costs one round trip", async () => {
|
|
118
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
119
|
+
const { tx, calls } = txReturning([[]]);
|
|
120
|
+
|
|
121
|
+
const result = await loadDeltaTreeState(
|
|
122
|
+
tx,
|
|
123
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
124
|
+
stats,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual([]);
|
|
128
|
+
expect(calls).toHaveLength(1);
|
|
129
|
+
expect(stats.roundTrips).toBe(1);
|
|
130
|
+
expect(stats.deltaResolutionRounds).toBe(0);
|
|
131
|
+
expect(stats.retrievedResources).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("applies the pruning function and counts what it dropped", async () => {
|
|
135
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
136
|
+
const { tx } = txReturning([
|
|
137
|
+
[frame("NG:0x1", { fields: [field("keep", "NG:0x9"), field("drop", "NG:0x8")] })],
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const result = await loadDeltaTreeState(
|
|
141
|
+
tx,
|
|
142
|
+
request({
|
|
143
|
+
seedResources: ["NG:0x1"],
|
|
144
|
+
// Both field targets already held, so pruning is the only thing under test.
|
|
145
|
+
knownResources: new Set(["NG:0x1", "NG:0x9", "NG:0x8"]),
|
|
146
|
+
pruningFunction: (r: { fields: { name: string }[] }) =>
|
|
147
|
+
r.fields.filter((f) => f.name === "keep"),
|
|
148
|
+
}),
|
|
149
|
+
stats,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(result).toHaveLength(1);
|
|
153
|
+
expect(result[0]?.fields.map((f) => f.name)).toEqual(["keep"]);
|
|
154
|
+
expect(stats.prunedFields).toBe(1);
|
|
155
|
+
expect(stats.retrievedFields).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("copies every resource property off the frame", async () => {
|
|
160
|
+
// The resource is built as an explicit literal rather than a spread, which is faster but
|
|
161
|
+
// silently omits whatever is forgotten. Measured: eight of the twelve could be deleted with
|
|
162
|
+
// the rest of this suite still green.
|
|
163
|
+
const { tx } = txReturning([
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
...frame("NG:0x1", { kv: [{ key: "k", value: new Uint8Array([9]) }] }),
|
|
167
|
+
type: { name: "Marker", version: "7" },
|
|
168
|
+
kind: "Value",
|
|
169
|
+
data: new Uint8Array([1, 2]),
|
|
170
|
+
resourceReady: true,
|
|
171
|
+
originalResourceId: "NG:0xORIG",
|
|
172
|
+
final: true,
|
|
173
|
+
inputsLocked: true,
|
|
174
|
+
outputsLocked: true,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const result = await loadDeltaTreeState(
|
|
180
|
+
tx,
|
|
181
|
+
request({
|
|
182
|
+
seedResources: ["NG:0x1"],
|
|
183
|
+
knownResources: new Set(["NG:0x1", "NG:0xORIG"]),
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(result).toHaveLength(1);
|
|
188
|
+
expect(result[0]).toMatchObject({
|
|
189
|
+
id: "NG:0x1",
|
|
190
|
+
type: { name: "Marker", version: "7" },
|
|
191
|
+
kind: "Value",
|
|
192
|
+
data: new Uint8Array([1, 2]),
|
|
193
|
+
resourceReady: true,
|
|
194
|
+
originalResourceId: "NG:0xORIG",
|
|
195
|
+
final: true,
|
|
196
|
+
inputsLocked: true,
|
|
197
|
+
outputsLocked: true,
|
|
198
|
+
kv: [{ key: "k", value: new Uint8Array([9]) }],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("final resources", () => {
|
|
203
|
+
test("skips a body for a resource the mirror already marked final", async () => {
|
|
204
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
205
|
+
// A token-less poll (first poll after a warm start, or after a token discard) is a full
|
|
206
|
+
// walk with no stop rules, so the backend emits bodies for final resources sitting under
|
|
207
|
+
// a non-final seed. updateFromResourceData throws on any body for a held final resource,
|
|
208
|
+
// so the batch must not carry them.
|
|
209
|
+
const { tx } = txReturning([[frame("NG:0x1"), frame("NG:0xFINAL")]]);
|
|
210
|
+
|
|
211
|
+
const result = await loadDeltaTreeState(
|
|
212
|
+
tx,
|
|
213
|
+
request({
|
|
214
|
+
seedResources: ["NG:0x1"],
|
|
215
|
+
finalResources: new Set(["NG:0xFINAL"]),
|
|
216
|
+
knownResources: new Set(["NG:0x1", "NG:0xFINAL"]),
|
|
217
|
+
changedSinceToken: undefined,
|
|
218
|
+
}),
|
|
219
|
+
stats,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(result.map((r) => r.id)).toEqual(["NG:0x1"]);
|
|
223
|
+
expect(stats.finalResourcesSkipped).toBe(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a skipped final resource still satisfies references pointing at it", async () => {
|
|
227
|
+
// Dropping it from the batch must not make it look unresolved: it is in the mirror.
|
|
228
|
+
const { tx, calls } = txReturning([
|
|
229
|
+
[frame("NG:0x1", { fields: [field("out", "NG:0xFINAL")] }), frame("NG:0xFINAL")],
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
await loadDeltaTreeState(
|
|
233
|
+
tx,
|
|
234
|
+
request({
|
|
235
|
+
seedResources: ["NG:0x1"],
|
|
236
|
+
finalResources: new Set(["NG:0xFINAL"]),
|
|
237
|
+
knownResources: new Set(["NG:0x1", "NG:0xFINAL"]),
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(calls).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("diagnostics", () => {
|
|
246
|
+
test("warns when stop rules are supplied to a token-less poll", async () => {
|
|
247
|
+
const warnings: string[] = [];
|
|
248
|
+
const { tx } = txReturning([[]]);
|
|
249
|
+
|
|
250
|
+
await loadDeltaTreeState(
|
|
251
|
+
tx,
|
|
252
|
+
request({
|
|
253
|
+
seedResources: ["NG:0x1"],
|
|
254
|
+
knownResources: new Set(["NG:0x1"]),
|
|
255
|
+
traverseStopRules: { marker: "stop" },
|
|
256
|
+
changedSinceToken: undefined,
|
|
257
|
+
}),
|
|
258
|
+
undefined,
|
|
259
|
+
{ warn: (m) => warnings.push(m) },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(warnings.some((w) => w.includes("unpruned"))).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("does not warn about stop rules when a token makes them moot", async () => {
|
|
266
|
+
const warnings: string[] = [];
|
|
267
|
+
const { tx } = txReturning([[]]);
|
|
268
|
+
|
|
269
|
+
await loadDeltaTreeState(
|
|
270
|
+
tx,
|
|
271
|
+
request({
|
|
272
|
+
seedResources: ["NG:0x1"],
|
|
273
|
+
knownResources: new Set(["NG:0x1"]),
|
|
274
|
+
traverseStopRules: { marker: "stop" },
|
|
275
|
+
}),
|
|
276
|
+
undefined,
|
|
277
|
+
{ warn: (m) => warnings.push(m) },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(warnings).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("flags a token poll answered with the whole mirror as a suspected rejection", async () => {
|
|
284
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
285
|
+
const warnings: string[] = [];
|
|
286
|
+
// Two held resources, and the response carries both: what a refused token looks like.
|
|
287
|
+
const { tx } = txReturning([[frame("NG:0x1"), frame("NG:0x2")]]);
|
|
288
|
+
|
|
289
|
+
await loadDeltaTreeState(
|
|
290
|
+
tx,
|
|
291
|
+
request({
|
|
292
|
+
seedResources: ["NG:0x1", "NG:0x2"],
|
|
293
|
+
roots: ["NG:0x1"],
|
|
294
|
+
knownResources: new Set(["NG:0x1", "NG:0x2"]),
|
|
295
|
+
}),
|
|
296
|
+
stats,
|
|
297
|
+
{ warn: (m) => warnings.push(m) },
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(stats.deltaSuspectedFullAnswers).toBe(1);
|
|
301
|
+
expect(warnings.some((w) => w.includes("refused the token"))).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("does not flag an ordinary delta that moved one resource of many", async () => {
|
|
305
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
306
|
+
const { tx } = txReturning([[frame("NG:0x1")]]);
|
|
307
|
+
|
|
308
|
+
await loadDeltaTreeState(
|
|
309
|
+
tx,
|
|
310
|
+
request({
|
|
311
|
+
seedResources: ["NG:0x1", "NG:0x2", "NG:0x3"],
|
|
312
|
+
knownResources: new Set(["NG:0x1", "NG:0x2", "NG:0x3"]),
|
|
313
|
+
}),
|
|
314
|
+
stats,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(stats.deltaSuspectedFullAnswers).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("reference resolution", () => {
|
|
322
|
+
test("resolves a reference the delta did not carry, at a subtree-clearing depth", async () => {
|
|
323
|
+
const stats: TreeLoadingStat = initialTreeLoadingStat();
|
|
324
|
+
const { tx, calls } = txReturning([
|
|
325
|
+
// Poll: a held resource repointed at a resource we have never seen.
|
|
326
|
+
[frame("NG:0x1", { fields: [field("out", "NG:0xNEW")] })],
|
|
327
|
+
// Resolution round serves it.
|
|
328
|
+
[frame("NG:0xNEW")],
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
const result = await loadDeltaTreeState(
|
|
332
|
+
tx,
|
|
333
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
334
|
+
stats,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(calls).toHaveLength(2);
|
|
338
|
+
expect(calls[1]?.seeds).toEqual(["NG:0xNEW"]);
|
|
339
|
+
// Pinned, not just "deep": at 0 a newly attached subtree costs one sequential round trip
|
|
340
|
+
// per level, and the value is the whole trade the constant's comment argues.
|
|
341
|
+
expect(calls[1]?.opts.unconditionalDepth).toBe(32);
|
|
342
|
+
// The token still rides the resolution round: what it returns is dated the same as the
|
|
343
|
+
// poll it came with.
|
|
344
|
+
expect(calls[1]?.opts.changedSinceToken).toEqual(new Uint8Array([7]));
|
|
345
|
+
expect(result.map((r) => r.id).sort()).toEqual(["NG:0x1", "NG:0xNEW"]);
|
|
346
|
+
expect(stats.deltaResolutionRounds).toBe(1);
|
|
347
|
+
expect(stats.deltaSeedsSent).toBe(2);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("needs no round when every reference is already in the mirror", async () => {
|
|
351
|
+
const { tx, calls } = txReturning([[frame("NG:0x1", { fields: [field("out", "NG:0xOLD")] })]]);
|
|
352
|
+
|
|
353
|
+
await loadDeltaTreeState(
|
|
354
|
+
tx,
|
|
355
|
+
request({
|
|
356
|
+
seedResources: ["NG:0x1"],
|
|
357
|
+
knownResources: new Set(["NG:0x1", "NG:0xOLD"]),
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(calls).toHaveLength(1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("follows a chain of references across successive rounds", async () => {
|
|
365
|
+
const { tx, calls } = txReturning([
|
|
366
|
+
[frame("NG:0x1", { fields: [field("out", "NG:0xA")] })],
|
|
367
|
+
[frame("NG:0xA", { fields: [field("out", "NG:0xB")] })],
|
|
368
|
+
[frame("NG:0xB")],
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
const result = await loadDeltaTreeState(
|
|
372
|
+
tx,
|
|
373
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// Only the resolution rounds are under test here, so the first (seeding) call is
|
|
377
|
+
// excluded: which ids seed the poll is the seeding tests' business.
|
|
378
|
+
expect(calls.slice(1).map((c) => c.seeds)).toEqual([["NG:0xA"], ["NG:0xB"]]);
|
|
379
|
+
expect(result).toHaveLength(3);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("resolves a resource error reference, not just field targets", async () => {
|
|
383
|
+
const { tx, calls } = txReturning([
|
|
384
|
+
[frame("NG:0x1", { error: "NG:0xERR" })],
|
|
385
|
+
[frame("NG:0xERR")],
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
await loadDeltaTreeState(
|
|
389
|
+
tx,
|
|
390
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(calls[1]?.seeds).toEqual(["NG:0xERR"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("asks for a shared reference once, however many bodies point at it", async () => {
|
|
397
|
+
const { tx, calls } = txReturning([
|
|
398
|
+
[
|
|
399
|
+
frame("NG:0x1", { fields: [field("out", "NG:0xSHARED")] }),
|
|
400
|
+
frame("NG:0x2", { fields: [field("out", "NG:0xSHARED")] }),
|
|
401
|
+
],
|
|
402
|
+
[frame("NG:0xSHARED")],
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
await loadDeltaTreeState(
|
|
406
|
+
tx,
|
|
407
|
+
request({
|
|
408
|
+
seedResources: ["NG:0x1", "NG:0x2"],
|
|
409
|
+
knownResources: new Set(["NG:0x1", "NG:0x2"]),
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(calls).toHaveLength(2);
|
|
414
|
+
expect(calls[1]?.seeds).toEqual(["NG:0xSHARED"]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("follows a deep reference chain to the end instead of erroring on its depth", async () => {
|
|
418
|
+
// Each answer introduces one more unknown reference, so the walk costs one sequential
|
|
419
|
+
// round trip per link. How deep a tree chains is the backend's business, not a fault
|
|
420
|
+
// here, so this must resolve rather than throw: a round cap would turn a legal tree shape
|
|
421
|
+
// into a desktop error. Terminates because the chain is finite and `fetched` only grows.
|
|
422
|
+
const DEPTH = 12;
|
|
423
|
+
let n = 0;
|
|
424
|
+
const tx = {
|
|
425
|
+
resourceTree: () => {
|
|
426
|
+
const id = `NG:0xCHAIN${n++}`;
|
|
427
|
+
return (async function* () {
|
|
428
|
+
yield frame(id, n < DEPTH ? { fields: [field("next", `NG:0xCHAIN${n}`)] } : {});
|
|
429
|
+
})();
|
|
430
|
+
},
|
|
431
|
+
} as unknown as Parameters<typeof loadDeltaTreeState>[0];
|
|
432
|
+
|
|
433
|
+
const stats = initialTreeLoadingStat();
|
|
434
|
+
const result = await loadDeltaTreeState(
|
|
435
|
+
tx,
|
|
436
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
437
|
+
stats,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect(result).toHaveLength(DEPTH);
|
|
441
|
+
// One seeding call plus one resolution round per remaining link.
|
|
442
|
+
expect(n).toBe(DEPTH);
|
|
443
|
+
expect(stats.deltaResolutionRounds).toBe(DEPTH - 1);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("skips a body-less frame but says so", async () => {
|
|
447
|
+
// The client reads "no body" as a stop marker, and stop markers only follow
|
|
448
|
+
// traverse_stop_rules, which delta never sends. Nothing here needs the frame, so the poll
|
|
449
|
+
// carries on - but it must leave a trace, or a backend that starts using body-less frames
|
|
450
|
+
// for something else changes delta's behaviour with nothing in the log to show for it.
|
|
451
|
+
const warnings: string[] = [];
|
|
452
|
+
const { tx } = txReturning([
|
|
453
|
+
[
|
|
454
|
+
{ frameKind: "stopMarker", id: "NG:0xSTOP", traverseWasStopped: true } as unknown as Frame,
|
|
455
|
+
frame("NG:0x1"),
|
|
456
|
+
],
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
const result = await loadDeltaTreeState(
|
|
460
|
+
tx,
|
|
461
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
462
|
+
undefined,
|
|
463
|
+
{ warn: (m: string) => warnings.push(m) },
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
expect(result.map((r) => r.id)).toEqual(["NG:0x1"]);
|
|
467
|
+
// Exactly one, and only once: a contract change would put a body-less frame on every
|
|
468
|
+
// resource visited, and one line per frame would bury the log.
|
|
469
|
+
const skipped = warnings.filter((w) => w.includes("NG:0xSTOP"));
|
|
470
|
+
expect(skipped).toHaveLength(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("throws with the ids rather than letting the apply invalidate the tree", async () => {
|
|
474
|
+
// The backend never serves the referenced resource, so the loop cannot make progress.
|
|
475
|
+
const { tx, calls } = txReturning([
|
|
476
|
+
[frame("NG:0x1", { fields: [field("out", "NG:0xGONE")] })],
|
|
477
|
+
[],
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
await expect(
|
|
481
|
+
loadDeltaTreeState(
|
|
482
|
+
tx,
|
|
483
|
+
request({ seedResources: ["NG:0x1"], knownResources: new Set(["NG:0x1"]) }),
|
|
484
|
+
),
|
|
485
|
+
).rejects.toThrow(TreeStateUpdateError);
|
|
486
|
+
|
|
487
|
+
// One poll, one resolution attempt, then it gives up instead of looping.
|
|
488
|
+
expect(calls).toHaveLength(2);
|
|
489
|
+
});
|
|
490
|
+
});
|