@dudousxd/nestjs-catalog 0.25.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,8 +9,10 @@
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.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
+ exports.unreachableConnectorKind = unreachableConnectorKind;
15
+ exports.workflowSourceObjectType = workflowSourceObjectType;
14
16
  exports.isSourceFormat = isSourceFormat;
15
17
  exports.unreachableSourceFormat = unreachableSourceFormat;
16
18
  exports.isTransformLanguage = isTransformLanguage;
@@ -42,6 +44,16 @@ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
42
44
  exports.workflowRenameUnnamed = workflowRenameUnnamed;
43
45
  exports.renameColumnRefusals = renameColumnRefusals;
44
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;
45
57
  exports.isReusableNodeKind = isReusableNodeKind;
46
58
  exports.nodeKindIsReusable = nodeKindIsReusable;
47
59
  exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
@@ -103,6 +115,45 @@ exports.CONNECTOR_KINDS = [
103
115
  's3',
104
116
  /** Records pasted into the config. For trying a transform against real shapes. */
105
117
  'inline',
118
+ /**
119
+ * The catalog's own data: the **current snapshot** of one object type.
120
+ *
121
+ * ## Why this is a kind and not a `sql` connector with a clever query
122
+ *
123
+ * Because that is what it was, and it was silently wrong. A workflow that
124
+ * needed rows the catalog already holds had exactly one way to get them — a
125
+ * `sql` connector naming the physical table, `SELECT … FROM obj_subworeplica`
126
+ * — and the store **retains every committed snapshot in that table**. So the
127
+ * read is not the dataset, it is every load that has ever run, stacked.
128
+ *
129
+ * Measured, with two snapshots present: the source read 89,440 rows against a
130
+ * type holding 44,720; the run finished `succeeded`; the rows *written* were
131
+ * unchanged at 16,119 because the downstream `GROUP BY` collapsed the
132
+ * duplicates; and every `SUM` doubled — `actualLaborCost` from 212,192,113 to
133
+ * 424,384,226. The row count, which is the one number anybody checks, did not
134
+ * move. The graph had been correct once, by the accident of exactly one
135
+ * snapshot existing at the time it was first run.
136
+ *
137
+ * Three things follow, and each of them is why this is a kind of its own
138
+ * rather than a documented convention about which table to name:
139
+ *
140
+ * - **The author names a type, not a table.** `obj_<type>` and `_snapshot_id`
141
+ * are internal schema. A graph that spells them is coupled to a storage
142
+ * layout it does not own and — as measured — quietly wrong about it.
143
+ * - **There is no URL and no credential.** Every other kind that reaches a
144
+ * database needs an address and a secret, and pointing the catalog at its
145
+ * own database meant an operator putting a connection URL on the pod and on
146
+ * the secret allowlist: a credential that did not need to exist. This kind
147
+ * reads through the store the process already holds.
148
+ * - **"Current" is resolved when the run starts**, by asking the store which
149
+ * snapshot it serves, and a type with nothing committed is refused rather
150
+ * than read as zero rows. See `fetchCatalog` in the pipeline package.
151
+ *
152
+ * Not connectable — see `CONNECTION_KINDS` in the react package. A connection
153
+ * is an address and a credential shared by several loads, and this kind has
154
+ * neither.
155
+ */
156
+ 'catalog',
106
157
  ];
