@platforma-sdk/model 1.81.1 → 1.83.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/bconfig/index.d.ts +2 -2
- package/dist/bconfig/lambdas.d.ts +2 -12
- package/dist/bconfig/lambdas.d.ts.map +1 -1
- package/dist/bconfig/v3.d.ts +1 -1
- 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 +71 -17
- 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 +72 -18
- 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/config/index.d.ts +2 -6
- package/dist/config/type_engine.d.ts +7 -28
- package/dist/config/type_engine.d.ts.map +1 -1
- package/dist/index.cjs +20 -60
- package/dist/index.d.ts +5 -11
- package/dist/index.js +4 -8
- package/dist/package.cjs +1 -1
- package/dist/package.js +1 -1
- package/dist/render/api.cjs +0 -22
- package/dist/render/api.cjs.map +1 -1
- package/dist/render/api.d.ts +1 -9
- package/dist/render/api.d.ts.map +1 -1
- package/dist/render/api.js +1 -22
- package/dist/render/api.js.map +1 -1
- package/dist/render/index.cjs +0 -1
- package/dist/render/index.d.ts +2 -2
- package/dist/render/index.js +2 -2
- package/package.json +10 -9
- package/src/bconfig/lambdas.ts +1 -25
- 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/config/index.ts +0 -4
- package/src/config/type_engine.ts +6 -49
- package/src/index.ts +0 -2
- package/src/kind_reference.test.ts +134 -0
- package/src/render/api.ts +0 -32
- package/src/template_init.test.ts +413 -0
- package/src/template_params.test.ts +135 -0
- package/src/typing.test.ts +4 -143
- package/dist/block_model_legacy.cjs +0 -240
- package/dist/block_model_legacy.cjs.map +0 -1
- package/dist/block_model_legacy.d.ts +0 -115
- package/dist/block_model_legacy.d.ts.map +0 -1
- package/dist/block_model_legacy.js +0 -240
- package/dist/block_model_legacy.js.map +0 -1
- package/dist/config/actions.cjs +0 -232
- package/dist/config/actions.cjs.map +0 -1
- package/dist/config/actions.d.ts +0 -48
- package/dist/config/actions.d.ts.map +0 -1
- package/dist/config/actions.js +0 -201
- package/dist/config/actions.js.map +0 -1
- package/dist/config/actions_kinds.d.ts +0 -116
- package/dist/config/actions_kinds.d.ts.map +0 -1
- package/dist/config/index.cjs +0 -33
- package/dist/config/index.js +0 -2
- package/dist/config/model_meta.d.ts +0 -9
- package/dist/config/model_meta.d.ts.map +0 -1
- package/dist/config/type_util.d.ts +0 -14
- package/dist/config/type_util.d.ts.map +0 -1
- package/dist/ref_util.cjs +0 -21
- package/dist/ref_util.cjs.map +0 -1
- package/dist/ref_util.d.ts +0 -19
- package/dist/ref_util.d.ts.map +0 -1
- package/dist/ref_util.js +0 -20
- package/dist/ref_util.js.map +0 -1
- package/src/block_model_legacy.ts +0 -465
- package/src/config/actions.ts +0 -468
- package/src/config/actions_kinds.ts +0 -264
- package/src/config/model_meta.ts +0 -5
- package/src/config/type_util.ts +0 -25
- package/src/ref_util.ts +0 -17
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, test } from "vitest";
|
|
2
|
+
import type { BlockConfigContainer, BlockKindReference } from "@milaboratories/pl-model-common";
|
|
3
|
+
import {
|
|
4
|
+
PROJECT_TEMPLATE_SCHEMA_V1,
|
|
5
|
+
parseProjectTemplateV1,
|
|
6
|
+
kindReferenceToSelectorReference,
|
|
7
|
+
} from "@milaboratories/pl-model-common";
|
|
8
|
+
import { extractConfig } from "./bconfig/normalization";
|
|
9
|
+
import { BlockModelV3 } from "./block_model";
|
|
10
|
+
import { DataModelBuilder } from "./block_migrations";
|
|
11
|
+
import { defineBlockKind } from "@platforma-sdk/block-kind";
|
|
12
|
+
|
|
13
|
+
// Template export needs one fact to hold at runtime: the kind reference a block
|
|
14
|
+
// declared at build time is still readable from its config, and reaches a
|
|
15
|
+
// template entry unchanged.
|
|
16
|
+
//
|
|
17
|
+
// No new code is needed for that read — `BlockConfigContainer.kind` is already
|
|
18
|
+
// typed `BlockKindReference | undefined`, so the middle layer's read is the
|
|
19
|
+
// property access `bp.info.config.kind`, and the widen to an entry's selector
|
|
20
|
+
// form already exists. What did not exist is proof that the path holds end to
|
|
21
|
+
// end, which is what this suite is: bake -> read -> widen -> entry, inside one
|
|
22
|
+
// package, with no backend.
|
|
23
|
+
|
|
24
|
+
type Params = { label: string };
|
|
25
|
+
type BlockData = { label: string };
|
|
26
|
+
|
|
27
|
+
const KIND_NAME = "@platforma-open/milaboratories.demo.kind";
|
|
28
|
+
const KIND_VERSION = "1.4.2";
|
|
29
|
+
const KIND_REF = `${KIND_NAME}@${KIND_VERSION}`;
|
|
30
|
+
|
|
31
|
+
const kind = defineBlockKind<Params>({
|
|
32
|
+
name: KIND_NAME,
|
|
33
|
+
version: KIND_VERSION,
|
|
34
|
+
parseInitializationParams: (value) => value as Params,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const dataModel = new DataModelBuilder({ kind })
|
|
38
|
+
.from<BlockData>("v1")
|
|
39
|
+
.init(({ params }) => ({ label: params?.label ?? "" }));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The config container `build-model` serializes into model.json.
|
|
43
|
+
*
|
|
44
|
+
* `done()` returns it whenever the model is not running in a UI — `isInUI()`
|
|
45
|
+
* checks for a `platforma` global, absent under vitest — which is exactly the
|
|
46
|
+
* path the build takes.
|
|
47
|
+
*/
|
|
48
|
+
function containerOf(model: unknown): BlockConfigContainer {
|
|
49
|
+
return (model as { config: BlockConfigContainer }).config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const kindfulContainer = () =>
|
|
53
|
+
containerOf(
|
|
54
|
+
BlockModelV3.create({ dataModel, kind })
|
|
55
|
+
.args((data) => ({ label: data.label }))
|
|
56
|
+
.templateParams((data) => ({ label: data.label }))
|
|
57
|
+
.done(),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
describe("reading the kind reference back at runtime", () => {
|
|
61
|
+
test("done() bakes the declared kind at the container level", () => {
|
|
62
|
+
// Beside `code`, orthogonal to the render envelope — a future v5 envelope
|
|
63
|
+
// would not move it.
|
|
64
|
+
expect(kindfulContainer().kind).toBe(KIND_REF);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the NORMALIZED config does not carry it — the container is the read point", () => {
|
|
68
|
+
// extractConfig normalizes the render envelope and returns only envelope
|
|
69
|
+
// fields, so the `cfg` every getBlockPackInfo caller holds is kind-blind by
|
|
70
|
+
// construction (lib/node/pl-middle-layer/src/middle_layer/util.ts). An
|
|
71
|
+
// exporter that goes looking there finds nothing and must read
|
|
72
|
+
// `info.config.kind` instead. Pinned so that stays true, or fails loudly.
|
|
73
|
+
expect("kind" in extractConfig(kindfulContainer())).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the authoring API can no longer produce a kind-less block", () => {
|
|
77
|
+
const kindlessDataModel = new DataModelBuilder().from<BlockData>("v1").init(() => ({
|
|
78
|
+
label: "",
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// The kind-less `create(dataModel)` overload is gone: a kind is mandatory, so there
|
|
82
|
+
// is no longer a way to author a block whose container carries no kind reference.
|
|
83
|
+
// @ts-expect-error - create takes { dataModel, kind }; a bare DataModel is not it
|
|
84
|
+
expect(() => BlockModelV3.create(kindlessDataModel)).toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the READ side stays optional — already-published blocks carry no kind", () => {
|
|
88
|
+
// `BlockConfigContainer.kind` is `BlockKindReference | undefined` and must stay that
|
|
89
|
+
// way: every block published before kinds existed is in that state, and the middle
|
|
90
|
+
// layer reads those configs. What the exporter should DO with such a block is
|
|
91
|
+
// decided — a template entry's `kind` is required, so there is no legal entry to
|
|
92
|
+
// write and the export fails naming the block (`template_serializer.ts`).
|
|
93
|
+
const container = kindfulContainer();
|
|
94
|
+
|
|
95
|
+
expect(container.kind).toBe(KIND_REF);
|
|
96
|
+
expectTypeOf(container.kind).toEqualTypeOf<BlockKindReference | undefined>();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("the reference as a template entry's kind", () => {
|
|
101
|
+
test("it widens to the exact tier, string unchanged", () => {
|
|
102
|
+
// An entry's kind is the exact version the block implements, `{name}@X.Y.Z`,
|
|
103
|
+
// read from the model's embedded kind reference. Widening changes the brand,
|
|
104
|
+
// not the string — export never loosens to a `~` or `^` tier.
|
|
105
|
+
expect(kindReferenceToSelectorReference(kindfulContainer().kind!)).toBe(KIND_REF);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the org-scoped name survives the split", () => {
|
|
109
|
+
// The kind name itself starts with `@`, so splitting on the FIRST `@` would
|
|
110
|
+
// truncate it to the empty name. block_kind_ref.ts owns that rule by
|
|
111
|
+
// splitting on the last `@`; this pins that a reference a real block
|
|
112
|
+
// declares round-trips through it intact.
|
|
113
|
+
const widened = kindReferenceToSelectorReference(kindfulContainer().kind!);
|
|
114
|
+
expect(widened.startsWith("@platforma-open/")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the widened reference is accepted as a template-v1 entry's kind", () => {
|
|
118
|
+
const blockId = "3f1c2b7a-0000-4000-8000-000000000001";
|
|
119
|
+
|
|
120
|
+
const [entry] = parseProjectTemplateV1({
|
|
121
|
+
schema: PROJECT_TEMPLATE_SCHEMA_V1,
|
|
122
|
+
blocks: [{ id: blockId, kind: kindReferenceToSelectorReference(kindfulContainer().kind!) }],
|
|
123
|
+
}).blocks;
|
|
124
|
+
|
|
125
|
+
// The whole path, end to end: what the block declared is what the file
|
|
126
|
+
// carries. `id` is the block's project-local UUID, reused verbatim, and `params` is the
|
|
127
|
+
// empty mapping the parser settles an omitted key to.
|
|
128
|
+
expect(entry).toEqual({ id: blockId, kind: KIND_REF, params: {} });
|
|
129
|
+
|
|
130
|
+
// No `block` override: a block implements exactly one kind version, so
|
|
131
|
+
// export has nothing to pin.
|
|
132
|
+
expect(entry.block).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/render/api.ts
CHANGED
|
@@ -728,34 +728,6 @@ export class BlockRenderCtx<Args = unknown, Data = unknown> extends RenderCtxBas
|
|
|
728
728
|
}
|
|
729
729
|
}
|
|
730
730
|
|
|
731
|
-
/** Render context for legacy v1/v2 blocks - provides backward compatibility */
|
|
732
|
-
export class RenderCtxLegacy<Args = unknown, UiState = unknown> extends RenderCtxBase<
|
|
733
|
-
Args,
|
|
734
|
-
UiState
|
|
735
|
-
> {
|
|
736
|
-
private argsCache?: { v: Args };
|
|
737
|
-
|
|
738
|
-
public get args(): Args {
|
|
739
|
-
if (this.argsCache === undefined) {
|
|
740
|
-
const raw = this.ctx.args;
|
|
741
|
-
const value = typeof raw === "function" ? raw() : raw;
|
|
742
|
-
this.argsCache = { v: JSON.parse(value) };
|
|
743
|
-
}
|
|
744
|
-
return this.argsCache.v;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
private uiStateCache?: { v: UiState };
|
|
748
|
-
|
|
749
|
-
public get uiState(): UiState {
|
|
750
|
-
if (this.uiStateCache === undefined) {
|
|
751
|
-
const raw = this.ctx.uiState!;
|
|
752
|
-
const value = typeof raw === "function" ? raw() : raw;
|
|
753
|
-
this.uiStateCache = { v: value ? JSON.parse(value) : ({} as UiState) };
|
|
754
|
-
}
|
|
755
|
-
return this.uiStateCache.v;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
731
|
/**
|
|
760
732
|
* Render context for plugin output functions.
|
|
761
733
|
* Reads plugin data from blockStorage and derives params from pre-wrapped input callbacks.
|
|
@@ -811,10 +783,6 @@ export type RenderFunction<Args = unknown, State = unknown, Ret = unknown> = (
|
|
|
811
783
|
rCtx: BlockRenderCtx<Args, State>,
|
|
812
784
|
) => Ret;
|
|
813
785
|
|
|
814
|
-
export type RenderFunctionLegacy<Args = unknown, State = unknown, Ret = unknown> = (
|
|
815
|
-
rCtx: RenderCtxLegacy<Args, State>,
|
|
816
|
-
) => Ret;
|
|
817
|
-
|
|
818
786
|
export type UnwrapFutureRef<K> =
|
|
819
787
|
K extends FutureRef<infer T>
|
|
820
788
|
? T
|
|
@@ -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
|
+
});
|