@milaboratories/pl-middle-layer 1.74.18 → 1.75.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 (60) hide show
  1. package/dist/middle_layer/block.cjs.map +1 -1
  2. package/dist/middle_layer/block.js.map +1 -1
  3. package/dist/middle_layer/build_stamp.cjs +1 -1
  4. package/dist/middle_layer/build_stamp.js +1 -1
  5. package/dist/middle_layer/column_links.cjs +106 -0
  6. package/dist/middle_layer/column_links.cjs.map +1 -0
  7. package/dist/middle_layer/column_links.js +104 -0
  8. package/dist/middle_layer/column_links.js.map +1 -0
  9. package/dist/middle_layer/middle_layer.cjs +1 -0
  10. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  11. package/dist/middle_layer/middle_layer.js +1 -0
  12. package/dist/middle_layer/middle_layer.js.map +1 -1
  13. package/dist/middle_layer/project.cjs +45 -19
  14. package/dist/middle_layer/project.cjs.map +1 -1
  15. package/dist/middle_layer/project.d.ts.map +1 -1
  16. package/dist/middle_layer/project.js +46 -20
  17. package/dist/middle_layer/project.js.map +1 -1
  18. package/dist/middle_layer/project_overview.cjs +56 -24
  19. package/dist/middle_layer/project_overview.cjs.map +1 -1
  20. package/dist/middle_layer/project_overview.js +55 -25
  21. package/dist/middle_layer/project_overview.js.map +1 -1
  22. package/dist/model/args_derivation.cjs +153 -0
  23. package/dist/model/args_derivation.cjs.map +1 -0
  24. package/dist/model/args_derivation.d.ts +21 -0
  25. package/dist/model/args_derivation.d.ts.map +1 -0
  26. package/dist/model/args_derivation.js +148 -0
  27. package/dist/model/args_derivation.js.map +1 -0
  28. package/dist/model/project_helper.cjs +34 -8
  29. package/dist/model/project_helper.cjs.map +1 -1
  30. package/dist/model/project_helper.d.ts +18 -5
  31. package/dist/model/project_helper.d.ts.map +1 -1
  32. package/dist/model/project_helper.js +34 -8
  33. package/dist/model/project_helper.js.map +1 -1
  34. package/dist/model/project_model.cjs +1 -0
  35. package/dist/model/project_model.cjs.map +1 -1
  36. package/dist/model/project_model.d.ts +1 -1
  37. package/dist/model/project_model.js +1 -0
  38. package/dist/model/project_model.js.map +1 -1
  39. package/dist/mutator/project.cjs +54 -8
  40. package/dist/mutator/project.cjs.map +1 -1
  41. package/dist/mutator/project.d.ts +1 -0
  42. package/dist/mutator/project.d.ts.map +1 -1
  43. package/dist/mutator/project.js +54 -8
  44. package/dist/mutator/project.js.map +1 -1
  45. package/package.json +16 -16
  46. package/src/middle_layer/block.ts +4 -1
  47. package/src/middle_layer/column_links.test.ts +350 -0
  48. package/src/middle_layer/column_links.ts +152 -0
  49. package/src/middle_layer/middle_layer.ts +2 -0
  50. package/src/middle_layer/project.ts +39 -5
  51. package/src/middle_layer/project_overview.test.ts +200 -0
  52. package/src/middle_layer/project_overview.ts +82 -28
  53. package/src/model/args.test.ts +39 -1
  54. package/src/model/args_derivation.test.ts +326 -0
  55. package/src/model/args_derivation.ts +194 -0
  56. package/src/model/project_helper.ts +55 -25
  57. package/src/model/project_model.ts +2 -0
  58. package/src/mutator/project-v3.test.ts +105 -1
  59. package/src/mutator/project.ts +81 -24
  60. package/src/test/fake_project.ts +222 -0