107
158
  /**
108
159
  * The type is derived from the list, not written beside it.
@@ -116,6 +167,59 @@ exports.CONNECTOR_KINDS = [
116
167
  function isConnectorKind(value) {
117
168
  return exports.CONNECTOR_KINDS.some((kind) => kind === value);
118
169
  }
170
+ /**
171
+ * The connector kind that never compiles quietly.
172
+ *
173
+ * The {@link unreachableNodeKind} of source kinds, and it arrived with
174
+ * `'catalog'` for the reason that kind arrived: two of the places that decide
175
+ * something *per source kind* — the fetcher map and the column question below —
176
+ * were keyed by `string` or answered for `source` as a whole, so a sixth kind
177
+ * could be added and be picked up by neither. A palette that offers a kind
178
+ * nothing can read is the failure {@link CONNECTOR_KINDS} opens by describing,
179
+ * one level down.
180
+ *
181
+ * It throws as well as failing to compile, for the reason its sibling does: a
182
+ * connector's `kind` is a string in a database row, so one written by a newer
183
+ * deployment and read by an older one is possible, and returning a default for
184
+ * it would read the wrong system entirely.
185
+ */
186
+ function unreachableConnectorKind(kind, where) {
187
+ throw new Error(`${where} does not handle a source of kind ${JSON.stringify(kind)}. The kind list and every decision made per kind are meant to move together.`);
188
+ }
189
+ /**
190
+ * The config key a `catalog` source names its object type in.
191
+ *
192
+ * A constant rather than a literal in four files, because it is the one field
193
+ * that kind has and it is written by a form, read by a fetcher, checked by a
194
+ * validator and hashed into the graph's fingerprint.
195
+ */
196
+ exports.CATALOG_SOURCE_TYPE_KEY = 'objectType';
197
+ /**
198
+ * Which object type a source reads, when it is a `catalog` source that names one.
199
+ *
200
+ * `undefined` covers both "not that kind" and "that kind, unconfigured", and the
201
+ * two callers want the same thing from both: a source that does not name a type
202
+ * is not one whose columns are known, and it is one the validator refuses. The
203
+ * string is trimmed, because a name that differs from a published type only by
204
+ * surrounding whitespace is a load that resolves nothing at run time and a
205
+ * refusal nobody can see the cause of.
206
+ */
207
+ function workflowSourceObjectType(node) {
208
+ // Both fields read reflectively rather than promised by the signature, for two
209
+ // reasons that point the same way: the parameter has to accept any node of the
210
+ // union — a sink carries neither field — and a node arrives out of a JSON
211
+ // column, where a `config` can be anything at all.
212
+ if (Reflect.get(node, 'sourceKind') !== 'catalog')
213
+ return undefined;
214
+ const config = Reflect.get(node, 'config');
215
+ if (typeof config !== 'object' || config === null)
216
+ return undefined;
217
+ const named = Reflect.get(config, exports.CATALOG_SOURCE_TYPE_KEY);
218
+ if (typeof named !== 'string')
219
+ return undefined;
220
+ const trimmed = named.trim();
221
+ return trimmed.length === 0 ? undefined : trimmed;
222
+ }
119
223
  /**
120
224
  * How the bytes behind a `file` or `s3` connector are read as records.
121
225
  *
@@ -438,6 +542,8 @@ exports.WORKFLOW_NODE_KINDS = [
438
542
  'filter',
439
543
  /** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
440
544
  'rename',
545
+ /** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
546
+ 'aggregate',
441
547
  ];
442
548
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
443
549
  function isWorkflowNodeKind(value) {
@@ -1212,6 +1318,403 @@ function isWorkflowRenameColumns(value) {
1212
1318
  }
1213
1319
  return false;
1214
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
+ }
1215
1718
  /* --- reusable nodes ------------------------------------------------------ */
1216
1719
  /**
1217
1720
  * The node kinds that can be saved once and used in several graphs.
@@ -1270,6 +1773,13 @@ function isReusableNodeKind(value) {
1270
1773
  * *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
1271
1774
  * dropped into a graph reading a different system renames nothing at all, and
1272
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.
1273
1783
  */
