@milaboratories/pl-tree 1.13.6 → 1.14.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/dist/accessors.d.ts +15 -17
- package/dist/accessors.d.ts.map +1 -1
- package/dist/dump.d.ts +14 -10
- package/dist/dump.d.ts.map +1 -1
- package/dist/index.cjs +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/persisted_tree.cjs +465 -0
- package/dist/persisted_tree.cjs.map +1 -0
- package/dist/persisted_tree.d.ts +99 -0
- package/dist/persisted_tree.d.ts.map +1 -0
- package/dist/persisted_tree.js +460 -0
- package/dist/persisted_tree.js.map +1 -0
- package/dist/snapshot.d.ts +12 -14
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/state.cjs +7 -0
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +13 -11
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +7 -0
- package/dist/state.js.map +1 -1
- package/dist/sync.d.ts +26 -19
- package/dist/sync.d.ts.map +1 -1
- package/dist/synchronized_tree.cjs +62 -4
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +64 -17
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +62 -4
- package/dist/synchronized_tree.js.map +1 -1
- package/dist/traversal_ops.d.ts +12 -10
- package/dist/traversal_ops.d.ts.map +1 -1
- package/dist/value_and_error.d.ts +3 -4
- package/dist/value_and_error.d.ts.map +1 -1
- package/dist/value_or_error.d.ts +1 -2
- package/dist/value_or_error.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/persisted_tree.test.ts +291 -0
- package/src/persisted_tree.ts +708 -0
- package/src/state.ts +8 -0
- package/src/synchronized_tree.ts +102 -5
package/dist/traversal_ops.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { FieldType, ResourceType } from "@milaboratories/pl-client";
|
|
2
|
-
|
|
3
2
|
//#region src/traversal_ops.d.ts
|
|
4
|
-
type CommonTraversalOps = {
|
|
3
|
+
export type CommonTraversalOps = {
|
|
5
4
|
/**
|
|
6
5
|
* Don't terminate chain if current resource or field has an error associated
|
|
7
6
|
* with, by default resource or field error will be thrown. If field has error
|
|
@@ -10,13 +9,14 @@ type CommonTraversalOps = {
|
|
|
10
9
|
* */
|
|
11
10
|
ignoreError?: boolean;
|
|
12
11
|
};
|
|
13
|
-
type CommonFieldTraverseOps = {
|
|
12
|
+
export type CommonFieldTraverseOps = {
|
|
14
13
|
/**
|
|
15
14
|
* Valid only if {@link assertFieldType} is defined and equal to 'Input',
|
|
16
15
|
* 'Service' or 'Output'. By default, if field is not found, and corresponding
|
|
17
16
|
* field list is locked, call will fail with exception.
|
|
18
17
|
* */
|
|
19
|
-
allowPermanentAbsence?: boolean;
|
|
18
|
+
allowPermanentAbsence?: boolean;
|
|
19
|
+
/** Will not mark current context as unstable, if field is not found. */
|
|
20
20
|
stableIfNotFound?: boolean;
|
|
21
21
|
/**
|
|
22
22
|
* If encounter field with error and no value, will silently terminate the
|
|
@@ -24,16 +24,19 @@ type CommonFieldTraverseOps = {
|
|
|
24
24
|
* */
|
|
25
25
|
pureFieldErrorToUndefined?: boolean;
|
|
26
26
|
};
|
|
27
|
-
type ResourceTraversalOps = CommonTraversalOps & {
|
|
27
|
+
export type ResourceTraversalOps = CommonTraversalOps & {
|
|
28
28
|
/**
|
|
29
29
|
* Assert resource type of the resource the fields points to. Call will fail
|
|
30
30
|
* with exception if this assertion is not fulfilled.
|
|
31
31
|
* */
|
|
32
32
|
assertResourceType?: ResourceType | ResourceType[];
|
|
33
33
|
};
|
|
34
|
-
type GetFieldStep = CommonFieldTraverseOps & {
|
|
35
|
-
/** Field name */
|
|
36
|
-
|
|
34
|
+
export type GetFieldStep = CommonFieldTraverseOps & {
|
|
35
|
+
/** Field name */
|
|
36
|
+
field: string;
|
|
37
|
+
/** Field must exist, if this option is set, instead error will be thrown */
|
|
38
|
+
errorIfFieldNotFound?: boolean;
|
|
39
|
+
/** Field must be resolved into resource if this option is set, instead error will be thrown */
|
|
37
40
|
errorIfFieldNotSet?: boolean;
|
|
38
41
|
/**
|
|
39
42
|
* Assert field type. Call will fail with exception if this assertion is not
|
|
@@ -41,7 +44,6 @@ type GetFieldStep = CommonFieldTraverseOps & {
|
|
|
41
44
|
* */
|
|
42
45
|
assertFieldType?: FieldType;
|
|
43
46
|
};
|
|
44
|
-
type FieldTraversalStep = GetFieldStep & ResourceTraversalOps;
|
|
47
|
+
export type FieldTraversalStep = GetFieldStep & ResourceTraversalOps;
|
|
45
48
|
//#endregion
|
|
46
|
-
export { CommonFieldTraverseOps, CommonTraversalOps, FieldTraversalStep, GetFieldStep, ResourceTraversalOps };
|
|
47
49
|
//# sourceMappingURL=traversal_ops.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"traversal_ops.d.ts","names":[],"sources":["../src/traversal_ops.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"traversal_ops.d.ts","names":[],"sources":["../src/traversal_ops.ts"],"mappings":";;YAEY;;;;;;;EAOV;;YAGU;;;;;;EAMV;;EAGA;;;;;EAMA;;YAGU,uBAAuB;;;;;EAKjC,qBAAqB,eAAe;;YAG1B,eAAe;;EAEzB;;EAGA;;EAGA;;;;;EAMA,kBAAkB;;YAGR,qBAAqB,eAAe"}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
//#region src/value_and_error.d.ts
|
|
2
|
-
interface ValueAndError<T> {
|
|
2
|
+
export interface ValueAndError<T> {
|
|
3
3
|
value?: T;
|
|
4
4
|
error?: T;
|
|
5
5
|
}
|
|
6
|
-
declare function mapValueAndErrorIfDefined<T1, T2>(input: ValueAndError<T1> | undefined, mapping: (v: T1) => T2): ValueAndError<T2> | undefined;
|
|
7
|
-
declare function mapValueAndError<T1, T2>(input: ValueAndError<T1>, mapping: (v: T1) => T2): ValueAndError<T2>;
|
|
6
|
+
export declare function mapValueAndErrorIfDefined<T1, T2>(input: ValueAndError<T1> | undefined, mapping: (v: T1) => T2): ValueAndError<T2> | undefined;
|
|
7
|
+
export declare function mapValueAndError<T1, T2>(input: ValueAndError<T1>, mapping: (v: T1) => T2): ValueAndError<T2>;
|
|
8
8
|
//#endregion
|
|
9
|
-
export { ValueAndError, mapValueAndError, mapValueAndErrorIfDefined };
|
|
10
9
|
//# sourceMappingURL=value_and_error.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"value_and_error.d.ts","names":[],"sources":["../src/value_and_error.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"value_and_error.d.ts","names":[],"sources":["../src/value_and_error.ts"],"mappings":";iBAAiB,cAAc;EAC7B,QAAQ;EACR,QAAQ;;wBAGM,0BAA0B,IAAI,IAC5C,OAAO,cAAc,iBACrB,UAAU,GAAG,OAAO,KACnB,cAAc;wBAKD,iBAAiB,IAAI,IAAI,OAAO,cAAc,KAAK,UAAU,GAAG,OAAO,KAAE,cAAA"}
|
package/dist/value_or_error.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/value_or_error.d.ts
|
|
2
|
-
type ValueOrError<V, E> = {
|
|
2
|
+
export type ValueOrError<V, E> = {
|
|
3
3
|
ok: true;
|
|
4
4
|
value: V;
|
|
5
5
|
} | {
|
|
@@ -7,5 +7,4 @@ type ValueOrError<V, E> = {
|
|
|
7
7
|
error: E;
|
|
8
8
|
};
|
|
9
9
|
//#endregion
|
|
10
|
-
export { ValueOrError };
|
|
11
10
|
//# sourceMappingURL=value_or_error.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"value_or_error.d.ts","names":[],"sources":["../src/value_or_error.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"value_or_error.d.ts","names":[],"sources":["../src/value_or_error.ts"],"mappings":";YAAY,aAAa,GAAG;EAEtB;EACA,OAAO;;EAGP;EACA,OAAO"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milaboratories/pl-tree",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Reactive pl tree state",
|
|
5
5
|
"files": [
|
|
6
6
|
"./dist/**/*",
|
|
@@ -22,18 +22,18 @@
|
|
|
22
22
|
"utility-types": "^3.11.0",
|
|
23
23
|
"zod": "~3.25.76",
|
|
24
24
|
"@milaboratories/computable": "2.9.8",
|
|
25
|
-
"@milaboratories/pl-errors": "1.4.35",
|
|
26
25
|
"@milaboratories/ts-helpers": "1.8.6",
|
|
27
|
-
"@milaboratories/pl-
|
|
26
|
+
"@milaboratories/pl-errors": "1.4.36",
|
|
27
|
+
"@milaboratories/pl-client": "3.14.7"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "~24.5.2",
|
|
31
31
|
"@vitest/coverage-istanbul": "^4.1.3",
|
|
32
|
-
"typescript": "
|
|
32
|
+
"typescript": "7.0.2",
|
|
33
33
|
"vitest": "^4.1.3",
|
|
34
|
-
"@milaboratories/build-configs": "2.0.0",
|
|
35
34
|
"@milaboratories/ts-configs": "1.4.0",
|
|
36
|
-
"@milaboratories/
|
|
35
|
+
"@milaboratories/build-configs": "2.0.1",
|
|
36
|
+
"@milaboratories/ts-builder": "1.7.1"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=22.19.0"
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { FinalResourceDataPredicate } from "@milaboratories/pl-client";
|
|
3
|
+
import { createSignedResourceId, toResourceSignature } from "@milaboratories/pl-client";
|
|
4
|
+
import type { ExtendedResourceData } from "./state";
|
|
5
|
+
import { PlTreeState } from "./state";
|
|
6
|
+
import { constructTreeLoadingRequest } from "./sync";
|
|
7
|
+
import {
|
|
8
|
+
captureTreeState,
|
|
9
|
+
decodePersistedTree,
|
|
10
|
+
encodePersistedTree,
|
|
11
|
+
PERSISTED_TREE_SCHEMA_VERSION,
|
|
12
|
+
readPersistedTreeHeader,
|
|
13
|
+
restoreTreeState,
|
|
14
|
+
} from "./persisted_tree";
|
|
15
|
+
import {
|
|
16
|
+
dField,
|
|
17
|
+
iField,
|
|
18
|
+
TestDynamicRootState1,
|
|
19
|
+
TestStructuralResourceState1,
|
|
20
|
+
TestValueResourceState1,
|
|
21
|
+
} from "./test_utils";
|
|
22
|
+
|
|
23
|
+
const sig = (hex: string) => toResourceSignature(Buffer.from(hex, "hex"));
|
|
24
|
+
|
|
25
|
+
/** Ids here carry real signature bytes, unlike the shared test fixtures, so the
|
|
26
|
+
* global-id / signature split is actually exercised rather than trivially satisfied. */
|
|
27
|
+
const rid = (id: bigint, signature = "a1b2c3d4") => createSignedResourceId(id, sig(signature));
|
|
28
|
+
|
|
29
|
+
const RootSignature = sig("deadbeef");
|
|
30
|
+
const RootId = createSignedResourceId(1000001n, RootSignature);
|
|
31
|
+
|
|
32
|
+
/** The shared fixtures use resource types `DefaultFinalResourceDataPredicate` does not know,
|
|
33
|
+
* so it settles nothing and the tree has no final/non-final split to test. Trusting the
|
|
34
|
+
* backend's derived flag instead gives one, which is what the loading request is built from. */
|
|
35
|
+
const finalByFlag: FinalResourceDataPredicate = (r) => r.final;
|
|
36
|
+
|
|
37
|
+
/** A tree with a settled branch, an unsettled branch, data, kv, a dynamic field pointing at
|
|
38
|
+
* a value, and an unresolved field. Enough shape that a codec losing a distinction the tree
|
|
39
|
+
* cares about shows up as a different loading request. */
|
|
40
|
+
function buildPopulatedTree(): PlTreeState {
|
|
41
|
+
const tree = new PlTreeState(RootId, finalByFlag);
|
|
42
|
+
tree.updateFromResourceData([
|
|
43
|
+
{
|
|
44
|
+
...TestDynamicRootState1,
|
|
45
|
+
id: RootId,
|
|
46
|
+
fields: [dField("settled", rid(10n)), dField("running", rid(20n)), dField("pending")],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
...TestStructuralResourceState1,
|
|
50
|
+
id: rid(10n),
|
|
51
|
+
inputsLocked: true,
|
|
52
|
+
outputsLocked: true,
|
|
53
|
+
resourceReady: true,
|
|
54
|
+
final: true,
|
|
55
|
+
fields: [iField("payload", rid(11n))],
|
|
56
|
+
kv: [
|
|
57
|
+
{ key: "meta", value: Buffer.from('{"n":1}') },
|
|
58
|
+
{ key: "binary", value: Uint8Array.from([0, 1, 2, 255]) },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
...TestValueResourceState1,
|
|
63
|
+
id: rid(11n),
|
|
64
|
+
data: Buffer.from("settled payload"),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
...TestStructuralResourceState1,
|
|
68
|
+
id: rid(20n),
|
|
69
|
+
fields: [iField("payload"), dField("progress", rid(21n))],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
...TestValueResourceState1,
|
|
73
|
+
id: rid(21n),
|
|
74
|
+
data: Buffer.from("in progress"),
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
return tree;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The claim the whole design rests on: what comes back addresses the backend the same way
|
|
81
|
+
* the original did. Compared as sorted arrays because neither the seed order nor the skip
|
|
82
|
+
* set's iteration order is part of the contract. */
|
|
83
|
+
function loadingRequestOf(tree: PlTreeState) {
|
|
84
|
+
const req = constructTreeLoadingRequest(tree);
|
|
85
|
+
return {
|
|
86
|
+
seeds: [...req.seedResources].sort(),
|
|
87
|
+
skips: [...req.finalResources].sort(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function roundTrip(tree: PlTreeState, compress?: boolean): Promise<PlTreeState> {
|
|
92
|
+
const captured = captureTreeState(tree, RootSignature);
|
|
93
|
+
const bytes = await encodePersistedTree(captured, { compress });
|
|
94
|
+
|
|
95
|
+
const decoded = await decodePersistedTree(bytes);
|
|
96
|
+
expect(decoded.ok).toBe(true);
|
|
97
|
+
if (!decoded.ok) throw new Error("unreachable");
|
|
98
|
+
|
|
99
|
+
const restored = restoreTreeState(decoded.value, finalByFlag);
|
|
100
|
+
expect(restored).toBeDefined();
|
|
101
|
+
return restored!;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("the contract", () => {
|
|
105
|
+
test.for([true, false])(
|
|
106
|
+
"restored tree builds the same loading request (compress: %s)",
|
|
107
|
+
async (compress) => {
|
|
108
|
+
const original = buildPopulatedTree();
|
|
109
|
+
const restored = await roundTrip(original, compress);
|
|
110
|
+
|
|
111
|
+
const expected = loadingRequestOf(original);
|
|
112
|
+
// Guards against a vacuous pass: an empty tree would match trivially.
|
|
113
|
+
expect(expected.seeds.length).toBeGreaterThan(0);
|
|
114
|
+
expect(expected.skips.length).toBeGreaterThan(0);
|
|
115
|
+
|
|
116
|
+
expect(loadingRequestOf(restored)).toStrictEqual(expected);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
test("restored tree holds the same resource states", async () => {
|
|
121
|
+
const original = buildPopulatedTree();
|
|
122
|
+
const restored = await roundTrip(original);
|
|
123
|
+
|
|
124
|
+
// Byte payloads are compared as plain arrays: the decoder hands back Uint8Array views
|
|
125
|
+
// while the fixtures were built from Buffers, and strict equality would read that
|
|
126
|
+
// prototype difference as a difference in state.
|
|
127
|
+
const asBytes = (b?: Uint8Array) => (b === undefined ? undefined : [...b]);
|
|
128
|
+
const normalize = (r: ExtendedResourceData) => ({
|
|
129
|
+
...r,
|
|
130
|
+
data: asBytes(r.data),
|
|
131
|
+
kv: r.kv.map((e) => ({ key: e.key, value: asBytes(e.value) })),
|
|
132
|
+
});
|
|
133
|
+
const states = (tree: PlTreeState) =>
|
|
134
|
+
tree
|
|
135
|
+
.dumpState()
|
|
136
|
+
.map(normalize)
|
|
137
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
138
|
+
|
|
139
|
+
expect(states(restored)).toStrictEqual(states(original));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("roots survive the round trip", async () => {
|
|
143
|
+
const original = buildPopulatedTree();
|
|
144
|
+
const restored = await roundTrip(original);
|
|
145
|
+
expect([...restored.roots]).toStrictEqual([...original.roots]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("finality is recomputed, not read from the file", async () => {
|
|
149
|
+
const original = buildPopulatedTree();
|
|
150
|
+
const captured = captureTreeState(original, RootSignature);
|
|
151
|
+
const decoded = await decodePersistedTree(await encodePersistedTree(captured));
|
|
152
|
+
expect(decoded.ok).toBe(true);
|
|
153
|
+
if (!decoded.ok) throw new Error("unreachable");
|
|
154
|
+
|
|
155
|
+
// A predicate that settles nothing must yield a tree that skips nothing, even though
|
|
156
|
+
// the file says otherwise. The file's finality is an artefact of the predicate in force
|
|
157
|
+
// when it was written, and the two are allowed to disagree.
|
|
158
|
+
const restored = restoreTreeState(decoded.value, () => false);
|
|
159
|
+
expect(restored).toBeDefined();
|
|
160
|
+
expect(constructTreeLoadingRequest(restored!).finalResources.size).toBe(0);
|
|
161
|
+
expect(constructTreeLoadingRequest(original).finalResources.size).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("the witness", () => {
|
|
166
|
+
test("is readable without inflating the payload", async () => {
|
|
167
|
+
const captured = captureTreeState(buildPopulatedTree(), RootSignature);
|
|
168
|
+
const bytes = await encodePersistedTree(captured);
|
|
169
|
+
|
|
170
|
+
const header = readPersistedTreeHeader(bytes);
|
|
171
|
+
expect(header.ok).toBe(true);
|
|
172
|
+
if (!header.ok) throw new Error("unreachable");
|
|
173
|
+
|
|
174
|
+
expect(header.value.schemaVersion).toBe(PERSISTED_TREE_SCHEMA_VERSION);
|
|
175
|
+
expect(Buffer.from(header.value.witness).equals(Buffer.from(RootSignature))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("a snapshot that cannot be read", () => {
|
|
180
|
+
const encoded = async () =>
|
|
181
|
+
await encodePersistedTree(captureTreeState(buildPopulatedTree(), RootSignature));
|
|
182
|
+
|
|
183
|
+
test("a foreign file is not a snapshot", async () => {
|
|
184
|
+
const result = await decodePersistedTree(Buffer.from("this is not a tree snapshot at all"));
|
|
185
|
+
expect(result).toStrictEqual({ ok: false, reason: "not-a-snapshot" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("an empty file is not a snapshot", async () => {
|
|
189
|
+
expect(await decodePersistedTree(Buffer.alloc(0))).toStrictEqual({
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: "not-a-snapshot",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a truncated file is rejected rather than replayed", async () => {
|
|
196
|
+
const bytes = await encoded();
|
|
197
|
+
const result = await decodePersistedTree(bytes.subarray(0, bytes.length - 32));
|
|
198
|
+
expect(result.ok).toBe(false);
|
|
199
|
+
if (result.ok) throw new Error("unreachable");
|
|
200
|
+
expect(["truncated", "checksum"]).toContain(result.reason);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("a damaged payload fails its checksum", async () => {
|
|
204
|
+
const bytes = Buffer.from(await encoded());
|
|
205
|
+
// Flip a bit inside the payload, leaving the header and the length trailer intact.
|
|
206
|
+
const target = Math.floor(bytes.length / 2);
|
|
207
|
+
bytes[target] = bytes[target] ^ 0xff;
|
|
208
|
+
|
|
209
|
+
expect(await decodePersistedTree(bytes)).toStrictEqual({ ok: false, reason: "checksum" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a payload that inflates past the ceiling is refused, not inflated", async () => {
|
|
213
|
+
// The checksum only says the compressed bytes are the ones that were written, so a
|
|
214
|
+
// replaced file can be consistent and still inflate to far more than a snapshot can be.
|
|
215
|
+
const bytes = await encoded();
|
|
216
|
+
expect(await decodePersistedTree(bytes, { maxPayloadBytes: 16 })).toStrictEqual({
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "checksum",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("an unknown schema version loads as absent", async () => {
|
|
223
|
+
const bytes = Buffer.from(await encoded());
|
|
224
|
+
bytes.writeUInt16LE(PERSISTED_TREE_SCHEMA_VERSION + 1, 4);
|
|
225
|
+
|
|
226
|
+
expect(await decodePersistedTree(bytes)).toStrictEqual({ ok: false, reason: "unknown-schema" });
|
|
227
|
+
expect(readPersistedTreeHeader(bytes)).toStrictEqual({ ok: false, reason: "unknown-schema" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("every truncation point is a clean failure, never a throw", async () => {
|
|
231
|
+
const bytes = await encoded();
|
|
232
|
+
for (let length = 0; length < bytes.length; length++) {
|
|
233
|
+
const result = await decodePersistedTree(bytes.subarray(0, length));
|
|
234
|
+
expect(result.ok).toBe(false);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("a snapshot that decodes but cannot be applied", () => {
|
|
240
|
+
test("a dangling reference leaves no tree and does not throw", async () => {
|
|
241
|
+
const tree = buildPopulatedTree();
|
|
242
|
+
const captured = captureTreeState(tree, RootSignature);
|
|
243
|
+
|
|
244
|
+
// Drop a resource that others still point at. The codec has no opinion on this; the
|
|
245
|
+
// state-update call is what refuses it, which is the point of reusing that path.
|
|
246
|
+
const dangling = {
|
|
247
|
+
...captured,
|
|
248
|
+
resources: captured.resources.filter((r) => r.id !== rid(11n)),
|
|
249
|
+
};
|
|
250
|
+
const decoded = await decodePersistedTree(await encodePersistedTree(dangling));
|
|
251
|
+
expect(decoded.ok).toBe(true);
|
|
252
|
+
if (!decoded.ok) throw new Error("unreachable");
|
|
253
|
+
|
|
254
|
+
expect(restoreTreeState(decoded.value, finalByFlag)).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a live tree survives a failed restore", async () => {
|
|
258
|
+
const live = buildPopulatedTree();
|
|
259
|
+
const before = loadingRequestOf(live);
|
|
260
|
+
|
|
261
|
+
const captured = captureTreeState(live, RootSignature);
|
|
262
|
+
const decoded = await decodePersistedTree(
|
|
263
|
+
await encodePersistedTree({
|
|
264
|
+
...captured,
|
|
265
|
+
resources: captured.resources.filter((r) => r.id !== rid(11n)),
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
if (!decoded.ok) throw new Error("unreachable");
|
|
269
|
+
expect(restoreTreeState(decoded.value, finalByFlag)).toBeUndefined();
|
|
270
|
+
|
|
271
|
+
// The throwaway tree absorbed the invalidation, so the working tree is untouched.
|
|
272
|
+
expect(live.isValid).toBe(true);
|
|
273
|
+
expect(loadingRequestOf(live)).toStrictEqual(before);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("capture", () => {
|
|
278
|
+
test("refuses an invalidated tree", () => {
|
|
279
|
+
const tree = buildPopulatedTree();
|
|
280
|
+
tree.invalidateTree("test");
|
|
281
|
+
expect(() => captureTreeState(tree, RootSignature)).toThrow(/invalidated/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("an empty tree round trips", async () => {
|
|
285
|
+
const empty = new PlTreeState(RootId, finalByFlag);
|
|
286
|
+
const restored = await roundTrip(empty);
|
|
287
|
+
expect(loadingRequestOf(restored)).toStrictEqual(loadingRequestOf(empty));
|
|
288
|
+
// An unmaterialized root is still seeded, which is how a cold tree starts.
|
|
289
|
+
expect(loadingRequestOf(restored).seeds).toStrictEqual([RootId]);
|
|
290
|
+
});
|
|
291
|
+
});
|