@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.
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 +17 -17
  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,274 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "vitest";
4
+ import YAML from "yaml";
5
+ import type { BlockKindReference, ColumnUniversalId } from "@milaboratories/pl-model-common";
6
+ import {
7
+ createColumnFilteredId,
8
+ createGlobalPObjectId,
9
+ createPlRef,
10
+ parseProjectTemplateV1,
11
+ } from "@milaboratories/pl-model-common";
12
+ import type { ProjectStructure } from "./project_model";
13
+ import type { TemplateParamsResult } from "./template_export";
14
+ import { exportProjectAsTemplateV1 } from "./template_serializer";
15
+
16
+ /**
17
+ * Golden-file tests for the exported document's TEXT.
18
+ *
19
+ * The sibling `template_serializer.test.ts` asserts the document field by field,
20
+ * which says nothing about how it is rendered. These pin the bytes: key order,
21
+ * indentation, how `params` nests, which scalars get quoted, and that nothing is
22
+ * line-folded. That is what "export emits exactly what import parses" rests on,
23
+ * and what makes a diff between two exported templates readable.
24
+ *
25
+ * Each expected file lives on disk rather than in a snapshot, deliberately. There
26
+ * is no snapshot infrastructure in this repo to fit into, and a snapshot carries an
27
+ * `-u` flag that rewrites the expectation without anyone reading it — for a file
28
+ * format promised to a second implementation, changing the expectation should be a
29
+ * deliberate edit that shows up in review. To update one, run the test, read the
30
+ * diff, and write the new content in by hand.
31
+ *
32
+ * Every fixture is also parsed back with the import-side parser, so a golden file
33
+ * can never be updated to something import cannot read.
34
+ */
35
+ const FIXTURE_DIR = join(import.meta.dirname, "..", "..", "test_fixtures", "template-v1");
36
+
37
+ /**
38
+ * Params as the block's callback hands them over — live, and untouched on the way out. What a
39
+ * block projects is what the file holds, so there is nothing between these values and the
40
+ * rendered bytes.
41
+ */
42
+ const ok = (value: unknown): TemplateParamsResult => ({ value });
43
+
44
+ const kind = (name: string, version: string) =>
45
+ `@platforma-open/milaboratories.${name}.kind@${version}` as BlockKindReference;
46
+
47
+ function structureOf(...ids: string[]): ProjectStructure {
48
+ return {
49
+ groups: [
50
+ {
51
+ id: "g1",
52
+ label: "Main",
53
+ blocks: ids.map((id) => ({ id, label: id, renderingMode: "Heavy" })),
54
+ },
55
+ ],
56
+ };
57
+ }
58
+
59
+ type Fixture = {
60
+ /** File under `test_fixtures/template-v1/`, and the test's name. */
61
+ readonly file: string;
62
+ /** What each fixture is here to pin — one property per fixture, not a grab bag. */
63
+ readonly pins: string;
64
+ readonly structure: ProjectStructure;
65
+ readonly params: Record<string, TemplateParamsResult>;
66
+ readonly kinds: Record<string, BlockKindReference>;
67
+ };
68
+
69
+ const FIXTURES: readonly Fixture[] = [
70
+ {
71
+ file: "empty-project.yaml",
72
+ pins: "an empty project is still a valid document, not an empty file",
73
+ structure: structureOf(),
74
+ params: {},
75
+ kinds: {},
76
+ },
77
+ {
78
+ file: "minimal.yaml",
79
+ pins: "the schema marker comes first, and a block with nothing to project renders `params: {}`",
80
+ structure: structureOf("11111111-1111-4111-8111-111111111111"),
81
+ params: { "11111111-1111-4111-8111-111111111111": ok({}) },
82
+ kinds: { "11111111-1111-4111-8111-111111111111": kind("pool-explorer", "1.0.0") },
83
+ },
84
+ {
85
+ file: "linear-chain.yaml",
86
+ pins: "the canonical shape — three blocks wired in a chain, ids reused verbatim",
87
+ structure: structureOf(
88
+ "aaaaaaaa-0000-4000-8000-000000000001",
89
+ "bbbbbbbb-0000-4000-8000-000000000002",
90
+ "cccccccc-0000-4000-8000-000000000003",
91
+ ),
92
+ params: {
93
+ "aaaaaaaa-0000-4000-8000-000000000001": ok({ dataset: "bulk-rna" }),
94
+ "bbbbbbbb-0000-4000-8000-000000000002": ok({
95
+ input: createPlRef("aaaaaaaa-0000-4000-8000-000000000001", "reads"),
96
+ species: "hsa",
97
+ }),
98
+ "cccccccc-0000-4000-8000-000000000003": ok({
99
+ clonotypes: createPlRef("bbbbbbbb-0000-4000-8000-000000000002", "clonotypes"),
100
+ }),
101
+ },
102
+ kinds: {
103
+ "aaaaaaaa-0000-4000-8000-000000000001": kind("import-fastq", "2.1.0"),
104
+ "bbbbbbbb-0000-4000-8000-000000000002": kind("mixcr-clonotyping", "3.0.4"),
105
+ "cccccccc-0000-4000-8000-000000000003": kind("clonotype-browser", "1.2.10"),
106
+ },
107
+ },
108
+ {
109
+ file: "nested-params.yaml",
110
+ pins: "how nesting renders — objects in arrays, references at depth, an empty object",
111
+ structure: structureOf(
112
+ "dddddddd-0000-4000-8000-000000000004",
113
+ "ffffffff-0000-4000-8000-000000000006",
114
+ ),
115
+ params: {
116
+ "dddddddd-0000-4000-8000-000000000004": ok({}),
117
+ "ffffffff-0000-4000-8000-000000000006": ok({
118
+ thresholds: { min: 0.01, max: 1, exact: 0.5 },
119
+ steps: [
120
+ { name: "filter", enabled: true },
121
+ { name: "cluster", enabled: false, seed: 42 },
122
+ ],
123
+ // Two references to the same upstream, inside an array.
124
+ inputs: [
125
+ createPlRef("dddddddd-0000-4000-8000-000000000004", "a"),
126
+ createPlRef("dddddddd-0000-4000-8000-000000000004", "b"),
127
+ ],
128
+ advanced: {},
129
+ tags: [],
130
+ note: null,
131
+ }),
132
+ },
133
+ kinds: {
134
+ "dddddddd-0000-4000-8000-000000000004": kind("import-fastq", "2.1.0"),
135
+ "ffffffff-0000-4000-8000-000000000006": kind("clonotype-clustering", "0.4.1"),
136
+ },
137
+ },
138
+ {
139
+ file: "column-ids.yaml",
140
+ pins: "an identifier is written verbatim whatever its form — key object or canonical string",
141
+ structure: structureOf(
142
+ "aaaaaaaa-0000-4000-8000-000000000001",
143
+ "bbbbbbbb-0000-4000-8000-000000000002",
144
+ ),
145
+ params: {
146
+ "aaaaaaaa-0000-4000-8000-000000000001": ok({}),
147
+ "bbbbbbbb-0000-4000-8000-000000000002": ok({
148
+ // A filtered column id — a canonical JSON string with the block id buried inside it.
149
+ // It goes into the file as it came: repointing it is the receiving block's business,
150
+ // in its own bundle, and nothing on the way out parses it.
151
+ anchor: createColumnFilteredId({
152
+ source: createGlobalPObjectId(
153
+ "aaaaaaaa-0000-4000-8000-000000000001",
154
+ "clonotypes",
155
+ ) as ColumnUniversalId,
156
+ axisFilters: [[0, "IGH"]],
157
+ }),
158
+ // The same column in its object spelling. Both survive as written — no marker, no
159
+ // normalization, so a round trip cannot change which of the two a block gets back.
160
+ upstream: createPlRef("aaaaaaaa-0000-4000-8000-000000000001", "clonotypes"),
161
+ }),
162
+ },
163
+ kinds: {
164
+ "aaaaaaaa-0000-4000-8000-000000000001": kind("import-fastq", "2.1.0"),
165
+ "bbbbbbbb-0000-4000-8000-000000000002": kind("clonotype-browser", "1.2.10"),
166
+ },
167
+ },
168
+ {
169
+ file: "scalar-quoting.yaml",
170
+ pins: "strings that YAML would otherwise read back as something else stay strings",
171
+ structure: structureOf("eeeeeeee-0000-4000-8000-000000000005"),
172
+ params: {
173
+ "eeeeeeee-0000-4000-8000-000000000005": ok({
174
+ // Each of these is a real hazard: unquoted, some YAML reader turns them
175
+ // into a boolean, a number, a null, an alias, or a nested mapping.
176
+ // The first group is 1.1-only — a 1.2 emitter leaves them bare.
177
+ looksBoolean: "yes",
178
+ looksBooleanNo: "no",
179
+ looksBooleanOn: "on",
180
+ looksBooleanOff: "off",
181
+ looksBooleanY: "y",
182
+ looksBooleanN: "n",
183
+ looksSexagesimal: "1:30",
184
+ looksBooleanToo: "true",
185
+ looksNumeric: "1.0",
186
+ looksOctal: "0755",
187
+ looksNull: "null",
188
+ looksEmpty: "",
189
+ hasColon: "label: value",
190
+ startsWithAsterisk: "*anchor",
191
+ startsWithHash: "#not-a-comment",
192
+ startsWithDash: "-dash",
193
+ multiline: "first\nsecond",
194
+ unicode: "受容体 — αβ",
195
+ // Long enough to be folded at any default width; folding is switched off.
196
+ long: `${"x".repeat(200)} ${"y".repeat(200)}`,
197
+ actuallyBoolean: true,
198
+ actuallyNumeric: 1,
199
+ }),
200
+ },
201
+ kinds: { "eeeeeeee-0000-4000-8000-000000000005": kind("enter-numbers", "1.0.0") },
202
+ },
203
+ ];
204
+
205
+ function exportFixture(fixture: Fixture) {
206
+ const result = exportProjectAsTemplateV1(
207
+ fixture.structure,
208
+ (blockId) => fixture.params[blockId],
209
+ (blockId) => fixture.kinds[blockId],
210
+ // The golden files describe portable templates, so every block is a registry one:
211
+ // a locator would pin them to whichever machine produced the fixture.
212
+ () => ({
213
+ type: "from-registry-v2",
214
+ registryUrl: "https://block.registry.platforma.bio/releases",
215
+ id: { organization: "milaboratories", name: "demo", version: "1.0.0" },
216
+ channel: "stable",
217
+ }),
218
+ );
219
+ if (!result.ok)
220
+ throw new Error(
221
+ `fixture '${fixture.file}' failed to export: ${result.problems.map((p) => p.error).join("; ")}`,
222
+ );
223
+ return result;
224
+ }
225
+
226
+ describe.each(FIXTURES)("$file", (fixture) => {
227
+ test(`renders as the golden file — ${fixture.pins}`, () => {
228
+ const expected = readFileSync(join(FIXTURE_DIR, fixture.file), "utf-8");
229
+
230
+ // Compared as text, not as a parsed value: the parsed comparison is the test
231
+ // below, and it cannot see formatting at all.
232
+ expect(exportFixture(fixture).yaml).toBe(expected);
233
+ });
234
+
235
+ test("the golden file parses back to the document it was rendered from", () => {
236
+ const result = exportFixture(fixture);
237
+ const fromDisk = readFileSync(join(FIXTURE_DIR, fixture.file), "utf-8");
238
+
239
+ expect(parseProjectTemplateV1(YAML.parse(fromDisk))).toEqual(result.document);
240
+ });
241
+ });
242
+
243
+ describe("what the golden files guard", () => {
244
+ test("no scalar is line-folded, however long", () => {
245
+ const yaml = exportFixture(FIXTURES.find((f) => f.file === "scalar-quoting.yaml")!).yaml;
246
+
247
+ // A folded scalar shows up as a continuation line; the value must stay on one.
248
+ const long = `${"x".repeat(200)} ${"y".repeat(200)}`;
249
+ expect(yaml).toContain(long);
250
+ });
251
+
252
+ test("scalars only YAML 1.1 would misread are quoted", () => {
253
+ // The emitter quotes against 1.1 rules even though we parse as 1.2, because the
254
+ // file is read by other implementations: PyYAML and Go's yaml.v2 default to 1.1,
255
+ // where a bare `yes` is `true` and a bare `1:30` is `90`. A 1.2 emitter leaves
256
+ // both bare, so this is the one formatting choice that is an interop bug rather
257
+ // than a matter of taste — and a round trip through our own parser cannot catch
258
+ // it, since 1.2 reads them back as strings either way.
259
+ const yaml = exportFixture(FIXTURES.find((f) => f.file === "scalar-quoting.yaml")!).yaml;
260
+
261
+ for (const bare of ["yes", "no", "on", "off", "y", "n", "1:30"]) {
262
+ expect(yaml).toContain(`: "${bare}"`);
263
+ expect(yaml).not.toContain(`: ${bare}\n`);
264
+ }
265
+ });
266
+
267
+ test("a document with no blocks is an empty sequence, not a null", () => {
268
+ // `blocks:` alone would parse as null and fail the schema, turning an empty
269
+ // project into an unreadable file.
270
+ const yaml = exportFixture(FIXTURES.find((f) => f.file === "empty-project.yaml")!).yaml;
271
+
272
+ expect(yaml).toContain("blocks: []");
273
+ });
274
+ });
@@ -14,13 +14,13 @@ import path from "node:path";
14
14
  // These blocks use the new unified state format (state instead of args+uiState)
