@platforma-sdk/model 1.81.0 → 1.82.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/block_migrations.cjs +92 -8
- package/dist/block_migrations.cjs.map +1 -1
- package/dist/block_migrations.d.ts +121 -32
- package/dist/block_migrations.d.ts.map +1 -1
- package/dist/block_migrations.js +92 -8
- package/dist/block_migrations.js.map +1 -1
- package/dist/block_model.cjs +69 -15
- package/dist/block_model.cjs.map +1 -1
- package/dist/block_model.d.ts +58 -22
- package/dist/block_model.d.ts.map +1 -1
- package/dist/block_model.js +71 -17
- package/dist/block_model.js.map +1 -1
- package/dist/block_storage_callbacks.cjs +194 -13
- package/dist/block_storage_callbacks.cjs.map +1 -1
- package/dist/block_storage_callbacks.js +192 -15
- package/dist/block_storage_callbacks.js.map +1 -1
- package/dist/block_storage_facade.cjs +4 -1
- package/dist/block_storage_facade.cjs.map +1 -1
- package/dist/block_storage_facade.d.ts +102 -0
- package/dist/block_storage_facade.d.ts.map +1 -1
- package/dist/block_storage_facade.js +4 -1
- package/dist/block_storage_facade.js.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/utils.cjs +1 -1
- package/dist/components/PlDataTable/createPlDataTable/utils.cjs.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/utils.js +1 -1
- package/dist/components/PlDataTable/createPlDataTable/utils.js.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.js +1 -1
- package/package.json +9 -8
- package/src/block_migrations.ts +205 -55
- package/src/block_model.ts +190 -59
- package/src/block_storage_callbacks.ts +294 -15
- package/src/block_storage_facade.ts +95 -0
- package/src/components/PlDataTable/createPlDataTable/utils.test.ts +31 -0
- package/src/components/PlDataTable/createPlDataTable/utils.ts +4 -1
- package/src/kind_reference.test.ts +134 -0
- package/src/template_init.test.ts +413 -0
- package/src/template_params.test.ts +135 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { PlRef } from "@milaboratories/pl-model-common";
|
|
3
|
+
import { createPlRef, stringifyJson } from "@milaboratories/pl-model-common";
|
|
4
|
+
import type { PluginHandle } from "./plugin_handle";
|
|
5
|
+
import type { PluginName } from "./block_storage";
|
|
6
|
+
import { BLOCK_STORAGE_KEY, createBlockStorage, isBlockStorage } from "./block_storage";
|
|
7
|
+
import {
|
|
8
|
+
createInitialStorage,
|
|
9
|
+
createInitialStorageFromParams,
|
|
10
|
+
deriveTemplateParamsFromStorage,
|
|
11
|
+
validateTemplateParams,
|
|
12
|
+
validateTemplateParamsJson,
|
|
13
|
+
} from "./block_storage_callbacks";
|
|
14
|
+
import { DataModelBuilder } from "./block_migrations";
|
|
15
|
+
import { defineBlockKind } from "@platforma-sdk/block-kind";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The apply half of the template contract: params in, storage out.
|
|
19
|
+
*
|
|
20
|
+
* `template_params.test.ts` covers the export direction. These cover the inverse,
|
|
21
|
+
* plus the one property that ties the two together — params that survive a round
|
|
22
|
+
* trip through storage.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type Params = { sources?: PlRef[]; label: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Deliberately permissive: these tests are about what the apply path does with params
|
|
29
|
+
* the kind already accepted, so the parser here only carries the shape through. The
|
|
30
|
+
* rejection behaviour is covered by the `validateTemplateParams` cases below, which
|
|
31
|
+
* supply their own strict parsers.
|
|
32
|
+
*/
|
|
33
|
+
const passThrough = (value: unknown) => value as Params;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* No entries created yet, so nothing to repoint.
|
|
37
|
+
*
|
|
38
|
+
* Relocation is the callback's other half and is covered where it lives — `relocateBlockIds`
|
|
39
|
+
* in `pl-model-common`. What these tests drive is the factory and the shape of the storage it
|
|
40
|
+
* produces, and an empty map keeps the params arriving exactly as written.
|
|
41
|
+
*/
|
|
42
|
+
const NO_IDS = JSON.stringify({});
|
|
43
|
+
|
|
44
|
+
const kind = defineBlockKind<Params>({
|
|
45
|
+
name: "@platforma-open/milaboratories.demo.kind",
|
|
46
|
+
version: "1.0.0",
|
|
47
|
+
parseInitializationParams: passThrough,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type BlockData = { sources: PlRef[]; label: string; scratch: number };
|
|
51
|
+
|
|
52
|
+
const dataModel = new DataModelBuilder({ kind }).from<BlockData>("v1").init(({ params }) => ({
|
|
53
|
+
sources: params?.sources ?? [],
|
|
54
|
+
label: params?.label ?? "",
|
|
55
|
+
scratch: 0,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const upstream = "3f1c2b7a-0000-4000-8000-000000000001";
|
|
59
|
+
|
|
60
|
+
/** Hooks for a block with no plugins — the common case. */
|
|
61
|
+
const noPlugins = {
|
|
62
|
+
getPluginRegistry: () => ({}),
|
|
63
|
+
createPluginData: () => {
|
|
64
|
+
throw new Error("no plugins registered");
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fromParams = (params: unknown) =>
|
|
69
|
+
createInitialStorageFromParams(JSON.stringify(params), NO_IDS, {
|
|
70
|
+
getBlockDataFromParams: (p) => dataModel.getDataFromParams(p),
|
|
71
|
+
parseInitializationParams: passThrough,
|
|
72
|
+
...noPlugins,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Storage the callback produced, parsed back. Fails the test if it errored. */
|
|
76
|
+
function storageOf(result: ReturnType<typeof fromParams>) {
|
|
77
|
+
if (result.error !== undefined) throw new Error(`expected storage, got: ${result.error}`);
|
|
78
|
+
return JSON.parse(result.storageJson) as Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("createInitialStorageFromParams", () => {
|
|
82
|
+
test("the block's init factory receives the entry's params", () => {
|
|
83
|
+
const storage = storageOf(fromParams({ label: "run 1" }));
|
|
84
|
+
|
|
85
|
+
expect(storage.__data).toEqual({ sources: [], label: "run 1", scratch: 0 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("references arrive as PlRefs and land in data untouched", () => {
|
|
89
|
+
// The engine resolves template-local references before this point, so what the
|
|
90
|
+
// factory sees is an ordinary live reference to a block that already exists.
|
|
91
|
+
const ref = createPlRef(upstream, "reads");
|
|
92
|
+
const storage = storageOf(fromParams({ label: "wired", sources: [ref] }));
|
|
93
|
+
|
|
94
|
+
expect((storage.__data as BlockData).sources).toEqual([ref]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the result is well-formed storage at the current data version", () => {
|
|
98
|
+
const storage = storageOf(fromParams({ label: "x" }));
|
|
99
|
+
|
|
100
|
+
expect(isBlockStorage(storage)).toBe(true);
|
|
101
|
+
expect(storage[BLOCK_STORAGE_KEY]).toBeDefined();
|
|
102
|
+
expect(storage.__dataVersion).toBe(dataModel.version);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a block created from params is shaped exactly like one created from defaults", () => {
|
|
106
|
+
// Same envelope, only `__data` differs — which is what lets an applied block be
|
|
107
|
+
// read, migrated and edited by every path that never heard of templates.
|
|
108
|
+
const fromDefaults = JSON.parse(
|
|
109
|
+
createInitialStorage({
|
|
110
|
+
getDefaultBlockData: () => dataModel.getDefaultData(),
|
|
111
|
+
getPluginRegistry: noPlugins.getPluginRegistry,
|
|
112
|
+
createPluginData: noPlugins.createPluginData,
|
|
113
|
+
}),
|
|
114
|
+
) as Record<string, unknown>;
|
|
115
|
+
const applied = storageOf(fromParams({ label: "x" }));
|
|
116
|
+
|
|
117
|
+
expect(Object.keys(applied).sort()).toEqual(Object.keys(fromDefaults).sort());
|
|
118
|
+
expect({ ...applied, __data: null }).toEqual({ ...fromDefaults, __data: null });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("empty params are used as-is, not treated as absent", () => {
|
|
122
|
+
// An entry with `params: {}` says "initialize from nothing in particular";
|
|
123
|
+
// the factory's own fallbacks fill in, and the applier never substitutes
|
|
124
|
+
// defaults on the block's behalf.
|
|
125
|
+
expect(storageOf(fromParams({})).__data).toEqual({ sources: [], label: "", scratch: 0 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("params that are not valid JSON are reported", () => {
|
|
129
|
+
const result = createInitialStorageFromParams("{not json", NO_IDS, {
|
|
130
|
+
getBlockDataFromParams: (p) => dataModel.getDataFromParams(p),
|
|
131
|
+
parseInitializationParams: passThrough,
|
|
132
|
+
...noPlugins,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result.error).toMatch(/params are not valid JSON/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a factory that rejects the params is reported, not propagated", () => {
|
|
139
|
+
// The expected failure mode for a hand-written template file: params the block
|
|
140
|
+
// cannot make sense of. It must come back as a problem the applier can attach
|
|
141
|
+
// to an entry, not as a throw that aborts the whole apply.
|
|
142
|
+
const result = createInitialStorageFromParams(JSON.stringify({ label: "" }), NO_IDS, {
|
|
143
|
+
getBlockDataFromParams: () => {
|
|
144
|
+
throw new Error("label must not be empty");
|
|
145
|
+
},
|
|
146
|
+
parseInitializationParams: passThrough,
|
|
147
|
+
...noPlugins,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result).toEqual({ error: "init() threw on the given params: label must not be empty" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("plugins are created at their defaults, never from params", () => {
|
|
154
|
+
// Params belong to the block's kind; a plugin has no params channel, so it is
|
|
155
|
+
// initialized the same way whether the block came from a template or the UI.
|
|
156
|
+
const handle = "p1" as PluginHandle;
|
|
157
|
+
const result = createInitialStorageFromParams(JSON.stringify({ label: "x" }), NO_IDS, {
|
|
158
|
+
getBlockDataFromParams: (p) => dataModel.getDataFromParams(p),
|
|
159
|
+
parseInitializationParams: passThrough,
|
|
160
|
+
getPluginRegistry: () => ({ [handle]: "demoPlugin" as PluginName }),
|
|
161
|
+
createPluginData: (h) => {
|
|
162
|
+
expect(h).toBe(handle);
|
|
163
|
+
return { version: "v1", data: { items: [] } };
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(storageOf(result).__plugins).toEqual({
|
|
168
|
+
[handle]: { __dataVersion: "v1", __data: { items: [] } },
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("DataModel.getDataFromParams", () => {
|
|
174
|
+
test("a factory that ignores params matches getDefaultData", () => {
|
|
175
|
+
const paramless = new DataModelBuilder().from<{ n: number }>("v1").init(() => ({ n: 7 }));
|
|
176
|
+
|
|
177
|
+
expect(paramless.getDataFromParams({ n: 99 })).toEqual(paramless.getDefaultData());
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("undefined params are what a factory sees from getDefaultData", () => {
|
|
181
|
+
// The two entry points must not diverge on the "no params" case: an entry
|
|
182
|
+
// without params goes through StorageInitial, and a factory written against
|
|
183
|
+
// `params?.x ?? default` has to behave identically either way.
|
|
184
|
+
expect(dataModel.getDataFromParams(undefined)).toEqual(dataModel.getDefaultData());
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("params round trip", () => {
|
|
189
|
+
test("init then templateParams returns the params it started from", () => {
|
|
190
|
+
// The closest thing to an export→import round trip available without the engine: params
|
|
191
|
+
// → storage → params. It comes back byte-identical, references included — a template
|
|
192
|
+
// holds what the block holds, so the two ends of the trip have nothing to convert.
|
|
193
|
+
const params = { label: "run 1", sources: [createPlRef(upstream, "reads")] };
|
|
194
|
+
|
|
195
|
+
const storageJson = stringifyJson(
|
|
196
|
+
createBlockStorage(storageOf(fromParams(params)).__data as BlockData),
|
|
197
|
+
);
|
|
198
|
+
const derived = deriveTemplateParamsFromStorage(storageJson, (data) => {
|
|
199
|
+
const d = data as BlockData;
|
|
200
|
+
return { sources: d.sources, label: d.label };
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(derived).toEqual({ value: params });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("validateTemplateParams", () => {
|
|
208
|
+
test("the parser's output is what flows on, not its input", () => {
|
|
209
|
+
// This is what makes a schema able to strip keys the kind does not declare, which
|
|
210
|
+
// is the difference between a typo being ignored and a typo being caught.
|
|
211
|
+
const result = validateTemplateParams({ label: "x", stray: 1 }, () => ({ label: "x" }));
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual({ value: { label: "x" } });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("there is no unchecked pass", () => {
|
|
217
|
+
// Every kind declares a parser, so the result carries no "was this checked" flag:
|
|
218
|
+
// a pass means the params were held to the contract. A parser is the only way a
|
|
219
|
+
// value gets through, and one that rejects everything is still a parser.
|
|
220
|
+
expect(
|
|
221
|
+
validateTemplateParams({ anything: true }, () => {
|
|
222
|
+
throw new Error("this kind takes no params");
|
|
223
|
+
}).error,
|
|
224
|
+
).toMatch(/do not match this block's kind/);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("params reaching init", () => {
|
|
229
|
+
test("init sees what the parser returned", () => {
|
|
230
|
+
const storage = storageOf(
|
|
231
|
+
createInitialStorageFromParams(JSON.stringify({ label: "raw", stray: 1 }), NO_IDS, {
|
|
232
|
+
getBlockDataFromParams: (p) => dataModel.getDataFromParams(p),
|
|
233
|
+
parseInitializationParams: () => ({ label: "parsed" }),
|
|
234
|
+
...noPlugins,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect((storage.__data as BlockData).label).toBe("parsed");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("params the kind rejects never reach init", () => {
|
|
242
|
+
// Checked here as well as in the caller's pre-flight: the pre-flight exists to
|
|
243
|
+
// report every bad entry before anything is created, this exists so the factory is
|
|
244
|
+
// never handed a value the kind refused, whichever path got here.
|
|
245
|
+
let reached = false;
|
|
246
|
+
const result = createInitialStorageFromParams(JSON.stringify({ label: "" }), NO_IDS, {
|
|
247
|
+
getBlockDataFromParams: (p) => {
|
|
248
|
+
reached = true;
|
|
249
|
+
return dataModel.getDataFromParams(p);
|
|
250
|
+
},
|
|
251
|
+
parseInitializationParams: () => {
|
|
252
|
+
throw new Error("label must not be empty");
|
|
253
|
+
},
|
|
254
|
+
...noPlugins,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(reached).toBe(false);
|
|
258
|
+
expect(result.error).toMatch(/do not match this block's kind/);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("validateTemplateParamsJson", () => {
|
|
263
|
+
test("valid params against a real schema", () => {
|
|
264
|
+
expect(validateTemplateParamsJson(JSON.stringify({ n: 1 }), (v) => v)).toEqual({});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("a rejection comes back as a message, not a throw", () => {
|
|
268
|
+
const result = validateTemplateParamsJson(JSON.stringify({}), () => {
|
|
269
|
+
throw new Error("n: Required");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result.error).toContain("n: Required");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("params that are not JSON are reported", () => {
|
|
276
|
+
expect(validateTemplateParamsJson("{oops", (v) => v).error).toMatch(/not valid JSON/);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("the kind carries the check", () => {
|
|
281
|
+
test("a kind's parser reaches the data model that declared it", () => {
|
|
282
|
+
// The threading that makes any of this work: the kind object is not kept, but the
|
|
283
|
+
// parser is lifted off it beside the kind reference, so `done()` can register it.
|
|
284
|
+
const parse = (v: unknown) => v as Params;
|
|
285
|
+
const checkedKind = defineBlockKind<Params>({
|
|
286
|
+
name: "@platforma-open/milaboratories.checked.kind",
|
|
287
|
+
version: "1.0.0",
|
|
288
|
+
parseInitializationParams: parse,
|
|
289
|
+
});
|
|
290
|
+
const model = new DataModelBuilder({ kind: checkedKind })
|
|
291
|
+
.from<BlockData>("v1")
|
|
292
|
+
.init(() => ({ sources: [], label: "", scratch: 0 }));
|
|
293
|
+
|
|
294
|
+
expect(model.templateParamsParser).toBe(parse);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a kind-less data model carries no parser — the plugin case", () => {
|
|
298
|
+
// The only remaining way this slot is empty. A BLOCK data model always has a kind,
|
|
299
|
+
// and a kind always supplies a parser; a PLUGIN data model is built with no kind at
|
|
300
|
+
// all, because a plugin has no params channel of its own. Such a model can no longer
|
|
301
|
+
// reach `BlockModelV3.create`, so nothing reads this as a block's parser.
|
|
302
|
+
const pluginDataModel = new DataModelBuilder()
|
|
303
|
+
.from<BlockData>("v1")
|
|
304
|
+
.init(() => ({ sources: [], label: "", scratch: 0 }));
|
|
305
|
+
|
|
306
|
+
expect(pluginDataModel.templateParamsParser).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("how a rejection reads", () => {
|
|
311
|
+
test("a schema library's issue list is unpacked, not dumped as JSON", () => {
|
|
312
|
+
// A zod error's own `message` is the entire issue array as JSON. Complete, and
|
|
313
|
+
// unreadable in the dialog this ends up in. The shape is duck-typed because this
|
|
314
|
+
// package prescribes no schema library.
|
|
315
|
+
const zodLike = Object.assign(new Error("[{...}]"), {
|
|
316
|
+
issues: [
|
|
317
|
+
{ code: "invalid_type", path: ["numbers", 0], message: "Expected number, received string" },
|
|
318
|
+
{ code: "unrecognized_keys", path: [], message: "Unrecognized key(s) in object: 'colour'" },
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const result = validateTemplateParams({}, () => {
|
|
323
|
+
throw zodLike;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(result.error).toBe(
|
|
327
|
+
"params do not match this block's kind: numbers[0]: Expected number, received string; " +
|
|
328
|
+
"Unrecognized key(s) in object: 'colour'",
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a path is written the way the params are written", () => {
|
|
333
|
+
const result = validateTemplateParams({}, () => {
|
|
334
|
+
throw Object.assign(new Error("x"), {
|
|
335
|
+
issues: [{ path: ["steps", 2, "name"], message: "Required" }],
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(result.error).toContain("steps[2].name: Required");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("a plain error keeps its own words", () => {
|
|
343
|
+
const result = validateTemplateParams({}, () => {
|
|
344
|
+
throw new Error("numbers must not be empty");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.error).toBe("params do not match this block's kind: numbers must not be empty");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("a caller that predates this callback", () => {
|
|
352
|
+
test("is named as the cause, instead of blaming the template's params", () => {
|
|
353
|
+
// A middle layer older than the block calls this with one argument, so the id map arrives
|
|
354
|
+
// as `undefined`. It happened for real: an entry with no references at all failed with
|
|
355
|
+
// "params are not valid JSON", sending the reader to a file that was perfectly fine.
|
|
356
|
+
const result = createInitialStorageFromParams(
|
|
357
|
+
JSON.stringify({ label: "x" }),
|
|
358
|
+
undefined as unknown as string,
|
|
359
|
+
{
|
|
360
|
+
getBlockDataFromParams: (p) => dataModel.getDataFromParams(p),
|
|
361
|
+
parseInitializationParams: passThrough,
|
|
362
|
+
...noPlugins,
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(result.error).toMatch(/not told which blocks/);
|
|
367
|
+
expect(result.error).toMatch(/older than the block; rebuild or update it/);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("the readable reference spelling", () => {
|
|
372
|
+
const hooks = () => ({
|
|
373
|
+
getBlockDataFromParams: (p: unknown) => dataModel.getDataFromParams(p),
|
|
374
|
+
parseInitializationParams: passThrough,
|
|
375
|
+
...noPlugins,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("becomes a PlRef, and is repointed like a long-form one", () => {
|
|
379
|
+
// What a hand-written file gets to say: `{ block, name }`, no `__isRef`. It arrives at the
|
|
380
|
+
// block as the reference it stands for, pointing at the block the entry was given.
|
|
381
|
+
const result = createInitialStorageFromParams(
|
|
382
|
+
JSON.stringify({ label: "x", sources: [{ block: "samples", name: "reads" }] }),
|
|
383
|
+
JSON.stringify({ samples: "block-1" }),
|
|
384
|
+
hooks(),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(JSON.parse(result.storageJson!).__data).toEqual({
|
|
388
|
+
label: "x",
|
|
389
|
+
sources: [createPlRef("block-1", "reads")],
|
|
390
|
+
scratch: 0,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("the kind's parser is the check, and it sees the expanded form", () => {
|
|
395
|
+
// The whole reason expansion happens before the parser: a contract declared against `PlRef`
|
|
396
|
+
// would reject `{ block, name }` outright, so the readable spelling would be unusable.
|
|
397
|
+
const rejectsShorthand = (value: unknown) => {
|
|
398
|
+
const { sources } = value as { sources: unknown };
|
|
399
|
+
if (!Array.isArray(sources) || sources.some((s) => (s as PlRef).__isRef !== true)) {
|
|
400
|
+
throw new Error("'sources' must be an array of references");
|
|
401
|
+
}
|
|
402
|
+
return value as Params;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const result = createInitialStorageFromParams(
|
|
406
|
+
JSON.stringify({ label: "x", sources: [{ block: "samples", name: "reads" }] }),
|
|
407
|
+
JSON.stringify({}),
|
|
408
|
+
{ ...hooks(), parseInitializationParams: rejectsShorthand },
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(result.error).toBeUndefined();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, test } from "vitest";
|
|
2
|
+
import type { PlRef } from "@milaboratories/pl-model-common";
|
|
3
|
+
import { createPlRef, stringifyJson } from "@milaboratories/pl-model-common";
|
|
4
|
+
import { createBlockStorage } from "./block_storage";
|
|
5
|
+
import { deriveTemplateParamsFromStorage } from "./block_storage_callbacks";
|
|
6
|
+
import { BlockModelV3 } from "./block_model";
|
|
7
|
+
import { DataModelBuilder } from "./block_migrations";
|
|
8
|
+
import { defineBlockKind } from "@platforma-sdk/block-kind";
|
|
9
|
+
|
|
10
|
+
// A kind whose params carry a reference — the case that exercises the rewrite.
|
|
11
|
+
type Params = { sources?: PlRef[]; label: string };
|
|
12
|
+
|
|
13
|
+
const kind = defineBlockKind<Params>({
|
|
14
|
+
name: "@platforma-open/milaboratories.demo.kind",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
// These tests are about the export direction, so the parser only carries the shape
|
|
17
|
+
// through; rejection behaviour lives in `template_init.test.ts`.
|
|
18
|
+
parseInitializationParams: (value) => value as Params,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type BlockData = { sources: PlRef[]; label: string; scratch: number };
|
|
22
|
+
|
|
23
|
+
const dataModel = new DataModelBuilder({ kind }).from<BlockData>("v1").init(({ params }) => ({
|
|
24
|
+
sources: params?.sources ?? [],
|
|
25
|
+
label: params?.label ?? "",
|
|
26
|
+
// Derived/runtime state — must NOT survive into a template entry.
|
|
27
|
+
scratch: 0,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const upstream = "3f1c2b7a-0000-4000-8000-000000000001";
|
|
31
|
+
|
|
32
|
+
const storageOf = (data: BlockData) => stringifyJson(createBlockStorage(data));
|
|
33
|
+
|
|
34
|
+
describe("deriveTemplateParamsFromStorage", () => {
|
|
35
|
+
test("projects data back to params, untouched", () => {
|
|
36
|
+
const result = deriveTemplateParamsFromStorage(
|
|
37
|
+
storageOf({ sources: [createPlRef(upstream, "reads")], label: "run 1", scratch: 42 }),
|
|
38
|
+
(data) => {
|
|
39
|
+
const d = data as BlockData;
|
|
40
|
+
return { sources: d.sources, label: d.label };
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// `scratch` is dropped because the lambda does not return it. Everything the lambda DID
|
|
45
|
+
// return is written exactly as it is: the reference is not marked, normalized or rewritten
|
|
46
|
+
// on the way out, because pointing it at another project is the receiving block's job, on
|
|
47
|
+
// the way back in.
|
|
48
|
+
expect(result).toEqual({
|
|
49
|
+
value: { sources: [createPlRef(upstream, "reads")], label: "run 1" },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a block with nothing to project yields empty params, not absent ones", () => {
|
|
54
|
+
// Every block declares the lambda, so there is no "no params" outcome to test for.
|
|
55
|
+
// A block whose state carries nothing worth restoring returns `{}`, and `{}` is what
|
|
56
|
+
// gets written — the entry is still checked against the kind on the way back in.
|
|
57
|
+
expect(
|
|
58
|
+
deriveTemplateParamsFromStorage(
|
|
59
|
+
storageOf({ sources: [], label: "", scratch: 0 }),
|
|
60
|
+
() => ({}),
|
|
61
|
+
),
|
|
62
|
+
).toEqual({ value: {} });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a throwing lambda is reported, not propagated", () => {
|
|
66
|
+
const result = deriveTemplateParamsFromStorage(
|
|
67
|
+
storageOf({ sources: [], label: "", scratch: 0 }),
|
|
68
|
+
() => {
|
|
69
|
+
throw new Error("not exportable yet");
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual({ error: "templateParams() threw: not exportable yet" });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("BlockModelV3.templateParams", () => {
|
|
78
|
+
test("the lambda's return type is the kind's params", () => {
|
|
79
|
+
const model = BlockModelV3.create({ dataModel, kind })
|
|
80
|
+
.args((data) => ({ label: data.label }))
|
|
81
|
+
.templateParams((data) => ({ sources: data.sources, label: data.label }));
|
|
82
|
+
|
|
83
|
+
expectTypeOf(model.templateParams).parameter(0).toEqualTypeOf<(data: BlockData) => Params>();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a projection that drifts from the kind's params does not compile", () => {
|
|
87
|
+
const builder = BlockModelV3.create({ dataModel, kind }).args((data) => ({
|
|
88
|
+
label: data.label,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// @ts-expect-error - `label` is required by Params
|
|
92
|
+
builder.templateParams((data) => ({ sources: data.sources }));
|
|
93
|
+
// @ts-expect-error - wrong type for a declared param
|
|
94
|
+
builder.templateParams(() => ({ label: 42 }));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("KNOWN HOLE: an extra field the kind does not declare is not rejected", () => {
|
|
98
|
+
const builder = BlockModelV3.create({ dataModel, kind }).args((data) => ({
|
|
99
|
+
label: data.label,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// No `@ts-expect-error` here, and that is the point: TypeScript does not
|
|
103
|
+
// apply excess-property checks to an object literal returned from a
|
|
104
|
+
// contextually-typed arrow, so `scratch` type-checks and would be written
|
|
105
|
+
// into the exported file. The kind's parser does not catch it either — that runs
|
|
106
|
+
// on params coming IN, and nothing runs on what the projection hands back. The
|
|
107
|
+
// asymmetry is real: such a file is written happily and rejected on re-import by
|
|
108
|
+
// the same kind that would have caught it here. This test pins current behaviour.
|
|
109
|
+
builder.templateParams((data) => ({
|
|
110
|
+
label: data.label,
|
|
111
|
+
sources: data.sources,
|
|
112
|
+
scratch: data.scratch,
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a model that declares it builds", () => {
|
|
117
|
+
expect(() =>
|
|
118
|
+
BlockModelV3.create({ dataModel, kind })
|
|
119
|
+
.args((data) => ({ label: data.label }))
|
|
120
|
+
.templateParams((data) => ({ sources: data.sources, label: data.label }))
|
|
121
|
+
.done(),
|
|
122
|
+
).not.toThrow();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a model that omits it does not build", () => {
|
|
126
|
+
// The projection is not optional. Without this gate a block exports an entry with
|
|
127
|
+
// no params, which applies as a default-initialized block that looks restored — so
|
|
128
|
+
// the failure has to land on whoever wrote the model, at build.
|
|
129
|
+
expect(() =>
|
|
130
|
+
BlockModelV3.create({ dataModel, kind })
|
|
131
|
+
.args((data) => ({ label: data.label }))
|
|
132
|
+
.done(),
|
|
133
|
+
).toThrow(/templateParams\(\) not set/);
|
|
134
|
+
});
|
|
135
|
+
});
|