@dudousxd/nestjs-catalog 0.26.0 → 0.28.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.
@@ -9,7 +9,7 @@
9
9
  * systems each believing they decide when a load runs.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CATALOG_SOURCE_TYPE_KEY = exports.CONNECTOR_KINDS = void 0;
12
+ exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_LOOKUP_MAX_REFERENCE_ROWS = exports.WORKFLOW_LOOKUP_MAX_FIELDS = exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = exports.WORKFLOW_AGGREGATE_MAX_GROUPS = exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = exports.WORKFLOW_LOOKUP_UNMATCHED = exports.WORKFLOW_AGGREGATE_FUNCTIONS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CATALOG_SOURCE_TYPE_KEY = exports.CONNECTOR_KINDS = void 0;
13
13
  exports.isConnectorKind = isConnectorKind;
14
14
  exports.unreachableConnectorKind = unreachableConnectorKind;
15
15
  exports.workflowSourceObjectType = workflowSourceObjectType;
@@ -44,6 +44,22 @@ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
44
44
  exports.workflowRenameUnnamed = workflowRenameUnnamed;
45
45
  exports.renameColumnRefusals = renameColumnRefusals;
46
46
  exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
47
+ exports.isWorkflowAggregateFunction = isWorkflowAggregateFunction;
48
+ exports.unreachableAggregateFunction = unreachableAggregateFunction;
49
+ exports.isWorkflowLookupUnmatched = isWorkflowLookupUnmatched;
50
+ exports.unreachableLookupUnmatched = unreachableLookupUnmatched;
51
+ exports.workflowAggregateMaxGroups = workflowAggregateMaxGroups;
52
+ exports.workflowAggregateSeparator = workflowAggregateSeparator;
53
+ exports.workflowAggregateJoinMaxLength = workflowAggregateJoinMaxLength;
54
+ exports.workflowAggregateNeedsColumn = workflowAggregateNeedsColumn;
55
+ exports.workflowAggregateColumns = workflowAggregateColumns;
56
+ exports.workflowAggregateOutputColumns = workflowAggregateOutputColumns;
57
+ exports.aggregateRefusals = aggregateRefusals;
58
+ exports.workflowLookupUnmatched = workflowLookupUnmatched;
59
+ exports.workflowLookupKey = workflowLookupKey;
60
+ exports.lookupConfigRefusals = lookupConfigRefusals;
61
+ exports.isWorkflowAggregates = isWorkflowAggregates;
62
+ exports.isWorkflowLookupFields = isWorkflowLookupFields;
47
63
  exports.isReusableNodeKind = isReusableNodeKind;
48
64
  exports.nodeKindIsReusable = nodeKindIsReusable;
49
65
  exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
@@ -64,6 +80,7 @@ exports.workflowNodeRuns = workflowNodeRuns;
64
80
  exports.workflowGraphHash = workflowGraphHash;
65
81
  exports.workflowFilterColumns = workflowFilterColumns;
66
82
  exports.workflowKnownColumns = workflowKnownColumns;
83
+ exports.workflowLookupColumns = workflowLookupColumns;
67
84
  exports.isWorkflowNode = isWorkflowNode;
68
85
  exports.isWorkflowEdge = isWorkflowEdge;
69
86
  exports.supportsWorkflows = supportsWorkflows;
