@milaboratories/pl-middle-layer 1.66.18 → 1.67.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_registry/index.cjs +3 -0
- package/dist/block_registry/index.d.ts +2 -1
- package/dist/block_registry/index.js +2 -1
- package/dist/block_registry/location_provider.cjs +117 -0
- package/dist/block_registry/location_provider.cjs.map +1 -0
- package/dist/block_registry/location_provider.js +114 -0
- package/dist/block_registry/location_provider.js.map +1 -0
- package/dist/block_registry/registry.cjs +13 -0
- package/dist/block_registry/registry.cjs.map +1 -1
- package/dist/block_registry/registry.d.ts +14 -1
- package/dist/block_registry/registry.d.ts.map +1 -1
- package/dist/block_registry/registry.js +13 -0
- package/dist/block_registry/registry.js.map +1 -1
- package/dist/block_registry/template_provider.cjs +104 -0
- package/dist/block_registry/template_provider.cjs.map +1 -0
- package/dist/block_registry/template_provider.d.ts +62 -0
- package/dist/block_registry/template_provider.d.ts.map +1 -0
- package/dist/block_registry/template_provider.js +103 -0
- package/dist/block_registry/template_provider.js.map +1 -0
- package/dist/block_registry/watcher.cjs +3 -3
- package/dist/block_registry/watcher.js +2 -2
- package/dist/dev_env/util.js +2 -2
- package/dist/dev_env/util.js.map +1 -1
- package/dist/index.cjs +10 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/js_render/computable_context.cjs +1 -1
- package/dist/js_render/computable_context.js +1 -1
- package/dist/middle_layer/driver_kit.d.ts +1 -1
- package/dist/middle_layer/middle_layer.cjs +144 -2
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +67 -1
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +146 -4
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project.cjs +1 -0
- package/dist/middle_layer/project.cjs.map +1 -1
- package/dist/middle_layer/project.d.ts +2 -2
- package/dist/middle_layer/project.js +4 -4
- package/dist/middle_layer/project.js.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +1 -1
- package/dist/model/args.cjs +9 -28
- package/dist/model/args.cjs.map +1 -1
- package/dist/model/args.js +9 -28
- package/dist/model/args.js.map +1 -1
- package/dist/model/index.cjs +7 -0
- package/dist/model/index.d.ts +4 -1
- package/dist/model/index.js +4 -1
- package/dist/model/project_helper.cjs +111 -0
- package/dist/model/project_helper.cjs.map +1 -1
- package/dist/model/project_helper.d.ts +76 -1
- package/dist/model/project_helper.d.ts.map +1 -1
- package/dist/model/project_helper.js +111 -0
- package/dist/model/project_helper.js.map +1 -1
- package/dist/model/project_model.d.ts +1 -1
- package/dist/model/sharing_model.d.ts +1 -1
- package/dist/model/template_apply.cjs +67 -0
- package/dist/model/template_apply.cjs.map +1 -0
- package/dist/model/template_apply.d.ts +50 -0
- package/dist/model/template_apply.d.ts.map +1 -0
- package/dist/model/template_apply.js +64 -0
- package/dist/model/template_apply.js.map +1 -0
- package/dist/model/template_export.cjs +82 -0
- package/dist/model/template_export.cjs.map +1 -0
- package/dist/model/template_export.d.ts +9 -0
- package/dist/model/template_export.d.ts.map +1 -0
- package/dist/model/template_export.js +82 -0
- package/dist/model/template_export.js.map +1 -0
- package/dist/model/template_parser.cjs +74 -0
- package/dist/model/template_parser.cjs.map +1 -0
- package/dist/model/template_parser.d.ts +43 -0
- package/dist/model/template_parser.d.ts.map +1 -0
- package/dist/model/template_parser.js +72 -0
- package/dist/model/template_parser.js.map +1 -0
- package/dist/model/template_resolve.cjs +134 -0
- package/dist/model/template_resolve.cjs.map +1 -0
- package/dist/model/template_resolve.d.ts +193 -0
- package/dist/model/template_resolve.d.ts.map +1 -0
- package/dist/model/template_resolve.js +133 -0
- package/dist/model/template_resolve.js.map +1 -0
- package/dist/model/template_serializer.cjs +162 -0
- package/dist/model/template_serializer.cjs.map +1 -0
- package/dist/model/template_serializer.d.ts +17 -0
- package/dist/model/template_serializer.d.ts.map +1 -0
- package/dist/model/template_serializer.js +157 -0
- package/dist/model/template_serializer.js.map +1 -0
- package/dist/mutator/block-pack/block_pack.cjs +1 -1
- package/dist/mutator/block-pack/block_pack.js +1 -1
- package/dist/mutator/migration.cjs +1 -1
- package/dist/mutator/migration.js +1 -1
- package/dist/mutator/project.cjs +35 -5
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +35 -5
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/template_construct.cjs +77 -0
- package/dist/mutator/template_construct.cjs.map +1 -0
- package/dist/mutator/template_construct.js +77 -0
- package/dist/mutator/template_construct.js.map +1 -0
- package/dist/network_check/template.js +3 -3
- package/dist/network_check/template.js.map +1 -1
- package/package.json +17 -17
- package/src/block_registry/index.ts +1 -0
- package/src/block_registry/location_provider.test.ts +236 -0
- package/src/block_registry/location_provider.ts +124 -0
- package/src/block_registry/registry.ts +22 -0
- package/src/block_registry/template_provider.test.ts +275 -0
- package/src/block_registry/template_provider.ts +168 -0
- package/src/middle_layer/middle_layer.ts +192 -1
- package/src/middle_layer/project.ts +1 -1
- package/src/model/args.test.ts +26 -1
- package/src/model/args.ts +16 -31
- package/src/model/index.ts +21 -0
- package/src/model/project_helper.ts +238 -0
- package/src/model/project_helper_params_init.test.ts +249 -0
- package/src/model/template_apply.test.ts +65 -0
- package/src/model/template_apply.ts +128 -0
- package/src/model/template_export.test.ts +255 -0
- package/src/model/template_export.ts +136 -0
- package/src/model/template_parser.test.ts +264 -0
- package/src/model/template_parser.ts +108 -0
- package/src/model/template_resolve.test.ts +397 -0
- package/src/model/template_resolve.ts +346 -0
- package/src/model/template_serializer.test.ts +398 -0
- package/src/model/template_serializer.ts +232 -0
- package/src/model/template_serializer_fixtures.test.ts +274 -0
- package/src/mutator/project-v3.test.ts +11 -11
- package/src/mutator/project.ts +92 -5
- package/src/mutator/template_construct.test.ts +456 -0
- package/src/mutator/template_construct.ts +150 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
import type { BlockKindReference } from "@milaboratories/pl-model-common";
|
|
4
|
+
import {
|
|
5
|
+
createPlRef,
|
|
6
|
+
kindReferenceToSelectorReference,
|
|
7
|
+
parseProjectTemplateV1,
|
|
8
|
+
} from "@milaboratories/pl-model-common";
|
|
9
|
+
import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
|
|
10
|
+
import type { ProjectStructure } from "./project_model";
|
|
11
|
+
import type { TemplateParamsResult } from "./template_export";
|
|
12
|
+
import {
|
|
13
|
+
assembleProjectTemplateV1,
|
|
14
|
+
exportProjectAsTemplateV1,
|
|
15
|
+
locationOf,
|
|
16
|
+
stringifyProjectTemplateV1,
|
|
17
|
+
} from "./template_serializer";
|
|
18
|
+
|
|
19
|
+
function simpleStructure(...ids: string[]): ProjectStructure {
|
|
20
|
+
return {
|
|
21
|
+
groups: [
|
|
22
|
+
{
|
|
23
|
+
id: "g1",
|
|
24
|
+
label: "G1",
|
|
25
|
+
blocks: ids.map((id) => ({ id, label: id, renderingMode: "Heavy" })),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ok = (value: unknown): TemplateParamsResult => ({ value });
|
|
32
|
+
|
|
33
|
+
const kindOf = (name: string, version = "1.4.2") =>
|
|
34
|
+
`@platforma-open/milaboratories.${name}.kind@${version}` as BlockKindReference;
|
|
35
|
+
|
|
36
|
+
/** Every block gets a kind derived from its own id. */
|
|
37
|
+
const kindPerBlock = (blockId: string) => kindOf(blockId);
|
|
38
|
+
|
|
39
|
+
/** A registry-installed block: found by name, so it needs no locator. */
|
|
40
|
+
const registrySpec: BlockPackSpec = {
|
|
41
|
+
type: "from-registry-v2",
|
|
42
|
+
registryUrl: "https://block.registry.platforma.bio/releases",
|
|
43
|
+
id: { organization: "milaboratories", name: "demo", version: "1.4.2" },
|
|
44
|
+
channel: "stable",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const devSpec = (folder: string): BlockPackSpec => ({ type: "dev-v2", folder });
|
|
48
|
+
|
|
49
|
+
function exportOf(
|
|
50
|
+
structure: ProjectStructure,
|
|
51
|
+
params: Record<string, TemplateParamsResult>,
|
|
52
|
+
kinds: (blockId: string) => BlockKindReference | undefined = kindPerBlock,
|
|
53
|
+
specs: (blockId: string) => BlockPackSpec | undefined = () => registrySpec,
|
|
54
|
+
) {
|
|
55
|
+
return exportProjectAsTemplateV1(structure, (id) => params[id], kinds, specs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("the document", () => {
|
|
59
|
+
test("an entry is the block id, its exact kind, and its params", () => {
|
|
60
|
+
const result = exportOf(simpleStructure("samples"), {
|
|
61
|
+
samples: ok({ dataset: "bulk-rna" }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result.ok).toBe(true);
|
|
65
|
+
if (!result.ok) return;
|
|
66
|
+
expect(result.document).toEqual({
|
|
67
|
+
schema: "template-v1",
|
|
68
|
+
blocks: [
|
|
69
|
+
{
|
|
70
|
+
id: "samples",
|
|
71
|
+
kind: "@platforma-open/milaboratories.samples.kind@1.4.2",
|
|
72
|
+
params: { dataset: "bulk-rna" },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the kind is emitted at the exact tier, never widened to a range", () => {
|
|
79
|
+
// A block implements exactly one kind version, so pinning it is the whole
|
|
80
|
+
// point; a `~` or `^` tier would let apply pick a different params contract.
|
|
81
|
+
const result = exportOf(simpleStructure("a"), { a: ok({}) });
|
|
82
|
+
|
|
83
|
+
expect(result.ok && result.document.blocks[0].kind).toBe(
|
|
84
|
+
"@platforma-open/milaboratories.a.kind@1.4.2",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("no `block` override is emitted", () => {
|
|
89
|
+
// The override pins an implementation against a kind version *range*. Export
|
|
90
|
+
// writes the exact version, so there is nothing left for it to pin.
|
|
91
|
+
const result = exportOf(simpleStructure("a"), { a: ok({}) });
|
|
92
|
+
|
|
93
|
+
expect(result.ok && "block" in result.document.blocks[0]).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("empty params are written as `params: {}`, never as `params: null`", () => {
|
|
97
|
+
// Every entry carries a `params` key, because every block projects one. `null` would
|
|
98
|
+
// be a third thing the schema does not define, and a reader would have to guess.
|
|
99
|
+
const result = exportOf(simpleStructure("empty"), { empty: ok({}) });
|
|
100
|
+
|
|
101
|
+
expect(result.ok).toBe(true);
|
|
102
|
+
if (!result.ok) return;
|
|
103
|
+
expect(result.document.blocks[0].params).toEqual({});
|
|
104
|
+
expect(result.yaml).toContain("params: {}");
|
|
105
|
+
expect(result.yaml).not.toContain("params: null");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("entry order is structure order", () => {
|
|
109
|
+
const result = exportOf(simpleStructure("samples", "mixcr", "browser"), {
|
|
110
|
+
samples: ok({}),
|
|
111
|
+
mixcr: ok({ input: createPlRef("samples", "reads") }),
|
|
112
|
+
browser: ok({ clones: createPlRef("mixcr", "clonotypes") }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result.ok && result.document.blocks.map((b) => b.id)).toEqual([
|
|
116
|
+
"samples",
|
|
117
|
+
"mixcr",
|
|
118
|
+
"browser",
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("the YAML", () => {
|
|
124
|
+
test("round-trips through the import-side parser unchanged", () => {
|
|
125
|
+
// The one property that matters: export emits exactly what import parses.
|
|
126
|
+
const result = exportOf(simpleStructure("samples", "mixcr"), {
|
|
127
|
+
samples: ok({ dataset: "bulk-rna", replicates: [1, 2, 3] }),
|
|
128
|
+
mixcr: ok({
|
|
129
|
+
input: createPlRef("samples", "reads"),
|
|
130
|
+
species: "hsa",
|
|
131
|
+
nested: { deep: { flag: true, absent: null } },
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result.ok).toBe(true);
|
|
136
|
+
if (!result.ok) return;
|
|
137
|
+
expect(parseProjectTemplateV1(YAML.parse(result.yaml))).toEqual(result.document);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("opens with the schema marker", () => {
|
|
141
|
+
const result = exportOf(simpleStructure("a"), { a: ok({}) });
|
|
142
|
+
|
|
143
|
+
expect(result.ok && result.yaml.startsWith("schema: template-v1\n")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("nothing is line-folded", () => {
|
|
147
|
+
// A folded scalar still parses, but it makes a diff between two exported
|
|
148
|
+
// templates unreadable, which is most of the reason to emit YAML at all.
|
|
149
|
+
const long = "x".repeat(400);
|
|
150
|
+
const yaml = stringifyProjectTemplateV1({
|
|
151
|
+
schema: "template-v1",
|
|
152
|
+
blocks: [
|
|
153
|
+
{
|
|
154
|
+
id: "a",
|
|
155
|
+
kind: kindReferenceToSelectorReference(kindOf("a")),
|
|
156
|
+
params: { note: long },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(yaml).toContain(long);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a reference is written exactly as the block holds it, with no marker", () => {
|
|
165
|
+
// Nothing in the file says this value is a reference, because nothing downstream needs
|
|
166
|
+
// told: the block that receives these params is the one that recognizes them. So the
|
|
167
|
+
// rendering is the block's own value and nothing else.
|
|
168
|
+
const result = exportOf(simpleStructure("samples", "mixcr"), {
|
|
169
|
+
samples: ok({}),
|
|
170
|
+
mixcr: ok({ input: createPlRef("samples", "reads") }),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result.ok).toBe(true);
|
|
174
|
+
if (!result.ok) return;
|
|
175
|
+
expect(result.yaml).not.toContain("$ref");
|
|
176
|
+
expect(result.yaml).toContain("__isRef: true");
|
|
177
|
+
expect(result.yaml).toContain("blockId: samples");
|
|
178
|
+
expect(result.document.blocks[1].params).toEqual({
|
|
179
|
+
input: createPlRef("samples", "reads"),
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("problems", () => {
|
|
185
|
+
test("a kind-less block fails the export and is named", () => {
|
|
186
|
+
// Every block published before kinds existed is in this state, so this is the
|
|
187
|
+
// common case today rather than an edge one.
|
|
188
|
+
const result = exportOf(
|
|
189
|
+
simpleStructure("modern", "legacy"),
|
|
190
|
+
{ modern: ok({}), legacy: ok({}) },
|
|
191
|
+
(id) => (id === "legacy" ? undefined : kindOf(id)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(result.ok).toBe(false);
|
|
195
|
+
if (result.ok) return;
|
|
196
|
+
expect(result.problems).toHaveLength(1);
|
|
197
|
+
expect(result.problems[0].blockId).toBe("legacy");
|
|
198
|
+
expect(result.problems[0].error).toContain("declares no kind");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a malformed stored kind reference is a problem, not a throw", () => {
|
|
202
|
+
const result = exportOf(
|
|
203
|
+
simpleStructure("a"),
|
|
204
|
+
{ a: ok({}) },
|
|
205
|
+
() => "no-version-here" as BlockKindReference,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.ok).toBe(false);
|
|
209
|
+
if (result.ok) return;
|
|
210
|
+
expect(result.problems[0].blockId).toBe("a");
|
|
211
|
+
expect(result.problems[0].error).toContain("malformed");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a reference to a block that is not in the project is NOT caught", () => {
|
|
215
|
+
// Deleting a block only removes it from the structure and does not rewrite downstream
|
|
216
|
+
// args, so a live project holds such references routinely. Recognizing one would mean
|
|
217
|
+
// knowing which values are identifiers, which the engine deliberately does not — so the
|
|
218
|
+
// reference is written out and surfaces on apply as a block wired to nothing.
|
|
219
|
+
const result = exportOf(simpleStructure("survivor"), {
|
|
220
|
+
survivor: ok({ input: createPlRef("deleted-upstream", "reads") }),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(result.ok).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a forward reference is not caught here, and cannot be", () => {
|
|
227
|
+
// Reading which entries an entry references means reading its params, which only the
|
|
228
|
+
// block that wrote them can do. A project's structure is topological by construction, so
|
|
229
|
+
// this state is not reachable from a real project — it is pinned to say that the export
|
|
230
|
+
// asserts nothing about it rather than to bless it.
|
|
231
|
+
const result = exportOf(simpleStructure("downstream", "upstream"), {
|
|
232
|
+
downstream: ok({ input: createPlRef("upstream", "reads") }),
|
|
233
|
+
upstream: ok({}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.ok).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("walk problems and assembly problems are reported together, in one pass", () => {
|
|
240
|
+
// Fixing an export should take one round, not one round per broken block.
|
|
241
|
+
const result = exportOf(
|
|
242
|
+
simpleStructure("unreadable", "kindless", "fine"),
|
|
243
|
+
{ kindless: ok({}), fine: ok({}) },
|
|
244
|
+
(id) => (id === "kindless" ? undefined : kindOf(id)),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(result.ok).toBe(false);
|
|
248
|
+
if (result.ok) return;
|
|
249
|
+
expect(result.problems.map((p) => p.blockId).sort()).toEqual(["kindless", "unreadable"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("no partial YAML is produced when anything is wrong", () => {
|
|
253
|
+
// All-or-nothing: a template missing blocks the user never chose to leave out
|
|
254
|
+
// would still look like a successful export.
|
|
255
|
+
const result = exportOf(simpleStructure("a", "b"), { a: ok({}), b: ok({}) }, (id) =>
|
|
256
|
+
id === "b" ? undefined : kindOf(id),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(result).not.toHaveProperty("yaml");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("an empty project exports an empty template", () => {
|
|
263
|
+
const result = exportOf(simpleStructure(), {});
|
|
264
|
+
|
|
265
|
+
expect(result.ok).toBe(true);
|
|
266
|
+
if (!result.ok) return;
|
|
267
|
+
expect(result.document.blocks).toEqual([]);
|
|
268
|
+
expect(parseProjectTemplateV1(YAML.parse(result.yaml))).toEqual(result.document);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("locationOf", () => {
|
|
273
|
+
test("a dev block's folder becomes a file URL", () => {
|
|
274
|
+
expect(locationOf(devSpec("/Users/dev/blocks/enter-numbers/block"))).toBe(
|
|
275
|
+
"file:///Users/dev/blocks/enter-numbers/block",
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("a path with a space is encoded, not written raw", () => {
|
|
280
|
+
// A raw space makes the value not a URI at all, so it would be rejected by the
|
|
281
|
+
// document parser on the way back in.
|
|
282
|
+
expect(locationOf(devSpec("/Users/dev/my blocks/x"))).toBe("file:///Users/dev/my%20blocks/x");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("an npm-consumed pack's own URL is passed through untouched", () => {
|
|
286
|
+
// The block emitted this locator itself; rebuilding one from it could only lose
|
|
287
|
+
// information, and the pack directory is not derivable from the package root.
|
|
288
|
+
expect(
|
|
289
|
+
locationOf({
|
|
290
|
+
type: "from-pack-v2",
|
|
291
|
+
packUrl: "file:///repo/node_modules/@o/x/block-pack",
|
|
292
|
+
rootUrl: "file:///repo/node_modules/@o/x",
|
|
293
|
+
}),
|
|
294
|
+
).toBe("file:///repo/node_modules/@o/x/block-pack");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a registry block gets no locator, which is what keeps it portable", () => {
|
|
298
|
+
expect(locationOf(registrySpec)).toBeUndefined();
|
|
299
|
+
expect(
|
|
300
|
+
locationOf({
|
|
301
|
+
type: "from-registry-v1",
|
|
302
|
+
registryUrl: "https://old",
|
|
303
|
+
id: { organization: "o", name: "n", version: "1.0.0" },
|
|
304
|
+
}),
|
|
305
|
+
).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a legacy dev block gets none either", () => {
|
|
309
|
+
// It predates kinds, so it has no kind reference and the assembler refuses it
|
|
310
|
+
// before a locator would matter.
|
|
311
|
+
expect(locationOf({ type: "dev-v1", folder: "/Users/dev/old" })).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("locating a block installed from a folder", () => {
|
|
316
|
+
test("the entry carries the folder, and keeps the kind alongside it", () => {
|
|
317
|
+
const result = exportOf(
|
|
318
|
+
simpleStructure("samples"),
|
|
319
|
+
{ samples: ok({ dataset: "bulk-rna" }) },
|
|
320
|
+
kindPerBlock,
|
|
321
|
+
() => devSpec("/Users/dev/blocks/samples/block"),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(result.ok).toBe(true);
|
|
325
|
+
if (!result.ok) return;
|
|
326
|
+
expect(result.document.blocks[0].location).toBe("file:///Users/dev/blocks/samples/block");
|
|
327
|
+
// The kind stays alongside: it is the params contract, not the locator.
|
|
328
|
+
expect(result.document.blocks[0].kind).toBe(
|
|
329
|
+
kindReferenceToSelectorReference(kindOf("samples")),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("what is written survives the round trip through YAML", () => {
|
|
334
|
+
const result = exportOf(simpleStructure("samples"), { samples: ok({}) }, kindPerBlock, () =>
|
|
335
|
+
devSpec("/Users/dev/blocks/samples/block"),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(result.ok).toBe(true);
|
|
339
|
+
if (!result.ok) return;
|
|
340
|
+
expect(parseProjectTemplateV1(YAML.parse(result.yaml))).toEqual(result.document);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("a registry-only project is portable and says nothing", () => {
|
|
344
|
+
const result = exportOf(simpleStructure("samples"), { samples: ok({}) });
|
|
345
|
+
|
|
346
|
+
expect(result.ok).toBe(true);
|
|
347
|
+
if (!result.ok) return;
|
|
348
|
+
expect("location" in result.document.blocks[0]).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("a block whose origin is unknown is written without a locator", () => {
|
|
352
|
+
// Not a failure: an entry with no locator is the normal, portable form, and the
|
|
353
|
+
// kind is what the importer resolves.
|
|
354
|
+
const result = exportOf(
|
|
355
|
+
simpleStructure("samples"),
|
|
356
|
+
{ samples: ok({}) },
|
|
357
|
+
kindPerBlock,
|
|
358
|
+
() => undefined,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(result.ok).toBe(true);
|
|
362
|
+
if (!result.ok) return;
|
|
363
|
+
expect("location" in result.document.blocks[0]).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a locator is written per block, and only for the located ones", () => {
|
|
367
|
+
const result = exportOf(
|
|
368
|
+
simpleStructure("a", "b", "c"),
|
|
369
|
+
{ a: ok({}), b: ok({}), c: ok({}) },
|
|
370
|
+
kindPerBlock,
|
|
371
|
+
(id) => (id === "b" ? registrySpec : devSpec(`/Users/dev/blocks/${id}/block`)),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(result.ok).toBe(true);
|
|
375
|
+
if (!result.ok) return;
|
|
376
|
+
expect(result.document.blocks.map((e) => e.location)).toEqual([
|
|
377
|
+
"file:///Users/dev/blocks/a/block",
|
|
378
|
+
undefined,
|
|
379
|
+
"file:///Users/dev/blocks/c/block",
|
|
380
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe("assembleProjectTemplateV1", () => {
|
|
385
|
+
test("carries the walk's problems through unchanged", () => {
|
|
386
|
+
const { document, problems } = assembleProjectTemplateV1(
|
|
387
|
+
{
|
|
388
|
+
entries: [{ blockId: "a", params: {} }],
|
|
389
|
+
problems: [{ blockId: "ghost", error: "state unavailable" }],
|
|
390
|
+
},
|
|
391
|
+
kindPerBlock,
|
|
392
|
+
() => registrySpec,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(problems).toEqual([{ blockId: "ghost", error: "state unavailable" }]);
|
|
396
|
+
expect(document.blocks.map((b) => b.id)).toEqual(["a"]);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type {
|
|
4
|
+
BlockKindReference,
|
|
5
|
+
BlockKindSelectorReference,
|
|
6
|
+
BlockPackLocationReference,
|
|
7
|
+
ProjectTemplateV1,
|
|
8
|
+
ProjectTemplateV1Entry,
|
|
9
|
+
} from "@milaboratories/pl-model-common";
|
|
10
|
+
import {
|
|
11
|
+
PROJECT_TEMPLATE_SCHEMA_V1,
|
|
12
|
+
kindReferenceToSelectorReference,
|
|
13
|
+
parseProjectTemplateV1,
|
|
14
|
+
} from "@milaboratories/pl-model-common";
|
|
15
|
+
import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
|
|
16
|
+
import type { ProjectStructure } from "./project_model";
|
|
17
|
+
import type {
|
|
18
|
+
TemplateExportProblem,
|
|
19
|
+
TemplateExportWalk,
|
|
20
|
+
TemplateParamsResult,
|
|
21
|
+
} from "./template_export";
|
|
22
|
+
import { walkProjectForTemplateExport } from "./template_export";
|
|
23
|
+
|
|
24
|
+
/** A block's exact kind reference, or `undefined` for a block that declares no kind. */
|
|
25
|
+
export type BlockKindProvider = (blockId: string) => BlockKindReference | undefined;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A block's origin spec — where the installed block came from — or `undefined` when
|
|
29
|
+
* it is not known for that block.
|
|
30
|
+
*
|
|
31
|
+
* The project stores this next to the kind reference, so both are read from the same
|
|
32
|
+
* place and neither costs an extra round-trip.
|
|
33
|
+
*/
|
|
34
|
+
export type BlockPackSpecProvider = (blockId: string) => BlockPackSpec | undefined;
|
|
35
|
+
|
|
36
|
+
/** What the caller gets back for a whole project. */
|
|
37
|
+
export type ProjectTemplateExportOutcome =
|
|
38
|
+
| {
|
|
39
|
+
readonly ok: true;
|
|
40
|
+
readonly yaml: string;
|
|
41
|
+
/** The document the YAML was rendered from, already validated. */
|
|
42
|
+
readonly document: ProjectTemplateV1;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly ok: false;
|
|
46
|
+
/** Every block that stands in the way, not just the first. */
|
|
47
|
+
readonly problems: readonly TemplateExportProblem[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `location` to write for a block installed from the filesystem, or `undefined`
|
|
52
|
+
* for one that came from a registry and therefore needs no locator.
|
|
53
|
+
*
|
|
54
|
+
* Both filesystem spec shapes are emitted, and they anchor at different directories
|
|
55
|
+
* — a dev block at its facade package, an npm-consumed one at its block-pack folder.
|
|
56
|
+
* The document does not distinguish them: one URI is written either way, and telling
|
|
57
|
+
* the two layouts apart is done by looking at what is actually there, by the side
|
|
58
|
+
* that has the filesystem anyway. Encoding the layout in the file instead would
|
|
59
|
+
* freeze today's two shapes into the format.
|
|
60
|
+
*
|
|
61
|
+
* A dev spec carries an OS path and is converted here, which also percent-encodes a
|
|
62
|
+
* path containing spaces. An npm-consumed spec already carries a `file:` URL and is
|
|
63
|
+
* passed through: it is the locator the block itself emitted, and reconstructing one
|
|
64
|
+
* from it could only lose information.
|
|
65
|
+
*/
|
|
66
|
+
export function locationOf(spec: BlockPackSpec): BlockPackLocationReference | undefined {
|
|
67
|
+
switch (spec.type) {
|
|
68
|
+
case "dev-v2":
|
|
69
|
+
return pathToFileURL(spec.folder).href as BlockPackLocationReference;
|
|
70
|
+
case "from-pack-v2":
|
|
71
|
+
return spec.packUrl as BlockPackLocationReference;
|
|
72
|
+
// A registry block is found by name, which is what makes the entry portable —
|
|
73
|
+
// writing where this machine happened to cache it would take that away. `dev-v1`
|
|
74
|
+
// predates kinds entirely, so such a block has no kind and never reaches here.
|
|
75
|
+
case "dev-v1":
|
|
76
|
+
case "from-registry-v1":
|
|
77
|
+
case "from-registry-v2":
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Turn a project into a template document.
|
|
84
|
+
*
|
|
85
|
+
* Assembly is deliberately dull — the entry is the block's id, its widened kind
|
|
86
|
+
* reference, and the params the walk already collected. The interesting decisions
|
|
87
|
+
* were made upstream; what is left here is the two things only this layer can
|
|
88
|
+
* check, both of which produce problems rather than a broken file:
|
|
89
|
+
*
|
|
90
|
+
* - **A block with no kind cannot be written.** An entry's `kind` is required — it
|
|
91
|
+
* is the params contract the entry is typed against — while a block's kind is
|
|
92
|
+
* optional, so a block that predates kinds, or that uses the deprecated
|
|
93
|
+
* kind-less model overload, has no legal entry. Reported per block. This is not
|
|
94
|
+
* an edge case today: it is what most existing projects will hit until their
|
|
95
|
+
* blocks are republished.
|
|
96
|
+
* - **References must point at an entry declared earlier.** Verbatim id reuse
|
|
97
|
+
* means a reference to a deleted block survives into the file naming nothing:
|
|
98
|
+
* deleting a block only removes it from the structure and does not rewrite
|
|
99
|
+
* downstream args, so a live project holds such references routinely.
|
|
100
|
+
*
|
|
101
|
+
* `block` is never emitted. That override exists to pin an implementation against
|
|
102
|
+
* a kind's version range, and export always writes the exact version the block
|
|
103
|
+
* implements, so there is nothing left for it to pin.
|
|
104
|
+
*
|
|
105
|
+
* `location` IS emitted, for every block that was installed from the filesystem. Such
|
|
106
|
+
* a block is not in any registry, so the kind reference alone names nothing the
|
|
107
|
+
* importer could find, and a file that omitted the one usable answer would describe a
|
|
108
|
+
* project that cannot be recreated. It costs portability, and nothing says so: such a
|
|
109
|
+
* file is the debugging path, read by the developer who wrote it on the machine that
|
|
110
|
+
* wrote it.
|
|
111
|
+
*
|
|
112
|
+
* Problems from `walk` are carried through, so a caller can hand a walk straight
|
|
113
|
+
* in and get one combined list.
|
|
114
|
+
*/
|
|
115
|
+
export function assembleProjectTemplateV1(
|
|
116
|
+
walk: TemplateExportWalk,
|
|
117
|
+
kindProvider: BlockKindProvider,
|
|
118
|
+
specProvider: BlockPackSpecProvider,
|
|
119
|
+
): { document: ProjectTemplateV1; problems: readonly TemplateExportProblem[] } {
|
|
120
|
+
const problems: TemplateExportProblem[] = [...walk.problems];
|
|
121
|
+
const blocks: ProjectTemplateV1Entry[] = [];
|
|
122
|
+
|
|
123
|
+
for (const entry of walk.entries) {
|
|
124
|
+
const kind = kindProvider(entry.blockId);
|
|
125
|
+
|
|
126
|
+
if (kind === undefined) {
|
|
127
|
+
problems.push({
|
|
128
|
+
blockId: entry.blockId,
|
|
129
|
+
error:
|
|
130
|
+
"Block declares no kind, so it cannot be written to a template: an entry's kind " +
|
|
131
|
+
"carries the params contract the entry is typed against",
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let selector: BlockKindSelectorReference;
|
|
137
|
+
try {
|
|
138
|
+
// Widening validates, and therefore throws — which is why it happens here
|
|
139
|
+
// and not where the reference is read: every read site sits inside a
|
|
140
|
+
// recomputed project overview, where one malformed stored reference must not
|
|
141
|
+
// be able to break unrelated blocks.
|
|
142
|
+
selector = kindReferenceToSelectorReference(kind);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
problems.push({
|
|
145
|
+
blockId: entry.blockId,
|
|
146
|
+
error: `Block's stored kind reference is malformed: ${e instanceof Error ? e.message : String(e)}`,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const spec = specProvider(entry.blockId);
|
|
152
|
+
const location = spec === undefined ? undefined : locationOf(spec);
|
|
153
|
+
|
|
154
|
+
blocks.push({
|
|
155
|
+
id: entry.blockId,
|
|
156
|
+
kind: selector,
|
|
157
|
+
params: entry.params,
|
|
158
|
+
...(location !== undefined ? { location } : {}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// References are not examined. A project's structure is topological by construction, so an
|
|
163
|
+
// entry cannot legally reference one below it — and checking would mean reading the params,
|
|
164
|
+
// which only the block that wrote them can do.
|
|
165
|
+
const document: ProjectTemplateV1 = { schema: PROJECT_TEMPLATE_SCHEMA_V1, blocks };
|
|
166
|
+
|
|
167
|
+
return { document, problems };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Render a template document to YAML text.
|
|
172
|
+
*
|
|
173
|
+
* Two non-default emitter settings, both about the file being read by someone
|
|
174
|
+
* else's code:
|
|
175
|
+
*
|
|
176
|
+
* - **No line folding.** A wrapped scalar still parses, but it makes a diff between
|
|
177
|
+
* two exported templates unreadable, which is most of the reason to prefer YAML
|
|
178
|
+
* over JSON here.
|
|
179
|
+
* - **Quote as if the reader were YAML 1.1**, while still parsing as 1.2. YAML 1.2
|
|
180
|
+
* dropped `yes`/`no`/`on`/`off`/`y`/`n` as booleans and dropped sexagesimal
|
|
181
|
+
* integers, so a 1.2 emitter leaves a params value of `"yes"` or `"1:30"` bare —
|
|
182
|
+
* which a 1.1 reader (PyYAML's default, and Go's yaml.v2) turns into `true` and
|
|
183
|
+
* `90`. A template is a contract for a second implementation, so the safe
|
|
184
|
+
* combination is to quote against the stricter ruleset and read with the looser
|
|
185
|
+
* one: a quoted scalar means the same thing under both. This adds no `%YAML`
|
|
186
|
+
* directive — it only changes which scalars get quotes.
|
|
187
|
+
*/
|
|
188
|
+
export function stringifyProjectTemplateV1(document: ProjectTemplateV1): string {
|
|
189
|
+
return YAML.stringify(document, { lineWidth: 0, version: "1.1" });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Export a project as `template-v1` YAML, or report every reason it cannot be.
|
|
194
|
+
*
|
|
195
|
+
* All-or-nothing on purpose. A partial template silently drops blocks and the
|
|
196
|
+
* surviving entries may reference the dropped ones, so what looks like a
|
|
197
|
+
* successful export would produce a project missing pieces the user never chose
|
|
198
|
+
* to leave out. Reporting everything at once instead of failing on the first
|
|
199
|
+
* problem is the other half of that: fixing an export should take one pass.
|
|
200
|
+
*
|
|
201
|
+
* @param structure The project structure, which supplies both membership and order
|
|
202
|
+
* @param paramsProvider A block's derived template params, in live form
|
|
203
|
+
* @param kindProvider A block's exact kind reference, read from its stored config
|
|
204
|
+
* @param specProvider A block's origin spec, read from the same stored container
|
|
205
|
+
*/
|
|
206
|
+
export function exportProjectAsTemplateV1(
|
|
207
|
+
structure: ProjectStructure,
|
|
208
|
+
paramsProvider: (blockId: string) => TemplateParamsResult | undefined,
|
|
209
|
+
kindProvider: BlockKindProvider,
|
|
210
|
+
specProvider: BlockPackSpecProvider,
|
|
211
|
+
): ProjectTemplateExportOutcome {
|
|
212
|
+
const walk = walkProjectForTemplateExport(structure, paramsProvider);
|
|
213
|
+
const { document, problems } = assembleProjectTemplateV1(walk, kindProvider, specProvider);
|
|
214
|
+
|
|
215
|
+
if (problems.length > 0) return { ok: false, problems };
|
|
216
|
+
|
|
217
|
+
// Export must emit exactly what import parses, so that is asserted on every
|
|
218
|
+
// export rather than only in tests — running the import-side parser over the
|
|
219
|
+
// document we are about to write is the cheapest possible proof of it. Nothing
|
|
220
|
+
// user-facing is expected to fail here: the kind grammar was checked by the
|
|
221
|
+
// widening above, params were checked to be a mapping by the walk, and the
|
|
222
|
+
// reference rules by the assembler. A throw means a bug in the assembler, with
|
|
223
|
+
// one known exception: a project structure holding two blocks with the same id,
|
|
224
|
+
// which is reachable through the mutator and produces duplicate entry ids.
|
|
225
|
+
parseProjectTemplateV1(document);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
yaml: stringifyProjectTemplateV1(document),
|
|
230
|
+
document,
|
|
231
|
+
};
|
|
232
|
+
}
|