1274
1784
  exports.NODE_KIND_IS_REUSABLE = {
1275
1785
  source: true,
@@ -1279,6 +1789,7 @@ exports.NODE_KIND_IS_REUSABLE = {
1279
1789
  if: false,
1280
1790
  filter: false,
1281
1791
  rename: false,
1792
+ aggregate: false,
1282
1793
  };
1283
1794
  /** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
1284
1795
  function nodeKindIsReusable(kind) {
@@ -1667,6 +2178,16 @@ exports.WORKFLOW_ISSUE_CODES = [
1667
2178
  'unreachable',
1668
2179
  'dead-end',
1669
2180
  'transform-not-named',
2181
+ /**
2182
+ * A `catalog` source that does not say which object type it reads.
2183
+ *
2184
+ * The sibling of `transform-not-named`, and it earns a code of its own for the
2185
+ * reason that one has one: the field is the *whole* of what the node does, and
2186
+ * a node missing it fails inside a durable step halfway through a load rather
2187
+ * than on the canvas. Refused rather than defaulted — there is no sensible
2188
+ * type to guess, and guessing would read somebody else's data.
2189
+ */
2190
+ 'source-type-not-named',
1670
2191
  'call-not-named',
1671
2192
  /**
1672
2193
  * A plain call wired into something. See {@link WORKFLOW_CALL_MODES}: a plain
@@ -1690,6 +2211,19 @@ exports.WORKFLOW_ISSUE_CODES = [
1690
2211
  * {@link renameColumnRefusals} for the sentences.
1691
2212
  */
1692
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',
1693
2227
  /**
1694
2228
  * A node naming a column that nothing upstream can produce.
1695
2229
  *
@@ -1732,8 +2266,16 @@ exports.WORKFLOW_ISSUE_CODES = [
1732
2266
  * point at nodes which do not exist produces a second page of consequences, and
1733
2267
  * burying the one real problem under them is how a validation message stops
1734
2268
  * being read.
2269
+ *
2270
+ * `knowledge` is optional and adds only refusals that could not otherwise be
2271
+ * made: with it, a filter or a rename below a `catalog` source can be told it
2272
+ * names a column the published type does not have. Omitting it is a supported
2273
+ * call and the answer is a subset, never a different one — see
2274
+ * {@link WorkflowColumnKnowledge}. The pure, dependency-free promise this
2275
+ * function opens with is unchanged: the lookup is the caller's, and this reaches
2276
+ * nothing.
1735
2277
  */
1736
- function validateWorkflow(graph) {
2278
+ function validateWorkflow(graph, knowledge) {
1737
2279
  const issues = [];
1738
2280
  const nodes = graph.nodes ?? [];
1739
2281
  const edges = graph.edges ?? [];
@@ -1779,7 +2321,7 @@ function validateWorkflow(graph) {
1779
2321
  checkReachability(nodes, roots, sinks, incoming, outgoing, issues);
1780
2322
  // After the cycle check has returned, so the walk it does cannot meet a loop
1781
2323
  // on a graph this function has already accepted as acyclic.
1782
- checkColumnsProduced({ nodes, edges }, issues);
2324
+ checkColumnsProduced({ nodes, edges }, issues, knowledge);
1783
2325
  return issues;
1784
2326
  }
1785
2327
  /**
@@ -2116,10 +2658,10 @@ function checkBranches(edges, byId, issues) {
2116
2658
  /**
2117
2659
  * A node that names none of the thing it exists to run.
2118
2660
  *
2119
- * The two kinds that point at something outside themselves — a transform at
2120
- * stored code, a call at a registered workflow and both are reported the same
2121
- * way because they are the same mistake: a box on the canvas with nothing
2122
- * behind it, which looks finished and fails at run time.
2661
+ * The kinds that point at something outside themselves — a transform at stored
2662
+ * code, a call at a registered workflow, a `catalog` source at an object type —
2663
+ * and they are reported the same way because they are the same mistake: a box on
2664
+ * the canvas with nothing behind it, which looks finished and fails at run time.
2123
2665
  */
2124
2666
  function nodeIsUnconfigured(node) {
2125
2667
  if (node.kind === 'transform' && node.transformId.length === 0) {
@@ -2129,6 +2671,20 @@ function nodeIsUnconfigured(node) {
2129
2671
  message: `Transform node "${node.name}" (${node.id}) names no transform, so there is no code for it to run.`,
2130
2672
  };
2131
2673
  }
2674
+ if (node.kind === 'source' && node.sourceKind === 'catalog') {
2675
+ // Only this kind, and only from the config: every other kind's address is
2676
+ // allowed to arrive from a named connection, so a blank field on the node is
2677
+ // not evidence of anything. A `catalog` source has no connection to borrow
2678
+ // from — the type name is the whole configuration — so a blank one is
2679
+ // decidable here.
2680
+ if (workflowSourceObjectType(node) === undefined) {
2681
+ return {
2682
+ code: 'source-type-not-named',
2683
+ nodeIds: [node.id],
2684
+ message: `Source "${node.name}" (${node.id}) reads from the catalog but does not say which object type, so there is nothing for it to read. Name the type on the node; there is no default, because a default would read somebody else's data.`,
2685
+ };
2686
+ }
2687
+ }
2132
2688
  if (node.kind === 'call')
2133
2689
  return callIsUnnamed(node);
2134
2690
  if (node.kind === 'if')
@@ -2137,8 +2693,31 @@ function nodeIsUnconfigured(node) {
2137
2693
  return filterIsUnconfigured(node);
2138
2694
  if (node.kind === 'rename')
2139
2695
  return renameIsUnconfigured(node);
2696
+ if (node.kind === 'aggregate')
2697
+ return aggregateIsUnconfigured(node);
2140
2698
  return undefined;
2141
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
+ }
2142
2721
  /**
2143
2722
  * A rename whose map this service will not store.
2144
2723
  *
@@ -2749,6 +3328,8 @@ function canonicalNode(node) {
2749
3328
  }
2750
3329
  if (node.kind === 'rename')
2751
3330
  return canonicalRename(node);
3331
+ if (node.kind === 'aggregate')
3332
+ return canonicalAggregate(node);
2752
3333
  if (node.kind === 'sink') {
2753
3334
  return JSON.stringify([
2754
3335
  node.id,
@@ -2782,6 +3363,47 @@ function canonicalRename(node) {
2782
3363
  ...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
2783
3364
  ]);
2784
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
+ }
2785
3407
  /**
2786
3408
  * The reusable reference, as zero, one or two trailing hash components.
2787
3409
  *
@@ -2938,9 +3560,23 @@ function workflowFilterColumns(predicate) {
2938
3560
  * with some keys re-labelled, and its input is unknown unless something
2939
3561
  * upstream closed it. So `undefined` propagates, and that is the honest
2940
3562
  * answer rather than an empty set.
2941
- * - **A source, a transform and a call are always unknown.** A source's shape is
2942
- * discovered against the live system rather than declared in the graph; a
2943
- * transform is a function body; a call is a workflow this graph does not own.
3563
+ * - **A transform and a call are always unknown.** A transform is a function
3564
+ * body; a call is a workflow this graph does not own.
3565
+ * - **A source is unknown, with one exception, and the exception needs a
3566
+ * lookup.** Every kind that reaches an outside system has a shape discovered
3567
+ * against that system rather than declared in the graph. A `catalog` source
3568
+ * is the one kind whose shape is *already published*: it names an object
3569
+ * type, and the type's properties are exactly the keys its records carry —
3570
+ * see `fetchCatalog`, which asks the store for those properties by name.
3571
+ *
3572
+ * But the properties are not in the graph either. The graph holds a type
3573
+ * **name**; the columns live in the catalog's registry, which this function is
3574
+ * pure and dependency-free in order not to reach. So the answer is a lookup
3575
+ * the caller supplies — {@link WorkflowColumnKnowledge} — and with no lookup
3576
+ * the answer stays `undefined`. That is the honest shape of the claim: a
3577
+ * caller that can see the catalog gets column checking through a source, which
3578
+ * nothing else in this file can offer, and a caller that cannot see it is told
3579
+ * nothing rather than told an empty set.
2944
3580
  * - **It says nothing about a sink's declared properties.** That is the check
2945
3581
  * worth wanting — "this sink writes a property no upstream node produces" —
2946
3582
  * and it is *not* available here: a {@link WorkflowSinkNode} carries a
@@ -2952,7 +3588,7 @@ function workflowFilterColumns(predicate) {
2952
3588
  * cyclic graph before it gets here, but the canvas calls this while a graph is
2953
3589
  * being drawn and is entitled to a wrong-but-terminating answer.
2954
3590
  */
2955
- function workflowKnownColumns(graph, nodeId) {
3591
+ function workflowKnownColumns(graph, nodeId, knowledge) {
2956
3592
  const nodes = graph.nodes ?? [];
2957
3593
  const byId = new Map(nodes.map((node) => [node.id, node]));
2958
3594
  const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
@@ -2969,7 +3605,7 @@ function workflowKnownColumns(graph, nodeId) {
2969
3605
  if (!node)
2970
3606
  return undefined;
2971
3607
  open.add(id);
2972
- const produced = producedColumns(node, () => intoNode(id));
3608
+ const produced = producedColumns(node, () => intoNode(id), knowledge);
2973
3609
  open.delete(id);
2974
3610
  answered.set(id, produced);
2975
3611
  return produced;
@@ -3005,7 +3641,7 @@ function workflowKnownColumns(graph, nodeId) {
3005
3641
  * here is a compile error rather than a silent `undefined` — which would be the
3006
3642
  * *safe* wrong answer and would therefore never be noticed.
3007
3643
  */
3008
- function producedColumns(node, upstream) {
3644
+ function producedColumns(node, upstream, knowledge) {
3009
3645
  if (node.kind === 'rename') {
3010
3646
  if (workflowRenameUnnamed(node) === 'drop')
3011
3647
  return new Set(Object.values(node.columns ?? {}));
@@ -3017,30 +3653,84 @@ function producedColumns(node, upstream) {
3017
3653
  renamed.add(node.columns?.[column] ?? column);
3018
3654
  return renamed;
3019
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));
3020
3663
  // Neither of these touches a column: a filter decides which *rows* survive and
3021
3664
  // an `if` decides which *nodes* run. Both hand on exactly the shape they were
3022
3665
  // given, which is what makes a closed set survive one.
3023
3666
  if (node.kind === 'filter' || node.kind === 'if')
3024
3667
  return upstream();
3025
- // A source's shape is discovered against the live system, a transform's is
3026
- // inside a function body, a call's belongs to a workflow this graph does not
3027
- // own, and nothing reads a sink's output. See {@link workflowKnownColumns}.
3028
- if (node.kind === 'source' ||
3029
- node.kind === 'transform' ||
3030
- node.kind === 'call' ||
3031
- node.kind === 'sink') {
3668
+ // A source is the one kind whose answer depends on which *source* kind it is.
3669
+ if (node.kind === 'source')
3670
+ return sourceProducedColumns(node, knowledge);
3671
+ // A transform's shape is inside a function body, a call's belongs to a workflow
3672
+ // this graph does not own, and nothing reads a sink's output. See
3673
+ // {@link workflowKnownColumns}.
3674
+ if (node.kind === 'transform' || node.kind === 'call' || node.kind === 'sink') {
3032
3675
  return undefined;
3033
3676
  }
3034
3677
  return unreachableNodeKind(node, 'workflowKnownColumns');
3035
3678
  }
3679
+ /**
3680
+ * What a source produces, per source kind.
3681
+ *
3682
+ * Exhaustive over {@link CONNECTOR_KINDS} rather than one blanket `undefined`
3683
+ * for the whole node kind, and that is the point of the function existing: the
3684
+ * blanket answer was correct for five kinds and became wrong for the sixth
3685
+ * without anything failing to compile. Ending in
3686
+ * {@link unreachableConnectorKind} makes the seventh a build error here.
3687
+ *
3688
+ * Four of the five outside systems answer `undefined` for the same reason: what
3689
+ * an HTTP endpoint, a file, a bucket or a query produces is discovered against
3690
+ * the live system, and the graph holds an address rather than a shape.
3691
+ *
3692
+ * `inline` answers `undefined` too, and that one is a judgement rather than an
3693
+ * absence. The records are *in the config*, so their keys could be read off
3694
+ * them — but they are a sample somebody pasted to try a transform against, and
3695
+ * a set derived from a sample is not closed: the real load reads the same source
3696
+ * with more records in it and no reason for them to share a key set. Treating
3697
+ * three pasted objects as the definition of a column set would refuse a filter
3698
+ * that is going to be right.
3699
+ *
3700
+ * `catalog` is the one that answers, when a caller supplied the lookup. Its
3701
+ * records are the store's own rows, keyed by the property names of the type it
3702
+ * names — `fetchCatalog` asks for exactly those and the store returns exactly
3703
+ * those — so the set is closed in the sense {@link workflowKnownColumns}
3704
+ * requires: an upper bound that holds whatever is upstream, since nothing is.
3705
+ */
3706
+ function sourceProducedColumns(node, knowledge) {
3707
+ const kind = node.sourceKind;
3708
+ if (kind === 'http' || kind === 'sql' || kind === 'file' || kind === 's3' || kind === 'inline') {
3709
+ return undefined;
3710
+ }
3711
+ if (kind === 'catalog') {
3712
+ const named = workflowSourceObjectType(node);
3713
+ if (named === undefined || knowledge === undefined)
3714
+ return undefined;
3715
+ const columns = knowledge.columnsOfType(named);
3716
+ // Absent means "this caller cannot see a type by that name", which is not
3717
+ // the same as a type with no columns and must not become an empty set — a
3718
+ // graph read by a console that has not loaded its types would otherwise have
3719
+ // every filter below the source refused.
3720
+ return columns === undefined ? undefined : new Set(columns);
3721
+ }
3722
+ return unreachableConnectorKind(kind, 'workflowKnownColumns');
3723
+ }
3036
3724
  /**
3037
3725
  * That no node names a column the graph can prove is not there.
3038
3726
  *
3039
- * Only where {@link workflowKnownColumns} answers, which is only downstream of a
3040
- * rename that drops what it does not name. Everywhere else this is silent, and
3041
- * that silence is correct rather than a gap being tolerated: refusing a column
3042
- * the graph merely has no opinion about would make every filter downstream of a
3043
- * transform unsaveable.
3727
+ * Only where {@link workflowKnownColumns} answers: downstream of a rename that
3728
+ * drops what it does not name, or when the caller supplied a
3729
+ * {@link WorkflowColumnKnowledge} downstream of a `catalog` source, whose
3730
+ * columns are the named type's published properties. Everywhere else this is
3731
+ * silent, and that silence is correct rather than a gap being tolerated:
3732
+ * refusing a column the graph merely has no opinion about would make every
3733
+ * filter downstream of a transform unsaveable.
3044
3734
  *
3045
3735
  * A refusal rather than a warning, because both failures are silent and total.
3046
3736
  * A filter on a column that cannot exist matches no row — a comparison against
@@ -3051,19 +3741,17 @@ function producedColumns(node, upstream) {
3051
3741
  * commits NULL into every row. That is the exact shape `property-names.ts` was
3052
3742
  * written about, one node upstream of where it can be caught.
3053
3743
  */
3054
- function checkColumnsProduced(graph, issues) {
3744
+ function checkColumnsProduced(graph, issues, knowledge) {
3055
3745
  for (const node of graph.nodes ?? []) {
3056
3746
  // Narrowed off the union rather than tested with a property check, so a kind
3057
3747
  // that starts naming columns without being answered for here is a type error
3058
3748
  // at `missingColumnMessage` and not a check that silently passes.
3059
- if (node.kind !== 'filter' && node.kind !== 'rename')
3749
+ if (node.kind !== 'filter' && node.kind !== 'rename' && node.kind !== 'aggregate')
3060
3750
  continue;
3061
- const named = node.kind === 'filter'
3062
- ? workflowFilterColumns(node.predicate)
3063
- : Object.keys(node.columns ?? {});
3751
+ const named = columnsNamedBy(node);
3064
3752
  if (named.length === 0)
3065
3753
  continue;
3066
- const known = workflowKnownColumns(graph, node.id);
3754
+ const known = workflowKnownColumns(graph, node.id, knowledge);
3067
3755
  if (known === undefined)
3068
3756
  continue;
3069
3757
  const missing = named.filter((column) => column.length > 0 && !known.has(column));
@@ -3076,13 +3764,33 @@ function checkColumnsProduced(graph, issues) {
3076
3764
  });
3077
3765
  }
3078
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
+ }
3079
3782
  /** The sentence {@link checkColumnsProduced} says, per kind. */
3080
3783
  function missingColumnMessage(node, missing, known) {
3081
3784
  const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
3082
3785
  const consequence = node.kind === 'filter'
3083
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.'
3084
- : '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.';
3085
- return `${node.kind === 'filter' ? 'Filter' : 'Rename'} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. A rename above this node drops every column it does not name, 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}`;
3086
3794
  }
3087
3795
  function sortedEntries(config) {
3088
3796
  return Object.keys(config)
@@ -3141,6 +3849,8 @@ function isWorkflowNode(value) {
3141
3849
  }
3142
3850
  if (kind === 'rename')
3143
3851
  return isRenameNodeShape(value);
3852
+ if (kind === 'aggregate')
3853
+ return isAggregateNodeShape(value);
3144
3854
  if (kind === 'source') {
3145
3855
  const sourceKind = Reflect.get(value, 'sourceKind');
3146
3856
  const config = Reflect.get(value, 'config');
@@ -3164,6 +3874,23 @@ function isRenameNodeShape(value) {
3164
3874
  return false;
3165
3875
  return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
3166
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
+ }
3167
3894
  /**
3168
3895
  * Everything a `call` node carries, checked as strictly as a source's.
3169
3896
  *