@@ -110,6 +110,7 @@ export interface ProjectField {
110
110
  | "prodArgs"
111
111
  | "currentArgs"
112
112
  | "currentPrerunArgs" // Derived args for staging/prerun rendering (from prerunArgs() or args())
113
+ | "inputsValid" // Model API v2: errors found in currentArgs, none when the block may run, with the fingerprint of those args; present while currentArgs are
113
114
  | "prodChainCtx" // Pre-built production context chain node (accumulated prodCtx from all blocks above)
114
115
  | "prodCtx"
115
116
  | "prodUiCtx"
@@ -132,6 +133,7 @@ export const FieldsToDuplicate: Set<ProjectField["fieldName"]> = new Set([
132
133
  "prodArgs",
133
134
  "currentArgs",
134
135
  "currentPrerunArgs",
136
+ "inputsValid",
135
137
  "prodCtx",
136
138
  "prodUiCtx",
137
139
  "prodOutput",
@@ -1,7 +1,9 @@
1
- import { field, poll, TestHelpers, toGlobalResourceId } from "@milaboratories/pl-client";
1
+ import { field, Pl, poll, TestHelpers, toGlobalResourceId } from "@milaboratories/pl-client";
2
+ import { canonicalJsonBytes } from "@milaboratories/ts-helpers";
2
3
  import { getQuickJS } from "quickjs-emscripten";
3
4
  import { expect, test } from "vitest";
4
5
  import { outputRef } from "../model/args";
6
+ import { argsFingerprint } from "../model/args_derivation";
5
7
  import { ProjectHelper } from "../model/project_helper";
6
8
  import { blockArgsAuthorKey, projectFieldName } from "../model/project_model";
7
9
  import { TestBPPreparer } from "../test/block_packs";
@@ -622,3 +624,105 @@ test("v3 blocks: migrateBlockPack does not crash when args() returns undefined",
622
624
  });
623
625
  });
624
626
  });
627
+
628
+ test("v3 blocks: every args derivation records the errors found in the args, none when there are none", async () => {
629
+ const quickJs = await getQuickJS();
630
+
631
+ await TestHelpers.withTempRoot(async (pl) => {
632
+ const prj = await pl.withWriteTx("CreatingProject", async (tx) => {
633
+ const prjRef = await createProject(tx);
634
+ tx.createField(field(tx.clientRoot, "prj"), "Dynamic", prjRef);
635
+ await tx.commit();
636
+ return await toGlobalResourceId(prjRef);
637
+ });
638
+
639
+ await pl.withWriteTx("AddBlock", async (tx) => {
640
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
641
+ mut.addBlock(
642
+ { id: "enter1", label: "Enter Numbers V3", renderingMode: "Heavy" },
643
+ { storageMode: "fromModel", blockPack: await TestBPPreparer.prepare(BPSpecEnterV3) },
644
+ );
645
+ mut.setStates([
646
+ {
647
+ modelAPIVersion: 2,
648
+ blockId: "enter1",
649
+ payload: { operation: "update-block-data", value: { numbers: [3, 1, 2] } },
650
+ },
651
+ ]);
652
+ mut.save();
653
+ await tx.commit();
654
+ });
655
+
656
+ await poll(pl, async (tx) => {
657
+ const prjR = await tx.get(prj);
658
+ const record = await prjR.get(projectFieldName("enter1", "inputsValid"));
659
+ expect(jsonOf(record.data.data)).toStrictEqual({
660
+ argsFingerprint: argsFingerprint({ numbers: [1, 2, 3] }),
661
+ errors: [],
662
+ });
663
+ });
664
+ });
665
+ });
666
+
667
+ test("v3 blocks: args without an args errors record of their own are kept and run, as before the record", async () => {
668
+ const quickJs = await getQuickJS();
669
+
670
+ await TestHelpers.withTempRoot(async (pl) => {
671
+ const prj = await pl.withWriteTx("CreatingProject", async (tx) => {
672
+ const prjRef = await createProject(tx);
673
+ tx.createField(field(tx.clientRoot, "prj"), "Dynamic", prjRef);
674
+ await tx.commit();
675
+ return await toGlobalResourceId(prjRef);
676
+ });
677
+
678
+ await pl.withWriteTx("AddBlock", async (tx) => {
679
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
680
+ mut.addBlock(
681
+ { id: "enter1", label: "Enter Numbers V3", renderingMode: "Heavy" },
682
+ { storageMode: "fromModel", blockPack: await TestBPPreparer.prepare(BPSpecEnterV3) },
683
+ );
684
+ mut.setStates([
685
+ {
686
+ modelAPIVersion: 2,
687
+ blockId: "enter1",
688
+ payload: { operation: "update-block-data", value: { numbers: [3, 1, 2] } },
689
+ },
690
+ ]);
691
+ mut.save();
692
+ await tx.commit();
693
+ });
694
+
695
+ // What a middle layer that does not record args errors leaves behind: current args it wrote
696
+ // itself, and no record of their args errors.
697
+ await pl.withWriteTx("WriteArgsWithoutRecord", async (tx) => {
698
+ tx.removeField(field(prj, projectFieldName("enter1", "inputsValid")));
699
+ tx.setField(
700
+ field(prj, projectFieldName("enter1", "currentArgs")),
701
+ tx.createValue(Pl.JsonObject, canonicalJsonBytes({ numbers: [9] })),
702
+ );
703
+ await tx.commit();
704
+ });
705
+
706
+ await pl.withWriteTx("Run", async (tx) => {
707
+ const mut = await ProjectMutator.load(new ProjectHelper(quickJs), tx, prj);
708
+ mut.renderProduction(["enter1"]);
709
+ mut.save();
710
+ await tx.commit();
711
+ });
712
+
713
+ await poll(pl, async (tx) => {
714
+ const prjR = await tx.get(prj);
715
+ const currentArgs = await prjR.get(projectFieldName("enter1", "currentArgs"));
716
+ expect(jsonOf(currentArgs.data.data)).toStrictEqual({ numbers: [9] });
717
+ const prodArgs = await prjR.get(projectFieldName("enter1", "prodArgs"));
718
+ expect(jsonOf(prodArgs.data.data)).toStrictEqual({ numbers: [9] });
719
+ });
720
+ });
721
+ });
722
+
723
+ // Internals
724
+
725
+ function jsonOf(data: Uint8Array | undefined): unknown {
726
+ if (data === undefined) throw new Error("The field holds no data");
727
+ return JSON.parse(Buffer.from(data).toString());
728
+ }
@@ -77,6 +77,14 @@ import {
77
77
  } from "@milaboratories/ts-helpers";