@@ -501,12 +518,25 @@ function isWorkflowSkipReason(value) {
501
518
  * {@link WorkflowIfNode} is the conditional one, and it earns its kind by
502
519
  * doing something no wiring can express — deciding that one of those
503
520
  * successors, and everything only it feeds, does not run at all.
504
- * - **merge / join** — a node with several inbound edges receives its inputs
505
- * concatenated in edge order (see {@link WorkflowEdge}). A keyed join is then
506
- * ordinary code inside the transform, which can already see every record.
507
- * A `merge` kind would have had to carry a strategy field whose values the
508
- * runner would have to implement one by one, and an unimplemented strategy in
509
- * a dropdown is the failure this list exists to avoid.
521
+ * - **merge (unkeyed)** — a node with several inbound edges already receives its
522
+ * inputs concatenated in edge order (see {@link WorkflowEdge}), so a `merge`
523
+ * kind would be a box that draws what the wires already say. It would also
524
+ * have had to carry a strategy field whose values the runner implements one by
525
+ * one, and an unimplemented strategy in a dropdown is the failure this list
526
+ * exists to avoid.
527
+ * - **join (keyed)** — *this half used to be refused with the entry above, and
528
+ * the reversal is left visible rather than edited out*, the way the `filter`
529
+ * entry leaves its own. The old argument was that a keyed join is ordinary
530
+ * code inside a transform, which can already see every record. Every word of
531
+ * that is true and it is exactly the problem: "can already see every record"
532
+ * is the same sentence as "holds the whole load", and it is why a transform
533
+ * makes `ConnectorRunnerService` log *"Held all N records in memory"*. A join
534
+ * does not need both sides held. It needs **one** side held — as a map, keyed
535
+ * — while the other streams past it, and that asymmetry is a property of the
536
+ * operation that a function over a batch cannot express and a runner therefore
537
+ * cannot exploit. {@link WorkflowLookupNode} is the keyed half, built narrow:
538
+ * one key, named enrichment fields, and a reference side that is bounded and
539
+ * refused loudly rather than held quietly.
510
540
  * - **call a durable *step*** — the sibling of {@link WorkflowCallNode} that
511
541
  * somebody will eventually come looking for, and it cannot be built. A
512
542
  * durable step has no global identity: it is dispatched by a routing name
@@ -532,6 +562,10 @@ exports.WORKFLOW_NODE_KINDS = [
532
562
  'filter',
533
563
  /** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
534
564
  'rename',
565
+ /** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
566
+ 'aggregate',
567
+ /** Enriches each row from a reference dataset, by key. See {@link WorkflowLookupNode}. */
568
+ 'lookup',
535
569
  ];
536
570
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
537
571
  function isWorkflowNodeKind(value) {
@@ -1306,6 +1340,609 @@ function isWorkflowRenameColumns(value) {
1306
1340
  }
1307
1341
  return false;
1308
1342
  }
1343
+ /* --- aggregate ----------------------------------------------------------- */
1344
+ /**
1345
+ * The aggregate functions this node computes, and the rule that closes the list.
1346
+ *
1347
+ * A closed list with an exhaustiveness guard, for the reason every other list in
1348
+ * this file is one. What is different here is that the list has a **stated
1349
+ * admission rule**, because "we will keep it narrow" is a promise nobody can
1350
+ * check and a rule is:
1351
+ *
1352
+ * > A function is in if it can be computed from a **fixed-size accumulator**,
1353
+ * > and if its answer does not depend on a decision the config would have to
1354
+ * > carry.
1355
+ *
1356
+ * The first half is the node's whole reason to exist. A hash aggregate is cheap
1357
+ * because it holds one entry per group; an accumulator whose size grows with the
1358
+ * number of *rows* in a group puts the rows back in memory and gives up the
1359
+ * property. The second half is what keeps the config from becoming a small
1360
+ * language: a function that needs an extra field to say what it means is a
1361
+ * function whose meaning was not decided.
1362
+ *
1363
+ * What the rule excludes, so the omissions are on the record rather than
1364
+ * implied:
1365
+ *
1366
+ * - **`countDistinct`** — the sharpest one. It needs a set of the distinct
1367
+ * values *per group per column*, so its accumulator is O(distinct values) and
1368
+ * a high-cardinality column inside a group holds the load. It is the exact
1369
+ * thing this node was built to stop doing, wearing an aggregate's name. A
1370
+ * sketch (HyperLogLog) is fixed-size and is a different function — an
1371
+ * estimate — which is not something to ship under the word `distinct`.
1372
+ * - **`median`, percentiles, `stddev` of a stream** — all need the values, or a
1373
+ * digest that is an approximation with an error bound the config would have to
1374
+ * carry.
1375
+ * - **`first` / `last`** — fixed-size, and excluded on the other half of the
1376
+ * rule: they mean "in input order", and this node's input order is a
1377
+ * `SELECT` without an `ORDER BY`. An aggregate that returns a different value
1378
+ * on a rerun is a load nobody can diff. `min`/`max` are the order-independent
1379
+ * version and are what somebody reaching for `first` usually wants.
1380
+ * - **Conditional aggregation — `MAX(CASE WHEN … THEN … END)`** — deliberately
1381
+ * out of scope, and it is the one omission a reader of flip's `wo` query will
1382
+ * go looking for, because that query has a three-branch status ladder in it.
1383
+ * Admitting it means admitting a predicate *inside* an aggregate, which is a
1384
+ * second expression language nested in the first, evaluated per row per
1385
+ * aggregate. That is transform territory and the generic
1386
+ * {@link WorkflowTransformNode} still exists. What the ladder actually is, is
1387
+ * a priority ordering over a closed set of codes, and it composes: map the
1388
+ * code to a rank in a transform above this node, `min` the rank, map it back
1389
+ * below. Two cheap per-record steps instead of a language.
1390
+ * - **`avg` is in**, and it is in *because* of the rule rather than despite it.
1391
+ * It is `sum` and `count` in one accumulator, both of which are already here,
1392
+ * and SQL has exactly one answer for it. Excluding it would have made the list
1393
+ * an arbitrary set that happened to cover one query, which is the thing the
1394
+ * rule is for.
1395
+ */
1396
+ exports.WORKFLOW_AGGREGATE_FUNCTIONS = [
1397
+ /** Rows in the group, or non-null values of a column. `COUNT(*)` / `COUNT(c)`. */
1398
+ 'count',
1399
+ /** The total. See `addToSum` for the summation error and what is done about it. */
1400
+ 'sum',
1401
+ /** The mean of the non-null values. `sum` and `count` in one accumulator. */
1402
+ 'avg',
1403
+ /** The least value. See `compareValues` for the order, which is not MySQL's. */
1404
+ 'min',
1405
+ /** The greatest value. Same comparison, same docblock. */
1406
+ 'max',
1407
+ /** The values, concatenated. `GROUP_CONCAT`, with a bound that refuses. */
1408
+ 'join',
1409
+ ];
1410
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1411
+ function isWorkflowAggregateFunction(value) {
1412
+ return exports.WORKFLOW_AGGREGATE_FUNCTIONS.some((fn) => fn === value);
1413
+ }
1414
+ /**
1415
+ * {@link unreachableNodeKind}, one level down, and for the identical reason.
1416
+ *
1417
+ * Every branch over {@link WorkflowAggregateFunction} ends here, so a seventh
1418
+ * function added to the list without an accumulator, a finisher, a canonical
1419
+ * form and a sentence is a type error naming the file rather than a node that
1420
+ * saves, draws and then computes nothing. It throws as well, because a function
1421
+ * name arrives as JSON out of a column and a build older than the data is a
1422
+ * thing that happens.
1423
+ */
1424
+ function unreachableAggregateFunction(fn, where) {
1425
+ throw new Error(`${where} has no rule for the aggregate function ${JSON.stringify(fn)}. It was added to WORKFLOW_AGGREGATE_FUNCTIONS without teaching this code how to compute it, and guessing would commit a number nobody derived.`);
1426
+ }
1427
+ /* --- lookup -------------------------------------------------------------- */
1428
+ /**
1429
+ * What happens to a driving row whose key matches no reference row.
1430
+ *
1431
+ * Three words rather than a boolean, because the three are the three joins SQL
1432
+ * has names for and each is a different node. See
1433
+ * {@link WorkflowLookupNode.unmatched} for which one to reach for.
1434
+ */
1435
+ exports.WORKFLOW_LOOKUP_UNMATCHED = [
1436
+ /** The enriched columns are set to `null` on that row. A LEFT JOIN. The default. */
1437
+ 'null',
1438
+ /** The row does not reach the next node at all. An INNER JOIN. */
1439
+ 'drop',
1440
+ /** The node fails, naming the key. For a reference that is a prerequisite. */
1441
+ 'fail',
1442
+ ];
1443
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1444
+ function isWorkflowLookupUnmatched(value) {
1445
+ return exports.WORKFLOW_LOOKUP_UNMATCHED.some((each) => each === value);
1446
+ }
1447
+ /**
1448
+ * The exhaustiveness guard for {@link WORKFLOW_LOOKUP_UNMATCHED}.
1449
+ *
1450
+ * {@link unreachableNodeKind}, one level down, and for the identical reason: the
1451
+ * three words decide whether a row keeps its data, disappears from the load, or
1452
+ * stops the run, and a fourth added without a branch would silently pick
1453
+ * whichever the last `if` was — which here means silently changing which rows
1454
+ * reach a published type.
1455
+ */
1456
+ function unreachableLookupUnmatched(value, where) {
1457
+ throw new Error(`${where} does not handle the lookup disposition ${JSON.stringify(value)}. The list and every decision made per entry are meant to move together.`);
1458
+ }
1459
+ /**
1460
+ * How many columns one node may group on.
1461
+ *
1462
+ * The same argument {@link WORKFLOW_RENAME_MAX_COLUMNS} makes, plus one specific
1463
+ * to this node: every extra group-by column can only ever *increase* the number
1464
+ * of groups, so a long list is the shape a high-cardinality grouping arrives in.
1465
+ * flip's real derivation groups on two.
1466
+ */
1467
+ exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = 16;
1468
+ /**
1469
+ * How many aggregates one node may compute.
1470
+ *
1471
+ * flip's `wo` derivation has 49, so the bound has to be comfortably above that
1472
+ * or the node does not do the job it was written for. Past a few hundred the
1473
+ * thing being expressed is a table definition rather than a summary, and the
1474
+ * cost is real: every aggregate is an accumulator held **per group**, so this
1475
+ * number multiplies {@link WORKFLOW_AGGREGATE_MAX_GROUPS} in the heap.
1476
+ */
1477
+ exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = 256;
1478
+ /**
1479
+ * The default ceiling on distinct groups, and the loud refusal that goes with
1480
+ * it.
1481
+ *
1482
+ * A hash aggregate is cheap **only while the groups are far fewer than the
1483
+ * rows**. Group on a near-unique column and it holds one accumulator row per
1484
+ * input row, which is the whole-batch behaviour this node replaces, arrived at
1485
+ * by a different route and with nothing on the canvas to point at. So the
1486
+ * ceiling exists, it is crossed loudly, and the message names the columns being
1487
+ * grouped on — because a bound that is merely reported is a bound that is
1488
+ * discovered by the machine running out of memory.
1489
+ *
1490
+ * A million is chosen against the measurement rather than as a round number:
1491
+ * flip's derivation holds 16,119, so the default is 62× the real case and no
1492
+ * author of a sane grouping ever meets it. What it catches is `groupBy:
1493
+ * ['combinedId']` on a 44,720-row type — a grouping that is *legal*, produces
1494
+ * one group per row, and is somebody having picked the wrong column.
1495
+ *
1496
+ * The number is a proxy and it is worth saying which part it cannot see: what a
1497
+ * group costs in bytes depends on how many aggregates the node has and how long
1498
+ * a `join` grows. The first is bounded by
1499
+ * {@link WORKFLOW_AGGREGATE_MAX_AGGREGATES}; the second has its own bound on the
1500
+ * aggregate, because it is the one accumulator whose size is not fixed by the
1501
+ * group count.
1502
+ */
1503
+ exports.WORKFLOW_AGGREGATE_MAX_GROUPS = 1_000_000;
1504
+ /**
1505
+ * The highest ceiling an author may ask for.
1506
+ *
1507
+ * Configurable because "how many groups is too many" genuinely depends on the
1508
+ * machine and on how wide the node is, and a hard-coded limit would make the
1509
+ * node unusable for the one legitimate large grouping. Bounded because past this
1510
+ * the answer is not a bigger number — it is that the grouping belongs in the
1511
+ * source query, where the database already has spill-to-disk and this process
1512
+ * does not.
1513
+ */
1514
+ exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = 20_000_000;
1515
+ /**
1516
+ * The default bound on one joined value, in characters.
1517
+ *
1518
+ * 65,535 because that is what a MySQL `TEXT` column holds, and a value the
1519
+ * target column cannot store is the same defect one layer further down. See
1520
+ * `appendJoin` for the full argument, including the five groups per column that
1521
+ * are silently truncated in production today under a limit of 1,024.
1522
+ */
1523
+ exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = 65_535;
1524
+ /** The highest an author may raise a `join` bound to. One `MEDIUMTEXT`. */
1525
+ exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = 16_777_215;
1526
+ /** The longest separator a `join` may use. Long enough for `" | "`, short enough not to be data. */
1527
+ exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = 16;
1528
+ /** The separator a `join` uses when the aggregate does not name one. */
1529
+ exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = ', ';
1530
+ /** {@link WorkflowAggregateNode.maxGroups}, resolved. One reader of the default. */
1531
+ function workflowAggregateMaxGroups(node) {
1532
+ const asked = node.maxGroups;
1533
+ if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
1534
+ return exports.WORKFLOW_AGGREGATE_MAX_GROUPS;
1535
+ }
1536
+ return Math.min(asked, exports.WORKFLOW_AGGREGATE_GROUPS_CEILING);
1537
+ }
1538
+ /** {@link WorkflowAggregate.separator}, resolved. One reader of the default. */
1539
+ function workflowAggregateSeparator(aggregate) {
1540
+ const asked = aggregate.separator;
1541
+ return typeof asked === 'string' ? asked : exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR;
1542
+ }
1543
+ /** {@link WorkflowAggregate.maxLength}, resolved. One reader of the default. */
1544
+ function workflowAggregateJoinMaxLength(aggregate) {
1545
+ const asked = aggregate.maxLength;
1546
+ if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
1547
+ return exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH;
1548
+ }
1549
+ return Math.min(asked, exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING);
1550
+ }
1551
+ /** Whether this function reads a column. Only `count` may go without one. */
1552
+ function workflowAggregateNeedsColumn(fn) {
1553
+ return fn !== 'count';
1554
+ }
1555
+ /**
1556
+ * The columns an aggregate node **reads**: its group keys and its inputs.
1557
+ *
1558
+ * What `checkColumnsProduced` tests against what the graph can prove is there,
1559
+ * and what the run log reports as never-seen. Deduplicated and in a stable
1560
+ * order, because it goes into a sentence.
1561
+ */
1562
+ function workflowAggregateColumns(node) {
1563
+ const columns = new Set();
1564
+ for (const column of node.groupBy ?? []) {
1565
+ if (typeof column === 'string' && column.length > 0)
1566
+ columns.add(column);
1567
+ }
1568
+ for (const aggregate of node.aggregates ?? []) {
1569
+ const column = aggregate?.column;
1570
+ if (typeof column === 'string' && column.length > 0)
1571
+ columns.add(column);
1572
+ }
1573
+ return [...columns];
1574
+ }
1575
+ /**
1576
+ * The columns an aggregate node **produces**, which is all of them and nothing
1577
+ * else.
1578
+ *
1579
+ * Closed by the config, and closed *exactly* rather than as an upper bound —
1580
+ * every emitted record carries every one of these keys, whatever was upstream
1581
+ * and whatever the values turned out to be. That is a stronger claim than the
1582
+ * one `rename` introduced, and it is stronger for a structural reason: a rename
1583
+ * only produces a target where the input actually held the source column,
1584
+ * whereas an aggregate writes a group's answer whether or not anything in the
1585
+ * group had a value for it.
1586
+ *
1587
+ * The one thing it does not claim is that the values are useful. An aggregate
1588
+ * over a column that no record carried produces the column, holding `null`.
1589
+ */
1590
+ function workflowAggregateOutputColumns(node) {
1591
+ const columns = [];
1592
+ for (const column of node.groupBy ?? []) {
1593
+ if (typeof column === 'string' && column.length > 0 && !columns.includes(column)) {
1594
+ columns.push(column);
1595
+ }
1596
+ }
1597
+ for (const aggregate of node.aggregates ?? []) {
1598
+ const as = aggregate?.as;
1599
+ if (typeof as === 'string' && as.length > 0 && !columns.includes(as))
1600
+ columns.push(as);
1601
+ }
1602
+ return columns;
1603
+ }
1604
+ /**
1605
+ * Every reason an aggregate cannot be stored, as sentences, or empty.
1606
+ *
1607
+ * One function, called by {@link validateWorkflow}, by the HTTP boundary, by the
1608
+ * canvas and by the fold itself, for the reason {@link renameColumnRefusals} is
1609
+ * shared: a screen with its own copy of the identifier pattern is a screen that
1610
+ * accepts something the server refuses, halfway through a save.
1611
+ *
1612
+ * All of them rather than the first, exactly as
1613
+ * {@link refuseUnpublishablePropertyNames} argues: a node with forty aggregates
1614
+ * typed in one sitting is usually wrong about several in the same way.
1615
+ */
1616
+ function aggregateRefusals(node) {
1617
+ const groupBy = Array.isArray(node.groupBy) ? node.groupBy : [];
1618
+ const aggregates = Array.isArray(node.aggregates) ? node.aggregates : [];
1619
+ const named = new Set();
1620
+ const refusals = [
1621
+ ...groupByRefusals(groupBy, named),
1622
+ ...aggregatesRefusals(aggregates, named),
1623
+ ...ceilingRefusals(node.maxGroups),
1624
+ ];
1625
+ return refusals;
1626
+ }
1627
+ /**
1628
+ * How many fields one lookup may bring across.
1629
+ *
1630
+ * The same argument {@link WORKFLOW_RENAME_MAX_COLUMNS} makes, plus one that is
1631
+ * specific to this node: every named field is held *per distinct key* for the
1632
+ * whole run, so this number multiplies {@link WORKFLOW_LOOKUP_MAX_REFERENCE_ROWS}
1633
+ * into the actual memory bill. Past a few dozen the thing being expressed is
1634
+ * "give me that whole table beside this one", which is a second source and a
1635
+ * union, not an enrichment.
1636
+ */
1637
+ exports.WORKFLOW_LOOKUP_MAX_FIELDS = 64;
1638
+ /**
1639
+ * How many rows the reference side may have before the node refuses to run.
1640
+ *
1641
+ * ## Why there is a number here at all
1642
+ *
1643
+ * Because exactly one side of a join can stream, and this node holds the other
1644
+ * one. That is the whole property it exists to have (see
1645
+ * {@link WorkflowLookupNode}) and it is also the whole hazard: a graph whose
1646
+ * reference edge is accidentally wired to the 7.6-million-row side does not fail,
1647
+ * it allocates until the pod is killed — and a pod killed by the kernel produces
1648
+ * no run log, no failed node and no message, which is the silence this file is
1649
+ * arranged against.
1650
+ *
1651
+ * ## Why it is measured in rows, and why it is free
1652
+ *
1653
+ * A staged input announces its `rowCount` before a single row is read back (see
1654
+ * `WorkflowStageRef`), so the refusal happens *before* anything is held. A bound
1655
+ * in bytes would have to be discovered by holding rows until they weighed too
1656
+ * much, which is a bound that has already done the damage by the time it fires.
1657
+ *
1658
+ * Two hundred thousand, and the arithmetic rather than a round number that feels
1659
+ * safe: what is retained per key is the key string plus the values of the named
1660
+ * fields — not the reference row — so a reference of this size with a handful of
1661
+ * short fields is tens of megabytes, and one at {@link WORKFLOW_LOOKUP_MAX_FIELDS}
1662
+ * is the point where it stops being obviously fine. The real reference tables
1663
+ * this was built against are three orders of magnitude below it: a work-plan code
1664
+ * table is hundreds of rows and a unit dictionary is dozens.
1665
+ *
1666
+ * The refusal names the count, the bound and the fix, because the fix is
1667
+ * genuinely available in the graph: put a filter on the reference side, or swap
1668
+ * the two edges if the smaller side is the one being streamed.
1669
+ */
1670
+ exports.WORKFLOW_LOOKUP_MAX_REFERENCE_ROWS = 200_000;
1671
+ /** {@link WorkflowLookupNode.unmatched}, resolved. One reader of the default. */
1672
+ function workflowLookupUnmatched(node) {
1673
+ return node.unmatched ?? 'null';
1674
+ }
1675
+ /**
1676
+ * A key, as this node compares them, or `undefined` for a row that has none.
1677
+ *
1678
+ * One function, exported, and called by the runner for both sides — because the
1679
+ * one way a join goes silently wrong is the two sides being read by two pieces
1680
+ * of code that agree today. See {@link WorkflowLookupNode.key} for what it does
1681
+ * and, more to the point, what it deliberately does not do.
1682
+ */
1683
+ function workflowLookupKey(value) {
1684
+ if (value === undefined || value === null)
1685
+ return undefined;
1686
+ // Objects and arrays are refused rather than stringified: `String({})` is
1687
+ // `"[object Object]"`, which is a key that every JSON column in a row would
1688
+ // share, so a join on a mis-chosen column would match everything to everything
1689
+ // and report a very large number of matches.
1690
+ if (typeof value === 'object')
1691
+ return undefined;
1692
+ const key = String(value);
1693
+ // The empty string is "no key", not a key. A reference table writes it where a
1694
+ // code was missing (flip's `planId: row.planId ?? ""`), so treating it as a
1695
+ // value would make one keyless reference row the answer for every keyless
1696
+ // driving row.
1697
+ return key.length === 0 ? undefined : key;
1698
+ }
1699
+ /**
1700
+ * Every reason a lookup's configuration cannot be stored, as sentences, or empty.
1701
+ *
1702
+ * One function, called by {@link validateWorkflow}, by the HTTP boundary and by
1703
+ * the canvas, for the reason {@link renameColumnRefusals} is: a screen that
1704
+ * checked a target name against its own copy of the pattern is a screen that
1705
+ * eventually accepts something the server refuses, halfway through a save.
1706
+ *
1707
+ * All of them rather than the first, for the reason `refuseUnpublishablePropertyNames`
1708
+ * gives: a form filled in one sitting is usually wrong about several things in
1709
+ * the same way.
1710
+ *
1711
+ * The *wiring* rules — that {@link WorkflowLookupNode.reference} names an inbound
1712
+ * edge, and that something other than the reference is wired in — are not here,
1713
+ * and that is not an omission. They are facts about the graph rather than about
1714
+ * the node, so they cannot be answered from the node alone; `validateWorkflow`
1715
+ * owns them and the inspector reads them from `validateWorkflow`.
1716
+ */
1717
+ function lookupConfigRefusals(node) {
1718
+ const refusals = [];
1719
+ if (typeof node.key !== 'string' || node.key.length === 0) {
1720
+ refusals.push('It names no key column on the rows being enriched, so there is nothing to match on.');
1721
+ }
1722
+ if (typeof node.referenceKey !== 'string' || node.referenceKey.length === 0) {
1723
+ refusals.push('It names no key column on the reference rows, so there is nothing to match against.');
1724
+ }
1725
+ refusals.push(...lookupFieldRefusals(node.fields ?? {}));
1726
+ // Last, so a blank key has already been reported as a blank key rather than as
1727
+ // a collision with itself, and so this reads after the sentences about the map.
1728
+ if (typeof node.key === 'string' && node.key.length > 0) {
1729
+ const onto = Object.entries(node.fields ?? {})
1730
+ .filter(([, to]) => to === node.key)
1731
+ .map(([from]) => JSON.stringify(from));
1732
+ if (onto.length > 0) {
1733
+ refusals.push(`${onto.join(' and ')} would land as ${JSON.stringify(node.key)}, which is the column this node matches on. The key would be overwritten by the reference's copy of it on every row that matched and left alone on every row that did not, so afterwards the column would no longer say which rows were enriched.`);
1734
+ }
1735
+ }
1736
+ return refusals;
1737
+ }
1738
+ /**
1739
+ * What the grouping half can be wrong about, and the names it accepted.
1740
+ *
1741
+ * The accepted set is threaded out rather than recomputed, because the sharpest
1742
+ * refusal in the file needs both halves: an aggregate writing into a column the
1743
+ * node also groups on is one name holding two values, and only a reader that has
1744
+ * seen the group-by list can see it.
1745
+ */
1746
+ function groupByRefusals(groupBy, accepted) {
1747
+ const refusals = [];
1748
+ if (groupBy.length === 0) {
1749
+ refusals.push('It groups on nothing. With no group-by columns an aggregate returns exactly one row whether it read a billion records or none, so a run that summarised everything and a run that read an empty source commit the same thing. If a grand total is wanted, add a constant column in a transform above this node and group on it, so the one row is visible in the graph.');
1750
+ }
1751
+ if (groupBy.length > exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY) {
1752
+ refusals.push(`It groups on ${groupBy.length} columns, and at most ${exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY} may be named. Every extra column can only increase the number of groups, and a long list is the shape a grouping that holds the whole load arrives in.`);
1753
+ }
1754
+ for (const column of groupBy) {
1755
+ if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
1756
+ refusals.push(`It groups on ${JSON.stringify(column)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A group key comes out under the name it went in under, so a name this service cannot carry downstream is one that loads NULL into every row and reports success. A source whose own headers are spelled like ${JSON.stringify('Work Order Id')} is what a rename node above this one is for.`);
1757
+ continue;
1758
+ }
1759
+ if (accepted.has(column)) {
1760
+ refusals.push(`It groups on ${JSON.stringify(column)} twice. The second one changes no group and produces no second column, so it is either a typo or a column somebody meant to name instead.`);
1761
+ continue;
1762
+ }
1763
+ accepted.add(column);
1764
+ }
1765
+ return refusals;
1766
+ }
1767
+ /** What the computing half can be wrong about, given the group keys already accepted. */
1768
+ function aggregatesRefusals(aggregates, groupedOn) {
1769
+ const refusals = [];
1770
+ if (aggregates.length === 0) {
1771
+ refusals.push('It computes nothing. An aggregate with no functions is a node that emits the distinct combinations of its group-by columns and drops every other column of every row — which is a real operation and a completely different one. Reaching it by deleting the last row of a form is how a published type loses forty columns.');
1772
+ }
1773
+ if (aggregates.length > exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES) {
1774
+ refusals.push(`It computes ${aggregates.length} aggregates, and at most ${exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES} may be named in one node. Every one of them is an accumulator held per group, so this number multiplies the group ceiling in memory.`);
1775
+ }
1776
+ const names = new Map();
1777
+ for (const entry of aggregates) {
1778
+ if (typeof entry !== 'object' || entry === null) {
1779
+ refusals.push(`One aggregate is ${JSON.stringify(entry)} rather than a function, a column and a name.`);
1780
+ continue;
1781
+ }
1782
+ refusals.push(...oneAggregateRefusals(entry, groupedOn, names));
1783
+ }
1784
+ for (const [as, count] of names) {
1785
+ if (count < 2)
1786
+ continue;
1787
+ refusals.push(`${count} aggregates are written out as ${JSON.stringify(as)}. Two columns cannot share one name, and picking a winner would be a rule about which of somebody's numbers survives.`);
1788
+ }
1789
+ return refusals;
1790
+ }
1791
+ /** One entry of the list: its name, its function, its column, and its two optional fields. */
1792
+ function oneAggregateRefusals(entry, groupedOn, names) {
1793
+ const refusals = [];
1794
+ const as = Reflect.get(entry, 'as');
1795
+ const fn = Reflect.get(entry, 'fn');
1796
+ const column = Reflect.get(entry, 'column');
1797
+ const label = typeof as === 'string' && as.length > 0 ? JSON.stringify(as) : 'One aggregate';
1798
+ if (typeof as !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(as)) {
1799
+ refusals.push(`${label} is written out as ${JSON.stringify(as)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A load looks every field up as \`row[name]\`, so a column this service cannot name downstream is one that loads NULL into every row and reports success.`);
1800
+ }
1801
+ else {
1802
+ names.set(as, (names.get(as) ?? 0) + 1);
1803
+ if (groupedOn.has(as)) {
1804
+ refusals.push(`${label} writes into a column this node also groups on. One name cannot hold both the group key and a summary of the group, and picking a winner would be a rule about which of somebody's data survives.`);
1805
+ }
1806
+ }
1807
+ if (!isWorkflowAggregateFunction(fn)) {
1808
+ refusals.push(`${label} uses the function ${JSON.stringify(fn)}, which this service cannot compute. The functions are ${exports.WORKFLOW_AGGREGATE_FUNCTIONS.join(', ')}; anything else is a transform.`);
1809
+ return refusals;
1810
+ }
1811
+ if (workflowAggregateNeedsColumn(fn)) {
1812
+ if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
1813
+ refusals.push(`${label} reads ${JSON.stringify(column)} with ${fn}, and that is not a column name: letters, digits and underscore, starting with a letter or an underscore. Only \`count\` may go without a column, where it means how many records landed in the group.`);
1814
+ }
1815
+ }
1816
+ else if (column !== undefined && !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(String(column))) {
1817
+ refusals.push(`${label} counts ${JSON.stringify(column)}, and that is not a column name. Leave the column out to count the records in the group, or name one to count its non-null values.`);
1818
+ }
1819
+ refusals.push(...joinFieldRefusals(entry, fn, label));
1820
+ return refusals;
1821
+ }
1822
+ /**
1823
+ * The half of {@link lookupConfigRefusals} that a field map answers on its own.
1824
+ *
1825
+ * Split out because {@link isWorkflowLookupFields} is handed a map and nothing
1826
+ * else, and calling the whole function with invented key columns would either
1827
+ * report two refusals about keys it was not asked about, or — with plausible
1828
+ * stand-ins — report a collision between a real target and a made-up key. A
1829
+ * guard that fires on the value it was given plus two values it made up is a
1830
+ * guard that rejects a graph for a reason that is not in the graph.
1831
+ */
1832
+ function lookupFieldRefusals(fields) {
1833
+ const refusals = [];
1834
+ const entries = Object.entries(fields);
1835
+ if (entries.length === 0) {
1836
+ refusals.push('It brings no fields across. An empty list is refused rather than stored: it is a node that draws as configured, costs a pass over both sides and changes nothing — and with unmatched rows dropped it silently becomes a filter that deletes every row whose key is not in the reference.');
1837
+ }
1838
+ if (entries.length > exports.WORKFLOW_LOOKUP_MAX_FIELDS) {
1839
+ refusals.push(`It brings ${entries.length} fields across, and at most ${exports.WORKFLOW_LOOKUP_MAX_FIELDS} may come from one lookup. Every one of them is held per distinct key for the whole run, and past this the thing being expressed is a second dataset beside this one rather than an enrichment.`);
1840
+ }
1841
+ const targets = new Map();
1842
+ for (const [from, to] of entries) {
1843
+ if (from.length === 0) {
1844
+ refusals.push('One field names no column on the reference rows, so there is nothing for it to bring across.');
1845
+ continue;
1846
+ }
1847
+ if (typeof to !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(to)) {
1848
+ refusals.push(`${JSON.stringify(from)} would land as ${JSON.stringify(to)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A load looks every field up as \`row[name]\`, so a column this service cannot name downstream is one that loads NULL into every row and reports success — which is the failure this node exists to end, not to relocate.`);
1849
+ continue;
1850
+ }
1851
+ targets.set(to, [...(targets.get(to) ?? []), from]);
1852
+ }
1853
+ for (const [to, sources] of targets) {
1854
+ if (sources.length < 2)
1855
+ continue;
1856
+ refusals.push(`${sources.map((from) => JSON.stringify(from)).join(' and ')} would both land as ${JSON.stringify(to)}. Two columns cannot share one name, and picking a winner would be a rule about which of somebody's data survives.`);
1857
+ }
1858
+ return refusals;
1859
+ }
1860
+ /**
1861
+ * The two fields only `join` reads.
1862
+ *
1863
+ * Refused on any other function rather than ignored, which is the rule the whole
1864
+ * config follows: a field that only some functions read is a field somebody sets
1865
+ * on the wrong one and never finds out.
1866
+ */
1867
+ function joinFieldRefusals(entry, fn, label) {
1868
+ const refusals = [];
1869
+ const separator = Reflect.get(entry, 'separator');
1870
+ if (separator !== undefined) {
1871
+ if (fn !== 'join') {
1872
+ refusals.push(`${label} carries a separator and computes ${fn}, which has nothing to separate. A field that only some functions read is a field somebody will set on the wrong one and never find out.`);
1873
+ }
1874
+ else if (typeof separator !== 'string' ||
1875
+ separator.length > exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR) {
1876
+ refusals.push(`${label} joins with ${JSON.stringify(separator)}, and a separator has to be text of at most ${exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR} characters. Past that it is data rather than punctuation, and it is data repeated once per record.`);
1877
+ }
1878
+ }
1879
+ const maxLength = Reflect.get(entry, 'maxLength');
1880
+ if (maxLength === undefined)
1881
+ return refusals;
1882
+ if (fn !== 'join') {
1883
+ refusals.push(`${label} carries a maximum length and computes ${fn}, which produces no text to bound.`);
1884
+ return refusals;
1885
+ }
1886
+ if (typeof maxLength !== 'number' ||
1887
+ !Number.isInteger(maxLength) ||
1888
+ maxLength < 1 ||
1889
+ maxLength > exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING) {
1890
+ refusals.push(`${label} bounds its joined value at ${JSON.stringify(maxLength)}, and the bound has to be a whole number of characters between 1 and ${exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING}. Past that the value cannot be stored in the column it is going into, which is the same failure one layer further down.`);
1891
+ }
1892
+ return refusals;
1893
+ }
1894
+ /** The group ceiling, when the node set one. See {@link WORKFLOW_AGGREGATE_MAX_GROUPS}. */
1895
+ function ceilingRefusals(maxGroups) {
1896
+ if (maxGroups === undefined)
1897
+ return [];
1898
+ if (typeof maxGroups === 'number' &&
1899
+ Number.isInteger(maxGroups) &&
1900
+ maxGroups >= 1 &&
1901
+ maxGroups <= exports.WORKFLOW_AGGREGATE_GROUPS_CEILING) {
1902
+ return [];
1903
+ }
1904
+ return [
1905
+ `It caps itself at ${JSON.stringify(maxGroups)} groups, and the cap has to be a whole number between 1 and ${exports.WORKFLOW_AGGREGATE_GROUPS_CEILING}. Past that the answer is not a bigger number — it is that the grouping belongs in the source query, where the database has spill-to-disk and this process does not.`,
1906
+ ];
1907
+ }
1908
+ /**
1909
+ * Whether a stored aggregate list is one this build can run.
1910
+ *
1911
+ * Refused rather than repaired, the stance {@link isWorkflowRenameColumns} takes
1912
+ * and for the same reason one step further along: an aggregate list read back
1913
+ * with one entry silently dropped is a graph that commits a column of nulls
1914
+ * under a name somebody put in an object type on purpose.
1915
+ */
1916
+ function isWorkflowAggregates(value) {
1917
+ if (!Array.isArray(value))
1918
+ return false;
1919
+ return aggregateRefusals({ groupBy: ['x'], aggregates: value }).length === 0;
1920
+ }
1921
+ /**
1922
+ * Whether a stored field map is one this build can run.
1923
+ *
1924
+ * Refused rather than repaired, the stance {@link isWorkflowRenameColumns} takes
1925
+ * and for the same reason: a map read back with one entry silently dropped is a
1926
+ * graph that commits a column of NULLs under a name nobody can now explain.
1927
+ *
1928
+ * `Object.entries` rather than a `for…in`, so an inherited key cannot enter the
1929
+ * map. The key columns are not checked here because they are not this value;
1930
+ * {@link lookupConfigRefusals} is what sees the whole node.
1931
+ */
1932
+ function isWorkflowLookupFields(value) {
1933
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1934
+ return false;
1935
+ const entries = Object.entries(value);
1936
+ const fields = {};
1937
+ for (const [from, to] of entries) {
1938
+ if (typeof to !== 'string')
1939
+ return false;
1940
+ fields[from] = to;
1941
+ }
1942
+ // Only the field-shaped refusals can be answered from this value alone. See
1943
+ // {@link lookupFieldRefusals} for why the whole function is not called here.
1944
+ return lookupFieldRefusals(fields).length === 0;
1945
+ }
1309
1946
  /* --- reusable nodes ------------------------------------------------------ */
