@milaboratories/pl-middle-layer 1.74.19 → 1.76.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/index.cjs +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/middle_layer/block.cjs.map +1 -1
- package/dist/middle_layer/block.js.map +1 -1
- package/dist/middle_layer/block_action_context.cjs +57 -0
- package/dist/middle_layer/block_action_context.cjs.map +1 -0
- package/dist/middle_layer/block_action_context.d.ts +47 -0
- package/dist/middle_layer/block_action_context.d.ts.map +1 -0
- package/dist/middle_layer/block_action_context.js +57 -0
- package/dist/middle_layer/block_action_context.js.map +1 -0
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/column_links.cjs +106 -0
- package/dist/middle_layer/column_links.cjs.map +1 -0
- package/dist/middle_layer/column_links.js +104 -0
- package/dist/middle_layer/column_links.js.map +1 -0
- package/dist/middle_layer/index.d.ts +2 -1
- package/dist/middle_layer/middle_layer.cjs +1 -0
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.js +1 -0
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project.cjs +53 -19
- package/dist/middle_layer/project.cjs.map +1 -1
- package/dist/middle_layer/project.d.ts +6 -0
- package/dist/middle_layer/project.d.ts.map +1 -1
- package/dist/middle_layer/project.js +54 -20
- package/dist/middle_layer/project.js.map +1 -1
- package/dist/middle_layer/project_overview.cjs +56 -24
- package/dist/middle_layer/project_overview.cjs.map +1 -1
- package/dist/middle_layer/project_overview.js +55 -25
- package/dist/middle_layer/project_overview.js.map +1 -1
- package/dist/model/args_derivation.cjs +153 -0
- package/dist/model/args_derivation.cjs.map +1 -0
- package/dist/model/args_derivation.d.ts +21 -0
- package/dist/model/args_derivation.d.ts.map +1 -0
- package/dist/model/args_derivation.js +148 -0
- package/dist/model/args_derivation.js.map +1 -0
- package/dist/model/project_helper.cjs +34 -8
- package/dist/model/project_helper.cjs.map +1 -1
- package/dist/model/project_helper.d.ts +18 -5
- package/dist/model/project_helper.d.ts.map +1 -1
- package/dist/model/project_helper.js +34 -8
- package/dist/model/project_helper.js.map +1 -1
- package/dist/model/project_model.cjs +2 -1
- package/dist/model/project_model.cjs.map +1 -1
- package/dist/model/project_model.d.ts +1 -1
- package/dist/model/project_model.js +2 -1
- package/dist/model/project_model.js.map +1 -1
- package/dist/mutator/project.cjs +73 -12
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts +1 -0
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +73 -12
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/template/render_block.cjs +35 -2
- package/dist/mutator/template/render_block.cjs.map +1 -1
- package/dist/mutator/template/render_block.js +35 -4
- package/dist/mutator/template/render_block.js.map +1 -1
- package/dist/mutator/template/render_template.cjs +16 -1
- package/dist/mutator/template/render_template.cjs.map +1 -1
- package/dist/mutator/template/render_template.d.ts +12 -1
- package/dist/mutator/template/render_template.d.ts.map +1 -1
- package/dist/mutator/template/render_template.js +16 -2
- package/dist/mutator/template/render_template.js.map +1 -1
- package/package.json +16 -16
- package/src/middle_layer/block.ts +4 -1
- package/src/middle_layer/block_action_context.test.ts +148 -0
- package/src/middle_layer/block_action_context.ts +162 -0
- package/src/middle_layer/column_links.test.ts +350 -0
- package/src/middle_layer/column_links.ts +152 -0
- package/src/middle_layer/index.ts +6 -0
- package/src/middle_layer/middle_layer.ts +2 -0
- package/src/middle_layer/project.ts +52 -5
- package/src/middle_layer/project_overview.test.ts +200 -0
- package/src/middle_layer/project_overview.ts +82 -28
- package/src/model/args.test.ts +39 -1
- package/src/model/args_derivation.test.ts +326 -0
- package/src/model/args_derivation.ts +194 -0
- package/src/model/project_helper.ts +55 -25
- package/src/model/project_model.ts +7 -2
- package/src/mutator/project-v3.test.ts +105 -1
- package/src/mutator/project.ts +128 -37
- package/src/mutator/project_action_context.test.ts +178 -0
- package/src/mutator/template/render_block.ts +70 -5
- package/src/mutator/template/render_template.ts +22 -2
- package/src/test/fake_project.ts +222 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ArgsIssue } from "@milaboratories/pl-model-common";
|
|
2
|
+
import type { BlockConfig } from "@platforma-sdk/model";
|
|
3
|
+
import { BlockStorageFacadeCallbacks } from "@platforma-sdk/model";
|
|
4
|
+
import { canonicalJsonBytes } from "@milaboratories/ts-helpers";
|
|
5
|
+
import { getQuickJS } from "quickjs-emscripten";
|
|
6
|
+
import { beforeAll, describe, expect, test } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
argsFingerprint,
|
|
9
|
+
derivedArgsValue,
|
|
10
|
+
parseArgsDeriveResult,
|
|
11
|
+
readArgsErrorsField,
|
|
12
|
+
argsErrorsFieldChange,
|
|
13
|
+
argsErrorsOfBlock,
|
|
14
|
+
type DerivedArgs,
|
|
15
|
+
type ArgsErrorsRecord,
|
|
16
|
+
} from "./args_derivation";
|
|
17
|
+
import { ProjectHelper } from "./project_helper";
|
|
18
|
+
|
|
19
|
+
let helper: ProjectHelper;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
helper = new ProjectHelper(await getQuickJS());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const missingThreshold: ArgsIssue = { severity: "error", message: "Set a threshold" };
|
|
26
|
+
const fewSamples: ArgsIssue = { severity: "warning", message: "Only two samples" };
|
|
27
|
+
|
|
28
|
+
describe("parseArgsDeriveResult", () => {
|
|
29
|
+
test("reads the results of a model without args issues as before", () => {
|
|
30
|
+
expect(parseArgsDeriveResult({ value: { n: 1 } })).toEqual({
|
|
31
|
+
type: "runnable",
|
|
32
|
+
args: { n: 1 },
|
|
33
|
+
});
|
|
34
|
+
expect(parseArgsDeriveResult({ error: "args() threw: no input" })).toEqual({
|
|
35
|
+
type: "absent",
|
|
36
|
+
error: new Error("args() threw: no input"),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("args that are undefined are no args", () => {
|
|
41
|
+
expect(parseArgsDeriveResult({ value: undefined })).toMatchObject({ type: "absent" });
|
|
42
|
+
expect(parseArgsDeriveResult({})).toMatchObject({ type: "absent" });
|
|
43
|
+
expect(
|
|
44
|
+
parseArgsDeriveResult({ error: "Set a threshold", issues: [missingThreshold] }),
|
|
45
|
+
).toMatchObject({ type: "absent" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("warnings leave the args runnable", () => {
|
|
49
|
+
expect(parseArgsDeriveResult({ value: { n: 1 }, issues: [fewSamples] })).toEqual({
|
|
50
|
+
type: "runnable",
|
|
51
|
+
args: { n: 1 },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("errors of argsIssues block the args, warnings are left out", () => {
|
|
56
|
+
expect(
|
|
57
|
+
parseArgsDeriveResult({
|
|
58
|
+
error: "Set a threshold",
|
|
59
|
+
args: { n: 1 },
|
|
60
|
+
issues: [fewSamples, missingThreshold],
|
|
61
|
+
}),
|
|
62
|
+
).toEqual({ type: "blocked", args: { n: 1 }, errors: [missingThreshold] });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a severity other than warning is an error", () => {
|
|
66
|
+
expect(
|
|
67
|
+
parseArgsDeriveResult({
|
|
68
|
+
error: "Set a threshold",
|
|
69
|
+
args: {},
|
|
70
|
+
issues: [{ severity: "fatal", message: "Set a threshold" }],
|
|
71
|
+
}),
|
|
72
|
+
).toEqual({ type: "blocked", args: {}, errors: [missingThreshold] });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("issues that cannot be read, or hold no error, give way to the reported error", () => {
|
|
76
|
+
const reported: ArgsIssue = { severity: "error", message: "Set a threshold; Pick a column" };
|
|
77
|
+
expect(
|
|
78
|
+
parseArgsDeriveResult({ error: reported.message, args: {}, issues: "not a list" }),
|
|
79
|
+
).toEqual({ type: "blocked", args: {}, errors: [reported] });
|
|
80
|
+
expect(parseArgsDeriveResult({ error: reported.message, args: {} })).toEqual({
|
|
81
|
+
type: "blocked",
|
|
82
|
+
args: {},
|
|
83
|
+
errors: [reported],
|
|
84
|
+
});
|
|
85
|
+
expect(
|
|
86
|
+
parseArgsDeriveResult({ error: reported.message, args: {}, issues: [fewSamples] }),
|
|
87
|
+
).toEqual({ type: "blocked", args: {}, errors: [reported] });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("refuses a result that is not an object, or whose error is not a string", () => {
|
|
91
|
+
expect(() => parseArgsDeriveResult(undefined)).toThrow();
|
|
92
|
+
expect(() => parseArgsDeriveResult("args")).toThrow();
|
|
93
|
+
expect(() => parseArgsDeriveResult({ error: 42 })).toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("derivedArgsValue", () => {
|
|
98
|
+
test("gives the args of runnable and blocked derivations, nothing for absent ones", () => {
|
|
99
|
+
expect(derivedArgsValue(runnable({ n: 1 }))).toEqual({ n: 1 });
|
|
100
|
+
expect(derivedArgsValue(blocked({ n: 2 }, [missingThreshold]))).toEqual({ n: 2 });
|
|
101
|
+
expect(derivedArgsValue(absent())).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("argsFingerprint", () => {
|
|
106
|
+
test("is the same for args that encode to the same JSON, whatever the key order", () => {
|
|
107
|
+
expect(argsFingerprint({ a: 1, b: { c: [1, 2], d: "x" } })).toBe(
|
|
108
|
+
argsFingerprint(JSON.parse('{ "b": { "d": "x", "c": [1, 2] }, "a": 1 }')),
|
|
109
|
+
);
|
|
110
|
+
expect(argsFingerprint({ a: 1, skipped: undefined })).toBe(argsFingerprint({ a: 1 }));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("differs for args with other content", () => {
|
|
114
|
+
expect(argsFingerprint({ a: 1 })).not.toBe(argsFingerprint({ a: 2 }));
|
|
115
|
+
expect(argsFingerprint({ a: [1, 2] })).not.toBe(argsFingerprint({ a: [2, 1] }));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("argsErrorsFieldChange", () => {
|
|
120
|
+
test("writes the errors of blocked args, with the fingerprint of the args, into a field not there yet", () => {
|
|
121
|
+
expect(argsErrorsFieldChange(undefined, blocked({ n: 1 }, [missingThreshold]))).toEqual({
|
|
122
|
+
type: "write",
|
|
123
|
+
data: recordBytes({ n: 1 }, [missingThreshold]),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("writes a record with no errors for args that may run", () => {
|
|
128
|
+
expect(argsErrorsFieldChange(undefined, runnable({ n: 1 }))).toEqual({
|
|
129
|
+
type: "write",
|
|
130
|
+
data: recordBytes({ n: 1 }, []),
|
|
131
|
+
});
|
|
132
|
+
const current = { value: recordBytes({ n: 1 }, [missingThreshold]) };
|
|
133
|
+
expect(argsErrorsFieldChange(current, runnable({ n: 1 }))).toEqual({
|
|
134
|
+
type: "write",
|
|
135
|
+
data: recordBytes({ n: 1 }, []),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("keeps a field that already holds the same errors for the same args", () => {
|
|
140
|
+
const current = { value: recordBytes({ n: 3 }, [missingThreshold]) };
|
|
141
|
+
expect(argsErrorsFieldChange(current, blocked({ n: 3 }, [missingThreshold]))).toEqual({
|
|
142
|
+
type: "keep",
|
|
143
|
+
});
|
|
144
|
+
const noErrors = { value: recordBytes({ n: 3 }, []) };
|
|
145
|
+
expect(argsErrorsFieldChange(noErrors, runnable({ n: 3 }))).toEqual({ type: "keep" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rewrites a field holding other errors", () => {
|
|
149
|
+
const pickColumn: ArgsIssue = { severity: "error", message: "Pick a column" };
|
|
150
|
+
const current = { value: recordBytes({}, [missingThreshold]) };
|
|
151
|
+
expect(argsErrorsFieldChange(current, blocked({}, [pickColumn]))).toEqual({
|
|
152
|
+
type: "write",
|
|
153
|
+
data: recordBytes({}, [pickColumn]),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("rewrites a field holding the same errors found in other args", () => {
|
|
158
|
+
const current = { value: recordBytes({ n: 1 }, [missingThreshold]) };
|
|
159
|
+
expect(argsErrorsFieldChange(current, blocked({ n: 2 }, [missingThreshold]))).toEqual({
|
|
160
|
+
type: "write",
|
|
161
|
+
data: recordBytes({ n: 2 }, [missingThreshold]),
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("deletes the field once there are no args, and never creates it for none", () => {
|
|
166
|
+
const current = { value: recordBytes({}, [missingThreshold]) };
|
|
167
|
+
expect(argsErrorsFieldChange(current, absent())).toEqual({ type: "delete" });
|
|
168
|
+
expect(argsErrorsFieldChange(undefined, absent())).toEqual({ type: "keep" });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("readArgsErrorsField", () => {
|
|
173
|
+
test("reads the errors the field was written with, for the args they were found in", () => {
|
|
174
|
+
const change = argsErrorsFieldChange(undefined, blocked({ n: 1 }, [missingThreshold]));
|
|
175
|
+
if (change.type !== "write") throw new Error(`expected a write, got ${change.type}`);
|
|
176
|
+
expect(readArgsErrorsField(change.data, { n: 1 })).toEqual([missingThreshold]);
|
|
177
|
+
// Current args are read back from their field, which need not be canonical JSON.
|
|
178
|
+
expect(readArgsErrorsField(change.data, JSON.parse('{ "n": 1 }'))).toEqual([missingThreshold]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("reads a record with no errors as none", () => {
|
|
182
|
+
expect(readArgsErrorsField(recordBytes({ n: 1 }, []), { n: 1 })).toEqual([]);
|
|
183
|
+
// Warnings are not errors.
|
|
184
|
+
expect(readArgsErrorsField(recordBytes({ n: 1 }, [fewSamples]), { n: 1 })).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a record written for other args, as a middle layer unaware of the field leaves it, records none", () => {
|
|
188
|
+
expect(readArgsErrorsField(recordBytes({ n: 1 }, [missingThreshold]), { n: 2 })).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("no record records none, as for args written before the record existed", () => {
|
|
192
|
+
expect(readArgsErrorsField(undefined, { n: 1 })).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("holds no errors while the block has no current args", () => {
|
|
196
|
+
expect(readArgsErrorsField(recordBytes({ n: 1 }, [missingThreshold]), undefined)).toEqual([]);
|
|
197
|
+
expect(readArgsErrorsField(undefined, undefined)).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a field that cannot be read records none", () => {
|
|
201
|
+
const unreadable = [
|
|
202
|
+
Buffer.from("{"),
|
|
203
|
+
Buffer.from("true"),
|
|
204
|
+
canonicalJsonBytes([missingThreshold]),
|
|
205
|
+
canonicalJsonBytes({ errors: [missingThreshold] }),
|
|
206
|
+
canonicalJsonBytes({ argsFingerprint: argsFingerprint({ n: 1 }) }),
|
|
207
|
+
];
|
|
208
|
+
for (const data of unreadable) {
|
|
209
|
+
expect(readArgsErrorsField(data, { n: 1 })).toEqual([]);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("argsErrorsOfBlock", () => {
|
|
215
|
+
const inputsValid = { value: recordBytes({ n: 1 }, [missingThreshold]) };
|
|
216
|
+
|
|
217
|
+
test("gives the errors of a block with model API version 2 for its current args", () => {
|
|
218
|
+
expect(argsErrorsOfBlock({ modelAPIVersion: 2, inputsValid, currentArgs: { n: 1 } })).toEqual([
|
|
219
|
+
missingThreshold,
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a block whose record was written for other args, or that has none, has none", () => {
|
|
224
|
+
expect(argsErrorsOfBlock({ modelAPIVersion: 2, inputsValid, currentArgs: { n: 2 } })).toEqual(
|
|
225
|
+
[],
|
|
226
|
+
);
|
|
227
|
+
expect(
|
|
228
|
+
argsErrorsOfBlock({ modelAPIVersion: 2, inputsValid: undefined, currentArgs: { n: 1 } }),
|
|
229
|
+
).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("ignores a field left over for a block whose model is not on model API version 2", () => {
|
|
233
|
+
expect(argsErrorsOfBlock({ modelAPIVersion: 1, inputsValid, currentArgs: { n: 1 } })).toEqual(
|
|
234
|
+
[],
|
|
235
|
+
);
|
|
236
|
+
expect(
|
|
237
|
+
argsErrorsOfBlock({ modelAPIVersion: undefined, inputsValid, currentArgs: { n: 1 } }),
|
|
238
|
+
).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("ProjectHelper.deriveArgsFromStorage", () => {
|
|
243
|
+
test("a model without args issues whose args() throws has no args and no args errors", () => {
|
|
244
|
+
// What the SDK's __pl_args_derive returned before args issues, and still returns for a
|
|
245
|
+
// block that declares no argsIssues.
|
|
246
|
+
const derived = helper.deriveArgsFromStorage(
|
|
247
|
+
configWith('() => ({ error: "args() threw: Select a dataset" })'),
|
|
248
|
+
"{}",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(derived).toEqual({ type: "absent", error: new Error("args() threw: Select a dataset") });
|
|
252
|
+
expect(argsErrorsFieldChange(undefined, derived)).toEqual({ type: "keep" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a model without args issues whose args() succeeds has runnable args", () => {
|
|
256
|
+
const derived = helper.deriveArgsFromStorage(configWith("() => ({ value: { n: 1 } })"), "{}");
|
|
257
|
+
|
|
258
|
+
expect(derived).toEqual({ type: "runnable", args: { n: 1 } });
|
|
259
|
+
expect(argsErrorsFieldChange(undefined, derived)).toEqual({
|
|
260
|
+
type: "write",
|
|
261
|
+
data: recordBytes({ n: 1 }, []),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("argsIssues that found errors block the args they were given", () => {
|
|
266
|
+
const derived = helper.deriveArgsFromStorage(
|
|
267
|
+
configWith(
|
|
268
|
+
"(storageJson) => ({ error: 'Set a threshold', args: JSON.parse(storageJson), " +
|
|
269
|
+
"issues: [{ severity: 'warning', message: 'Only two samples' }, " +
|
|
270
|
+
"{ severity: 'error', message: 'Set a threshold' }] })",
|
|
271
|
+
),
|
|
272
|
+
JSON.stringify({ n: 5 }),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(derived).toEqual({ type: "blocked", args: { n: 5 }, errors: [missingThreshold] });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a model that fails, or returns something unreadable, gives no args", () => {
|
|
279
|
+
expect(
|
|
280
|
+
helper.deriveArgsFromStorage(configWith("() => { throw new Error('boom'); }"), "{}"),
|
|
281
|
+
).toMatchObject({ type: "absent" });
|
|
282
|
+
expect(helper.deriveArgsFromStorage(configWith("() => 42"), "{}")).toMatchObject({
|
|
283
|
+
type: "absent",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Internals
|
|
289
|
+
|
|
290
|
+
function runnable(args: unknown): DerivedArgs {
|
|
291
|
+
return { type: "runnable", args };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function blocked(args: unknown, errors: ArgsIssue[]): DerivedArgs {
|
|
295
|
+
return { type: "blocked", args, errors };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function recordBytes(args: unknown, errors: ArgsIssue[]): Uint8Array {
|
|
299
|
+
const record: ArgsErrorsRecord = { argsFingerprint: argsFingerprint(args), errors };
|
|
300
|
+
return canonicalJsonBytes(record);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function absent(): DerivedArgs {
|
|
304
|
+
return { type: "absent", error: new Error("args() threw: no input") };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A block config whose model code registers `body` as its __pl_args_derive callback, the way the
|
|
309
|
+
* SDK registers callbacks inside a real bundle.
|
|
310
|
+
*/
|
|
311
|
+
function configWith(body: string): BlockConfig {
|
|
312
|
+
const handle = BlockStorageFacadeCallbacks.ArgsDerive;
|
|
313
|
+
return {
|
|
314
|
+
configVersion: 4,
|
|
315
|
+
modelAPIVersion: 2,
|
|
316
|
+
sdkVersion: "1.0.0",
|
|
317
|
+
renderingMode: "Heavy",
|
|
318
|
+
sections: { __renderLambda: true, handle: "sections" },
|
|
319
|
+
outputs: {},
|
|
320
|
+
code: {
|
|
321
|
+
type: "plain",
|
|
322
|
+
content: `globalThis.cfgRenderCtx.callbackRegistry[${JSON.stringify(handle)}] = ${body};`,
|
|
323
|
+
},
|
|
324
|
+
blockLifecycleCallbacks: { [handle]: { __renderLambda: true, handle } },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { parseArgsIssues, type ArgsIssue } from "@milaboratories/pl-model-common";
|
|
2
|
+
import { cachedDeserialize, canonicalJsonBytes } from "@milaboratories/ts-helpers";
|
|
3
|
+
import { isString } from "es-toolkit";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { BLOCK_STORAGE_FACADE_VERSION } from "@platforma-sdk/model";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the args derivation of a block with model API version 2 made of the block's storage.
|
|
9
|
+
*
|
|
10
|
+
* - `runnable`: the block has args and may run with them.
|
|
11
|
+
* - `blocked`: the block has args, but its `argsIssues()` found errors in them, so it must not run.
|
|
12
|
+
* `errors` is never empty.
|
|
13
|
+
* - `absent`: the block has no args, because `args()` threw or returned nothing.
|
|
14
|
+
*/
|
|
15
|
+
export type DerivedArgs =
|
|
16
|
+
| { readonly type: "runnable"; readonly args: unknown }
|
|
17
|
+
| { readonly type: "blocked"; readonly args: unknown; readonly errors: readonly ArgsIssue[] }
|
|
18
|
+
| { readonly type: "absent"; readonly error: Error };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a block's `inputsValid` field holds: the errors found in the block's current args, and the
|
|
22
|
+
* fingerprint of the args they were found in. Every args derivation that produces args writes
|
|
23
|
+
* it, with no errors when the block may run.
|
|
24
|
+
*
|
|
25
|
+
* A middle layer that predates args issues never writes or deletes the field, so after it edits
|
|
26
|
+
* the block, the field can be missing or outlive the args it was computed for. The fingerprint
|
|
27
|
+
* tells such a stale field apart: it no longer matches the current args, and says nothing of them.
|
|
28
|
+
*/
|
|
29
|
+
export type ArgsErrorsRecord = {
|
|
30
|
+
/** {@link argsFingerprint} of the current args the errors were found in. */
|
|
31
|
+
readonly argsFingerprint: string;
|
|
32
|
+
/** The errors; empty when the block may run with the args. */
|
|
33
|
+
readonly errors: readonly ArgsIssue[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** How a block's `inputsValid` field must change to hold what an args derivation found. */
|
|
37
|
+
export type ArgsErrorsFieldChange =
|
|
38
|
+
| { readonly type: "keep" }
|
|
39
|
+
| { readonly type: "write"; readonly data: Uint8Array }
|
|
40
|
+
| { readonly type: "delete" };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads the result of the model's `__pl_args_derive` callback.
|
|
44
|
+
*
|
|
45
|
+
* A model built before args issues returns `{ value }` for args and `{ error }` for none; both
|
|
46
|
+
* read as they did then. A newer model adds the args issues, and returns `{ error, args, issues }`
|
|
47
|
+
* when its `argsIssues()` found errors. Args that are `undefined` are no args, whatever
|
|
48
|
+
* `argsIssues()` said.
|
|
49
|
+
*
|
|
50
|
+
* @throws when `value` is not an object, or carries an `error` that is not a string
|
|
51
|
+
*/
|
|
52
|
+
export function parseArgsDeriveResult(value: unknown): DerivedArgs {
|
|
53
|
+
if (typeof value !== "object" || value === null) {
|
|
54
|
+
throw new Error(`Args derivation returned ${value === null ? "null" : typeof value}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const error = "error" in value ? value.error : undefined;
|
|
58
|
+
if (error !== undefined) {
|
|
59
|
+
if (!isString(error)) throw new Error(`Args derivation error is ${typeof error}, not a string`);
|
|
60
|
+
const args = "args" in value ? value.args : undefined;
|
|
61
|
+
if (args === undefined) return { type: "absent", error: new Error(error) };
|
|
62
|
+
const issues = "issues" in value ? value.issues : undefined;
|
|
63
|
+
return { type: "blocked", args, errors: argsErrorsOf(issues, error) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const args = "value" in value ? value.value : undefined;
|
|
67
|
+
if (args === undefined) return { type: "absent", error: new Error("args() returned no args") };
|
|
68
|
+
return { type: "runnable", args };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The args an args derivation produced; `undefined` when it produced none. */
|
|
72
|
+
export function derivedArgsValue(derived: DerivedArgs): unknown {
|
|
73
|
+
return derived.type === "absent" ? undefined : derived.args;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Identifies a value of a block's current args by its content: the SHA-256 of its canonical JSON,
|
|
78
|
+
* in hex. Args that encode to the same JSON, whatever the key order, share a fingerprint.
|
|
79
|
+
*/
|
|
80
|
+
export function argsFingerprint(args: unknown): string {
|
|
81
|
+
return createHash("sha256").update(canonicalJsonBytes(args)).digest("hex");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a block's `inputsValid` field must become after an args derivation, given the field as it
|
|
86
|
+
* is now.
|
|
87
|
+
*
|
|
88
|
+
* The field holds a {@link ArgsErrorsRecord} as canonical JSON, and exists while the block has
|
|
89
|
+
* args: with the errors its `argsIssues()` found in them, and with none when it found none. A
|
|
90
|
+
* field already holding the same record is kept as it is, so that readers of the project are not
|
|
91
|
+
* woken by a write that changes nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function argsErrorsFieldChange(
|
|
94
|
+
current: { readonly value?: Uint8Array } | undefined,
|
|
95
|
+
derived: DerivedArgs,
|
|
96
|
+
): ArgsErrorsFieldChange {
|
|
97
|
+
if (derived.type === "absent")
|
|
98
|
+
return current === undefined ? { type: "keep" } : { type: "delete" };
|
|
99
|
+
const record: ArgsErrorsRecord = {
|
|
100
|
+
argsFingerprint: argsFingerprint(derived.args),
|
|
101
|
+
errors: derived.type === "blocked" ? derived.errors : [],
|
|
102
|
+
};
|
|
103
|
+
const data = canonicalJsonBytes(record);
|
|
104
|
+
if (current?.value !== undefined && Buffer.compare(current.value, data) === 0) {
|
|
105
|
+
return { type: "keep" };
|
|
106
|
+
}
|
|
107
|
+
return { type: "write", data };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The errors a block's `inputsValid` field, as {@link argsErrorsFieldChange} writes it, records
|
|
112
|
+
* for the block's current args.
|
|
113
|
+
*
|
|
114
|
+
* A field that is missing, cannot be read, or was written for other args records none: the args
|
|
115
|
+
* were written by a middle layer that does not know the field. Such a middle layer refuses every
|
|
116
|
+
* block whose `argsIssues()` could find errors, so it wrote args only for blocks whose `args()`
|
|
117
|
+
* throws instead, and those args may run, as they always could. No current args hold no errors:
|
|
118
|
+
* without args the block may not run anyway.
|
|
119
|
+
*
|
|
120
|
+
* @param data - Content of the field; `undefined` when there is none, or while it is not loaded
|
|
121
|
+
* @param currentArgs - The block's current args as decoded from its `currentArgs` field;
|
|
122
|
+
* `undefined` when the block has none
|
|
123
|
+
*/
|
|
124
|
+
export function readArgsErrorsField(
|
|
125
|
+
data: Uint8Array | undefined,
|
|
126
|
+
currentArgs: unknown,
|
|
127
|
+
): readonly ArgsIssue[] {
|
|
128
|
+
if (currentArgs === undefined || data === undefined) return [];
|
|
129
|
+
let record: ArgsErrorsRecord;
|
|
130
|
+
try {
|
|
131
|
+
record = parseArgsErrorsRecord(cachedDeserialize(data));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
return record.argsFingerprint === argsFingerprint(currentArgs) ? record.errors : [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The errors found in a block's current args, read from its `inputsValid` field as
|
|
140
|
+
* {@link readArgsErrorsField} reads it.
|
|
141
|
+
*
|
|
142
|
+
* Only a block with model API version 2 has args errors. An earlier model tells whether the block
|
|
143
|
+
* may run by itself, and a field left over from a later model version does not apply to it: such
|
|
144
|
+
* a block has none.
|
|
145
|
+
*/
|
|
146
|
+
export function argsErrorsOfBlock(block: {
|
|
147
|
+
/** Model API version of the block's current block pack. */
|
|
148
|
+
readonly modelAPIVersion: number | undefined;
|
|
149
|
+
/** The block's `inputsValid` field; `undefined` when there is none. */
|
|
150
|
+
readonly inputsValid: { readonly value?: Uint8Array } | undefined;
|
|
151
|
+
/** The block's current args; `undefined` when it has none. */
|
|
152
|
+
readonly currentArgs: unknown;
|
|
153
|
+
}): readonly ArgsIssue[] {
|
|
154
|
+
const { modelAPIVersion, inputsValid, currentArgs } = block;
|
|
155
|
+
if (modelAPIVersion !== BLOCK_STORAGE_FACADE_VERSION) return [];
|
|
156
|
+
return readArgsErrorsField(inputsValid?.value, currentArgs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Internals
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The errors among the issues of a derivation whose `argsIssues()` found errors. Issues that
|
|
163
|
+
* cannot be read, or that hold no error, give way to `error`, the joined messages the model
|
|
164
|
+
* reported.
|
|
165
|
+
*/
|
|
166
|
+
function argsErrorsOf(issues: unknown, error: string): readonly ArgsIssue[] {
|
|
167
|
+
try {
|
|
168
|
+
const errors = parseArgsIssues(issues).filter(isError);
|
|
169
|
+
if (errors.length > 0) return errors;
|
|
170
|
+
} catch {
|
|
171
|
+
// An older or broken model: `error` alone tells why the block must not run.
|
|
172
|
+
}
|
|
173
|
+
return [{ severity: "error", message: error }];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reads a {@link ArgsErrorsRecord} from decoded field content. Issues other than errors are
|
|
178
|
+
* dropped.
|
|
179
|
+
*
|
|
180
|
+
* @throws when `value` is not an object carrying a string fingerprint and a list of issues
|
|
181
|
+
*/
|
|
182
|
+
function parseArgsErrorsRecord(value: unknown): ArgsErrorsRecord {
|
|
183
|
+
if (typeof value !== "object" || value === null) {
|
|
184
|
+
throw new Error("Args errors record must be an object");
|
|
185
|
+
}
|
|
186
|
+
const fingerprint = "argsFingerprint" in value ? value.argsFingerprint : undefined;
|
|
187
|
+
if (!isString(fingerprint)) throw new Error("Args errors record must carry a fingerprint");
|
|
188
|
+
const errors = parseArgsIssues("errors" in value ? value.errors : undefined).filter(isError);
|
|
189
|
+
return { argsFingerprint: fingerprint, errors };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isError(issue: ArgsIssue): boolean {
|
|
193
|
+
return issue.severity === "error";
|
|
194
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResultOrError, BlockConfig, BlockStorage, PlRef } from "@platforma-sdk/model";
|
|
2
|
-
import type { StringifiedJson } from "@milaboratories/pl-model-common";
|
|
2
|
+
import type { ColumnReference, StringifiedJson } from "@milaboratories/pl-model-common";
|
|
3
|
+
import { collectColumnReferences } from "@milaboratories/pl-model-common";
|
|
3
4
|
import {
|
|
4
5
|
extractCodeWithInfo,
|
|
5
6
|
ensureError,
|
|
@@ -12,6 +13,7 @@ import { executeSingleLambda } from "../js_render";
|
|
|
12
13
|
import type { SignedResourceId } from "@milaboratories/pl-client";
|
|
13
14
|
import { ConsoleLoggerAdapter, type MiLogger } from "@milaboratories/ts-helpers";
|
|
14
15
|
import { getDebugFlags } from "../debug";
|
|
16
|
+
import { parseArgsDeriveResult, type DerivedArgs } from "./args_derivation";
|
|
15
17
|
|
|
16
18
|
type EnrichmentTargetsRequest = {
|
|
17
19
|
blockConfig: () => BlockConfig;
|
|
@@ -34,10 +36,11 @@ export type MigrationResult =
|
|
|
34
36
|
| { error?: undefined; newStorageJson: StringifiedJson<BlockStorage>; info: string };
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* Result of
|
|
38
|
-
* Returned by
|
|
39
|
+
* Result of a derivation from storage that either produced a value or failed.
|
|
40
|
+
* Returned by the __pl_prerunArgs_derive and __pl_initializationParams_derive VM callbacks; a
|
|
41
|
+
* newer model adds the issues found in the prerun args, which are not read here.
|
|
39
42
|
*/
|
|
40
|
-
type
|
|
43
|
+
type DeriveResult = { error: string } | { error?: undefined; value: unknown };
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Result of building initial storage from params.
|
|
@@ -65,6 +68,12 @@ export class ProjectHelper {
|
|
|
65
68
|
},
|
|
66
69
|
});
|
|
67
70
|
|
|
71
|
+
// An args resource never changes, so its id alone identifies the columns found in it.
|
|
72
|
+
private readonly columnReferencesCache = new LRUCache<
|
|
73
|
+
SignedResourceId,
|
|
74
|
+
readonly ColumnReference[]
|
|
75
|
+
>({ max: 1024 });
|
|
76
|
+
|
|
68
77
|
constructor(
|
|
69
78
|
private readonly quickJs: QuickJSWASMModule,
|
|
70
79
|
public readonly logger: MiLogger = new ConsoleLoggerAdapter(),
|
|
@@ -76,39 +85,37 @@ export class ProjectHelper {
|
|
|
76
85
|
|
|
77
86
|
/**
|
|
78
87
|
* Derives args directly from storage JSON using VM callback.
|
|
79
|
-
* The VM extracts data from storage
|
|
88
|
+
* The VM extracts data from storage, calls the block's args() function and then its argsIssues().
|
|
80
89
|
*
|
|
81
90
|
* This allows the middle layer to work only with storage JSON,
|
|
82
91
|
* without needing to know the underlying data structure.
|
|
83
92
|
*
|
|
84
93
|
* @param blockConfig The block configuration (provides the model code)
|
|
85
94
|
* @param storageJson Storage as JSON string
|
|
86
|
-
* @returns The derived args
|
|
95
|
+
* @returns The derived args and whether the block may run with them, or why there are none
|
|
87
96
|
*/
|
|
88
|
-
public deriveArgsFromStorage(
|
|
89
|
-
blockConfig: BlockConfig,
|
|
90
|
-
storageJson: string,
|
|
91
|
-
): ResultOrError<unknown> {
|
|
97
|
+
public deriveArgsFromStorage(blockConfig: BlockConfig, storageJson: string): DerivedArgs {
|
|
92
98
|
if (blockConfig.modelAPIVersion !== BLOCK_STORAGE_FACADE_VERSION) {
|
|
93
99
|
return {
|
|
100
|
+
type: "absent",
|
|
94
101
|
error: new Error("deriveArgsFromStorage is only supported for model API version 2"),
|
|
95
102
|
};
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return { error: new Error(result.error) };
|
|
108
|
-
}
|
|
109
|
-
return { value: result.value };
|
|
106
|
+
return parseArgsDeriveResult(
|
|
107
|
+
executeSingleLambda(
|
|
108
|
+
this.quickJs,
|
|
109
|
+
blockConfig.blockLifecycleCallbacks[BlockStorageFacadeCallbacks.ArgsDerive],
|
|
110
|
+
extractCodeWithInfo(blockConfig),
|
|
111
|
+
storageJson,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
110
114
|
} catch (e) {
|
|
111
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
type: "absent",
|
|
117
|
+
error: new Error("Args derivation from storage failed", { cause: ensureError(e) }),
|
|
118
|
+
};
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
|
|
@@ -116,9 +123,13 @@ export class ProjectHelper {
|
|
|
116
123
|
* Derives prerunArgs directly from storage JSON using VM callback.
|
|
117
124
|
* Falls back to args() if prerunArgs is not defined in the block model.
|
|
118
125
|
*
|
|
126
|
+
* Errors the block's `prerunArgsIssues()` finds in its prerun args, or for a block without its
|
|
127
|
+
* own prerunArgs, the errors its `argsIssues()` finds in its args, come back as an `error` and
|
|
128
|
+
* skip staging the same way.
|
|
129
|
+
*
|
|
119
130
|
* @param blockConfig The block configuration (provides the model code)
|
|
120
131
|
* @param storageJson Storage as JSON string
|
|
121
|
-
* @returns The derived prerunArgs, or undefined if derivation fails
|
|
132
|
+
* @returns The derived prerunArgs, or undefined if derivation fails or found errors
|
|
122
133
|
*/
|
|
123
134
|
public derivePrerunArgsFromStorage(blockConfig: BlockConfig, storageJson: string): unknown {
|
|
124
135
|
if (blockConfig.modelAPIVersion !== BLOCK_STORAGE_FACADE_VERSION) {
|
|
@@ -131,7 +142,7 @@ export class ProjectHelper {
|
|
|
131
142
|
blockConfig.blockLifecycleCallbacks[BlockStorageFacadeCallbacks.PrerunArgsDerive],
|
|
132
143
|
extractCodeWithInfo(blockConfig),
|
|
133
144
|
storageJson,
|
|
134
|
-
) as
|
|
145
|
+
) as DeriveResult;
|
|
135
146
|
|
|
136
147
|
if (result.error !== undefined) {
|
|
137
148
|
// Return undefined if derivation fails (skip block in staging)
|
|
@@ -207,7 +218,7 @@ export class ProjectHelper {
|
|
|
207
218
|
callback,
|
|
208
219
|
extractCodeWithInfo(blockConfig),
|
|
209
220
|
storageJson,
|
|
210
|
-
) as
|
|
221
|
+
) as DeriveResult;
|
|
211
222
|
|
|
212
223
|
if (result.error !== undefined) {
|
|
213
224
|
return { error: new Error(result.error) };
|
|
@@ -250,6 +261,25 @@ export class ProjectHelper {
|
|
|
250
261
|
return this.enrichmentTargetsCache.memo(cacheKey, { context: req }).value;
|
|
251
262
|
}
|
|
252
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Columns referenced by a block's args, with the values of them the args picked, read from the
|
|
266
|
+
* args resource `argsRid`. `undefined` while the resource data is not loaded yet: `args`
|
|
267
|
+
* returns `undefined` then, and nothing is remembered for such a read, so the references are
|
|
268
|
+
* found once the data arrives.
|
|
269
|
+
*/
|
|
270
|
+
public getColumnReferences(
|
|
271
|
+
argsRid: SignedResourceId,
|
|
272
|
+
args: () => unknown,
|
|
273
|
+
): undefined | readonly ColumnReference[] {
|
|
274
|
+
const cached = this.columnReferencesCache.get(argsRid);
|
|
275
|
+
if (cached !== undefined) return cached;
|
|
276
|
+
const data = args();
|
|
277
|
+
if (data === undefined) return undefined;
|
|
278
|
+
const references = collectColumnReferences(data);
|
|
279
|
+
this.columnReferencesCache.set(argsRid, references);
|
|
280
|
+
return references;
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
// =============================================================================
|
|
254
284
|
// VM-based Storage Operations
|
|
255
285
|
// =============================================================================
|