@milaboratories/pl-middle-layer 1.66.19 → 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.
Files changed (130) hide show
  1. package/dist/block_registry/index.cjs +3 -0
  2. package/dist/block_registry/index.d.ts +2 -1
  3. package/dist/block_registry/index.js +2 -1
  4. package/dist/block_registry/location_provider.cjs +117 -0
  5. package/dist/block_registry/location_provider.cjs.map +1 -0
  6. package/dist/block_registry/location_provider.js +114 -0
  7. package/dist/block_registry/location_provider.js.map +1 -0
  8. package/dist/block_registry/registry.cjs +13 -0
  9. package/dist/block_registry/registry.cjs.map +1 -1
  10. package/dist/block_registry/registry.d.ts +14 -1
  11. package/dist/block_registry/registry.d.ts.map +1 -1
  12. package/dist/block_registry/registry.js +13 -0
  13. package/dist/block_registry/registry.js.map +1 -1
  14. package/dist/block_registry/template_provider.cjs +104 -0
  15. package/dist/block_registry/template_provider.cjs.map +1 -0
  16. package/dist/block_registry/template_provider.d.ts +62 -0
  17. package/dist/block_registry/template_provider.d.ts.map +1 -0
  18. package/dist/block_registry/template_provider.js +103 -0
  19. package/dist/block_registry/template_provider.js.map +1 -0
  20. package/dist/block_registry/watcher.cjs +3 -3
  21. package/dist/block_registry/watcher.js +2 -2
  22. package/dist/dev_env/util.js +2 -2
  23. package/dist/dev_env/util.js.map +1 -1
  24. package/dist/index.cjs +10 -0
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +5 -1
  27. package/dist/js_render/computable_context.cjs +1 -1
  28. package/dist/js_render/computable_context.js +1 -1
  29. package/dist/middle_layer/driver_kit.d.ts +1 -1
  30. package/dist/middle_layer/middle_layer.cjs +144 -2
  31. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  32. package/dist/middle_layer/middle_layer.d.ts +67 -1
  33. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  34. package/dist/middle_layer/middle_layer.js +146 -4
  35. package/dist/middle_layer/middle_layer.js.map +1 -1
  36. package/dist/middle_layer/project.cjs +1 -0
  37. package/dist/middle_layer/project.cjs.map +1 -1
  38. package/dist/middle_layer/project.d.ts +2 -2
  39. package/dist/middle_layer/project.js +4 -4
  40. package/dist/middle_layer/project.js.map +1 -1
  41. package/dist/middle_layer/sharing_list.d.ts +1 -1
  42. package/dist/model/args.cjs +9 -28
  43. package/dist/model/args.cjs.map +1 -1
  44. package/dist/model/args.js +9 -28
  45. package/dist/model/args.js.map +1 -1
  46. package/dist/model/index.cjs +7 -0
  47. package/dist/model/index.d.ts +4 -1
  48. package/dist/model/index.js +4 -1
  49. package/dist/model/project_helper.cjs +111 -0
  50. package/dist/model/project_helper.cjs.map +1 -1
  51. package/dist/model/project_helper.d.ts +76 -1
  52. package/dist/model/project_helper.d.ts.map +1 -1
  53. package/dist/model/project_helper.js +111 -0
  54. package/dist/model/project_helper.js.map +1 -1
  55. package/dist/model/project_model.d.ts +1 -1
  56. package/dist/model/sharing_model.d.ts +1 -1
  57. package/dist/model/template_apply.cjs +67 -0
  58. package/dist/model/template_apply.cjs.map +1 -0
  59. package/dist/model/template_apply.d.ts +50 -0
  60. package/dist/model/template_apply.d.ts.map +1 -0
  61. package/dist/model/template_apply.js +64 -0
  62. package/dist/model/template_apply.js.map +1 -0
  63. package/dist/model/template_export.cjs +82 -0
  64. package/dist/model/template_export.cjs.map +1 -0
  65. package/dist/model/template_export.d.ts +9 -0
  66. package/dist/model/template_export.d.ts.map +1 -0
  67. package/dist/model/template_export.js +82 -0
  68. package/dist/model/template_export.js.map +1 -0
  69. package/dist/model/template_parser.cjs +74 -0
  70. package/dist/model/template_parser.cjs.map +1 -0
  71. package/dist/model/template_parser.d.ts +43 -0
  72. package/dist/model/template_parser.d.ts.map +1 -0
  73. package/dist/model/template_parser.js +72 -0
  74. package/dist/model/template_parser.js.map +1 -0
  75. package/dist/model/template_resolve.cjs +134 -0
  76. package/dist/model/template_resolve.cjs.map +1 -0
  77. package/dist/model/template_resolve.d.ts +193 -0
  78. package/dist/model/template_resolve.d.ts.map +1 -0
  79. package/dist/model/template_resolve.js +133 -0
  80. package/dist/model/template_resolve.js.map +1 -0
  81. package/dist/model/template_serializer.cjs +162 -0
  82. package/dist/model/template_serializer.cjs.map +1 -0
  83. package/dist/model/template_serializer.d.ts +17 -0
  84. package/dist/model/template_serializer.d.ts.map +1 -0
  85. package/dist/model/template_serializer.js +157 -0
  86. package/dist/model/template_serializer.js.map +1 -0
  87. package/dist/mutator/block-pack/block_pack.cjs +1 -1
  88. package/dist/mutator/block-pack/block_pack.js +1 -1
  89. package/dist/mutator/migration.cjs +1 -1
  90. package/dist/mutator/migration.js +1 -1
  91. package/dist/mutator/project.cjs +35 -5
  92. package/dist/mutator/project.cjs.map +1 -1
  93. package/dist/mutator/project.d.ts.map +1 -1
  94. package/dist/mutator/project.js +35 -5
  95. package/dist/mutator/project.js.map +1 -1
  96. package/dist/mutator/template_construct.cjs +77 -0
  97. package/dist/mutator/template_construct.cjs.map +1 -0
  98. package/dist/mutator/template_construct.js +77 -0
  99. package/dist/mutator/template_construct.js.map +1 -0
  100. package/dist/network_check/template.js +3 -3
  101. package/dist/network_check/template.js.map +1 -1
  102. package/package.json +18 -18
  103. package/src/block_registry/index.ts +1 -0
  104. package/src/block_registry/location_provider.test.ts +236 -0
  105. package/src/block_registry/location_provider.ts +124 -0
  106. package/src/block_registry/registry.ts +22 -0
  107. package/src/block_registry/template_provider.test.ts +275 -0
  108. package/src/block_registry/template_provider.ts +168 -0
  109. package/src/middle_layer/middle_layer.ts +192 -1
  110. package/src/middle_layer/project.ts +1 -1
  111. package/src/model/args.test.ts +26 -1
  112. package/src/model/args.ts +16 -31
  113. package/src/model/index.ts +21 -0
  114. package/src/model/project_helper.ts +238 -0
  115. package/src/model/project_helper_params_init.test.ts +249 -0
  116. package/src/model/template_apply.test.ts +65 -0
  117. package/src/model/template_apply.ts +128 -0
  118. package/src/model/template_export.test.ts +255 -0
  119. package/src/model/template_export.ts +136 -0
  120. package/src/model/template_parser.test.ts +264 -0
  121. package/src/model/template_parser.ts +108 -0
  122. package/src/model/template_resolve.test.ts +397 -0
  123. package/src/model/template_resolve.ts +346 -0
  124. package/src/model/template_serializer.test.ts +398 -0
  125. package/src/model/template_serializer.ts +232 -0
  126. package/src/model/template_serializer_fixtures.test.ts +274 -0
  127. package/src/mutator/project-v3.test.ts +11 -11
  128. package/src/mutator/project.ts +92 -5
  129. package/src/mutator/template_construct.test.ts +456 -0
  130. 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
+ }