1310
1947
  /**
1311
1948
  * The node kinds that can be saved once and used in several graphs.
@@ -1364,6 +2001,18 @@ function isReusableNodeKind(value) {
1364
2001
  * *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
1365
2002
  * dropped into a graph reading a different system renames nothing at all, and
1366
2003
  * the symptom is a column of NULLs rather than a failure.
2004
+ * - `aggregate` — the same again, and it fails in both directions at once. Its
2005
+ * group-by columns and its inputs name one type's columns, so a shared one
2006
+ * groups a graph it was not written for on a column that is not there — which
2007
+ * collapses every record into one null-keyed group rather than erroring. And
2008
+ * its *output* column set is the thing downstream nodes are validated against,
2009
+ * so a shared node editable from elsewhere would silently change what another
2010
+ * graph's sink is allowed to write.
2011
+ * - `lookup` — that argument, and one that is not an argument at all but an
2012
+ * impossibility: {@link WorkflowLookupNode.reference} is **a node id in this
2013
+ * graph**. A shared body carrying one would name a node the adopting graph has
2014
+ * never had, and it is not a cosmetic field — it is the one that decides which
2015
+ * side of the join is held in memory.
1367
2016
  */
1368
2017
  exports.NODE_KIND_IS_REUSABLE = {
1369
2018
  source: true,
@@ -1373,6 +2022,8 @@ exports.NODE_KIND_IS_REUSABLE = {
1373
2022
  if: false,
1374
2023
  filter: false,
1375
2024
  rename: false,
2025
+ aggregate: false,
2026
+ lookup: false,
1376
2027
  };
1377
2028
  /** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
1378
2029
  function nodeKindIsReusable(kind) {
@@ -1794,6 +2445,42 @@ exports.WORKFLOW_ISSUE_CODES = [
1794
2445
  * {@link renameColumnRefusals} for the sentences.
1795
2446
  */
1796
2447
  'rename-invalid',
2448
+ /**
2449
+ * An aggregate this service will not store: grouping on nothing or on a name
2450
+ * a column cannot have, computing nothing, two aggregates sharing an output
2451
+ * name, a function this build cannot compute, a separator on something that
2452
+ * does not join.
2453
+ *
2454
+ * Every one of those is decidable from the node alone, and every one of them
2455
+ * is silent if it is let through — the two worst being an empty `groupBy`,
2456
+ * which commits exactly one row whether the source held everything or nothing,
2457
+ * and an empty `aggregates`, which drops every column the node does not group
2458
+ * on. See {@link aggregateRefusals} for the sentences.
2459
+ */
2460
+ 'aggregate-invalid',
2461
+ /**
2462
+ * A lookup whose configuration cannot be stored: no key on one side or the
2463
+ * other, no fields, too many, or a target that is not a column name, that two
2464
+ * reference columns share, or that would overwrite the key. All from the node
2465
+ * alone — see {@link lookupConfigRefusals} for the sentences.
2466
+ */
2467
+ 'lookup-invalid',
2468
+ /**
2469
+ * A lookup whose `reference` does not name one of its inbound edges.
2470
+ *
2471
+ * A code of its own rather than part of `lookup-invalid`, because it is a fact
2472
+ * about the *graph* and points at two boxes: this node and, when it exists, the
2473
+ * node it wrongly names. It is also the one lookup mistake that produces a
2474
+ * green run — with no reference rows to hold, every row comes out enriched with
2475
+ * nulls and the load commits.
2476
+ */
2477
+ 'lookup-reference-not-wired',
2478
+ /**
2479
+ * A lookup with nothing wired in but its reference. The reference is held as a
2480
+ * map and never passed on, so the node produces no rows and a full-mode sink
2481
+ * below it commits an empty snapshot over what is published.
2482
+ */
2483
+ 'lookup-nothing-to-enrich',
1797
2484
  /**
1798
2485
  * A node naming a column that nothing upstream can produce.
1799
2486
  *
@@ -1870,6 +2557,10 @@ function validateWorkflow(graph, knowledge) {
1870
2557
  const roots = nodes.filter(runsWithoutInput);
1871
2558
  const sinks = nodes.filter((node) => node.kind === 'sink');
1872
2559
  checkNodeWiring(nodes, incoming, outgoing, issues);
2560
+ // After `checkEdges` has returned, so every id named by an edge is a node that
2561
+ // exists and this cannot report a reference whose real problem is a wire
2562
+ // pointing at something deleted.
2563
+ checkLookupWiring(nodes, incoming, byId, issues);
1873
2564
  checkEndpoints(originators, sinks, issues);
1874
2565
  checkBranches(edges, byId, issues);
1875
2566
  checkPlainCallOutputs(nodes, outgoing, byId, issues);
@@ -2263,8 +2954,116 @@ function nodeIsUnconfigured(node) {
2263
2954
  return filterIsUnconfigured(node);
2264
2955
  if (node.kind === 'rename')
2265
2956
  return renameIsUnconfigured(node);
2957
+ if (node.kind === 'aggregate')
2958
+ return aggregateIsUnconfigured(node);
2959
+ if (node.kind === 'lookup')
2960
+ return lookupIsUnconfigured(node);
2266
2961
  return undefined;
2267
2962
  }
2963
+ /**
2964
+ * An aggregate this service will not store.
2965
+ *
2966
+ * The refusals come from {@link aggregateRefusals} rather than being restated
2967
+ * here, so the canvas, the HTTP boundary, the validator and the fold itself say
2968
+ * the same sentence about the same node. Every one of them is a *silent* failure
2969
+ * if it were let through, and the two most dangerous point in opposite
2970
+ * directions: no group-by columns commits exactly one row whatever the source
2971
+ * held, and no aggregates commits the distinct group keys with every other
2972
+ * column of every row gone.
2973
+ */
2974
+ function aggregateIsUnconfigured(node) {
2975
+ const refusals = aggregateRefusals(node);
2976
+ if (refusals.length === 0)
2977
+ return undefined;
2978
+ return {
2979
+ code: 'aggregate-invalid',
2980
+ nodeIds: [node.id],
2981
+ message: `Aggregate "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
2982
+ };
2983
+ }
2984
+ /**
2985
+ * A lookup whose configuration this service will not store.
2986
+ *
2987
+ * The refusals come from {@link lookupConfigRefusals} rather than being restated
2988
+ * here, so the canvas, the HTTP boundary and this validator say the same sentence
2989
+ * about the same node. Every one of them is a *silent* failure if let through: no
2990
+ * key column is a join that matches nothing, no fields is a node that either does
2991
+ * nothing or quietly becomes a filter, and a target this service cannot name
2992
+ * downstream loads NULL into every row and reports success.
2993
+ *
2994
+ * The wiring rules are next door in {@link checkLookupWiring}, because they are
2995
+ * facts about the graph rather than about the node — and because a lookup with
2996
+ * the reference edge missing has to point at *two* boxes, which is a thing an
2997
+ * `unconfigured` issue about one node cannot do.
2998
+ */
2999
+ function lookupIsUnconfigured(node) {
3000
+ const refusals = lookupConfigRefusals(node);
3001
+ if (refusals.length === 0)
3002
+ return undefined;
3003
+ return {
3004
+ code: 'lookup-invalid',
3005
+ nodeIds: [node.id],
3006
+ message: `Lookup "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
3007
+ };
3008
+ }
3009
+ /**
3010
+ * A lookup's two edges: the reference it holds, and something to enrich.
3011
+ *
3012
+ * ## Why this is refused here rather than discovered at run time
3013
+ *
3014
+ * Both failures produce a run that finishes. A lookup whose `reference` names a
3015
+ * node that is not wired into it has no reference rows at all, so under the
3016
+ * default disposition every row is enriched with nulls and the load commits —
3017
+ * which is the exact defect this node was built to end, arrived at through the
3018
+ * wiring instead of through the data. A lookup with *only* the reference wired in
3019
+ * has nothing to enrich, so it produces zero rows and a full-mode sink below it
3020
+ * commits an empty snapshot over whatever was live.
3021
+ *
3022
+ * Named by id rather than taken from edge order, and {@link WorkflowLookupNode}
3023
+ * argues why at length: edge order decides which side is held in memory, and
3024
+ * reordering two wires is invisible on a canvas.
3025
+ */
3026
+ function checkLookupWiring(nodes, incoming, byId, issues) {
3027
+ for (const node of nodes) {
3028
+ if (node.kind !== 'lookup')
3029
+ continue;
3030
+ const feeds = incoming.get(node.id) ?? [];
3031
+ const named = typeof node.reference === 'string' ? node.reference : '';
3032
+ if (named.length === 0 || !feeds.includes(named)) {
3033
+ issues.push(lookupReferenceNotWired(node, named, feeds, byId));
3034
+ continue;
3035
+ }
3036
+ if (feeds.length < 2) {
3037
+ issues.push({
3038
+ code: 'lookup-nothing-to-enrich',
3039
+ nodeIds: [node.id, named],
3040
+ message: `Lookup "${node.name}" (${node.id}) has only its reference wired into it. The reference is held as a map and is not passed on, so this node would produce no rows at all and a full-mode sink below it would commit an empty snapshot over whatever is published. Wire in the rows you want enriched as well.`,
3041
+ });
3042
+ }
3043
+ }
3044
+ }
3045
+ /**
3046
+ * The sentence for a reference that is not one of this node's inbound edges.
3047
+ *
3048
+ * Its own function because it has three cases to name — a reference that is
3049
+ * blank, one that names a node in the graph, and one that names nothing at all —
3050
+ * and each has to say something different for the message to be worth reading.
3051
+ * The last two are genuinely different mistakes: the first is a wire that was
3052
+ * never drawn, the second is an id that was typed or that survived a node being
3053
+ * deleted.
3054
+ */
3055
+ function lookupReferenceNotWired(node, named, feeds, byId) {
3056
+ const known = byId.get(named);
3057
+ const names = named.length === 0 ? 'no node at all' : known ? `"${known.name}" (${named})` : `"${named}"`;
3058
+ const wired = feeds.length === 0
3059
+ ? 'Nothing is wired into it.'
3060
+ : `What is wired into it is ${listNodes(feeds, byId)}.`;
3061
+ return {
3062
+ code: 'lookup-reference-not-wired',
3063
+ nodeIds: known ? [node.id, named] : [node.id],
3064
+ message: `Lookup "${node.name}" (${node.id}) takes its reference rows from ${names}, which is not wired into it. ${wired} A lookup holds one of its inputs in memory as a map and streams the rest past it, so it has to be told which one — and with no reference rows to hold, every row would come out enriched with nulls and the load would commit.`,
3065
+ };
3066
+ }
2268
3067
  /**
2269
3068
  * A rename whose map this service will not store.
2270
3069
  *
@@ -2797,25 +3596,8 @@ function workflowGraphHash(graph) {
2797
3596
  }
2798
3597
  /** The parts of a node that change what a run produces. */
2799
3598
  function canonicalNode(node) {
2800
- if (node.kind === 'source') {
2801
- return JSON.stringify([
2802
- node.id,
2803
- node.kind,
2804
- node.sourceKind,
2805
- node.connectionId ?? '',
2806
- node.secretEnvVar ?? '',
2807
- node.mode ?? 'full',
2808
- // Sorted keys, so a canvas that rewrites the object in a different order
2809
- // does not look like an edit.
2810
- sortedEntries(node.config),
2811
- // Appended only when there is a reference, exactly as `edge.branch` above
2812
- // is appended only when there is a label, and for the same reason: adding
2813
- // reusable nodes to this file must not renumber the version of a single
2814
- // graph that did not change. Every source drawn before they existed
2815
- // hashes to the string it always did.
2816
- ...canonicalReuse(node),
2817
- ]);
2818
- }
3599
+ if (node.kind === 'source')
3600
+ return canonicalSource(node);
2819
3601
  if (node.kind === 'transform') {
2820
3602
  // The transform's *version as stored* is deliberately not in here, and that
2821
3603
  // has not changed: editing a transform is recorded as a new transform
@@ -2875,6 +3657,10 @@ function canonicalNode(node) {
2875
3657
  }
2876
3658
  if (node.kind === 'rename')
2877
3659
  return canonicalRename(node);
3660
+ if (node.kind === 'aggregate')
3661
+ return canonicalAggregate(node);
3662
+ if (node.kind === 'lookup')
3663
+ return canonicalLookup(node);
2878
3664
  if (node.kind === 'sink') {
2879
3665
  return JSON.stringify([
2880
3666
  node.id,
@@ -2886,6 +3672,34 @@ function canonicalNode(node) {
2886
3672
  }
2887
3673
  return unreachableNodeKind(node, 'workflowGraphHash');
2888
3674
  }
3675
+ /**
3676
+ * A source, canonicalised.
3677
+ *
3678
+ * Its own function, beside the ones every other configured kind already has, for
3679
+ * the reason `toTransformNode` is one package over: `canonicalNode` is a
3680
+ * dispatcher, and a dispatcher that also carries the longest of its cases inline
3681
+ * is where the next case gets written inline too. Nothing about what is hashed
3682
+ * changed with the move — this is the string it always produced.
3683
+ */
3684
+ function canonicalSource(node) {
3685
+ return JSON.stringify([
3686
+ node.id,
3687
+ node.kind,
3688
+ node.sourceKind,
3689
+ node.connectionId ?? '',
3690
+ node.secretEnvVar ?? '',
3691
+ node.mode ?? 'full',
3692
+ // Sorted keys, so a canvas that rewrites the object in a different order
3693
+ // does not look like an edit.
3694
+ sortedEntries(node.config),
3695
+ // Appended only when there is a reference, exactly as `edge.branch` above
3696
+ // is appended only when there is a label, and for the same reason: adding
3697
+ // reusable nodes to this file must not renumber the version of a single
3698
+ // graph that did not change. Every source drawn before they existed
3699
+ // hashes to the string it always did.
3700
+ ...canonicalReuse(node),
3701
+ ]);
3702
+ }
2889
3703
  /**
2890
3704
  * A rename, canonicalised.
2891
3705
  *
@@ -2908,6 +3722,79 @@ function canonicalRename(node) {
2908
3722
  ...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
2909
3723
  ]);
2910
3724
  }
3725
+ /**
3726
+ * An aggregate, canonicalised.
3727
+ *
3728
+ * **`groupBy` is sorted and the aggregates are sorted by output name**, because
3729
+ * neither order changes what the node computes: a set of group-by columns
3730
+ * defines the same groups whatever order they are listed in, and two aggregates
3731
+ * are independent of each other. Order *is* what the output columns come out in,
3732
+ * and a record's key order is not something anything downstream reads — the sink
3733
+ * looks every property up by name. So reordering rows in the inspector is not an
3734
+ * edit and does not bump a version.
3735
+ *
3736
+ * Each aggregate's optional fields are appended **only when set**, exactly as
3737
+ * `edge.branch` and a rename's `unnamed` are, so the default has one spelling.
3738
+ * The point of that rule is the same one every time: adding a node kind to this
3739
+ * file must not renumber a graph that did not change. Here it holds for a
3740
+ * stronger reason as well — no stored graph contains an aggregate node at all,
3741
+ * because this release is the first one in which such a node can be saved, so
3742
+ * every existing graph's canonical string is byte-identical to what it was.
3743
+ */
3744
+ function canonicalAggregate(node) {
3745
+ const groupBy = [...(node.groupBy ?? [])].sort();
3746
+ const aggregates = [...(node.aggregates ?? [])]
3747
+ .sort((left, right) => (left.as < right.as ? -1 : left.as > right.as ? 1 : 0))
3748
+ .map((each) => [
3749
+ each.as,
3750
+ each.fn,
3751
+ each.column ?? '',
3752
+ ...(each.separator === undefined ? [] : [each.separator]),
3753
+ ...(each.maxLength === undefined ? [] : [each.maxLength]),
3754
+ ]);
3755
+ return JSON.stringify([
3756
+ node.id,
3757
+ node.kind,
3758
+ groupBy,
3759
+ aggregates,
3760
+ // In the fingerprint because it decides whether a run finishes or refuses,
3761
+ // which is a difference between two runs of "the same" graph worth being
3762
+ // able to point at. Appended only when set, so the default has one spelling.
3763
+ ...(node.maxGroups === undefined ? [] : [node.maxGroups]),
3764
+ ]);
3765
+ }
3766
+ /**
3767
+ * A lookup, canonicalised.
3768
+ *
3769
+ * `reference` is in here and it is the field somebody would be tempted to leave
3770
+ * out, because it names a node rather than describing an operation. It has to be
3771
+ * in: repointing the reference edge at a different node is a change to what the
3772
+ * load produces on every row, and it is one of the few edits that leaves the
3773
+ * canvas looking identical.
3774
+ *
3775
+ * The fields are sorted by reference column, safe for the reason a rename's map
3776
+ * is: the whole map is applied to one row at once, so its order changes nothing
3777
+ * about the result and a canvas rewriting the object in another order is not an
3778
+ * edit.
3779
+ *
3780
+ * `unmatched` is appended only when it is not the default, exactly as
3781
+ * `edge.branch` is appended only when there is a label. That rule buys nothing
3782
+ * today — no stored graph has a lookup in it, since the kind is new — and it is
3783
+ * followed anyway, because the version *after* this one is where a default gets
3784
+ * normalised onto the node by some canvas and renumbers every graph that has one.
3785
+ */
3786
+ function canonicalLookup(node) {
3787
+ const unmatched = workflowLookupUnmatched(node);
3788
+ return JSON.stringify([
3789
+ node.id,
3790
+ node.kind,
3791
+ node.reference,
3792
+ node.key,
3793
+ node.referenceKey,
3794
+ sortedEntries(node.fields),
3795
+ ...(unmatched === 'null' ? [] : [unmatched]),
3796
+ ]);
3797
+ }
2911
3798
  /**
2912
3799
  * The reusable reference, as zero, one or two trailing hash components.
2913
3800
  *
@@ -3092,7 +3979,7 @@ function workflowFilterColumns(predicate) {
3092
3979
  * cyclic graph before it gets here, but the canvas calls this while a graph is
3093
3980
  * being drawn and is entitled to a wrong-but-terminating answer.
3094
3981
  */
3095
- function workflowKnownColumns(graph, nodeId, knowledge) {
3982
+ function workflowKnownColumns(graph, nodeId, knowledge, onlyFrom) {
3096
3983
  const nodes = graph.nodes ?? [];
3097
3984
  const byId = new Map(nodes.map((node) => [node.id, node]));
3098
3985
  const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
@@ -3109,13 +3996,16 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
3109
3996
  if (!node)
3110
3997
  return undefined;
3111
3998
  open.add(id);
3112
- const produced = producedColumns(node, () => intoNode(id), knowledge);
3999
+ const produced = producedColumns(node, (only) => intoNode(id, only), knowledge);
3113
4000
  open.delete(id);
4001
+ // Memoised without the filter, and safe because the filter is only ever
4002
+ // applied at the node being *asked about*: `outOf` answers what a node
4003
+ // passes on, which does not depend on who is asking.
3114
4004
  answered.set(id, produced);
3115
4005
  return produced;
3116
4006
  };
3117
- const intoNode = (id) => {
3118
- const feeds = incoming.get(id) ?? [];
4007
+ const intoNode = (id, only) => {
4008
+ const feeds = (incoming.get(id) ?? []).filter((from) => only?.(from) ?? true);
3119
4009
  if (feeds.length === 0)
3120
4010
  return undefined;
3121
4011
  const union = new Set();
@@ -3131,7 +4021,28 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
3131
4021
  }
3132
4022
  return union;
3133
4023
  };
3134
- return intoNode(nodeId);
4024
+ return intoNode(nodeId, onlyFrom);
4025
+ }
4026
+ /**
4027
+ * The two column sets a lookup sees, told apart.
4028
+ *
4029
+ * Exported because three callers need the same split and each one getting it
4030
+ * right separately is how they come to disagree: the validator refuses a key
4031
+ * column that is not on the driving side, the walk answers what the node passes
4032
+ * on, and the inspector says both out loud on the screen where the columns are
4033
+ * typed.
4034
+ *
4035
+ * Either side answers `undefined` for the ordinary reason — see
4036
+ * {@link workflowKnownColumns} — and `undefined` must not be read as empty.
4037
+ */
4038
+ function workflowLookupColumns(graph, node, knowledge) {
4039
+ const named = typeof node.reference === 'string' ? node.reference : '';
4040
+ return {
4041
+ driving: workflowKnownColumns(graph, node.id, knowledge, (from) => from !== named),
4042
+ reference: named.length === 0
4043
+ ? undefined
4044
+ : workflowKnownColumns(graph, node.id, knowledge, (from) => from === named),
4045
+ };
3135
4046
  }
3136
4047
  /**
3137
4048
  * What one node passes on, given what reaches it.
@@ -3146,17 +4057,17 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
3146
4057
  * *safe* wrong answer and would therefore never be noticed.
3147
4058
  */
3148
4059
  function producedColumns(node, upstream, knowledge) {
3149
- if (node.kind === 'rename') {
3150
- if (workflowRenameUnnamed(node) === 'drop')
3151
- return new Set(Object.values(node.columns ?? {}));
3152
- const known = upstream();
3153
- if (known === undefined)
3154
- return undefined;
3155
- const renamed = new Set();
3156
- for (const column of known)
3157
- renamed.add(node.columns?.[column] ?? column);
3158
- return renamed;
3159
- }
4060
+ if (node.kind === 'lookup')
4061
+ return lookupProducedColumns(node, upstream);
4062
+ if (node.kind === 'rename')
4063
+ return renameProducedColumns(node, upstream);
4064
+ // The one kind whose output set is *exact* rather than an upper bound, and it
4065
+ // is exact without looking upstream at all: an aggregate emits its group-by
4066
+ // columns and its named aggregates on every record it produces, whatever it
4067
+ // was handed and whatever the values turned out to be. See
4068
+ // {@link workflowAggregateOutputColumns} for the one thing it does not claim.
4069
+ if (node.kind === 'aggregate')
4070
+ return new Set(workflowAggregateOutputColumns(node));
3160
4071
  // Neither of these touches a column: a filter decides which *rows* survive and
3161
4072
  // an `if` decides which *nodes* run. Both hand on exactly the shape they were
3162
4073
  // given, which is what makes a closed set survive one.
@@ -3173,6 +4084,50 @@ function producedColumns(node, upstream, knowledge) {
3173
4084
  }
3174
4085
  return unreachableNodeKind(node, 'workflowKnownColumns');
3175
4086
  }
4087
+ /**
4088
+ * What a rename passes on, given what reaches it.
4089
+ *
4090
+ * The one that makes the upstream argument a thunk: a rename that drops its
4091
+ * unnamed columns answers from its own config, so the walk stops here rather
4092
+ * than climbing to a source it would learn nothing from.
4093
+ */
4094
+ function renameProducedColumns(node, upstream) {
4095
+ if (workflowRenameUnnamed(node) === 'drop')
4096
+ return new Set(Object.values(node.columns ?? {}));
4097
+ const known = upstream();
4098
+ if (known === undefined)
4099
+ return undefined;
4100
+ const renamed = new Set();
4101
+ for (const column of known)
4102
+ renamed.add(node.columns?.[column] ?? column);
4103
+ return renamed;
4104
+ }
4105
+ /**
4106
+ * What a lookup passes on, which is not what it was given.
4107
+ *
4108
+ * The reference side is asked *not* to contribute, and that is the whole reason
4109
+ * {@link producedColumns} hands its caller a filter. Its rows are held as a map
4110
+ * and never passed on, so a column that only exists over there is not a column
4111
+ * anything below this node can see — and a set that claimed otherwise would make
4112
+ * the validator accept a filter that matches no row, which is the precise failure
4113
+ * `checkColumnsProduced` exists to catch.
4114
+ *
4115
+ * Exact rather than an upper bound, and that is the payoff of the config being
4116
+ * data: what leaves a lookup is what arrived plus the names it was told to add,
4117
+ * and nothing else can appear. Under `unmatched: 'null'` and `'fail'` every row
4118
+ * carries every target; under `'drop'` the rows that would not have are gone, so
4119
+ * it holds there too.
4120
+ */
4121
+ function lookupProducedColumns(node, upstream) {
4122
+ const named = typeof node.reference === 'string' ? node.reference : '';
4123
+ const driving = upstream((from) => from !== named);
4124
+ if (driving === undefined)
4125
+ return undefined;
4126
+ const enriched = new Set(driving);
4127
+ for (const to of Object.values(node.fields ?? {}))
4128
+ enriched.add(to);
4129
+ return enriched;
4130
+ }
3176
4131
  /**
3177
4132
  * What a source produces, per source kind.
3178
4133
  *
@@ -3243,33 +4198,143 @@ function checkColumnsProduced(graph, issues, knowledge) {
3243
4198
  // Narrowed off the union rather than tested with a property check, so a kind
3244
4199
  // that starts naming columns without being answered for here is a type error
3245
4200
  // at `missingColumnMessage` and not a check that silently passes.
3246
- if (node.kind !== 'filter' && node.kind !== 'rename')
3247
- continue;
3248
- const named = node.kind === 'filter'
3249
- ? workflowFilterColumns(node.predicate)
3250
- : Object.keys(node.columns ?? {});
3251
- if (named.length === 0)
3252
- continue;
3253
- const known = workflowKnownColumns(graph, node.id, knowledge);
3254
- if (known === undefined)
3255
- continue;
3256
- const missing = named.filter((column) => column.length > 0 && !known.has(column));
3257
- if (missing.length === 0)
3258
- continue;
4201
+ if (node.kind === 'lookup')
4202
+ checkLookupColumns(graph, node, issues, knowledge);
4203
+ else if (node.kind === 'filter' || node.kind === 'rename' || node.kind === 'aggregate') {
4204
+ checkNamedColumns(graph, node, issues, knowledge);
4205
+ }
4206
+ }
4207
+ }
4208
+ /**
4209
+ * The columns a node names, per kind that names any.
4210
+ *
4211
+ * Its own function so the union it takes and the union
4212
+ * {@link missingColumnMessage} takes are the same three kinds written down
4213
+ * twice — which is what makes a fourth kind that starts naming columns a type
4214
+ * error in both places rather than a check that silently passes.
4215
+ */
4216
+ function columnsNamedBy(node) {
4217
+ if (node.kind === 'filter')
4218
+ return workflowFilterColumns(node.predicate);
4219
+ if (node.kind === 'rename')
4220
+ return Object.keys(node.columns ?? {});
4221
+ return workflowAggregateColumns(node);
4222
+ }
4223
+ /**
4224
+ * The columns a filter, a rename or an aggregate names, checked against
4225
+ * everything reaching the node.
4226
+ *
4227
+ * The union is the right set for these three, and it is the whole difference
4228
+ * from {@link checkLookupColumns}: the rows a multi-input node receives arrive
4229
+ * concatenated, so a column any one of its inputs carries is a column it can
4230
+ * see. A lookup is the one kind that is not like that — its reference is held as
4231
+ * a map and never passed on — which is why it is dispatched away from here
4232
+ * rather than folded in.
4233
+ */
4234
+ function checkNamedColumns(graph, node, issues, knowledge) {
4235
+ const named = columnsNamedBy(node);
4236
+ if (named.length === 0)
4237
+ return;
4238
+ const known = workflowKnownColumns(graph, node.id, knowledge);
4239
+ if (known === undefined)
4240
+ return;
4241
+ const missing = named.filter((column) => column.length > 0 && !known.has(column));
4242
+ if (missing.length === 0)
4243
+ return;
4244
+ issues.push({
4245
+ code: 'column-not-produced',
4246
+ nodeIds: [node.id],
4247
+ message: missingColumnMessage(node, missing, known),
4248
+ });
4249
+ }
4250
+ /**
4251
+ * A lookup's columns, checked against the side each of them lives on.
4252
+ *
4253
+ * Its own function rather than a branch inside {@link checkColumnsProduced},
4254
+ * because a lookup is the one node whose named columns do not all come from one
4255
+ * place: `key` is on the rows being enriched, and `referenceKey` and every field
4256
+ * source are on the reference. Folding it into the loop above would have meant
4257
+ * checking both against the union — which is *worse than not checking*, because
4258
+ * the union would accept a key column that only exists on the reference. That
4259
+ * join matches nothing on every row and the load commits, which is what this node
4260
+ * is for.
4261
+ *
4262
+ * What is deliberately **not** checked here is a field landing on a name the
4263
+ * driving rows already carry. See {@link checkLookupDriving}.
4264
+ *
4265
+ * Every check is skipped where the set is unknown, for the reason the walk
4266
+ * answers `undefined` rather than empty: silence is correct here, and refusing a
4267
+ * column the graph merely has no opinion about would make every lookup below a
4268
+ * transform unsaveable.
4269
+ */
4270
+ function checkLookupColumns(graph, node, issues, knowledge) {
4271
+ const { driving, reference } = workflowLookupColumns(graph, node, knowledge);
4272
+ if (driving !== undefined)
4273
+ checkLookupDriving(node, driving, issues);
4274
+ if (reference !== undefined)
4275
+ checkLookupReference(node, reference, issues);
4276
+ }
4277
+ /** `"a", "b"`, for a message that has to name a set of columns. */
4278
+ function quotedColumns(names) {
4279
+ return [...names].map((column) => JSON.stringify(column)).join(', ');
4280
+ }
4281
+ /**
4282
+ * The key this lookup matches on, against the rows it enriches.
4283
+ *
4284
+ * The *driving* set specifically, and pooling the inputs would make this wrong in
4285
+ * the dangerous direction: a key column that exists only on the reference would
4286
+ * be accepted here and then match nothing on every row.
4287
+ *
4288
+ * ## Why a name the driving rows already carry is not refused here
4289
+ *
4290
+ * Because *that is the normal case*, and refusing it made the node unable to do
4291
+ * the one thing it was built for. A published object type declares the columns it
4292
+ * holds, so a graph that reads a type back to enrich it receives every one of
4293
+ * those columns — `SubwoReplica` hands over 44,720 rows all carrying `planName`,
4294
+ * `planDescription` and `unitMel` with `null` in them, which is precisely the
4295
+ * three columns the lookup exists to fill. Whether a column holds a *value* is a
4296
+ * fact about the data and not about the graph, so the run decides it, per row:
4297
+ * empty is filled and occupied fails the node, naming the row. See
4298
+ * `withLookupFields`.
4299
+ */
4300
+ function checkLookupDriving(node, driving, issues) {
4301
+ if (typeof node.key === 'string' && node.key.length > 0 && !driving.has(node.key)) {
3259
4302
  issues.push({
3260
4303
  code: 'column-not-produced',
3261
4304
  nodeIds: [node.id],
3262
- message: missingColumnMessage(node, missing, known),
4305
+ message: `Lookup "${node.name}" (${node.id}) matches on ${quotedColumns([node.key])}, and nothing feeding the rows it enriches produces that column. Something above this node closes the column set — a rename that drops what it does not name, or a source reading a published object type — so what reaches here is exactly ${quotedColumns(driving)}. A key column that is not there has no key on any row, so nothing would match and every row would come out with the enriched columns null.`,
3263
4306
  });
3264
4307
  }
3265
4308
  }
4309
+ /** The reference key and every field source, against the reference side only. */
4310
+ function checkLookupReference(node, reference, issues) {
4311
+ const wanted = [
4312
+ ...(typeof node.referenceKey === 'string' && node.referenceKey.length > 0
4313
+ ? [node.referenceKey]
4314
+ : []),
4315
+ ...Object.keys(node.fields ?? {}).filter((from) => from.length > 0),
4316
+ ];
4317
+ const missing = wanted.filter((column) => !reference.has(column));
4318
+ if (missing.length === 0)
4319
+ return;
4320
+ issues.push({
4321
+ code: 'column-not-produced',
4322
+ nodeIds: [node.id, node.reference],
4323
+ message: `Lookup "${node.name}" (${node.id}) reads ${quotedColumns(missing)} off its reference rows, and nothing on that side produces ${missing.length === 1 ? 'that column' : 'those columns'} — what reaches it is exactly ${quotedColumns(reference)}. A reference key that is not there means no reference row has a key, so nothing matches; a field that is not there lands as undefined on every row that did.`,
4324
+ });
4325
+ }
3266
4326
  /** The sentence {@link checkColumnsProduced} says, per kind. */
3267
4327
  function missingColumnMessage(node, missing, known) {
3268
4328
  const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
3269
4329
  const consequence = node.kind === 'filter'
3270
4330
  ? 'A test on a column that is not there matches no row — not even a "does not equal" test — so this load would come out empty and every node would report success.'
3271
- : 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.';
3272
- return `${node.kind === 'filter' ? 'Filter' : 'Rename'} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. Something above this node closes the column set — a rename that drops what it does not name, or a source reading a published object type so what reaches here is exactly ${quoted(known)}. ${consequence}`;
4331
+ : node.kind === 'rename'
4332
+ ? 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.'
4333
+ : // Both halves of an aggregate fail silently, and they fail differently,
4334
+ // which is why this sentence names both rather than picking one.
4335
+ 'Grouping on a column that is not there puts every record into one null-keyed group, so a summary of sixteen thousand work orders comes out as a single row. Aggregating one that is not there answers null for every group, which a sink commits as a column of NULLs. Neither reports an error.';
4336
+ const label = node.kind === 'filter' ? 'Filter' : node.kind === 'rename' ? 'Rename' : 'Aggregate';
4337
+ return `${label} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. Something above this node closes the column set — a rename that drops what it does not name, an aggregate, or a source reading a published object type — so what reaches here is exactly ${quoted(known)}. ${consequence}`;
3273
4338
  }
3274
4339
  function sortedEntries(config) {
3275
4340
  return Object.keys(config)
@@ -3328,13 +4393,27 @@ function isWorkflowNode(value) {
3328
4393
  }
3329
4394
  if (kind === 'rename')
3330
4395
  return isRenameNodeShape(value);
3331
- if (kind === 'source') {
3332
- const sourceKind = Reflect.get(value, 'sourceKind');
3333
- const config = Reflect.get(value, 'config');
3334
- return isConnectorKind(sourceKind) && typeof config === 'object' && config !== null;
3335
- }
4396
+ if (kind === 'aggregate')
4397
+ return isAggregateNodeShape(value);
4398
+ if (kind === 'lookup')
4399
+ return isLookupNodeShape(value);
4400
+ if (kind === 'source')
4401
+ return isSourceNodeShape(value);
3336
4402
  return isWorkflowNodeKindUnhandled(kind);
3337
4403
  }
4404
+ /**
4405
+ * Everything a `source` node carries.
4406
+ *
4407
+ * Its own function beside the four kinds that already have one, and for the
4408
+ * reason those have one: {@link isWorkflowNode} is a narrowing dispatcher, and a
4409
+ * dispatcher that also carries its cases inline stops being readable at exactly
4410
+ * the point a new kind is added to it.
4411
+ */
4412
+ function isSourceNodeShape(value) {
4413
+ const sourceKind = Reflect.get(value, 'sourceKind');
4414
+ const config = Reflect.get(value, 'config');
4415
+ return isConnectorKind(sourceKind) && typeof config === 'object' && config !== null;
4416
+ }
3338
4417
  /**
3339
4418
  * Everything a `rename` node carries.
3340
4419
  *
@@ -3351,6 +4430,52 @@ function isRenameNodeShape(value) {
3351
4430
  return false;
3352
4431
  return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
3353
4432
  }
4433
+ /**
4434
+ * Everything an `aggregate` node carries, through the one refusal list.
4435
+ *
4436
+ * The whole node rather than field by field, so a stored aggregate is read back
4437
+ * under exactly the rule that would have refused to store it. Refused rather
4438
+ * than repaired, the stance every guard in this file takes: an aggregate read
4439
+ * back with one entry silently dropped is a load that commits a column of nulls
4440
+ * under a name somebody put in an object type on purpose, and one read back with
4441
+ * its `groupBy` dropped is a load that commits a single row.
4442
+ */
4443
+ function isAggregateNodeShape(value) {
4444
+ return (aggregateRefusals({
4445
+ groupBy: Reflect.get(value, 'groupBy'),
4446
+ aggregates: Reflect.get(value, 'aggregates'),
4447
+ maxGroups: Reflect.get(value, 'maxGroups'),
4448
+ }).length === 0);
4449
+ }
4450
+ /**
4451
+ * Everything a `lookup` node carries.
4452
+ *
4453
+ * `reference` is checked as a non-empty string and no further, because whether
4454
+ * it names a node that is actually wired in is a fact about the graph and this
4455
+ * guard sees one node. `validateWorkflow` owns that, and refuses it loudly.
4456
+ *
4457
+ * An unrecognised `unmatched` is refused rather than defaulted, and this is the
4458
+ * one where defaulting would be worst of the three kinds that have the same
4459
+ * field: reading an unknown word back as `null` would turn a `fail` — which
4460
+ * somebody chose because the reference is a prerequisite — into a load that
4461
+ * commits nulls and reports success.
4462
+ */
4463
+ function isLookupNodeShape(value) {
4464
+ const unmatched = Reflect.get(value, 'unmatched');
4465
+ if (unmatched !== undefined && !isWorkflowLookupUnmatched(unmatched))
4466
+ return false;
4467
+ const reference = Reflect.get(value, 'reference');
4468
+ if (typeof reference !== 'string' || reference.length === 0)
4469
+ return false;
4470
+ // Read into an `unknown` before the guard, so the narrowing that follows is
4471
+ // the guard's and not an assertion dressed as one.
4472
+ const fields = Reflect.get(value, 'fields');
4473
+ if (!isWorkflowLookupFields(fields))
4474
+ return false;
4475
+ const key = Reflect.get(value, 'key');
4476
+ const referenceKey = Reflect.get(value, 'referenceKey');
4477
+ return lookupConfigRefusals({ key, referenceKey, fields }).length === 0;
4478
+ }
3354
4479
  /**
3355
4480
  * Everything a `call` node carries, checked as strictly as a source's.
3356
4481
  *