78
78
  import { gzipSync } from "node:zlib";
79
79
  import type { ProjectHelper } from "../model/project_helper";
80
+ import {
81
+ derivedArgsValue,
82
+ argsErrorsFieldChange,
83
+ argsErrorsOfBlock,
84
+ type DerivedArgs,
85
+ type ArgsErrorsFieldChange,
86
+ } from "../model/args_derivation";
87
+ import type { ArgsIssue } from "@milaboratories/pl-model-common";
80
88
  import {
81
89
  extractConfig,
82
90
  UiError,
@@ -273,6 +281,24 @@ class BlockInfo {
273
281
  return this.currentPrerunArgsC();
274
282
  }
275
283
 
284
+ private readonly argsErrorsC = cached(
285
+ () => `${this.fields.inputsValid?.modCount}:${this.fields.currentArgs?.modCount}`,
286
+ () =>
287
+ argsErrorsOfBlock({
288
+ modelAPIVersion: this.config.modelAPIVersion,
289
+ inputsValid: this.fields.inputsValid,
290
+ currentArgs: this.currentArgs,
291
+ }),
292
+ );
293
+
294
+ /**
295
+ * The errors the block's `argsIssues()` found in its current args. A block whose model API
296
+ * version has no args issues, or that has no current args, has none.
297
+ */
298
+ get argsErrors(): readonly ArgsIssue[] {
299
+ return this.argsErrorsC();
300
+ }
301
+
276
302
  get stagingRendered(): boolean {
277
303
  return this.fields.stagingCtx !== undefined;
278
304
  }
@@ -756,6 +782,32 @@ export class ProjectMutator {
756
782
  return deleted;
757
783
  }
758
784
 
785
+ /**
786
+ * Brings the block's `inputsValid` field in line with what its args derivation found: the run
787
+ * errors of its args, none when it may run with them, and no field when it has no args.
788
+ */
789
+ private setArgsErrors(blockId: string, derivedArgs: DerivedArgs): void {
790
+ this.applyArgsErrorsChange(
791
+ blockId,
792
+ argsErrorsFieldChange(this.getBlockInfo(blockId).fields.inputsValid, derivedArgs),
793
+ );
794
+ }
795
+
796
+ private applyArgsErrorsChange(blockId: string, change: ArgsErrorsFieldChange): void {
797
+ switch (change.type) {
798
+ case "keep":
799
+ return;
800
+ case "delete":
801
+ this.deleteBlockFields(blockId, "inputsValid");
802
+ return;
803
+ case "write": {
804
+ const ref = this.tx.createValue(Pl.JsonObject, change.data);
805
+ this.setBlockField(blockId, "inputsValid", ref, "Ready", change.data);
806
+ return;
807
+ }
808
+ }
809
+ }
810
+
759
811
  private updateLastModified() {
760
812
  this.lastModified = Date.now();
761
813
  this.lastModifiedChanged = true;
@@ -908,21 +960,16 @@ export class ProjectMutator {
908
960
  }
909
961
 
910
962
  // Derive args from storage - set or clear currentArgs based on derivation result
911
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(
912
- blockConfig,
913
- initialStorageJson,
914
- );
963
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(blockConfig, initialStorageJson);
915
964
  // args() legitimately returns undefined when inputs are incomplete/invalid;
916
965
  // treat that like a derivation failure and clear the field (never JSON-encode undefined).
917
- if (!deriveArgsResult.error && deriveArgsResult.value !== undefined) {
918
- this.setBlockFieldObj(
919
- blockId,
920
- "currentArgs",
921
- this.createJsonFieldValue(deriveArgsResult.value),
922
- );
966
+ const args = derivedArgsValue(derivedArgs);
967
+ if (args !== undefined) {
968
+ this.setBlockFieldObj(blockId, "currentArgs", this.createJsonFieldValue(args));
923
969
  } else {
924
970
  this.deleteBlockFields(blockId, "currentArgs");
925
971
  }
972
+ this.setArgsErrors(blockId, derivedArgs);
926
973
  }
927
974
 
928
975
  /** Optimally sets inputs for multiple blocks in one go */
@@ -974,11 +1021,12 @@ export class ProjectMutator {
974
1021
  );
975
1022
 
976
1023
  // Derive args from storage (VM extracts data internally)
977
- const derivedArgsResult = this.projectHelper.deriveArgsFromStorage(
1024
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(
978
1025
  blockConfig,
979
1026
  updatedStorageJson,
980
1027
  );
981
- args = derivedArgsResult.error ? undefined : derivedArgsResult.value;
1028
+ args = derivedArgsValue(derivedArgs);
1029
+ this.setArgsErrors(req.blockId, derivedArgs);
982
1030
  } else {
983
1031
  this.setBlockFieldObj(req.blockId, "blockStorage", this.createGzJsonFieldValue(req.state));
984
1032
  if (req.state !== null && typeof req.state === "object" && "args" in req.state) {
@@ -1194,6 +1242,13 @@ export class ProjectMutator {
1194
1242
  throw new Error(`Can't render production for block ${blockId}: currentArgs not set`);
1195
1243
  }
1196
1244
 
1245
+ // Nor if its argsIssues() found errors in them
1246
+ const argsErrors = info.argsErrors;
1247
+ if (argsErrors.length > 0) {
1248
+ const messages = argsErrors.map(({ message }) => message).join("; ");
1249
+ throw new Error(`Can't render production for block ${blockId}: ${messages}`);
1250
+ }
1251
+
1197
1252
  const ctx = this.createProdCtx(this.getPendingProductionGraph().nodes.get(blockId)!.upstream);
1198
1253
 
1199
1254
  if (this.getBlock(blockId).renderingMode === "Light")
@@ -1266,11 +1321,9 @@ export class ProjectMutator {
1266
1321
  prerunArgs = this.projectHelper.derivePrerunArgsFromStorage(blockConfig, storageToWrite);
1267
1322
 
1268
1323
  // Derive args from storage (VM extracts data internally)
1269
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(
1270
- blockConfig,
1271
- storageToWrite,
1272
- );
1273
- args = deriveArgsResult.error ? undefined : deriveArgsResult.value;
1324
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(blockConfig, storageToWrite);
1325
+ args = derivedArgsValue(derivedArgs);
1326
+ this.setArgsErrors(blockId, derivedArgs);
1274
1327
  } else if (spec.storageMode === "legacy") {
1275
1328
  // Model API v1: use legacyState from spec
1276
1329
  const parsedState = JSON.parse(spec.legacyState);
@@ -1560,18 +1613,16 @@ export class ProjectMutator {
1560
1613
  this.deleteBlockFields(blockId, "currentPrerunArgs");
1561
1614
  }
1562
1615
 
1563
- const deriveArgsResult = this.projectHelper.deriveArgsFromStorage(newConfig, storageJson);
1616
+ const derivedArgs = this.projectHelper.deriveArgsFromStorage(newConfig, storageJson);
1564
1617
  // args() legitimately returns undefined when inputs are incomplete/invalid;
1565
1618
  // treat that like a derivation failure and clear the field (never JSON-encode undefined).
1566
- if (!deriveArgsResult.error && deriveArgsResult.value !== undefined) {
1567
- this.setBlockFieldObj(
1568
- blockId,
1569
- "currentArgs",
1570
- this.createJsonFieldValue(deriveArgsResult.value),
1571
- );
1619
+ const args = derivedArgsValue(derivedArgs);
1620
+ if (args !== undefined) {
1621
+ this.setBlockFieldObj(blockId, "currentArgs", this.createJsonFieldValue(args));
1572
1622
  } else {
1573
1623
  this.deleteBlockFields(blockId, "currentArgs");
1574
1624
  }
1625
+ this.setArgsErrors(blockId, derivedArgs);
1575
1626
  };
1576
1627
 
1577
1628
  if (newClearState !== undefined) {
@@ -1587,6 +1638,9 @@ export class ProjectMutator {
1587
1638
  } else {
1588
1639
  // V1: Use setStates with legacy state format
1589
1640
  persistBlockPack();
1641
+ // A V1 model tells whether the block may run by itself; args errors of an earlier V2 model
1642
+ // no longer apply.
1643
+ this.deleteBlockFields(blockId, "inputsValid");
1590
1644
  this.setStates([{ modelAPIVersion: 1, blockId, state: newClearState.state }]);
1591
1645
  }
1592
1646
  } else {
@@ -1617,6 +1671,9 @@ export class ProjectMutator {
1617
1671
  } else {
1618
1672
  // Legacy blocks (modelAPIVersion 1): persist block pack, set prerunArgs = currentArgs
1619
1673
  persistBlockPack();
1674
+ // A V1 model tells whether the block may run by itself; args errors of an earlier V2 model
1675
+ // no longer apply.
1676
+ this.deleteBlockFields(blockId, "inputsValid");
1620
1677
  if (info.fields.currentArgs !== undefined) {
1621
1678
  this.setBlockFieldObj(blockId, "currentPrerunArgs", info.fields.currentArgs);
1622
1679
  }
@@ -0,0 +1,222 @@
1
+ import {
2
+ createSignedResourceId,
3
+ DefaultFinalResourceDataPredicate,
4
+ NullSignedResourceId,
5
+ type FieldData,
6
+ type FieldType,
7
+ type SignedResourceId,
8
+ } from "@milaboratories/pl-client";
9
+ import type { PColumnSpec, ArgsIssue } from "@milaboratories/pl-model-common";
10
+ import { PlTreeState, type ExtendedResourceData } from "@milaboratories/pl-tree";
11
+ import { argsFingerprint, type ArgsErrorsRecord } from "../model/args_derivation";
12
+ import {
13
+ projectFieldName,
14
+ ProjectStructureKey,
15
+ type ProjectField,
16
+ type ProjectStructure,
17
+ } from "../model/project_model";
18
+
19
+ /** What a project field holds: a resource, an error, or nothing yet. */
20
+ export type FieldContent = SignedResourceId | { error: SignedResourceId } | "unresolved";
21
+
22
+ /** Columns of a result list by name: the spec of each and the cells of its data. */
23
+ export type Columns = Record<string, { spec: PColumnSpec; cells: string[] }>;
24
+
25
+ /**
26
+ * State of a result list: `locked` — complete; `open` — more columns may come; `failed` — it has
27
+ * an error and will never lock.
28
+ */
29
+ export type FrameState = { state: "locked" | "open" | "failed" };
30
+
31
+ /** The block fields a {@link FakeProject} holds. */
32
+ export type FakeProjectField = Extract<
33
+ ProjectField["fieldName"],
34
+ | "currentArgs"
35
+ | "inputsValid"
36
+ | "prodArgs"
37
+ | "prodCtx"
38
+ | "prodUiCtx"
39
+ | "stagingCtx"
40
+ | "stagingUiCtx"
41
+ >;
42
+
43
+ /** A project resource tree built in memory: its blocks, in project order, and their fields. */
44
+ export class FakeProject {
45
+ private readonly nodes: ExtendedResourceData[] = [];
46
+ private sent = 0;
47
+ private readonly blocks = new Map<string, Partial<Record<FakeProjectField, FieldContent>>>();
48
+ private readonly frameFields = new Map<SignedResourceId, FieldData[]>();
49
+
50
+ /** Adds a block below the others, or replaces the fields of one already added. */
51
+ block(blockId: string, fields: Partial<Record<FakeProjectField, FieldContent>>): void {
52
+ this.blocks.set(blockId, fields);
53
+ }
54
+
55
+ /** Args of a block. */
56
+ args(args: unknown): SignedResourceId {
57
+ return this.value("json/object", args);
58
+ }
59
+
60
+ /** Args errors of a block found in the args `forArgs`, as its `inputsValid` field holds them. */
61
+ argsErrors(errors: readonly ArgsIssue[], forArgs: unknown): SignedResourceId {
62
+ const record: ArgsErrorsRecord = { argsFingerprint: argsFingerprint(forArgs), errors };
63
+ return this.value("json/object", record);
64
+ }
65
+
66
+ /** A ready value resource holding `json`. */
67
+ value(type: string, json: unknown): SignedResourceId {
68
+ const id = this.newId();
69
+ this.nodes.push({
70
+ ...READY,
71
+ id,
72
+ kind: "Value",
73
+ type: { name: type, version: "1" },
74
+ data: Buffer.from(JSON.stringify(json)),
75
+ fields: [],
76
+ });
77
+ return id;
78
+ }
79
+
80
+ /** An error a resource or field can hold. */
81
+ error(message: string): SignedResourceId {
82
+ return this.value("json/resourceError", { message });
83
+ }
84
+
85
+ /** A result list exporting the given columns, each with JSON data holding `cells`. */
86
+ frame(columns: Columns, state: FrameState = { state: "locked" }): SignedResourceId {
87
+ const id = this.newId();
88
+ this.frameFields.set(id, []);
89
+ this.extend(id, columns, state);
90
+ return id;
91
+ }
92
+
93
+ /** Adds columns to a result list made by {@link frame}, and moves it to `state`. */
94
+ extend(id: SignedResourceId, columns: Columns, { state }: FrameState): void {
95
+ const fields = this.frameFields.get(id);
96
+ if (fields === undefined) throw new Error("Not a result list of this project");
97
+ for (const [name, { spec, cells }] of Object.entries(columns)) {
98
+ const data = Object.fromEntries(cells.map((cell, i) => [`["s${i}"]`, cell]));
99
+ fields.push(field("Input", `${name}.spec`, this.value("PColumnSpec", spec)));
100
+ fields.push(
101
+ field("Input", `${name}.data`, this.value("PColumnData/Json", { keyLength: 1, data })),
102
+ );
103
+ }
104
+ const error = state === "failed" ? this.error("render failed") : NullSignedResourceId;
105
+ this.nodes.push({
106
+ ...READY,
107
+ id,
108
+ kind: "Structural",
109
+ type: { name: "PFrame", version: "1" },
110
+ inputsLocked: state === "locked",
111
+ resourceReady: state === "locked",
112
+ final: state === "locked",
113
+ error,
114
+ fields: [...fields],
115
+ });
116
+ }
117
+
118
+ /** The tree of the project as it is now. */
119
+ tree(): PlTreeState {
120
+ const tree = new PlTreeState(ROOT_ID, DefaultFinalResourceDataPredicate);
121
+ tree.updateFromResourceData(this.changes());
122
+ return tree;
123
+ }
124
+
125
+ /**
126
+ * The project resource and the resources added or changed since the last call: a final
127
+ * resource already in the tree is not sent again.
128
+ */
129
+ changes(): ExtendedResourceData[] {
130
+ const structure: ProjectStructure = {
131
+ groups: [
132
+ {
133
+ id: "main",
134
+ label: "Main",
135
+ blocks: [...this.blocks.keys()].map((id) => ({ id, label: id, renderingMode: "Heavy" })),
136
+ },
137
+ ],
138
+ };
139
+ const fields = [...this.blocks].flatMap(([blockId, contents]) =>
140
+ PROJECT_FIELDS.flatMap((name) => {
141
+ const content = contents[name];
142
+ return content === undefined
143
+ ? []
144
+ : [projectField(projectFieldName(blockId, name), content)];
145
+ }),
146
+ );
147
+ const root: ExtendedResourceData = {
148
+ ...READY,
149
+ final: false,
150
+ id: ROOT_ID,
151
+ kind: "Structural",
152
+ type: { name: "UserProject", version: "2" },
153
+ fields,
154
+ kv: [{ key: ProjectStructureKey, value: Buffer.from(JSON.stringify(structure)) }],
155
+ };
156
+ const added = this.nodes.slice(this.sent);
157
+ this.sent = this.nodes.length;
158
+ return [root, ...added];
159
+ }
160
+
161
+ private newId(): SignedResourceId {
162
+ return createSignedResourceId(nextResourceId++);
163
+ }
164
+ }
165
+
166
+ // Internals
167
+
168
+ const ROOT_ID = createSignedResourceId(1_000_001n);
169
+
170
+ /**
171
+ * Resource ids stay unique across tests, as they are on a backend: the middle layer caches the
172
+ * columns an args resource references by its id.
173
+ */
174
+ let nextResourceId = 100n;
175
+
176
+ const READY: Omit<ExtendedResourceData, "id" | "kind" | "type" | "fields"> = {
177
+ originalResourceId: NullSignedResourceId,
178
+ error: NullSignedResourceId,
179
+ inputsLocked: true,
180
+ outputsLocked: true,
181
+ resourceReady: true,
182
+ final: true,
183
+ kv: [],
184
+ };
185
+
186
+ const PROJECT_FIELDS: readonly FakeProjectField[] = [
187
+ "currentArgs",
188
+ "inputsValid",
189
+ "prodArgs",
190
+ "prodCtx",
191
+ "prodUiCtx",
192
+ "stagingCtx",
193
+ "stagingUiCtx",
194
+ ];
195
+
196
+ function field(type: FieldType, name: string, value: SignedResourceId): FieldData {
197
+ return { name, type, value, error: NullSignedResourceId, status: "Resolved", valueIsFinal: true };
198
+ }
199
+
200
+ function projectField(name: string, content: FieldContent): FieldData {
201
+ if (content === "unresolved") {
202
+ return {
203
+ name,
204
+ type: "Dynamic",
205
+ value: NullSignedResourceId,
206
+ error: NullSignedResourceId,
207
+ status: "Empty",
208
+ valueIsFinal: false,
209
+ };
210
+ }
211
+ if (typeof content === "object" && "error" in content) {
212
+ return {
213
+ name,
214
+ type: "Dynamic",
215
+ value: NullSignedResourceId,
216
+ error: content.error,
217
+ status: "Resolved",
218
+ valueIsFinal: true,
219
+ };
220
+ }
221
+ return field("Dynamic", name, content);
222
+ }