@uipath/maestro-builder-sdk 6.18.10 → 6.20.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/api-index.md +185 -184
- package/dist/api-members.md +506 -473
- package/dist/check.js +358 -695
- package/dist/cli/commands/flow.js +3 -0
- package/dist/compile-cli.js +51 -0
- package/dist/core/actions.d.ts +132 -765
- package/dist/core/actions.js +1 -307
- package/dist/core/binding-identity.d.ts +22 -0
- package/dist/core/binding-identity.js +28 -0
- package/dist/core/context-index.d.ts +34 -0
- package/dist/core/context-index.js +38 -0
- package/dist/core/definitionless-node-types.d.ts +16 -0
- package/dist/core/definitionless-node-types.js +16 -0
- package/dist/core/error-port.d.ts +6 -0
- package/dist/core/error-port.js +9 -0
- package/dist/core/form-field-type.d.ts +33 -0
- package/dist/core/form-field-type.js +30 -0
- package/dist/core/hitl-routing.d.ts +8 -0
- package/dist/core/hitl-routing.js +17 -1
- package/dist/core/node-classes.d.ts +4 -63
- package/dist/core/node-classes.js +0 -112
- package/dist/core/nodes/family.d.ts +36 -0
- package/dist/core/nodes/family.js +1 -0
- package/dist/core/nodes/node-type.d.ts +136 -0
- package/dist/core/nodes/node-type.js +1 -0
- package/dist/core/nodes/registry.d.ts +57 -0
- package/dist/core/nodes/registry.js +122 -0
- package/dist/core/nodes/versioned.d.ts +50 -0
- package/dist/core/nodes/versioned.js +46 -0
- package/dist/core/process-resource-sort-order.d.ts +40 -0
- package/dist/core/process-resource-sort-order.js +44 -0
- package/dist/core/typed-raw-node.d.ts +13 -62
- package/dist/core/typed-raw-node.js +40 -154
- package/dist/core/wire-expr.d.ts +27 -0
- package/dist/core/wire-expr.js +34 -0
- package/dist/decompile-cli.js +11 -1
- package/dist/decompile.d.ts +8 -3
- package/dist/decompile.js +665 -354
- package/dist/flow-definitions.d.ts +10 -0
- package/dist/flow-definitions.js +35 -0
- package/dist/flow-expr-check.js +1 -1
- package/dist/flow-node-registry.d.ts +13 -0
- package/dist/flow-node-registry.js +19 -0
- package/dist/flow-node-type.d.ts +192 -0
- package/dist/flow-node-type.js +1 -0
- package/dist/flow-sdk.d.ts +40 -1
- package/dist/flow-sdk.js +29 -2
- package/dist/flow-step-tree.js +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/merge-cli.js +25 -8
- package/dist/merge-roundtrip.d.ts +81 -0
- package/dist/merge-roundtrip.js +404 -0
- package/dist/merge.d.ts +35 -6
- package/dist/merge.js +124 -24
- package/dist/node-placement.d.ts +93 -0
- package/dist/node-placement.js +163 -0
- package/dist/nodes/data-fabric/flow/check.d.ts +13 -0
- package/dist/nodes/data-fabric/flow/check.js +164 -0
- package/dist/nodes/data-fabric/flow/factories.d.ts +113 -0
- package/dist/nodes/data-fabric/flow/factories.js +134 -0
- package/dist/nodes/data-fabric/flow/family.d.ts +7 -0
- package/dist/nodes/data-fabric/flow/family.js +9 -0
- package/dist/nodes/data-fabric/flow/index.d.ts +8 -0
- package/dist/nodes/data-fabric/flow/index.js +8 -0
- package/dist/nodes/data-fabric/flow/raw.d.ts +32 -0
- package/dist/nodes/data-fabric/flow/raw.js +100 -0
- package/dist/nodes/data-fabric/flow/shared.d.ts +77 -0
- package/dist/nodes/data-fabric/flow/shared.js +174 -0
- package/dist/nodes/data-fabric/flow/spec.d.ts +238 -0
- package/dist/nodes/data-fabric/flow/spec.js +1 -0
- package/dist/nodes/data-fabric/flow/verbs.d.ts +29 -0
- package/dist/nodes/data-fabric/flow/verbs.js +239 -0
- package/dist/nodes/delay/flow/check.d.ts +16 -0
- package/dist/nodes/delay/flow/check.js +86 -0
- package/dist/nodes/delay/flow/factories.d.ts +28 -0
- package/dist/nodes/delay/flow/factories.js +38 -0
- package/dist/nodes/delay/flow/family.d.ts +3 -0
- package/dist/nodes/delay/flow/family.js +8 -0
- package/dist/nodes/delay/flow/index.d.ts +3 -0
- package/dist/nodes/delay/flow/index.js +3 -0
- package/dist/nodes/delay/flow/node-type.d.ts +13 -0
- package/dist/nodes/delay/flow/node-type.js +71 -0
- package/dist/nodes/delay/flow/spec.d.ts +43 -0
- package/dist/nodes/delay/flow/spec.js +16 -0
- package/dist/nodes/document/flow/factories.d.ts +27 -0
- package/dist/nodes/document/flow/factories.js +30 -0
- package/dist/nodes/document/flow/family.d.ts +3 -0
- package/dist/nodes/document/flow/family.js +7 -0
- package/dist/nodes/document/flow/index.d.ts +3 -0
- package/dist/nodes/document/flow/index.js +3 -0
- package/dist/nodes/document/flow/node-type.d.ts +14 -0
- package/dist/nodes/document/flow/node-type.js +150 -0
- package/dist/nodes/document/flow/spec.d.ts +65 -0
- package/dist/nodes/document/flow/spec.js +1 -0
- package/dist/nodes/mock/flow/factories.d.ts +51 -0
- package/dist/nodes/mock/flow/factories.js +47 -0
- package/dist/nodes/mock/flow/family.d.ts +3 -0
- package/dist/nodes/mock/flow/family.js +7 -0
- package/dist/nodes/mock/flow/index.d.ts +2 -0
- package/dist/nodes/mock/flow/index.js +2 -0
- package/dist/nodes/mock/flow/node-type.d.ts +11 -0
- package/dist/nodes/mock/flow/node-type.js +51 -0
- package/dist/nodes/script/flow/check.d.ts +19 -0
- package/dist/nodes/script/flow/check.js +49 -0
- package/dist/nodes/script/flow/factories.d.ts +14 -0
- package/dist/nodes/script/flow/factories.js +15 -0
- package/dist/nodes/script/flow/family.d.ts +3 -0
- package/dist/nodes/script/flow/family.js +8 -0
- package/dist/nodes/script/flow/index.d.ts +3 -0
- package/dist/nodes/script/flow/index.js +3 -0
- package/dist/nodes/script/flow/node-type.d.ts +7 -0
- package/dist/nodes/script/flow/node-type.js +125 -0
- package/dist/nodes/script/flow/spec.d.ts +52 -0
- package/dist/nodes/script/flow/spec.js +1 -0
- package/dist/nodes/transform/flow/check.d.ts +6 -0
- package/dist/nodes/transform/flow/check.js +229 -0
- package/dist/nodes/transform/flow/factories.d.ts +52 -0
- package/dist/nodes/transform/flow/factories.js +53 -0
- package/dist/nodes/transform/flow/family.d.ts +3 -0
- package/dist/nodes/transform/flow/family.js +8 -0
- package/dist/nodes/transform/flow/index.d.ts +3 -0
- package/dist/nodes/transform/flow/index.js +3 -0
- package/dist/nodes/transform/flow/node-type.d.ts +7 -0
- package/dist/nodes/transform/flow/node-type.js +245 -0
- package/dist/nodes/transform/flow/spec.d.ts +116 -0
- package/dist/nodes/transform/flow/spec.js +10 -0
- package/dist/raw-node-advice.d.ts +16 -0
- package/dist/raw-node-advice.js +36 -0
- package/dist/raw-node.d.ts +71 -0
- package/dist/raw-node.js +116 -0
- package/dist/registry/uip/pipeline-front-door.js +5 -3
- package/dist/serialize.js +246 -549
- package/package.json +2 -2
package/dist/merge.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* preserved from the original; `subflows` and edges (with their ids) come from
|
|
29
29
|
* `edited`; `definitions` and `bindings` are unioned
|
|
30
30
|
* (original wins on conflict, since the original's are the tenant-resolved
|
|
31
|
-
* authority). Node ids are stable by construction (decompile names every step by
|
|
31
|
+
* authority). A binding row's identity is what it binds, not its row id. Node ids are stable by construction (decompile names every step by
|
|
32
32
|
* its node id), which is what lets every re-attach key on id.
|
|
33
33
|
*
|
|
34
34
|
* ## Definitions are keyed by `nodeType@version`, not by `nodeType`
|
|
@@ -63,8 +63,23 @@
|
|
|
63
63
|
* A node whose pair is in NEITHER file cannot be given a definition here, and a
|
|
64
64
|
* merge that stayed silent about it wrote a file it already knew would fail
|
|
65
65
|
* `validate`. Those are reported through {@link MergeOptions.warn}.
|
|
66
|
+
*
|
|
67
|
+
* ## The baseline is also compared with the original
|
|
68
|
+
*
|
|
69
|
+
* `baseline` is the no-edit round trip, so wherever it differs from the original
|
|
70
|
+
* in wire identity (a node, an edge, a switch case id, a human-task outcome id,
|
|
71
|
+
* a binding row), the merged file inherits that change unless the author's edit
|
|
72
|
+
* undid it. Each difference the merged file still carries is reported through
|
|
73
|
+
* `warn`, and `strict` refuses the merge over it; the merged file is also
|
|
74
|
+
* checked for an edge that leaves a handle its kept source node does not
|
|
75
|
+
* declare. See merge-roundtrip.ts.
|
|
66
76
|
*/
|
|
77
|
+
import { bindingIdentity } from './core/binding-identity.js';
|
|
67
78
|
import { jsonClone } from './core/json-file.js';
|
|
79
|
+
import { DEFINITIONLESS_NODE_TYPES } from './core/definitionless-node-types.js';
|
|
80
|
+
import { MergeRefusedError, mergedFileFindings, roundTripFindings } from './merge-roundtrip.js';
|
|
81
|
+
import { placeAddedNodes } from './node-placement.js';
|
|
82
|
+
export { MergeRefusedError } from './merge-roundtrip.js';
|
|
68
83
|
function byId(nodes) {
|
|
69
84
|
return new Map((nodes ?? []).map((n) => [n.id, n]));
|
|
70
85
|
}
|
|
@@ -82,11 +97,31 @@ function nodeUnchanged(a, b) {
|
|
|
82
97
|
return false;
|
|
83
98
|
return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {});
|
|
84
99
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Give an edited node the original's position, by id.
|
|
102
|
+
*
|
|
103
|
+
* The original stores it in one of two places: `node.ui` (an SDK-compiled file)
|
|
104
|
+
* or `layout.nodes[id]` (every canvas-saved file — the designer's save moves
|
|
105
|
+
* `ui` into `layout.nodes`). From `node.ui` it is grafted onto the node. With a
|
|
106
|
+
* `layout.nodes` entry, the merged file keeps that entry (it is carried over
|
|
107
|
+
* with the original's `layout`) and the edited node's compile placeholder `ui`
|
|
108
|
+
* is dropped, so the node has one position, not two.
|
|
109
|
+
*/
|
|
110
|
+
function withOriginalLayout(edited, orig, originalLayout) {
|
|
111
|
+
if (!orig)
|
|
88
112
|
return edited;
|
|
89
|
-
|
|
113
|
+
if (orig.ui !== undefined)
|
|
114
|
+
return { ...edited, ui: jsonClone(orig.ui) };
|
|
115
|
+
if (originalLayout?.[orig.id] !== undefined) {
|
|
116
|
+
const { ui: _placeholder, ...rest } = edited;
|
|
117
|
+
return rest;
|
|
118
|
+
}
|
|
119
|
+
return edited;
|
|
120
|
+
}
|
|
121
|
+
/** The original's `layout.nodes` map, when it has one. */
|
|
122
|
+
function layoutNodesOf(f) {
|
|
123
|
+
const nodes = f.layout?.nodes;
|
|
124
|
+
return nodes && typeof nodes === 'object' && !Array.isArray(nodes) ? nodes : undefined;
|
|
90
125
|
}
|
|
91
126
|
export function mergeFlow(original, edited, options = {}) {
|
|
92
127
|
const result = jsonClone(original);
|
|
@@ -104,7 +139,11 @@ export function mergeFlow(original, edited, options = {}) {
|
|
|
104
139
|
}
|
|
105
140
|
else {
|
|
106
141
|
// Added or edited → take the edited node, re-attaching original layout.
|
|
107
|
-
finalNodes.push(withOriginalLayout(jsonClone(e), orig));
|
|
142
|
+
finalNodes.push(withOriginalLayout(jsonClone(e), orig, layoutNodesOf(original)));
|
|
143
|
+
if (orig && orig.type === e.type && orig.typeVersion !== e.typeVersion) {
|
|
144
|
+
options.warn?.(`node ${JSON.stringify(e.id)} moved ${e.type}@${String(orig.typeVersion)} -> ${String(e.typeVersion)} `
|
|
145
|
+
+ `(the edited node takes the SDK's version; flow-builder-sdk#1167).`);
|
|
146
|
+
}
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
149
|
result.nodes = finalNodes;
|
|
@@ -124,15 +163,25 @@ export function mergeFlow(original, edited, options = {}) {
|
|
|
124
163
|
// one already taken elsewhere in the file, gets a fresh id, from ids unused
|
|
125
164
|
// anywhere in the file. ──
|
|
126
165
|
result.edges = withFileUniqueEdgeIds(edited.edges ?? [], subflowScopes);
|
|
166
|
+
// ── Placement: a node the edit ADDED has only compile's placeholder, an
|
|
167
|
+
// emission-index slot that can land on an original node. Put it next to
|
|
168
|
+
// its predecessor, collision-free, in the store the
|
|
169
|
+
// original uses — see node-placement.ts. Root scope only: a subflow is
|
|
170
|
+
// taken from `edited` whole, layout included. ──
|
|
171
|
+
placeAddedNodes(finalNodes, result.edges, layoutNodesOf(result), (id) => !origNodes.has(id));
|
|
127
172
|
// ── Definitions: the (type, version) pairs used by EVERY node in the file —
|
|
128
173
|
// root and subflows — original winning on conflict (its defs are the
|
|
129
174
|
// tenant-resolved authority). Selecting by root nodes only dropped the
|
|
130
175
|
// definitions a subflow-only node needs, and validate refused the result
|
|
131
176
|
// ("has no matching definition"). ──
|
|
132
177
|
const allNodes = [...finalNodes, ...subflowScopes.flatMap((sf) => sf?.nodes ?? [])];
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
178
|
+
const originalNodes = [
|
|
179
|
+
...(original.nodes ?? []),
|
|
180
|
+
...Object.values(original.subflows ?? {}).flatMap((sf) => sf?.nodes ?? []),
|
|
181
|
+
];
|
|
182
|
+
result.definitions = mergeDefinitions(original.definitions, edited.definitions, allNodes, originalNodes, options.warn);
|
|
183
|
+
// ── Bindings: union by what each row binds (original wins), so resources for
|
|
184
|
+
// newly-added nodes come from `edited` while the original's stay intact. ──
|
|
136
185
|
result.bindings = mergeBindings(original.bindings, edited.bindings);
|
|
137
186
|
// metadata / solutionId / projectId / layout / runtime were carried by the
|
|
138
187
|
// initial clone of `original` and are intentionally left untouched.
|
|
@@ -147,6 +196,19 @@ export function mergeFlow(original, edited, options = {}) {
|
|
|
147
196
|
if (options.baseline && edited.version !== undefined && edited.version !== options.baseline.version) {
|
|
148
197
|
result.version = edited.version;
|
|
149
198
|
}
|
|
199
|
+
// ── What the no-edit round trip changed, and what mixing sources broke. ──
|
|
200
|
+
// Under strict, the findings that do not refuse are still warned, so a
|
|
201
|
+
// refused run shows them too.
|
|
202
|
+
const findings = [
|
|
203
|
+
...(options.baseline ? roundTripFindings(original, options.baseline, result) : []),
|
|
204
|
+
...mergedFileFindings(result, original),
|
|
205
|
+
];
|
|
206
|
+
const refusing = options.strict ? findings.filter((f) => f.refuses) : [];
|
|
207
|
+
for (const f of findings)
|
|
208
|
+
if (!refusing.includes(f))
|
|
209
|
+
options.warn?.(f.message);
|
|
210
|
+
if (refusing.length > 0)
|
|
211
|
+
throw new MergeRefusedError(refusing);
|
|
150
212
|
return result;
|
|
151
213
|
}
|
|
152
214
|
/**
|
|
@@ -186,8 +248,9 @@ function indexDefinitions(defs) {
|
|
|
186
248
|
}
|
|
187
249
|
return ix;
|
|
188
250
|
}
|
|
189
|
-
function mergeDefinitions(orig, edited, nodes, warn) {
|
|
251
|
+
function mergeDefinitions(orig, edited, nodes, originalNodes, warn) {
|
|
190
252
|
const origIx = indexDefinitions(orig);
|
|
253
|
+
const originalPairs = new Set(originalNodes.filter((n) => n.type !== undefined && n.typeVersion !== undefined).map((n) => defKey(n.type, n.typeVersion)));
|
|
191
254
|
const editedIx = indexDefinitions(edited);
|
|
192
255
|
const out = new Map();
|
|
193
256
|
const missing = [];
|
|
@@ -207,25 +270,62 @@ function mergeDefinitions(orig, edited, nodes, warn) {
|
|
|
207
270
|
const def = origIx.get(key) ?? editedIx.get(key);
|
|
208
271
|
if (def)
|
|
209
272
|
out.set(key, def);
|
|
210
|
-
|
|
273
|
+
// A definitionless type is not missing anything.
|
|
274
|
+
else if (!DEFINITIONLESS_NODE_TYPES.has(n.type) && !missing.includes(key))
|
|
211
275
|
missing.push(key);
|
|
212
276
|
}
|
|
213
277
|
for (const key of missing) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
278
|
+
// A pair the original already pinned with no definition still fails
|
|
279
|
+
// `validate`; the warning says the merge inherited it rather than caused it.
|
|
280
|
+
warn?.(originalPairs.has(key)
|
|
281
|
+
? `node type ${key} is pinned by a merged node and already had no definition in the original, `
|
|
282
|
+
+ `and the edited flow carries none either, so the merged file has none; `
|
|
283
|
+
+ `\`validate\` will report "has no matching definition", as it does for the original. `
|
|
284
|
+
+ `Recompile the edited source against an SDK that bundles ${key} to add one.`
|
|
285
|
+
: `node type ${key} is pinned by a merged node but neither the original nor the edited flow `
|
|
286
|
+
+ `carries a definition for that exact pair, so the merged file has none; `
|
|
287
|
+
+ `\`validate\` will report "has no matching definition". `
|
|
288
|
+
+ `Recompile the edited source against an SDK that bundles ${key}, or align the node's typeVersion `
|
|
289
|
+
+ `with a definition one of the inputs does carry.`);
|
|
219
290
|
}
|
|
220
291
|
return [...out.values()].map((d) => jsonClone(d));
|
|
221
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* The original's rows, then each edited row that binds something the original
|
|
295
|
+
* does not (a resource of a node the user added, or one an edit re-pointed).
|
|
296
|
+
*
|
|
297
|
+
* Original wins on the same identity: its rows are the tenant-resolved
|
|
298
|
+
* authority, and a canvas row can carry values (a `default`, a folder) the
|
|
299
|
+
* compile does not. An added row keeps its id unless an original row already
|
|
300
|
+
* holds it, in which case it gets a fresh one — ids are unreferenced labels, but
|
|
301
|
+
* two rows with one id are not a valid file.
|
|
302
|
+
*
|
|
303
|
+
* Rows no surviving node uses are NOT pruned. The file has no uniform
|
|
304
|
+
* node-to-row reference to decide "unused" by: an in-solution Flow is named by
|
|
305
|
+
* the node TYPE's suffix, a connection by an id in the node's inputs, a published
|
|
306
|
+
* process by a folder-qualified key that no node field spells. A wrong prune
|
|
307
|
+
* drops a row a kept node needs, and the run fails to resolve its resource; an
|
|
308
|
+
* orphan row is accepted by `validate` and ignored at run time.
|
|
309
|
+
*/
|
|
222
310
|
function mergeBindings(orig, edited) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
311
|
+
const out = (orig ?? []).map((b) => jsonClone(b));
|
|
312
|
+
const identities = new Set(out.map(bindingIdentity));
|
|
313
|
+
const ids = new Set(out.map((b) => b.id).filter((id) => typeof id === 'string'));
|
|
314
|
+
for (const b of edited ?? []) {
|
|
315
|
+
const identity = bindingIdentity(b);
|
|
316
|
+
if (identities.has(identity))
|
|
317
|
+
continue;
|
|
318
|
+
identities.add(identity);
|
|
319
|
+
const row = jsonClone(b);
|
|
320
|
+
if (typeof row.id === 'string' && ids.has(row.id)) {
|
|
321
|
+
let n = 2;
|
|
322
|
+
while (ids.has(`${row.id}${n}`))
|
|
323
|
+
n++;
|
|
324
|
+
row.id = `${row.id}${n}`;
|
|
325
|
+
}
|
|
326
|
+
if (typeof row.id === 'string')
|
|
327
|
+
ids.add(row.id);
|
|
328
|
+
out.push(row);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
231
331
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-placement — where compile puts a node it has no position for, and where
|
|
3
|
+
* merge moves one the edit added.
|
|
4
|
+
*
|
|
5
|
+
* Compile gives every node a placeholder from its emission index,
|
|
6
|
+
* `x = 256 + 288 × index, y = 144` (`+160` inside a container). Merge replaces
|
|
7
|
+
* that placeholder on each node the edit added:
|
|
8
|
+
*
|
|
9
|
+
* - next to a positioned predecessor in the same container: one slot right;
|
|
10
|
+
* - else, when the incoming edge comes from the node's own container (the first
|
|
11
|
+
* node of a loop body): at the body origin, inside the container's padding;
|
|
12
|
+
* - else, one slot left of a positioned successor in the same container;
|
|
13
|
+
* - then the nearest free spot around that candidate.
|
|
14
|
+
*
|
|
15
|
+
* Example: the edit inserts `auditLog` between `extractInvoice (448,144)` and
|
|
16
|
+
* `routeInvoice (640,144)`. The candidate `(640,144)` is taken, so `auditLog`
|
|
17
|
+
* goes to the first free spot, `(640,264)`.
|
|
18
|
+
*
|
|
19
|
+
* A child's position is relative to its container, as on the canvas.
|
|
20
|
+
*
|
|
21
|
+
* The position goes where the original stores positions: `layout.nodes[id]`
|
|
22
|
+
* when it has a `layout.nodes` map, otherwise `node.ui`. Positions are read the
|
|
23
|
+
* way the designer reads them (flow-workbench `flow-schema`
|
|
24
|
+
* `workflow-format-conversion.ts` `mergeLayout`): a `layout.nodes[id]` entry
|
|
25
|
+
* wins over `node.ui`, field by field.
|
|
26
|
+
*
|
|
27
|
+
* `findFreePosition` is a copy of flow-workbench `flow-core`
|
|
28
|
+
* `layout/collision-detection.ts` (same 24 px padding, 40 px step, 50 rings,
|
|
29
|
+
* offset order, 96 px default size, sticky notes excluded), because the SDK may
|
|
30
|
+
* not import `@uipath/flow-core` (lint FB015).
|
|
31
|
+
*/
|
|
32
|
+
/** A position on the canvas. */
|
|
33
|
+
export interface Point {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Compile's placeholder for the `index`-th node it emits.
|
|
39
|
+
*
|
|
40
|
+
* @param index - The node's emission index in the file.
|
|
41
|
+
* @param nested - Whether the node sits inside a container.
|
|
42
|
+
* @returns The placeholder position.
|
|
43
|
+
*/
|
|
44
|
+
export declare function placeholderPosition(index: number, nested: boolean): Point;
|
|
45
|
+
/**
|
|
46
|
+
* Whether `p` is a compile placeholder rather than a position someone chose.
|
|
47
|
+
* An author `.layout()` entry that happens to hit the same grid point reads as
|
|
48
|
+
* a placeholder; that is the one ambiguity.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isPlaceholderPosition(p: Point | undefined): boolean;
|
|
51
|
+
/** A node's `ui` (or its `layout.nodes` entry): the fields placement reads. */
|
|
52
|
+
interface Ui {
|
|
53
|
+
position?: Point;
|
|
54
|
+
size?: {
|
|
55
|
+
width?: number;
|
|
56
|
+
height?: number;
|
|
57
|
+
};
|
|
58
|
+
[k: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
/** A node as merge holds it; `ui` is untyped in the file model. */
|
|
61
|
+
interface PlacedNode {
|
|
62
|
+
id: string;
|
|
63
|
+
type?: string;
|
|
64
|
+
parentId?: string;
|
|
65
|
+
ui?: unknown;
|
|
66
|
+
[k: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Give each node the edit added a position next to its predecessor, in place.
|
|
70
|
+
*
|
|
71
|
+
* A node is placed when it is not in the original and its position is a
|
|
72
|
+
* compile placeholder (an author `.layout()` position is kept). Its candidate,
|
|
73
|
+
* in order: one slot right of the first predecessor in the same container that
|
|
74
|
+
* already has a position (an original node, or an added node placed before it);
|
|
75
|
+
* the body origin, when an edge enters it from its own container; one slot left
|
|
76
|
+
* of a positioned successor in the same container. With none, the node keeps
|
|
77
|
+
* its placeholder, and placed nodes avoid it.
|
|
78
|
+
*
|
|
79
|
+
* Where the result is written follows the file: when the original stores
|
|
80
|
+
* positions in `layout.nodes` (every canvas-saved file does), the position goes
|
|
81
|
+
* there and the placeholder `node.ui` is dropped, so the file has one store;
|
|
82
|
+
* otherwise `node.ui.position` is updated.
|
|
83
|
+
*
|
|
84
|
+
* @param nodes - The merged root nodes (mutated).
|
|
85
|
+
* @param edges - The merged root edges.
|
|
86
|
+
* @param layoutNodes - The merged file's `layout.nodes`, or undefined when the original has none (mutated).
|
|
87
|
+
* @param isAdded - Whether a node id is new in this merge.
|
|
88
|
+
*/
|
|
89
|
+
export declare function placeAddedNodes(nodes: PlacedNode[], edges: ReadonlyArray<{
|
|
90
|
+
sourceNodeId: string;
|
|
91
|
+
targetNodeId: string;
|
|
92
|
+
}>, layoutNodes: Record<string, Ui> | undefined, isAdded: (id: string) => boolean): void;
|
|
93
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-placement — where compile puts a node it has no position for, and where
|
|
3
|
+
* merge moves one the edit added.
|
|
4
|
+
*
|
|
5
|
+
* Compile gives every node a placeholder from its emission index,
|
|
6
|
+
* `x = 256 + 288 × index, y = 144` (`+160` inside a container). Merge replaces
|
|
7
|
+
* that placeholder on each node the edit added:
|
|
8
|
+
*
|
|
9
|
+
* - next to a positioned predecessor in the same container: one slot right;
|
|
10
|
+
* - else, when the incoming edge comes from the node's own container (the first
|
|
11
|
+
* node of a loop body): at the body origin, inside the container's padding;
|
|
12
|
+
* - else, one slot left of a positioned successor in the same container;
|
|
13
|
+
* - then the nearest free spot around that candidate.
|
|
14
|
+
*
|
|
15
|
+
* Example: the edit inserts `auditLog` between `extractInvoice (448,144)` and
|
|
16
|
+
* `routeInvoice (640,144)`. The candidate `(640,144)` is taken, so `auditLog`
|
|
17
|
+
* goes to the first free spot, `(640,264)`.
|
|
18
|
+
*
|
|
19
|
+
* A child's position is relative to its container, as on the canvas.
|
|
20
|
+
*
|
|
21
|
+
* The position goes where the original stores positions: `layout.nodes[id]`
|
|
22
|
+
* when it has a `layout.nodes` map, otherwise `node.ui`. Positions are read the
|
|
23
|
+
* way the designer reads them (flow-workbench `flow-schema`
|
|
24
|
+
* `workflow-format-conversion.ts` `mergeLayout`): a `layout.nodes[id]` entry
|
|
25
|
+
* wins over `node.ui`, field by field.
|
|
26
|
+
*
|
|
27
|
+
* `findFreePosition` is a copy of flow-workbench `flow-core`
|
|
28
|
+
* `layout/collision-detection.ts` (same 24 px padding, 40 px step, 50 rings,
|
|
29
|
+
* offset order, 96 px default size, sticky notes excluded), because the SDK may
|
|
30
|
+
* not import `@uipath/flow-core` (lint FB015).
|
|
31
|
+
*/
|
|
32
|
+
// Sticky notes may overlap anything (flow-core skips them in collisions).
|
|
33
|
+
import { STICKY_NOTE_TYPE } from './core/definitionless-node-types.js';
|
|
34
|
+
/** Where compile's placeholder row starts, and its spacing. */
|
|
35
|
+
const PLACEHOLDER = { x0: 256, dx: 288, y0: 144, nestedDy: 160 };
|
|
36
|
+
/** The default node box the canvas assumes (flow-core `resolveWidth`/`resolveHeight`). */
|
|
37
|
+
const DEFAULT_SIZE = 96;
|
|
38
|
+
/** One slot to the right of a node: its width plus an equal gap, the spacing of a canvas row. */
|
|
39
|
+
const SLOT_DX = 192;
|
|
40
|
+
/** flow-core `findFreePosition` defaults. */
|
|
41
|
+
const PADDING = 24;
|
|
42
|
+
const STEP = 40;
|
|
43
|
+
const MAX_RINGS = 50;
|
|
44
|
+
/**
|
|
45
|
+
* Where a container's first body node goes, relative to the container: inside
|
|
46
|
+
* its left and top padding (flow-core `defaultContainerFit`: 144 left, 96 top).
|
|
47
|
+
*/
|
|
48
|
+
const BODY_ORIGIN = { x: 144, y: 96 };
|
|
49
|
+
/**
|
|
50
|
+
* Compile's placeholder for the `index`-th node it emits.
|
|
51
|
+
*
|
|
52
|
+
* @param index - The node's emission index in the file.
|
|
53
|
+
* @param nested - Whether the node sits inside a container.
|
|
54
|
+
* @returns The placeholder position.
|
|
55
|
+
*/
|
|
56
|
+
export function placeholderPosition(index, nested) {
|
|
57
|
+
return { x: PLACEHOLDER.x0 + index * PLACEHOLDER.dx, y: PLACEHOLDER.y0 + (nested ? PLACEHOLDER.nestedDy : 0) };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether `p` is a compile placeholder rather than a position someone chose.
|
|
61
|
+
* An author `.layout()` entry that happens to hit the same grid point reads as
|
|
62
|
+
* a placeholder; that is the one ambiguity.
|
|
63
|
+
*/
|
|
64
|
+
export function isPlaceholderPosition(p) {
|
|
65
|
+
if (!p)
|
|
66
|
+
return false;
|
|
67
|
+
const k = (p.x - PLACEHOLDER.x0) / PLACEHOLDER.dx;
|
|
68
|
+
return Number.isInteger(k) && k >= 0 && (p.y === PLACEHOLDER.y0 || p.y === PLACEHOLDER.y0 + PLACEHOLDER.nestedDy);
|
|
69
|
+
}
|
|
70
|
+
const overlaps = (a, b) => !(a.x + a.width + PADDING <= b.x || b.x + b.width + PADDING <= a.x
|
|
71
|
+
|| a.y + a.height + PADDING <= b.y || b.y + b.height + PADDING <= a.y);
|
|
72
|
+
/** flow-core `findFreePosition`: the candidate, else the first free spot on rings around it. */
|
|
73
|
+
function findFreePosition(candidate, size, others) {
|
|
74
|
+
const free = (p) => !others.some((o) => overlaps({ ...p, ...size }, o));
|
|
75
|
+
if (free(candidate))
|
|
76
|
+
return candidate;
|
|
77
|
+
for (let d = STEP; d <= STEP * MAX_RINGS; d += STEP) {
|
|
78
|
+
for (const [dx, dy] of [[d, 0], [0, d], [d, d], [-d, 0], [0, -d], [-d, -d]]) {
|
|
79
|
+
const p = { x: candidate.x + dx, y: candidate.y + dy };
|
|
80
|
+
if (free(p))
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Give each node the edit added a position next to its predecessor, in place.
|
|
88
|
+
*
|
|
89
|
+
* A node is placed when it is not in the original and its position is a
|
|
90
|
+
* compile placeholder (an author `.layout()` position is kept). Its candidate,
|
|
91
|
+
* in order: one slot right of the first predecessor in the same container that
|
|
92
|
+
* already has a position (an original node, or an added node placed before it);
|
|
93
|
+
* the body origin, when an edge enters it from its own container; one slot left
|
|
94
|
+
* of a positioned successor in the same container. With none, the node keeps
|
|
95
|
+
* its placeholder, and placed nodes avoid it.
|
|
96
|
+
*
|
|
97
|
+
* Where the result is written follows the file: when the original stores
|
|
98
|
+
* positions in `layout.nodes` (every canvas-saved file does), the position goes
|
|
99
|
+
* there and the placeholder `node.ui` is dropped, so the file has one store;
|
|
100
|
+
* otherwise `node.ui.position` is updated.
|
|
101
|
+
*
|
|
102
|
+
* @param nodes - The merged root nodes (mutated).
|
|
103
|
+
* @param edges - The merged root edges.
|
|
104
|
+
* @param layoutNodes - The merged file's `layout.nodes`, or undefined when the original has none (mutated).
|
|
105
|
+
* @param isAdded - Whether a node id is new in this merge.
|
|
106
|
+
*/
|
|
107
|
+
export function placeAddedNodes(nodes, edges, layoutNodes, isAdded) {
|
|
108
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
109
|
+
const uiOf = (n) => {
|
|
110
|
+
const entry = layoutNodes?.[n.id];
|
|
111
|
+
const own = n.ui;
|
|
112
|
+
return entry ? { ...own, ...entry } : own;
|
|
113
|
+
};
|
|
114
|
+
const sizeOf = (n) => ({
|
|
115
|
+
width: uiOf(n)?.size?.width ?? DEFAULT_SIZE,
|
|
116
|
+
height: uiOf(n)?.size?.height ?? DEFAULT_SIZE,
|
|
117
|
+
});
|
|
118
|
+
const pending = new Set(nodes.filter((n) => isAdded(n.id) && n.type !== STICKY_NOTE_TYPE && isPlaceholderPosition(uiOf(n)?.position)).map((n) => n.id));
|
|
119
|
+
const positioned = (id) => {
|
|
120
|
+
const n = byId.get(id);
|
|
121
|
+
return n !== undefined && !pending.has(id) && uiOf(n)?.position !== undefined;
|
|
122
|
+
};
|
|
123
|
+
// Place in waves, so a run of added nodes chains off the first one placed.
|
|
124
|
+
for (let progress = true; progress && pending.size > 0;) {
|
|
125
|
+
progress = false;
|
|
126
|
+
for (const id of [...pending]) {
|
|
127
|
+
const n = byId.get(id);
|
|
128
|
+
const sameScope = (other) => byId.get(other)?.parentId === n.parentId;
|
|
129
|
+
const pred = edges.find((e) => e.targetNodeId === id && sameScope(e.sourceNodeId) && positioned(e.sourceNodeId));
|
|
130
|
+
const entersBody = !pred && n.parentId !== undefined && byId.has(n.parentId)
|
|
131
|
+
&& edges.some((e) => e.targetNodeId === id && e.sourceNodeId === n.parentId);
|
|
132
|
+
const succ = pred || entersBody ? undefined
|
|
133
|
+
: edges.find((e) => e.sourceNodeId === id && sameScope(e.targetNodeId) && positioned(e.targetNodeId));
|
|
134
|
+
let candidate;
|
|
135
|
+
if (pred || succ) {
|
|
136
|
+
const anchor = uiOf(byId.get(pred?.sourceNodeId ?? succ.targetNodeId)).position;
|
|
137
|
+
candidate = { x: anchor.x + (pred ? SLOT_DX : -SLOT_DX), y: anchor.y };
|
|
138
|
+
}
|
|
139
|
+
else if (entersBody) {
|
|
140
|
+
candidate = { ...BODY_ORIGIN };
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Still-pending nodes count at their placeholder: one that never finds an
|
|
146
|
+
// anchor stays there, and nothing may land on it.
|
|
147
|
+
const others = nodes
|
|
148
|
+
.filter((o) => o.id !== id && o.parentId === n.parentId && o.type !== STICKY_NOTE_TYPE && uiOf(o)?.position !== undefined)
|
|
149
|
+
.map((o) => ({ ...uiOf(o).position, ...sizeOf(o) }));
|
|
150
|
+
const position = findFreePosition(candidate, sizeOf(n), others);
|
|
151
|
+
const ui = { ...uiOf(n), position };
|
|
152
|
+
if (layoutNodes) {
|
|
153
|
+
layoutNodes[id] = ui;
|
|
154
|
+
delete n.ui;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
n.ui = ui;
|
|
158
|
+
}
|
|
159
|
+
pending.delete(id);
|
|
160
|
+
progress = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DataFabricCreateInputs, DataFabricDeleteInputs, DataFabricReadInputs, DataFabricUpdateInputs } from './spec.js';
|
|
2
|
+
import type { DiagnosticCatalog, Placement } from '../../../core/nodes/family.js';
|
|
3
|
+
import type { FlowCheckContext, FlowFamilyCheckContext } from '../../../flow-node-type.js';
|
|
4
|
+
export declare const DATA_FABRIC_DIAGNOSTICS: DiagnosticCatalog;
|
|
5
|
+
export declare function checkRead(inputs: DataFabricReadInputs, ctx: FlowCheckContext): void;
|
|
6
|
+
export declare function checkCreate(inputs: DataFabricCreateInputs, ctx: FlowCheckContext): void;
|
|
7
|
+
export declare function checkUpdate(inputs: DataFabricUpdateInputs, ctx: FlowCheckContext): void;
|
|
8
|
+
export declare function checkWrite(inputs: DataFabricDeleteInputs, ctx: FlowCheckContext): void;
|
|
9
|
+
/**
|
|
10
|
+
* Update and delete target a record `{ fromRead: '<step>' }` with the same
|
|
11
|
+
* selector, so they share the check: the step must be a read, and not a list.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkFromRead(placements: readonly Placement[], ctx: FlowFamilyCheckContext): void;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Flow Data Fabric family's rules: the per-step checks each verb runs, and
|
|
3
|
+
* the family-level `{ fromRead }` rules that need every placement at once. Moved
|
|
4
|
+
* verbatim from check.ts (Phase 2 of docs/NODE_TYPE_ARCHITECTURE_DESIGN.md);
|
|
5
|
+
* codes, messages and order are unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import { Expr } from '../../../core/expr.js';
|
|
8
|
+
export const DATA_FABRIC_DIAGNOSTICS = {
|
|
9
|
+
DATAFABRIC_NO_ENTITY: { severity: 'error', summary: 'a verb names no entity' },
|
|
10
|
+
DATAFABRIC_EMPTY_VALUES: { severity: 'error', summary: 'a create writes no non-blank column' },
|
|
11
|
+
DATAFABRIC_EMPTY_SET: { severity: 'error', summary: 'an update writes no column' },
|
|
12
|
+
DATAFABRIC_SYSTEM_COLUMN: { severity: 'error', summary: 'a write targets a system column' },
|
|
13
|
+
DATAFABRIC_FOLDER_NO_RESOURCE_KEY: { severity: 'error', summary: 'folderKey without resourceKey' },
|
|
14
|
+
DATAFABRIC_RESOURCE_KEY_NO_FOLDER: { severity: 'error', summary: 'resourceKey without folderKey' },
|
|
15
|
+
DATAFABRIC_QUERY_OPTS_WITHOUT_MULTIPLE: { severity: 'error', summary: 'list-query options on a single read' },
|
|
16
|
+
DATAFABRIC_LIMIT_RANGE: { severity: 'error', summary: 'limit outside 1–1000' },
|
|
17
|
+
DATAFABRIC_IN_REQUIRES_MULTIPLE: { severity: 'error', summary: "an 'in' filter on a single read" },
|
|
18
|
+
DATAFABRIC_FILTER_BLANK_VALUE: { severity: 'error', summary: 'a filter with a blank value' },
|
|
19
|
+
DATAFABRIC_FROMREAD_UNKNOWN: { severity: 'error', summary: '{ fromRead } names no read step' },
|
|
20
|
+
DATAFABRIC_FROMREAD_MULTIPLE: { severity: 'error', summary: "{ fromRead } names a resultMode: 'multiple' query" },
|
|
21
|
+
DATAFABRIC_DELETE_HAS_NO_OUTPUT: { severity: 'error', summary: "a read of a delete's output" },
|
|
22
|
+
};
|
|
23
|
+
function checkEntity(inputs, ctx) {
|
|
24
|
+
if (!String(inputs.entity ?? '').trim()) {
|
|
25
|
+
ctx.report('DATAFABRIC_NO_ENTITY', `"${ctx.step}" names no Data Fabric entity. Set { entity: '<entity name>' }.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function checkRead(inputs, ctx) {
|
|
29
|
+
checkEntity(inputs, ctx);
|
|
30
|
+
checkQuery(inputs, ctx);
|
|
31
|
+
checkScope(inputs, ctx);
|
|
32
|
+
}
|
|
33
|
+
export function checkCreate(inputs, ctx) {
|
|
34
|
+
checkEntity(inputs, ctx);
|
|
35
|
+
// Not the same failure as an empty update: a BLANK value is omitted from the
|
|
36
|
+
// insert so the column default applies, so an all-blank `fieldValues`
|
|
37
|
+
// serializes to an empty body and runs green having inserted nothing. The
|
|
38
|
+
// node's schema rejects that — and the fix is a real value, never a
|
|
39
|
+
// placeholder to satisfy the rule.
|
|
40
|
+
const written = Object.values(inputs.values ?? {})
|
|
41
|
+
.filter((v) => (v instanceof Expr ? true : String(v ?? '').trim() !== ''));
|
|
42
|
+
if (written.length === 0) {
|
|
43
|
+
ctx.report('DATAFABRIC_EMPTY_VALUES', `"${ctx.step}" sets no column to a non-blank value, so it would insert nothing and run green. `
|
|
44
|
+
+ 'The node\'s schema rejects that — give a real column a real value rather than a placeholder.');
|
|
45
|
+
}
|
|
46
|
+
checkColumns(Object.keys(inputs.values ?? {}), 'values', ctx);
|
|
47
|
+
checkScope(inputs, ctx);
|
|
48
|
+
}
|
|
49
|
+
export function checkUpdate(inputs, ctx) {
|
|
50
|
+
checkEntity(inputs, ctx);
|
|
51
|
+
if (Object.keys(inputs.set ?? {}).length === 0) {
|
|
52
|
+
ctx.report('DATAFABRIC_EMPTY_SET', `"${ctx.step}" writes no columns — { set } is empty, so the update would no-op green.`);
|
|
53
|
+
}
|
|
54
|
+
checkColumns(Object.keys(inputs.set ?? {}), 'set', ctx);
|
|
55
|
+
checkScope(inputs, ctx);
|
|
56
|
+
}
|
|
57
|
+
export function checkWrite(inputs, ctx) {
|
|
58
|
+
checkEntity(inputs, ctx);
|
|
59
|
+
checkScope(inputs, ctx);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Columns Data Fabric assigns and never accepts a write for.
|
|
63
|
+
*
|
|
64
|
+
* The canvas hides them from both write editors, but the serializer has no
|
|
65
|
+
* guard: a hand-authored `Id` row satisfies the node's own "at least one field"
|
|
66
|
+
* rule, ships in the body, and is rejected — and the rejection is only logged,
|
|
67
|
+
* so the row comes back unchanged and the run goes green.
|
|
68
|
+
*/
|
|
69
|
+
const SYSTEM_COLUMNS = new Set(['Id', 'CreateTime', 'CreatedBy', 'UpdateTime', 'UpdatedBy']);
|
|
70
|
+
function checkColumns(columns, option, ctx) {
|
|
71
|
+
const system = columns.filter((c) => SYSTEM_COLUMNS.has(c));
|
|
72
|
+
if (system.length) {
|
|
73
|
+
ctx.report('DATAFABRIC_SYSTEM_COLUMN', `"${ctx.step}" writes ${system.map((c) => JSON.stringify(c)).join(', ')} in { ${option} }. `
|
|
74
|
+
+ 'Data Fabric assigns its system columns (Id, CreateTime, CreatedBy, UpdateTime, UpdatedBy) and rejects a '
|
|
75
|
+
+ 'write to them — and it only LOGS the rejection, so the row comes back unchanged and the run goes green.');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A folder-scoped entity is portable only with its `Entity` bindings.
|
|
80
|
+
*
|
|
81
|
+
* `_folderKey` on its own still serializes — it falls back to a source-org
|
|
82
|
+
* literal and logs a warning — so the flow looks fine until it is deployed to
|
|
83
|
+
* another org. Refusing the half-authored pair here is the whole point: the
|
|
84
|
+
* alternative is a defect that only appears at deploy time, in a different org,
|
|
85
|
+
* to somebody else.
|
|
86
|
+
*/
|
|
87
|
+
function checkScope(inputs, ctx) {
|
|
88
|
+
if (inputs.folderKey !== undefined && inputs.resourceKey === undefined) {
|
|
89
|
+
ctx.report('DATAFABRIC_FOLDER_NO_RESOURCE_KEY', `"${ctx.step}" sets { folderKey } without { resourceKey }. A folder-scoped entity needs both — with only `
|
|
90
|
+
+ 'the folder key the entity serializes as a source-org literal, which validates, runs in this org, and '
|
|
91
|
+
+ 'breaks on deploy to another one.', 'resolve the key with `uip solution resources list --kind Entity`, or drop { folderKey } and keep '
|
|
92
|
+
+ 'the entity tenant-scoped');
|
|
93
|
+
}
|
|
94
|
+
if (inputs.resourceKey !== undefined && inputs.folderKey === undefined) {
|
|
95
|
+
ctx.report('DATAFABRIC_RESOURCE_KEY_NO_FOLDER', `"${ctx.step}" sets { resourceKey } without { folderKey }. The pair exists to scope a FOLDER entity; a `
|
|
96
|
+
+ 'tenant-scoped entity needs neither and serializes as a dotted literal.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The query options, none of which the node's `inputDefinition` declares — they
|
|
101
|
+
* are `_`-prefixed extras, so `uip maestro flow validate` checks none of them
|
|
102
|
+
* and every mistake here is silent at run time.
|
|
103
|
+
*/
|
|
104
|
+
function checkQuery(inputs, ctx) {
|
|
105
|
+
const step = ctx.step;
|
|
106
|
+
const listOnly = ['limit', 'skip', 'sort', 'fields'].filter((k) => inputs[k] !== undefined);
|
|
107
|
+
if (listOnly.length && inputs.resultMode !== 'multiple') {
|
|
108
|
+
ctx.report('DATAFABRIC_QUERY_OPTS_WITHOUT_MULTIPLE', `"${step}" sets { ${listOnly.join(', ')} }, which only a list query uses, but its resultMode is `
|
|
109
|
+
+ `${inputs.resultMode ? JSON.stringify(inputs.resultMode) : 'unset (a single-record read)'}.`, "resultMode: 'multiple'");
|
|
110
|
+
}
|
|
111
|
+
// The emitted query ALWAYS carries a limit — omitted it resolves to 100 — and
|
|
112
|
+
// 1000 is a hard ceiling that TRUNCATES rather than erroring above it.
|
|
113
|
+
if (inputs.limit !== undefined && (!Number.isInteger(inputs.limit) || inputs.limit < 1 || inputs.limit > 1000)) {
|
|
114
|
+
ctx.report('DATAFABRIC_LIMIT_RANGE', `"${step}": limit must be a whole number between 1 and 1000 (got ${inputs.limit}). 1000 is a hard `
|
|
115
|
+
+ 'ceiling — a larger value truncates silently, so page with { skip } instead.');
|
|
116
|
+
}
|
|
117
|
+
// `in` is honoured only on a list query; on a single read the row compiles
|
|
118
|
+
// away, and a read whose filters compiled away returns EVERY record.
|
|
119
|
+
if (inputs.resultMode !== 'multiple' && (inputs.filters ?? []).some((f) => f.operator === 'in')) {
|
|
120
|
+
ctx.report('DATAFABRIC_IN_REQUIRES_MULTIPLE', `"${step}" filters with the 'in' operator, which is honoured only when resultMode is 'multiple'. `
|
|
121
|
+
+ 'On a single-record read that row compiles away — and a read whose filters compiled away matches every record.');
|
|
122
|
+
}
|
|
123
|
+
// A filter expression switched on and left blank is refused by the serializer,
|
|
124
|
+
// and it refuses the WHOLE query rather than dropping the row, which strands
|
|
125
|
+
// every downstream reference to this step.
|
|
126
|
+
for (const f of inputs.filters ?? []) {
|
|
127
|
+
const blank = f.value instanceof Expr ? f.value.js.trim() === '' : String(f.value ?? '').trim() === '';
|
|
128
|
+
if (blank) {
|
|
129
|
+
ctx.report('DATAFABRIC_FILTER_BLANK_VALUE', `"${step}" has a filter on ${JSON.stringify(f.field)} with a blank value. The serializer refuses the `
|
|
130
|
+
+ 'ENTIRE query rather than dropping the row, so the step emits nothing and every downstream $vars read of '
|
|
131
|
+
+ 'it breaks.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Update and delete target a record `{ fromRead: '<step>' }` with the same
|
|
137
|
+
* selector, so they share the check: the step must be a read, and not a list.
|
|
138
|
+
*/
|
|
139
|
+
export function checkFromRead(placements, ctx) {
|
|
140
|
+
for (const p of placements) {
|
|
141
|
+
if (p.kind !== 'dataFabricUpdate' && p.kind !== 'dataFabricDelete')
|
|
142
|
+
continue;
|
|
143
|
+
const fromRead = p.spec.inputs?.record?.fromRead;
|
|
144
|
+
if (typeof fromRead !== 'string')
|
|
145
|
+
continue;
|
|
146
|
+
const factory = p.kind;
|
|
147
|
+
// A step whose version did not resolve is already an error; saying it does
|
|
148
|
+
// not exist would send the author to the selector instead of the pin.
|
|
149
|
+
if (ctx.versionUnresolved(fromRead))
|
|
150
|
+
continue;
|
|
151
|
+
const read = ctx.placementOf(fromRead);
|
|
152
|
+
if (read?.kind !== 'dataFabricRead') {
|
|
153
|
+
ctx.report(p.step, 'DATAFABRIC_FROMREAD_UNKNOWN', `"${p.step}" targets record: { fromRead: ${JSON.stringify(fromRead)} }, but no dataFabricRead step has that name.`, `${factory}({ …, record: { byId: '<record id>' } })`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// A LIST is not a write target: the serializer refuses it and emits a plain
|
|
157
|
+
// no-op task, so the flow validates, runs green, and writes nothing.
|
|
158
|
+
if (read.spec.inputs?.resultMode === 'multiple') {
|
|
159
|
+
ctx.report(p.step, 'DATAFABRIC_FROMREAD_MULTIPLE', `"${p.step}" targets record: { fromRead: ${JSON.stringify(fromRead)} }, but "${fromRead}" is a `
|
|
160
|
+
+ "resultMode: 'multiple' query. A multi-record read is refused as a write target — the step compiles to "
|
|
161
|
+
+ 'a no-op and the run goes green having written nothing.', `loop the results and target each record with ${factory}({ …, record: { byId: … } })`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|