@dudousxd/nestjs-catalog 0.26.0 → 0.27.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_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_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,16 @@ 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.workflowAggregateMaxGroups = workflowAggregateMaxGroups;
50
+ exports.workflowAggregateSeparator = workflowAggregateSeparator;
51
+ exports.workflowAggregateJoinMaxLength = workflowAggregateJoinMaxLength;
52
+ exports.workflowAggregateNeedsColumn = workflowAggregateNeedsColumn;
53
+ exports.workflowAggregateColumns = workflowAggregateColumns;
54
+ exports.workflowAggregateOutputColumns = workflowAggregateOutputColumns;
55
+ exports.aggregateRefusals = aggregateRefusals;
56
+ exports.isWorkflowAggregates = isWorkflowAggregates;
47
57
  exports.isReusableNodeKind = isReusableNodeKind;
48
58
  exports.nodeKindIsReusable = nodeKindIsReusable;
49
59
  exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
@@ -532,6 +542,8 @@ exports.WORKFLOW_NODE_KINDS = [
532
542
  'filter',
533
543
  /** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
534
544
  'rename',
545
+ /** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
546
+ 'aggregate',
535
547
  ];
536
548
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
537
549
  function isWorkflowNodeKind(value) {
@@ -1306,6 +1318,403 @@ function isWorkflowRenameColumns(value) {
1306
1318
  }
1307
1319
  return false;
1308
1320
  }
1321
+ /* --- aggregate ----------------------------------------------------------- */
1322
+ /**
1323
+ * The aggregate functions this node computes, and the rule that closes the list.
1324
+ *
1325
+ * A closed list with an exhaustiveness guard, for the reason every other list in
1326
+ * this file is one. What is different here is that the list has a **stated
1327
+ * admission rule**, because "we will keep it narrow" is a promise nobody can
1328
+ * check and a rule is:
1329
+ *
1330
+ * > A function is in if it can be computed from a **fixed-size accumulator**,
1331
+ * > and if its answer does not depend on a decision the config would have to
1332
+ * > carry.
1333
+ *
1334
+ * The first half is the node's whole reason to exist. A hash aggregate is cheap
1335
+ * because it holds one entry per group; an accumulator whose size grows with the
1336
+ * number of *rows* in a group puts the rows back in memory and gives up the
1337
+ * property. The second half is what keeps the config from becoming a small
1338
+ * language: a function that needs an extra field to say what it means is a
1339
+ * function whose meaning was not decided.
1340
+ *
1341
+ * What the rule excludes, so the omissions are on the record rather than
1342
+ * implied:
1343
+ *
1344
+ * - **`countDistinct`** — the sharpest one. It needs a set of the distinct
1345
+ * values *per group per column*, so its accumulator is O(distinct values) and
1346
+ * a high-cardinality column inside a group holds the load. It is the exact
1347
+ * thing this node was built to stop doing, wearing an aggregate's name. A
1348
+ * sketch (HyperLogLog) is fixed-size and is a different function — an
1349
+ * estimate — which is not something to ship under the word `distinct`.
1350
+ * - **`median`, percentiles, `stddev` of a stream** — all need the values, or a
1351
+ * digest that is an approximation with an error bound the config would have to
1352
+ * carry.
1353
+ * - **`first` / `last`** — fixed-size, and excluded on the other half of the
1354
+ * rule: they mean "in input order", and this node's input order is a
1355
+ * `SELECT` without an `ORDER BY`. An aggregate that returns a different value
1356
+ * on a rerun is a load nobody can diff. `min`/`max` are the order-independent
1357
+ * version and are what somebody reaching for `first` usually wants.
1358
+ * - **Conditional aggregation — `MAX(CASE WHEN … THEN … END)`** — deliberately
1359
+ * out of scope, and it is the one omission a reader of flip's `wo` query will
1360
+ * go looking for, because that query has a three-branch status ladder in it.
1361
+ * Admitting it means admitting a predicate *inside* an aggregate, which is a
1362
+ * second expression language nested in the first, evaluated per row per
1363
+ * aggregate. That is transform territory and the generic
1364
+ * {@link WorkflowTransformNode} still exists. What the ladder actually is, is
1365
+ * a priority ordering over a closed set of codes, and it composes: map the
1366
+ * code to a rank in a transform above this node, `min` the rank, map it back
1367
+ * below. Two cheap per-record steps instead of a language.
1368
+ * - **`avg` is in**, and it is in *because* of the rule rather than despite it.
1369
+ * It is `sum` and `count` in one accumulator, both of which are already here,
1370
+ * and SQL has exactly one answer for it. Excluding it would have made the list
1371
+ * an arbitrary set that happened to cover one query, which is the thing the
1372
+ * rule is for.
1373
+ */
1374
+ exports.WORKFLOW_AGGREGATE_FUNCTIONS = [
1375
+ /** Rows in the group, or non-null values of a column. `COUNT(*)` / `COUNT(c)`. */
1376
+ 'count',
1377
+ /** The total. See `addToSum` for the summation error and what is done about it. */
1378
+ 'sum',
1379
+ /** The mean of the non-null values. `sum` and `count` in one accumulator. */
1380
+ 'avg',
1381
+ /** The least value. See `compareValues` for the order, which is not MySQL's. */
1382
+ 'min',
1383
+ /** The greatest value. Same comparison, same docblock. */
1384
+ 'max',
1385
+ /** The values, concatenated. `GROUP_CONCAT`, with a bound that refuses. */
1386
+ 'join',
1387
+ ];
1388
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1389
+ function isWorkflowAggregateFunction(value) {
1390
+ return exports.WORKFLOW_AGGREGATE_FUNCTIONS.some((fn) => fn === value);
1391
+ }
1392
+ /**
1393
+ * {@link unreachableNodeKind}, one level down, and for the identical reason.
1394
+ *
1395
+ * Every branch over {@link WorkflowAggregateFunction} ends here, so a seventh
1396
+ * function added to the list without an accumulator, a finisher, a canonical
1397
+ * form and a sentence is a type error naming the file rather than a node that
1398
+ * saves, draws and then computes nothing. It throws as well, because a function
1399
+ * name arrives as JSON out of a column and a build older than the data is a
1400
+ * thing that happens.
1401
+ */
1402
+ function unreachableAggregateFunction(fn, where) {
1403
+ 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.`);
1404
+ }
1405
+ /**
1406
+ * How many columns one node may group on.
1407
+ *
1408
+ * The same argument {@link WORKFLOW_RENAME_MAX_COLUMNS} makes, plus one specific
1409
+ * to this node: every extra group-by column can only ever *increase* the number
1410
+ * of groups, so a long list is the shape a high-cardinality grouping arrives in.
1411
+ * flip's real derivation groups on two.
1412
+ */
1413
+ exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = 16;
1414
+ /**
1415
+ * How many aggregates one node may compute.
1416
+ *
1417
+ * flip's `wo` derivation has 49, so the bound has to be comfortably above that
1418
+ * or the node does not do the job it was written for. Past a few hundred the
1419
+ * thing being expressed is a table definition rather than a summary, and the
1420
+ * cost is real: every aggregate is an accumulator held **per group**, so this
1421
+ * number multiplies {@link WORKFLOW_AGGREGATE_MAX_GROUPS} in the heap.
1422
+ */
1423
+ exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = 256;
1424
+ /**
1425
+ * The default ceiling on distinct groups, and the loud refusal that goes with
1426
+ * it.
1427
+ *
1428
+ * A hash aggregate is cheap **only while the groups are far fewer than the
1429
+ * rows**. Group on a near-unique column and it holds one accumulator row per
1430
+ * input row, which is the whole-batch behaviour this node replaces, arrived at
1431
+ * by a different route and with nothing on the canvas to point at. So the
1432
+ * ceiling exists, it is crossed loudly, and the message names the columns being
1433
+ * grouped on — because a bound that is merely reported is a bound that is
1434
+ * discovered by the machine running out of memory.
1435
+ *
1436
+ * A million is chosen against the measurement rather than as a round number:
1437
+ * flip's derivation holds 16,119, so the default is 62× the real case and no
1438
+ * author of a sane grouping ever meets it. What it catches is `groupBy:
1439
+ * ['combinedId']` on a 44,720-row type — a grouping that is *legal*, produces
1440
+ * one group per row, and is somebody having picked the wrong column.
1441
+ *
1442
+ * The number is a proxy and it is worth saying which part it cannot see: what a
1443
+ * group costs in bytes depends on how many aggregates the node has and how long
1444
+ * a `join` grows. The first is bounded by
1445
+ * {@link WORKFLOW_AGGREGATE_MAX_AGGREGATES}; the second has its own bound on the
1446
+ * aggregate, because it is the one accumulator whose size is not fixed by the
1447
+ * group count.
1448
+ */
1449
+ exports.WORKFLOW_AGGREGATE_MAX_GROUPS = 1_000_000;
1450
+ /**
1451
+ * The highest ceiling an author may ask for.
1452
+ *
1453
+ * Configurable because "how many groups is too many" genuinely depends on the
1454
+ * machine and on how wide the node is, and a hard-coded limit would make the
1455
+ * node unusable for the one legitimate large grouping. Bounded because past this
1456
+ * the answer is not a bigger number — it is that the grouping belongs in the
1457
+ * source query, where the database already has spill-to-disk and this process
1458
+ * does not.
1459
+ */
1460
+ exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = 20_000_000;
1461
+ /**
1462
+ * The default bound on one joined value, in characters.
1463
+ *
1464
+ * 65,535 because that is what a MySQL `TEXT` column holds, and a value the
1465
+ * target column cannot store is the same defect one layer further down. See
1466
+ * `appendJoin` for the full argument, including the five groups per column that
1467
+ * are silently truncated in production today under a limit of 1,024.
1468
+ */
1469
+ exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = 65_535;
1470
+ /** The highest an author may raise a `join` bound to. One `MEDIUMTEXT`. */
1471
+ exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = 16_777_215;
1472
+ /** The longest separator a `join` may use. Long enough for `" | "`, short enough not to be data. */
1473
+ exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = 16;
1474
+ /** The separator a `join` uses when the aggregate does not name one. */
1475
+ exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = ', ';
1476
+ /** {@link WorkflowAggregateNode.maxGroups}, resolved. One reader of the default. */
1477
+ function workflowAggregateMaxGroups(node) {
1478
+ const asked = node.maxGroups;
1479
+ if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
1480
+ return exports.WORKFLOW_AGGREGATE_MAX_GROUPS;
1481
+ }
1482
+ return Math.min(asked, exports.WORKFLOW_AGGREGATE_GROUPS_CEILING);
1483
+ }
1484
+ /** {@link WorkflowAggregate.separator}, resolved. One reader of the default. */
1485
+ function workflowAggregateSeparator(aggregate) {
1486
+ const asked = aggregate.separator;
1487
+ return typeof asked === 'string' ? asked : exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR;
1488
+ }
1489
+ /** {@link WorkflowAggregate.maxLength}, resolved. One reader of the default. */
1490
+ function workflowAggregateJoinMaxLength(aggregate) {
1491
+ const asked = aggregate.maxLength;
1492
+ if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
1493
+ return exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH;
1494
+ }
1495
+ return Math.min(asked, exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING);
1496
+ }
1497
+ /** Whether this function reads a column. Only `count` may go without one. */
1498
+ function workflowAggregateNeedsColumn(fn) {
1499
+ return fn !== 'count';
1500
+ }
1501
+ /**
1502
+ * The columns an aggregate node **reads**: its group keys and its inputs.
1503
+ *
1504
+ * What `checkColumnsProduced` tests against what the graph can prove is there,
1505
+ * and what the run log reports as never-seen. Deduplicated and in a stable
1506
+ * order, because it goes into a sentence.
1507
+ */
1508
+ function workflowAggregateColumns(node) {
1509
+ const columns = new Set();
1510
+ for (const column of node.groupBy ?? []) {
1511
+ if (typeof column === 'string' && column.length > 0)
1512
+ columns.add(column);
1513
+ }
1514
+ for (const aggregate of node.aggregates ?? []) {
1515
+ const column = aggregate?.column;
1516
+ if (typeof column === 'string' && column.length > 0)
1517
+ columns.add(column);
1518
+ }
1519
+ return [...columns];
1520
+ }
1521
+ /**
1522
+ * The columns an aggregate node **produces**, which is all of them and nothing
1523
+ * else.
1524
+ *
1525
+ * Closed by the config, and closed *exactly* rather than as an upper bound —
1526
+ * every emitted record carries every one of these keys, whatever was upstream
1527
+ * and whatever the values turned out to be. That is a stronger claim than the
1528
+ * one `rename` introduced, and it is stronger for a structural reason: a rename
1529
+ * only produces a target where the input actually held the source column,
1530
+ * whereas an aggregate writes a group's answer whether or not anything in the
1531
+ * group had a value for it.
1532
+ *
1533
+ * The one thing it does not claim is that the values are useful. An aggregate
1534
+ * over a column that no record carried produces the column, holding `null`.
1535
+ */
1536
+ function workflowAggregateOutputColumns(node) {
1537
+ const columns = [];
1538
+ for (const column of node.groupBy ?? []) {
1539
+ if (typeof column === 'string' && column.length > 0 && !columns.includes(column)) {
1540
+ columns.push(column);
1541
+ }
1542
+ }
1543
+ for (const aggregate of node.aggregates ?? []) {
1544
+ const as = aggregate?.as;
1545
+ if (typeof as === 'string' && as.length > 0 && !columns.includes(as))
1546
+ columns.push(as);
1547
+ }
1548
+ return columns;
1549
+ }
1550
+ /**
1551
+ * Every reason an aggregate cannot be stored, as sentences, or empty.
1552
+ *
1553
+ * One function, called by {@link validateWorkflow}, by the HTTP boundary, by the
1554
+ * canvas and by the fold itself, for the reason {@link renameColumnRefusals} is
1555
+ * shared: a screen with its own copy of the identifier pattern is a screen that
1556
+ * accepts something the server refuses, halfway through a save.
1557
+ *
1558
+ * All of them rather than the first, exactly as
1559
+ * {@link refuseUnpublishablePropertyNames} argues: a node with forty aggregates
1560
+ * typed in one sitting is usually wrong about several in the same way.
1561
+ */
1562
+ function aggregateRefusals(node) {
1563
+ const groupBy = Array.isArray(node.groupBy) ? node.groupBy : [];
1564
+ const aggregates = Array.isArray(node.aggregates) ? node.aggregates : [];
1565
+ const named = new Set();
1566
+ const refusals = [
1567
+ ...groupByRefusals(groupBy, named),
1568
+ ...aggregatesRefusals(aggregates, named),
1569
+ ...ceilingRefusals(node.maxGroups),
1570
+ ];
1571
+ return refusals;
1572
+ }
1573
+ /**
1574
+ * What the grouping half can be wrong about, and the names it accepted.
1575
+ *
1576
+ * The accepted set is threaded out rather than recomputed, because the sharpest
1577
+ * refusal in the file needs both halves: an aggregate writing into a column the
1578
+ * node also groups on is one name holding two values, and only a reader that has
1579
+ * seen the group-by list can see it.
1580
+ */
1581
+ function groupByRefusals(groupBy, accepted) {
1582
+ const refusals = [];
1583
+ if (groupBy.length === 0) {
1584
+ 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.');
1585
+ }
1586
+ if (groupBy.length > exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY) {
1587
+ 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.`);
1588
+ }
1589
+ for (const column of groupBy) {
1590
+ if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
1591
+ 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.`);
1592
+ continue;
1593
+ }
1594
+ if (accepted.has(column)) {
1595
+ 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.`);
1596
+ continue;
1597
+ }
1598
+ accepted.add(column);
1599
+ }
1600
+ return refusals;
1601
+ }
1602
+ /** What the computing half can be wrong about, given the group keys already accepted. */
1603
+ function aggregatesRefusals(aggregates, groupedOn) {
1604
+ const refusals = [];
1605
+ if (aggregates.length === 0) {
1606
+ 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.');
1607
+ }
1608
+ if (aggregates.length > exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES) {
1609
+ 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.`);
1610
+ }
1611
+ const names = new Map();
1612
+ for (const entry of aggregates) {
1613
+ if (typeof entry !== 'object' || entry === null) {
1614
+ refusals.push(`One aggregate is ${JSON.stringify(entry)} rather than a function, a column and a name.`);
1615
+ continue;
1616
+ }
1617
+ refusals.push(...oneAggregateRefusals(entry, groupedOn, names));
1618
+ }
1619
+ for (const [as, count] of names) {
1620
+ if (count < 2)
1621
+ continue;
1622
+ 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.`);
1623
+ }
1624
+ return refusals;
1625
+ }
1626
+ /** One entry of the list: its name, its function, its column, and its two optional fields. */
1627
+ function oneAggregateRefusals(entry, groupedOn, names) {
1628
+ const refusals = [];
1629
+ const as = Reflect.get(entry, 'as');
1630
+ const fn = Reflect.get(entry, 'fn');
1631
+ const column = Reflect.get(entry, 'column');
1632
+ const label = typeof as === 'string' && as.length > 0 ? JSON.stringify(as) : 'One aggregate';
1633
+ if (typeof as !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(as)) {
1634
+ 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.`);
1635
+ }
1636
+ else {
1637
+ names.set(as, (names.get(as) ?? 0) + 1);
1638
+ if (groupedOn.has(as)) {
1639
+ 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.`);
1640
+ }
1641
+ }
1642
+ if (!isWorkflowAggregateFunction(fn)) {
1643
+ 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.`);
1644
+ return refusals;
1645
+ }
1646
+ if (workflowAggregateNeedsColumn(fn)) {
1647
+ if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
1648
+ 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.`);
1649
+ }
1650
+ }
1651
+ else if (column !== undefined && !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(String(column))) {
1652
+ 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.`);
1653
+ }
1654
+ refusals.push(...joinFieldRefusals(entry, fn, label));
1655
+ return refusals;
1656
+ }
1657
+ /**
1658
+ * The two fields only `join` reads.
1659
+ *
1660
+ * Refused on any other function rather than ignored, which is the rule the whole
1661
+ * config follows: a field that only some functions read is a field somebody sets
1662
+ * on the wrong one and never finds out.
1663
+ */
1664
+ function joinFieldRefusals(entry, fn, label) {
1665
+ const refusals = [];
1666
+ const separator = Reflect.get(entry, 'separator');
1667
+ if (separator !== undefined) {
1668
+ if (fn !== 'join') {
1669
+ 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.`);
1670
+ }
1671
+ else if (typeof separator !== 'string' ||
1672
+ separator.length > exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR) {
1673
+ 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.`);
1674
+ }
1675
+ }
1676
+ const maxLength = Reflect.get(entry, 'maxLength');
1677
+ if (maxLength === undefined)
1678
+ return refusals;
1679
+ if (fn !== 'join') {
1680
+ refusals.push(`${label} carries a maximum length and computes ${fn}, which produces no text to bound.`);
1681
+ return refusals;
1682
+ }
1683
+ if (typeof maxLength !== 'number' ||
1684
+ !Number.isInteger(maxLength) ||
1685
+ maxLength < 1 ||
1686
+ maxLength > exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING) {
1687
+ 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.`);
1688
+ }
1689
+ return refusals;
1690
+ }
1691
+ /** The group ceiling, when the node set one. See {@link WORKFLOW_AGGREGATE_MAX_GROUPS}. */
1692
+ function ceilingRefusals(maxGroups) {
1693
+ if (maxGroups === undefined)
1694
+ return [];
1695
+ if (typeof maxGroups === 'number' &&
1696
+ Number.isInteger(maxGroups) &&
1697
+ maxGroups >= 1 &&
1698
+ maxGroups <= exports.WORKFLOW_AGGREGATE_GROUPS_CEILING) {
1699
+ return [];
1700
+ }
1701
+ return [
1702
+ `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.`,
1703
+ ];
1704
+ }
1705
+ /**
1706
+ * Whether a stored aggregate list is one this build can run.
1707
+ *
1708
+ * Refused rather than repaired, the stance {@link isWorkflowRenameColumns} takes
1709
+ * and for the same reason one step further along: an aggregate list read back
1710
+ * with one entry silently dropped is a graph that commits a column of nulls
1711
+ * under a name somebody put in an object type on purpose.
1712
+ */
1713
+ function isWorkflowAggregates(value) {
1714
+ if (!Array.isArray(value))
1715
+ return false;
1716
+ return aggregateRefusals({ groupBy: ['x'], aggregates: value }).length === 0;
1717
+ }
1309
1718
  /* --- reusable nodes ------------------------------------------------------ */