15
15
  const BPSpecEnterV3: BlockPackSpec = {
16
16
  type: "dev-v2",
17
- // Navigate from lib/node/pl-middle-layer/src/mutator to etc/blocks/enter-numbers-v3/block
18
- folder: path.resolve(__dirname, "../../../../../etc/blocks/enter-numbers-v3/block"),
17
+ // Navigate from lib/node/pl-middle-layer/src/mutator to etc/blocks/enter-numbers/block
18
+ folder: path.resolve(__dirname, "../../../../../etc/blocks/enter-numbers/block"),
19
19
  };
20
20
 
21
21
  const BPSpecSumV3: BlockPackSpec = {
22
22
  type: "dev-v2",
23
- folder: path.resolve(__dirname, "../../../../../etc/blocks/sum-numbers-v3/block"),
23
+ folder: path.resolve(__dirname, "../../../../../etc/blocks/sum-numbers/block"),
24
24
  };
25
25
 
26
26
  test("v3 blocks: basic test with unified state", async () => {
@@ -34,7 +34,7 @@ test("v3 blocks: basic test with unified state", async () => {
34
34
  return await toGlobalResourceId(prjRef);
35
35
  });
36
36
 
37
- // Add enter-numbers-v3 block with storageMode: 'fromModel'
37
+ // Add enter-numbers block with storageMode: 'fromModel'
38
38
  // Initial storage comes from VM, then we set desired state via setStates
39
39
  await pl.withWriteTx("AddEnterNumbersV3Block", async (tx) => {
40
40
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
@@ -68,7 +68,7 @@ test("v3 blocks: basic test with unified state", async () => {
68
68
  expect(JSON.parse(stateData).__data).toStrictEqual({ numbers: [1, 2, 3] });
69
69
  });
70
70
 
71
- // Add second enter-numbers-v3 block
71
+ // Add second enter-numbers block
72
72
  await pl.withWriteTx("AddEnterNumbersV3Block2", async (tx) => {
73
73
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
74
74
  mut.addBlock(
@@ -93,7 +93,7 @@ test("v3 blocks: basic test with unified state", async () => {
93
93
  await tx.commit();
94
94
  });
95
95
 
96
- // Add sum-numbers-v3 block that references both enter blocks
96
+ // Add sum-numbers block that references both enter blocks
97
97
  await pl.withWriteTx("AddSumNumbersV3Block", async (tx) => {
98
98
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
99
99
  mut.addBlock(
@@ -126,7 +126,7 @@ test("v3 blocks: basic test with unified state", async () => {
126
126
  .get(projectFieldName("enter1", "stagingOutput"))
127
127
  .then((r) => r.final());
128
128
  const all = await stagingOutput.getAllFinal();
129
- // V3 enter-numbers-v3 block has prerun with numbersCount output
129
+ // V3 enter-numbers block has prerun with numbersCount output
130
130
  expect(Object.keys(all)).toContain("numbersCount");
131
131
  });
132
132
 
@@ -167,7 +167,7 @@ test("v3 blocks: prerunArgs skip test", async () => {
167
167
  return await toGlobalResourceId(prjRef);
168
168
  });
169
169
 
170
- // Add enter-numbers-v3 block
170
+ // Add enter-numbers block
171
171
  await pl.withWriteTx("AddEnterNumbersV3Block", async (tx) => {
172
172
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
173
173
  mut.addBlock(
@@ -296,7 +296,7 @@ test("v3 blocks: migrateBlockPack preserves state and re-derives args and prerun
296
296
  return await toGlobalResourceId(prjRef);
297
297
  });
298
298
 
299
- // Add enter-numbers-v3 block and set data with even numbers
299
+ // Add enter-numbers block and set data with even numbers
300
300
  await pl.withWriteTx("AddBlock", async (tx) => {
301
301
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
302
302
  mut.addBlock(
@@ -368,7 +368,7 @@ test("v3 blocks: migrateBlockPack with storage migration re-derives args and pre
368
368
  return await toGlobalResourceId(prjRef);
369
369
  });
370
370
 
371
- // Add enter-numbers-v3 block with initial data
371
+ // Add enter-numbers block with initial data
372
372
  await pl.withWriteTx("AddBlock", async (tx) => {
373
373
  const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
374
374
  mut.addBlock(
@@ -559,7 +559,7 @@ test("v3 blocks: migrateBlockPack assigns author marker", async () => {
559
559
  // return undefined`) must not crash. Previously applyStorageAndDeriveArgs passed the
560
560
  // undefined value straight to createJsonFieldValue -> Buffer.from(undefined) -> ERR_INVALID_ARG_TYPE,
561
561
  // aborting the update-block-pack task (reported by AstraZeneca on mixcr-clonotyping 2.14 -> 2.20).
562
- // The sentinel input [777] makes the enter-numbers-v3 model's args() return undefined without throwing.
562
+ // The sentinel input [777] makes the enter-numbers model's args() return undefined without throwing.
563
563
  test("v3 blocks: migrateBlockPack does not crash when args() returns undefined", async () => {
564
564
  const quickJs = await getQuickJS();
565
565
 
@@ -82,7 +82,11 @@ import {
82
82
  UiError,
83
83
  BLOCK_STORAGE_FACADE_VERSION,
84
84
  type BlockConfig,
85
+ type BlockKindReference,
85
86
  } from "@platforma-sdk/model";
87
+ import type { ProjectTemplateExportOutcome } from "../model/template_serializer";
88
+ import { exportProjectAsTemplateV1 } from "../model/template_serializer";
89
+ import type { TemplateParamsResult } from "../model/template_export";
86
90
  import { getDebugFlags } from "../debug";
87
91
  import type { BlockPackInfo } from "../model/block_pack";
88
92
 
@@ -112,6 +116,12 @@ interface BlockInfoState {
112
116
  readonly id: string;
113
117
  readonly fields: BlockFieldStates;
114
118
  blockConfig?: BlockConfig;
119
+ /**
120
+ * The block's kind reference, kept separately because `blockConfig` cannot carry
121
+ * it: `extractConfig` normalizes the render envelope and the kind sits at the
122
+ * container level, one level above. Undefined for a block that declares no kind.
123
+ */
124
+ blockKind?: BlockKindReference;
115
125
  blockPack?: BlockPackSpec;
116
126
  }
117
127
 
@@ -141,6 +151,8 @@ class BlockInfo {
141
151
  public readonly fields: BlockFieldStates,
142
152
  public readonly config: BlockConfig,
143
153
  public readonly source: BlockPackSpec,
154
+ /** See {@link BlockInfoState.blockKind}. */
155
+ public readonly kind: BlockKindReference | undefined,
144
156
  private readonly logger: MiLogger = new ConsoleLoggerAdapter(),
145
157
  ) {}
146
158
 
@@ -303,7 +315,25 @@ class BlockInfo {
303
315
  */
304
316
  /** Specification for creating a new block. Discriminated union based on `storageMode`. */
305
317
  export type NewBlockSpec =
306
- | { storageMode: "fromModel"; blockPack: BlockPackSpecPrepared }
318
+ | {
319
+ storageMode: "fromModel";
320
+ blockPack: BlockPackSpecPrepared;
321
+ /**
322
+ * Storage to start the block with, instead of the model's own default.
323
+ *
324
+ * Only for a caller that produced it through the block's own model, which today
325
+ * means one seeding a block from a template entry's params: the model's
326
+ * params-to-storage initializer can reject those params, and it has to do so
327
+ * before anything in the transaction changes. Computing the storage first turns
328
+ * that rejection into a value the caller can report, and leaves this method with
329
+ * no failure path it did not already have.
330
+ *
331
+ * The result is the same storage the block would have written itself, so args
332
+ * derivation below is unchanged — which is why this is the `fromModel` mode with
333
+ * one input filled in rather than a mode of its own.
334
+ */
335
+ initialStorage?: string;
336
+ }
307
337
  | { storageMode: "legacy"; blockPack: BlockPackSpecPrepared; legacyState: string };
308
338
 
309
339
  const NoNewBlocks = (blockId: string) => {
@@ -516,6 +546,48 @@ export class ProjectMutator {
516
546
  };
517
547
  }
518
548
 
549
+ //
550
+ // Template export
551
+ //
552
+
553
+ /**
554
+ * Render this project as a `template-v1` YAML document, or report every reason it
555
+ * cannot be.
556
+ *
557
+ * Read-only: it derives params in the VM and touches no field, so a `withProject`
558
+ * wrapper sees `wasModified === false` and skips the commit.
559
+ *
560
+ * This is where the three providers the serializer needs come from, and all are
561
+ * only reachable here. A block's storage lives behind `BlockInfo`, which is
562
+ * populated by the loader's batched round-trips, and both its kind reference and
563
+ * its origin spec are read off the block-pack container during that same load —
564
+ * `config` cannot carry the kind, since `extractConfig` normalizes the render
565
+ * envelope one level below it, and the spec is not part of the config at all.
566
+ */
567
+ public exportAsTemplateV1(): ProjectTemplateExportOutcome {
568
+ return exportProjectAsTemplateV1(
569
+ this.struct,
570
+ (blockId) => this.deriveTemplateParams(blockId),
571
+ (blockId) => this.blockInfos.get(blockId)?.kind,
572
+ (blockId) => this.blockInfos.get(blockId)?.source,
573
+ );
574
+ }
575
+
576
+ private deriveTemplateParams(blockId: string): TemplateParamsResult | undefined {
577
+ const info = this.blockInfos.get(blockId);
578
+ // A block in the structure with no loaded info, or with no storage yet, has no
579
+ // state to project into params. Returning undefined makes the walk report it
580
+ // rather than quietly leaving the block out of the template.
581
+ if (info === undefined) return undefined;
582
+
583
+ const storageJson = info.blockStorageJson;
584
+ if (storageJson === undefined) return undefined;
585
+
586
+ const derived = this.projectHelper.deriveTemplateParamsFromStorage(info.config, storageJson);
587
+ if (derived.error !== undefined) return { error: derived.error.message };
588
+ return { value: derived.value };
589
+ }
590
+
519
591
  private getPendingProductionGraph(): BlockGraph {
520
592
  if (this.pendingProductionGraph === undefined)
521
593
  this.pendingProductionGraph = productionGraph(this.struct, (blockId) =>
@@ -1142,6 +1214,7 @@ export class ProjectMutator {
1142
1214
  {},
1143
1215
  extractConfig(spec.blockPack.config),
1144
1216
  spec.blockPack.source,
1217
+ spec.blockPack.config.kind,
1145
1218
  this.projectHelper.logger,
1146
1219
  );
1147
1220
  this.blockInfos.set(blockId, info);
@@ -1164,8 +1237,11 @@ export class ProjectMutator {
1164
1237
  let storageToWrite: string;
1165
1238
 
1166
1239
  if (spec.storageMode === "fromModel") {
1167
- // Model API v2+: get initial storage and derive args from it
1168
- storageToWrite = this.projectHelper.getInitialStorageInVM(blockConfig);
1240
+ // Model API v2+: get initial storage and derive args from it. A caller that
1241
+ // already produced the storage — a template seeding a block from its params —
1242
+ // passes it in, precisely so the VM call that could reject those params happens
1243
+ // before the transaction is touched; see `initialStorage`.
1244
+ storageToWrite = spec.initialStorage ?? this.projectHelper.getInitialStorageInVM(blockConfig);
1169
1245
 
1170
1246
  // Derive prerunArgs first — always derived independently of args validation
1171
1247
  prerunArgs = this.projectHelper.derivePrerunArgsFromStorage(blockConfig, storageToWrite);
@@ -1251,6 +1327,7 @@ export class ProjectMutator {
1251
1327
  {},
1252
1328
  originalBlockInfo.config,
1253
1329
  originalBlockInfo.source,
1330
+ originalBlockInfo.kind,
1254
1331
  this.projectHelper.logger,
1255
1332
  );
1256
1333
 
@@ -1895,6 +1972,9 @@ export class ProjectMutator {
1895
1972
  const result = await response;
1896
1973
  const bpInfo = cachedDeserialize<BlockPackInfo>(notEmpty(result.data));
1897
1974
  info.blockConfig = extractConfig(bpInfo.config);
1975
+ // Read off the container before `extractConfig`'s result replaces it as the
1976
+ // only thing we keep. Costs no round-trip — `bpInfo` is already in hand.
1977
+ info.blockKind = bpInfo.config.kind;
1898
1978
  info.blockPack = bpInfo.source;
1899
1979
  }
1900
1980
 
@@ -1926,10 +2006,17 @@ export class ProjectMutator {
1926
2006
  const blocksInLimboSet = new Set(blocksInLimbo);
1927
2007
 
1928
2008
  const blockInfos = new Map<string, BlockInfo>();
1929
- blockInfoStates.forEach(({ id, fields, blockConfig, blockPack }) =>
2009
+ blockInfoStates.forEach(({ id, fields, blockConfig, blockKind, blockPack }) =>
1930
2010
  blockInfos.set(
1931
2011
  id,
1932
- new BlockInfo(id, fields, notEmpty(blockConfig), notEmpty(blockPack), projectHelper.logger),
2012
+ new BlockInfo(
2013
+ id,
2014
+ fields,
2015
+ notEmpty(blockConfig),
2016
+ notEmpty(blockPack),
2017
+ blockKind,
2018
+ projectHelper.logger,
2019
+ ),
1933
2020
  ),
1934
2021
  );
1935
2022