|
42 | 42 | * - **record fields**: the trigger record's fields are flattened to top-level |
43 | 43 | * names. Every field of EVERY object the stack declares counts, with the |
44 | 44 | * registry-injected columns — not only the trigger object's — because the run |
45 | | - * flattens whichever record its entrance hands it: a flow action passes the |
46 | | - * action's own record to any flow it starts, a `subflow` child inherits its |
47 | | - * parent's record, and a `map` child gets each record item. A field the stack |
48 | | - * declares anywhere is therefore a root this door cannot prove unbound. |
| 45 | + * flattens whichever record its entrance hands it (the entrances below). A |
| 46 | + * field the stack declares anywhere is therefore a root this door cannot |
| 47 | + * prove unbound. An object-less action that launches the flow hands an empty |
| 48 | + * record carrying at most the row `id` it was given, so `id` is bound there. |
49 | 49 | * |
50 | 50 | * ## When the door cannot prove a root unbound, it stands down |
51 | 51 | * |
|
62 | 62 | * - a `screen` with no input contract — an `object-form` screen, or one that |
63 | 63 | * declares no fields — because `refuseInvalidScreenInput` checks a resume bag |
64 | 64 | * only against a declared field list, so a contract-less screen folds any bag; |
65 | | - * - a trigger object the stack does not declare (a platform object, another |
66 | | - * package's): its row's keys are not in hand. |
| 65 | + * - an ENTRANCE that hands the flow a record whose keys are not in hand |
| 66 | + * ({@link flowCelEntrances}, the rule below). |
67 | 67 | * |
68 | | - * ⚠️ The boundary that remains: a record of an object this stack does not |
69 | | - * declare, handed in by a flow action, a parent `subflow` or a `map` item, can |
70 | | - * carry a key the reader has never seen. Measured over the example apps and |
71 | | - * hotcrm, no flow reads a bare root that is not its own binding, an engine root |
72 | | - * or a field of its trigger object. |
| 68 | + * ## The rule for records: every entrance the stack declares is read |
| 69 | + * |
| 70 | + * The run flattens whichever record its entrance hands it (`seedRunVariables` |
| 71 | + * reads `context.record` and nothing else), so the record half of the bound set |
| 72 | + * holds only while every entrance that can hand this flow a record names an |
| 73 | + * object the stack declares. {@link flowCelEntrances} reads each entrance from |
| 74 | + * the stack's own declarations and opens the flow when one does not: |
| 75 | + * |
| 76 | + * - **a record trigger** — the start node's `config.objectName` (the trigger |
| 77 | + * object a record-change run's row belongs to) names an undeclared object; |
| 78 | + * - **a time-relative sweep** — the start node's `config.timeRelative.object` |
| 79 | + * (`TimeRelativeTriggerSchema`; its own key, independent of `objectName`, and |
| 80 | + * the object whose rows the sweep hands each run) is undeclared; |
| 81 | + * - **the inbound hook** — the flow's trigger kind is `api` |
| 82 | + * (`resolveFlowTriggerKind`: `type: 'api'` or `config.triggerType: 'api'`), so |
| 83 | + * the hook hands the request body in as the record (`trigger-api`), keys and |
| 84 | + * all; |
| 85 | + * - **an action** — an `ActionSchema` entry with `type: 'flow'` and |
| 86 | + * `target: <this flow>` in `actions[]` (its `objectName`) or in |
| 87 | + * `objects[].actions[]` (its `objectName`, else the owning object) names an |
| 88 | + * undeclared object: `dispatchFlowAction` hands the action's own record to |
| 89 | + * whatever flow it targets; |
| 90 | + * - **a `map` node** — `MapConfigSchema`'s `flowName` names this flow and its |
| 91 | + * `itemObject` is undeclared or absent: each id-bearing item becomes the |
| 92 | + * child's record, and with no `itemObject` the items' object is not in hand; |
| 93 | + * - **a parent that is itself open** — a `subflow` node (`SubflowConfigSchema`'s |
| 94 | + * `flowName`) or a `map` node names this flow inside a flow that is open: the |
| 95 | + * child spreads its parent's context, record included. Read to a fixpoint. |
| 96 | + * |
| 97 | + * The API run door hands no record (measured): both REST trigger routes and the |
| 98 | + * declarative endpoint build their context with `buildAutomationContext`, which |
| 99 | + * carries `params` and `recordId` but no `record`, and the engine never loads a |
| 100 | + * record from `object` + `recordId`. A schedule run hands none either. |
| 101 | + * |
| 102 | + * ## The runtime publish gate stands down (#22636 is the carrier) |
| 103 | + * |
| 104 | + * The same pass runs at the runtime publish gate on a flow write, and there the |
| 105 | + * per-write snapshot carries the written flow alone beside `objects` / |
| 106 | + * `permissions` / `books` / `datasets` (`buildRuntimeWriteSnapshotSet`): no |
| 107 | + * action and no other flow, so no entrance is visible and a parent's `subflow` or |
| 108 | + * `map` node could hand this flow any record. A false 422 on the only door a |
| 109 | + * Studio tenant has is worse than the gap, so this one judgment stands down there |
| 110 | + * ({@link perWriteSnapshotEntrance}); every other expression verdict at that door |
| 111 | + * is unchanged, and `objectstack validate` judges under the rule above. #22636 |
| 112 | + * widens that snapshot to carry flows and actions; when it lands, this |
| 113 | + * stand-down goes. |
73 | 114 | */ |
74 | 115 |
|
75 | 116 | import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula'; |
76 | 117 | import { |
77 | 118 | APPROVAL_NODE_TYPE, |
78 | 119 | APPROVAL_REVISE_NODE_TYPE, |
| 120 | + collectFlowGraphs, |
79 | 121 | LOOP_NODE_TYPE, |
80 | 122 | PARALLEL_NODE_TYPE, |
| 123 | + resolveFlowTriggerKind, |
81 | 124 | TRY_CATCH_NODE_TYPE, |
82 | 125 | } from '@objectstack/spec/automation'; |
| 126 | +import type { FlowEdgeParsed, FlowNodeParsed } from '@objectstack/spec/automation'; |
83 | 127 |
|
84 | 128 | import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js'; |
85 | 129 | import { nearestName, recordsOf } from './object-graph.js'; |
@@ -167,24 +211,42 @@ export interface UnboundFlowCelRoot { |
167 | 211 | readonly members: readonly string[]; |
168 | 212 | } |
169 | 213 |
|
| 214 | +/** |
| 215 | + * What the flow's entrances contribute to its scope ({@link flowCelEntrances}): |
| 216 | + * the reason one of them hands it a record whose keys are not in hand, and any |
| 217 | + * name an entrance binds that no object field covers. |
| 218 | + */ |
| 219 | +export interface FlowCelEntrance { |
| 220 | + /** Set ⇒ the flow is not judged; names the entrance. */ |
| 221 | + readonly openedBy?: string; |
| 222 | + /** Names an entrance binds beyond the objects' fields (an object-less action's `id`). */ |
| 223 | + readonly bound?: readonly string[]; |
| 224 | +} |
| 225 | + |
170 | 226 | /** |
171 | 227 | * The bound set of one flow, or the reason it cannot be proved. |
172 | 228 | * |
173 | 229 | * @param graphs every graph of the flow (`collectFlowGraphs`), so a region's |
174 | 230 | * nodes count — one variable map serves the whole run. |
175 | | - * @param triggerObject the start node's `objectName`, when it names one. |
| 231 | + * @param triggerObject the start node's `objectName`, when it names one — its |
| 232 | + * fields lead the did-you-mean order. |
176 | 233 | * @param fieldIndex object name → its field names, injected columns included. |
| 234 | + * @param entrance what the flow's entrances contribute: from |
| 235 | + * {@link flowCelEntrances} at the build door, {@link perWriteSnapshotEntrance} |
| 236 | + * at the runtime publish gate. Its reason takes precedence over a node's. |
177 | 237 | */ |
178 | 238 | export function flowCelRootScope( |
179 | 239 | flow: FlowCelRootHost, |
180 | 240 | graphs: readonly FlowGraphLike[], |
181 | 241 | triggerObject: string | undefined, |
182 | 242 | fieldIndex: ReadonlyMap<string, readonly string[]>, |
| 243 | + entrance: FlowCelEntrance = {}, |
183 | 244 | ): FlowCelRootScope { |
184 | 245 | const bound = new Set<string>(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []); |
185 | 246 | for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name); |
186 | 247 | for (const name of ENGINE_BOUND_ROOTS) bound.add(name); |
187 | | - let openedBy: string | undefined; |
| 248 | + for (const name of entrance.bound ?? []) bound.add(name); |
| 249 | + let openedBy: string | undefined = entrance.openedBy; |
188 | 250 | const open = (reason: string): void => { openedBy ??= reason; }; |
189 | 251 |
|
190 | 252 | for (const graph of graphs) { |
@@ -216,14 +278,153 @@ export function flowCelRootScope( |
216 | 278 | } |
217 | 279 | } |
218 | 280 |
|
219 | | - if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) { |
220 | | - open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`); |
221 | | - } |
222 | 281 | for (const names of fieldIndex.values()) for (const name of names) bound.add(name); |
223 | 282 |
|
224 | 283 | return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy }; |
225 | 284 | } |
226 | 285 |
|
| 286 | +/** The stack slice {@link flowCelEntrances} reads. */ |
| 287 | +export interface FlowCelEntranceHost { |
| 288 | + readonly flows?: unknown; |
| 289 | + readonly actions?: unknown; |
| 290 | + readonly objects?: unknown; |
| 291 | +} |
| 292 | + |
| 293 | +function configOf(node: AnyRec): AnyRec { |
| 294 | + return node.config && typeof node.config === 'object' && !Array.isArray(node.config) ? (node.config as AnyRec) : {}; |
| 295 | +} |
| 296 | + |
| 297 | +function nameOf(value: unknown): string | undefined { |
| 298 | + return typeof value === 'string' && value ? value : undefined; |
| 299 | +} |
| 300 | + |
| 301 | +/** |
| 302 | + * Every flow of the stack whose entrances open it, or bind a name beyond the |
| 303 | + * objects' fields — read from the stack's own declarations (the module's rule |
| 304 | + * for records). A flow absent from the map is judged as its own text reads. |
| 305 | + * |
| 306 | + * Built once per stack, because an entrance lives OUTSIDE the flow it feeds (an |
| 307 | + * action, another flow's `subflow` / `map` node) and a parent's openness passes |
| 308 | + * to its children: the parent edges are read to a fixpoint. A child named by a |
| 309 | + * node but not declared in this stack is another package's flow and is not |
| 310 | + * judged here. |
| 311 | + * |
| 312 | + * @param fieldIndex object name → field names; an object is "declared" when it |
| 313 | + * is a key here, the same universe the bound set reads. |
| 314 | + */ |
| 315 | +export function flowCelEntrances( |
| 316 | + stack: FlowCelEntranceHost, |
| 317 | + fieldIndex: ReadonlyMap<string, readonly string[]>, |
| 318 | +): ReadonlyMap<string, FlowCelEntrance> { |
| 319 | + const flows = recordsOf(stack.flows); |
| 320 | + const flowNames = new Set(flows.map((flow) => nameOf(flow.name)).filter((name): name is string => !!name)); |
| 321 | + const openedBy = new Map<string, string>(); |
| 322 | + const bound = new Map<string, string[]>(); |
| 323 | + const open = (flow: string, reason: string): void => { |
| 324 | + if (flowNames.has(flow) && !openedBy.has(flow)) openedBy.set(flow, reason); |
| 325 | + }; |
| 326 | + const undeclared = (object: string): boolean => !fieldIndex.has(object); |
| 327 | + |
| 328 | + // The flow's own trigger: a record trigger, a time-relative sweep, the inbound hook. |
| 329 | + for (const flow of flows) { |
| 330 | + const name = nameOf(flow.name); |
| 331 | + if (!name) continue; |
| 332 | + const start = recordsOf(flow.nodes).find((node) => node.type === 'start'); |
| 333 | + const config = start ? configOf(start) : {}; |
| 334 | + const triggerObject = nameOf(config.objectName); |
| 335 | + if (triggerObject && undeclared(triggerObject)) { |
| 336 | + open(name, `its trigger object '${triggerObject}' (start config.objectName) is not declared in this stack, so the record's keys are not in hand`); |
| 337 | + } |
| 338 | + const sweep = config.timeRelative && typeof config.timeRelative === 'object' ? (config.timeRelative as AnyRec) : undefined; |
| 339 | + const sweepObject = sweep ? nameOf(sweep.object) : undefined; |
| 340 | + if (sweepObject && undeclared(sweepObject)) { |
| 341 | + open(name, `its time-relative sweep's object '${sweepObject}' (start config.timeRelative.object) is not declared in this stack, so the record's keys are not in hand`); |
| 342 | + } |
| 343 | + if (resolveFlowTriggerKind(flow) === 'api') { |
| 344 | + open(name, 'its trigger kind is api, so the inbound hook hands the request body in as the record, whatever its keys'); |
| 345 | + } |
| 346 | + } |
| 347 | + |
| 348 | + // Actions that launch a flow: `actions[]`, and each object's own `actions[]`. |
| 349 | + const launches: Array<{ action: AnyRec; object: string | undefined }> = []; |
| 350 | + for (const action of recordsOf(stack.actions)) launches.push({ action, object: nameOf(action.objectName) }); |
| 351 | + for (const object of recordsOf(stack.objects)) { |
| 352 | + for (const action of recordsOf(object.actions)) { |
| 353 | + launches.push({ action, object: nameOf(action.objectName) ?? nameOf(object.name) }); |
| 354 | + } |
| 355 | + } |
| 356 | + for (const { action, object } of launches) { |
| 357 | + const target = action.type === 'flow' ? nameOf(action.target) : undefined; |
| 358 | + if (!target || !flowNames.has(target)) continue; |
| 359 | + if (object === undefined) { |
| 360 | + // Object-less: the action hands an empty record, carrying at most the row id it was given. |
| 361 | + bound.set(target, [...(bound.get(target) ?? []), 'id']); |
| 362 | + } else if (undeclared(object)) { |
| 363 | + open(target, `action '${nameOf(action.name) ?? '?'}' launches it on '${object}' (the action's objectName), which this stack does not declare`); |
| 364 | + } |
| 365 | + } |
| 366 | + |
| 367 | + // Parent edges: a `subflow` or `map` node naming a flow of this stack. |
| 368 | + const edges: Array<{ parent: string; child: string; kind: 'subflow' | 'map'; node: string }> = []; |
| 369 | + for (const flow of flows) { |
| 370 | + const parent = nameOf(flow.name); |
| 371 | + if (!parent) continue; |
| 372 | + const graphs = collectFlowGraphs({ |
| 373 | + ...flow, |
| 374 | + nodes: recordsOf(flow.nodes) as unknown as FlowNodeParsed[], |
| 375 | + edges: recordsOf(flow.edges) as unknown as FlowEdgeParsed[], |
| 376 | + }); |
| 377 | + for (const graph of graphs) { |
| 378 | + for (const node of recordsOf(graph.nodes)) { |
| 379 | + if (node.type !== 'subflow' && node.type !== 'map') continue; |
| 380 | + const config = configOf(node); |
| 381 | + const child = nameOf(config.flowName); |
| 382 | + if (!child || !flowNames.has(child)) continue; |
| 383 | + const nodeId = nameOf(node.id) ?? '?'; |
| 384 | + edges.push({ parent, child, kind: node.type, node: nodeId }); |
| 385 | + if (node.type === 'map') { |
| 386 | + const itemObject = nameOf(config.itemObject); |
| 387 | + if (!itemObject) { |
| 388 | + open(child, `map node '${nodeId}' in flow '${parent}' feeds it items and declares no config.itemObject, so the items' object is not in hand`); |
| 389 | + } else if (undeclared(itemObject)) { |
| 390 | + open(child, `map node '${nodeId}' in flow '${parent}' feeds it items of '${itemObject}' (config.itemObject), which this stack does not declare`); |
| 391 | + } |
| 392 | + } |
| 393 | + } |
| 394 | + } |
| 395 | + } |
| 396 | + for (let changed = true; changed;) { |
| 397 | + changed = false; |
| 398 | + for (const edge of edges) { |
| 399 | + if (!openedBy.has(edge.parent) || openedBy.has(edge.child)) continue; |
| 400 | + open(edge.child, `${edge.kind} node '${edge.node}' in flow '${edge.parent}' hands it that flow's record, and '${edge.parent}' is itself open`); |
| 401 | + changed = true; |
| 402 | + } |
| 403 | + } |
| 404 | + |
| 405 | + const out = new Map<string, FlowCelEntrance>(); |
| 406 | + for (const name of flowNames) { |
| 407 | + const reason = openedBy.get(name); |
| 408 | + const names = bound.get(name); |
| 409 | + if (reason === undefined && names === undefined) continue; |
| 410 | + out.set(name, { ...(reason !== undefined ? { openedBy: reason } : {}), ...(names !== undefined ? { bound: names } : {}) }); |
| 411 | + } |
| 412 | + return out; |
| 413 | +} |
| 414 | + |
| 415 | +/** |
| 416 | + * The runtime publish gate's entrance: none visible. Its per-write snapshot |
| 417 | + * carries the written flow alone (module note), so this one judgment stands down |
| 418 | + * there until #22636 widens the snapshot. |
| 419 | + */ |
| 420 | +export function perWriteSnapshotEntrance(): FlowCelEntrance { |
| 421 | + return { |
| 422 | + openedBy: |
| 423 | + 'the runtime publish gate judges a flow write against a per-write snapshot that carries no action and no ' |
| 424 | + + 'other flow, so no entrance that can hand this flow a record is visible', |
| 425 | + }; |
| 426 | +} |
| 427 | + |
227 | 428 | /** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */ |
228 | 429 | const COMPREHENSION_MACROS: ReadonlySet<string> = new Set(['all', 'exists', 'exists_one', 'map', 'filter']); |
229 | 430 |
|
|
0 commit comments