1310
1719
  /**
1311
1720
  * The node kinds that can be saved once and used in several graphs.
@@ -1364,6 +1773,13 @@ function isReusableNodeKind(value) {
1364
1773
  * *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
1365
1774
  * dropped into a graph reading a different system renames nothing at all, and
1366
1775
  * the symptom is a column of NULLs rather than a failure.
1776
+ * - `aggregate` — the same again, and it fails in both directions at once. Its
1777
+ * group-by columns and its inputs name one type's columns, so a shared one
1778
+ * groups a graph it was not written for on a column that is not there — which
1779
+ * collapses every record into one null-keyed group rather than erroring. And
1780
+ * its *output* column set is the thing downstream nodes are validated against,
1781
+ * so a shared node editable from elsewhere would silently change what another
1782
+ * graph's sink is allowed to write.
1367
1783
  */
1368
1784
  exports.NODE_KIND_IS_REUSABLE = {
1369
1785
  source: true,
@@ -1373,6 +1789,7 @@ exports.NODE_KIND_IS_REUSABLE = {
1373
1789
  if: false,
1374
1790
  filter: false,
1375
1791
  rename: false,
1792
+ aggregate: false,
1376
1793
  };
1377
1794
  /** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
1378
1795
  function nodeKindIsReusable(kind) {
@@ -1794,6 +2211,19 @@ exports.WORKFLOW_ISSUE_CODES = [
1794
2211
  * {@link renameColumnRefusals} for the sentences.
1795
2212
  */
1796
2213
  'rename-invalid',
2214
+ /**
2215
+ * An aggregate this service will not store: grouping on nothing or on a name
2216
+ * a column cannot have, computing nothing, two aggregates sharing an output
2217
+ * name, a function this build cannot compute, a separator on something that
2218
+ * does not join.
2219
+ *
2220
+ * Every one of those is decidable from the node alone, and every one of them
2221
+ * is silent if it is let through — the two worst being an empty `groupBy`,
2222
+ * which commits exactly one row whether the source held everything or nothing,
2223
+ * and an empty `aggregates`, which drops every column the node does not group
2224
+ * on. See {@link aggregateRefusals} for the sentences.
2225
+ */
2226
+ 'aggregate-invalid',
1797
2227
  /**
1798
2228
  * A node naming a column that nothing upstream can produce.
1799
2229
  *
@@ -2263,8 +2693,31 @@ function nodeIsUnconfigured(node) {
2263
2693
  return filterIsUnconfigured(node);
2264
2694
  if (node.kind === 'rename')
2265
2695
  return renameIsUnconfigured(node);
2696
+ if (node.kind === 'aggregate')
2697
+ return aggregateIsUnconfigured(node);
2266
2698
  return undefined;
2267
2699
  }
2700
+ /**
2701
+ * An aggregate this service will not store.
2702
+ *
2703
+ * The refusals come from {@link aggregateRefusals} rather than being restated
2704
+ * here, so the canvas, the HTTP boundary, the validator and the fold itself say
2705
+ * the same sentence about the same node. Every one of them is a *silent* failure
2706
+ * if it were let through, and the two most dangerous point in opposite
2707
+ * directions: no group-by columns commits exactly one row whatever the source
2708
+ * held, and no aggregates commits the distinct group keys with every other
2709
+ * column of every row gone.
2710
+ */
2711
+ function aggregateIsUnconfigured(node) {
2712
+ const refusals = aggregateRefusals(node);
2713
+ if (refusals.length === 0)
2714
+ return undefined;
2715
+ return {
2716
+ code: 'aggregate-invalid',
2717
+ nodeIds: [node.id],
2718
+ message: `Aggregate "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
2719
+ };
2720
+ }
2268
2721
  /**
2269
2722
  * A rename whose map this service will not store.
2270
2723
  *
@@ -2875,6 +3328,8 @@ function canonicalNode(node) {
2875
3328
  }
2876
3329
  if (node.kind === 'rename')
2877
3330
  return canonicalRename(node);
3331
+ if (node.kind === 'aggregate')
3332
+ return canonicalAggregate(node);
2878
3333
  if (node.kind === 'sink') {
2879
3334
  return JSON.stringify([
2880
3335
  node.id,
@@ -2908,6 +3363,47 @@ function canonicalRename(node) {
2908
3363
  ...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
2909
3364
  ]);
2910
3365
  }
3366
+ /**
3367
+ * An aggregate, canonicalised.
3368
+ *
3369
+ * **`groupBy` is sorted and the aggregates are sorted by output name**, because
3370
+ * neither order changes what the node computes: a set of group-by columns
3371
+ * defines the same groups whatever order they are listed in, and two aggregates
3372
+ * are independent of each other. Order *is* what the output columns come out in,
3373
+ * and a record's key order is not something anything downstream reads — the sink
3374
+ * looks every property up by name. So reordering rows in the inspector is not an
3375
+ * edit and does not bump a version.
3376
+ *
3377
+ * Each aggregate's optional fields are appended **only when set**, exactly as
3378
+ * `edge.branch` and a rename's `unnamed` are, so the default has one spelling.
3379
+ * The point of that rule is the same one every time: adding a node kind to this
3380
+ * file must not renumber a graph that did not change. Here it holds for a
3381
+ * stronger reason as well — no stored graph contains an aggregate node at all,
3382
+ * because this release is the first one in which such a node can be saved, so
3383
+ * every existing graph's canonical string is byte-identical to what it was.
3384
+ */
3385
+ function canonicalAggregate(node) {
3386
+ const groupBy = [...(node.groupBy ?? [])].sort();
3387
+ const aggregates = [...(node.aggregates ?? [])]
3388
+ .sort((left, right) => (left.as < right.as ? -1 : left.as > right.as ? 1 : 0))
3389
+ .map((each) => [
3390
+ each.as,
3391
+ each.fn,
3392
+ each.column ?? '',
3393
+ ...(each.separator === undefined ? [] : [each.separator]),
3394
+ ...(each.maxLength === undefined ? [] : [each.maxLength]),
3395
+ ]);
3396
+ return JSON.stringify([
3397
+ node.id,
3398
+ node.kind,
3399
+ groupBy,
3400
+ aggregates,
3401
+ // In the fingerprint because it decides whether a run finishes or refuses,
3402
+ // which is a difference between two runs of "the same" graph worth being
3403
+ // able to point at. Appended only when set, so the default has one spelling.
3404
+ ...(node.maxGroups === undefined ? [] : [node.maxGroups]),
3405
+ ]);
3406
+ }
2911
3407
  /**
2912
3408
  * The reusable reference, as zero, one or two trailing hash components.
2913
3409
  *
@@ -3157,6 +3653,13 @@ function producedColumns(node, upstream, knowledge) {
3157
3653
  renamed.add(node.columns?.[column] ?? column);
3158
3654
  return renamed;
3159
3655
  }
3656
+ // The one kind whose output set is *exact* rather than an upper bound, and it
3657
+ // is exact without looking upstream at all: an aggregate emits its group-by
3658
+ // columns and its named aggregates on every record it produces, whatever it
3659
+ // was handed and whatever the values turned out to be. See
3660
+ // {@link workflowAggregateOutputColumns} for the one thing it does not claim.
3661
+ if (node.kind === 'aggregate')
3662
+ return new Set(workflowAggregateOutputColumns(node));
3160
3663
  // Neither of these touches a column: a filter decides which *rows* survive and
3161
3664
  // an `if` decides which *nodes* run. Both hand on exactly the shape they were
3162
3665
  // given, which is what makes a closed set survive one.
@@ -3243,11 +3746,9 @@ function checkColumnsProduced(graph, issues, knowledge) {
3243
3746
  // Narrowed off the union rather than tested with a property check, so a kind
3244
3747
  // that starts naming columns without being answered for here is a type error
3245
3748
  // at `missingColumnMessage` and not a check that silently passes.
3246
- if (node.kind !== 'filter' && node.kind !== 'rename')
3749
+ if (node.kind !== 'filter' && node.kind !== 'rename' && node.kind !== 'aggregate')
3247
3750
  continue;
3248
- const named = node.kind === 'filter'
3249
- ? workflowFilterColumns(node.predicate)
3250
- : Object.keys(node.columns ?? {});
3751
+ const named = columnsNamedBy(node);
3251
3752
  if (named.length === 0)
3252
3753
  continue;
3253
3754
  const known = workflowKnownColumns(graph, node.id, knowledge);
@@ -3263,13 +3764,33 @@ function checkColumnsProduced(graph, issues, knowledge) {
3263
3764
  });
3264
3765
  }
3265
3766
  }
3767
+ /**
3768
+ * The columns a node names, per kind that names any.
3769
+ *
3770
+ * Its own function so the union it takes and the union
3771
+ * {@link missingColumnMessage} takes are the same three kinds written down
3772
+ * twice — which is what makes a fourth kind that starts naming columns a type
3773
+ * error in both places rather than a check that silently passes.
3774
+ */
3775
+ function columnsNamedBy(node) {
3776
+ if (node.kind === 'filter')
3777
+ return workflowFilterColumns(node.predicate);
3778
+ if (node.kind === 'rename')
3779
+ return Object.keys(node.columns ?? {});
3780
+ return workflowAggregateColumns(node);
3781
+ }
3266
3782
  /** The sentence {@link checkColumnsProduced} says, per kind. */
3267
3783
  function missingColumnMessage(node, missing, known) {
3268
3784
  const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
3269
3785
  const consequence = node.kind === 'filter'
3270
3786
  ? '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}`;
3787
+ : node.kind === 'rename'
3788
+ ? '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.'
3789
+ : // Both halves of an aggregate fail silently, and they fail differently,
3790
+ // which is why this sentence names both rather than picking one.
3791
+ '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.';
3792
+ const label = node.kind === 'filter' ? 'Filter' : node.kind === 'rename' ? 'Rename' : 'Aggregate';
3793
+ 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
3794
  }
3274
3795
  function sortedEntries(config) {
3275
3796
  return Object.keys(config)
@@ -3328,6 +3849,8 @@ function isWorkflowNode(value) {
3328
3849
  }
3329
3850
  if (kind === 'rename')
3330
3851
  return isRenameNodeShape(value);
3852
+ if (kind === 'aggregate')
3853
+ return isAggregateNodeShape(value);
3331
3854
  if (kind === 'source') {
3332
3855
  const sourceKind = Reflect.get(value, 'sourceKind');
3333
3856
  const config = Reflect.get(value, 'config');
@@ -3351,6 +3874,23 @@ function isRenameNodeShape(value) {
3351
3874
  return false;
3352
3875
  return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
3353
3876
  }
3877
+ /**
3878
+ * Everything an `aggregate` node carries, through the one refusal list.
3879
+ *
3880
+ * The whole node rather than field by field, so a stored aggregate is read back
3881
+ * under exactly the rule that would have refused to store it. Refused rather
3882
+ * than repaired, the stance every guard in this file takes: an aggregate read
3883
+ * back with one entry silently dropped is a load that commits a column of nulls
3884
+ * under a name somebody put in an object type on purpose, and one read back with
3885
+ * its `groupBy` dropped is a load that commits a single row.
3886
+ */
3887
+ function isAggregateNodeShape(value) {
3888
+ return (aggregateRefusals({
3889
+ groupBy: Reflect.get(value, 'groupBy'),
3890
+ aggregates: Reflect.get(value, 'aggregates'),
3891
+ maxGroups: Reflect.get(value, 'maxGroups'),
3892
+ }).length === 0);
3893
+ }
3354
3894
  /**
3355
3895
  * Everything a `call` node carries, checked as strictly as a source's.
3356
3896
  *