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