@dudousxd/nestjs-catalog 0.27.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.pipeline.d.ts +399 -10
- package/dist/catalog.pipeline.js +648 -63
- package/dist/client.d.ts +2 -2
- package/dist/client.js +20 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +17 -4
- 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_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;
|
|
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;
|
|
@@ -46,6 +46,8 @@ exports.renameColumnRefusals = renameColumnRefusals;
|
|
|
46
46
|
exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
|
|
47
47
|
exports.isWorkflowAggregateFunction = isWorkflowAggregateFunction;
|
|
48
48
|
exports.unreachableAggregateFunction = unreachableAggregateFunction;
|
|
49
|
+
exports.isWorkflowLookupUnmatched = isWorkflowLookupUnmatched;
|
|
50
|
+
exports.unreachableLookupUnmatched = unreachableLookupUnmatched;
|
|
49
51
|
exports.workflowAggregateMaxGroups = workflowAggregateMaxGroups;
|
|
50
52
|
exports.workflowAggregateSeparator = workflowAggregateSeparator;
|
|
51
53
|
exports.workflowAggregateJoinMaxLength = workflowAggregateJoinMaxLength;
|
|
@@ -53,7 +55,11 @@ exports.workflowAggregateNeedsColumn = workflowAggregateNeedsColumn;
|
|
|
53
55
|
exports.workflowAggregateColumns = workflowAggregateColumns;
|
|
54
56
|
exports.workflowAggregateOutputColumns = workflowAggregateOutputColumns;
|
|
55
57
|
exports.aggregateRefusals = aggregateRefusals;
|
|
58
|
+
exports.workflowLookupUnmatched = workflowLookupUnmatched;
|
|
59
|
+
exports.workflowLookupKey = workflowLookupKey;
|
|
60
|
+
exports.lookupConfigRefusals = lookupConfigRefusals;
|
|
56
61
|
exports.isWorkflowAggregates = isWorkflowAggregates;
|
|
62
|
+
exports.isWorkflowLookupFields = isWorkflowLookupFields;
|
|
57
63
|
exports.isReusableNodeKind = isReusableNodeKind;
|
|
58
64
|
exports.nodeKindIsReusable = nodeKindIsReusable;
|
|
59
65
|
exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
|
|
@@ -74,6 +80,7 @@ exports.workflowNodeRuns = workflowNodeRuns;
|
|
|
74
80
|
exports.workflowGraphHash = workflowGraphHash;
|
|
75
81
|
exports.workflowFilterColumns = workflowFilterColumns;
|
|
76
82
|
exports.workflowKnownColumns = workflowKnownColumns;
|
|
83
|
+
exports.workflowLookupColumns = workflowLookupColumns;
|
|
77
84
|
exports.isWorkflowNode = isWorkflowNode;
|
|
78
85
|
exports.isWorkflowEdge = isWorkflowEdge;
|
|
79
86
|
exports.supportsWorkflows = supportsWorkflows;
|
|
@@ -511,12 +518,25 @@ function isWorkflowSkipReason(value) {
|
|
|
511
518
|
* {@link WorkflowIfNode} is the conditional one, and it earns its kind by
|
|
512
519
|
* doing something no wiring can express — deciding that one of those
|
|
513
520
|
* successors, and everything only it feeds, does not run at all.
|
|
514
|
-
* - **merge
|
|
515
|
-
* concatenated in edge order (see {@link WorkflowEdge})
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
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.
|
|
520
540
|
* - **call a durable *step*** — the sibling of {@link WorkflowCallNode} that
|
|
521
541
|
* somebody will eventually come looking for, and it cannot be built. A
|
|
522
542
|
* durable step has no global identity: it is dispatched by a routing name
|
|
@@ -544,6 +564,8 @@ exports.WORKFLOW_NODE_KINDS = [
|
|
|
544
564
|
'rename',
|
|
545
565
|
/** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
|
|
546
566
|
'aggregate',
|
|
567
|
+
/** Enriches each row from a reference dataset, by key. See {@link WorkflowLookupNode}. */
|
|
568
|
+
'lookup',
|
|
547
569
|
];
|
|
548
570
|
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
549
571
|
function isWorkflowNodeKind(value) {
|
|
@@ -1402,6 +1424,38 @@ function isWorkflowAggregateFunction(value) {
|
|
|
1402
1424
|
function unreachableAggregateFunction(fn, where) {
|
|
1403
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.`);
|
|
1404
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
|
+
}
|
|
1405
1459
|
/**
|
|
1406
1460
|
* How many columns one node may group on.
|
|
1407
1461
|
*
|
|
@@ -1570,6 +1624,117 @@ function aggregateRefusals(node) {
|
|
|
1570
1624
|
];
|
|
1571
1625
|
return refusals;
|
|
1572
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
|
+
}
|
|
1573
1738
|
/**
|
|
1574
1739
|
* What the grouping half can be wrong about, and the names it accepted.
|
|
1575
1740
|
*
|
|
@@ -1654,6 +1819,44 @@ function oneAggregateRefusals(entry, groupedOn, names) {
|
|
|
1654
1819
|
refusals.push(...joinFieldRefusals(entry, fn, label));
|
|
1655
1820
|
return refusals;
|
|
1656
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
|
+
}
|
|
1657
1860
|
/**
|
|
1658
1861
|
* The two fields only `join` reads.
|
|
1659
1862
|
*
|
|
@@ -1715,6 +1918,31 @@ function isWorkflowAggregates(value) {
|
|
|
1715
1918
|
return false;
|
|
1716
1919
|
return aggregateRefusals({ groupBy: ['x'], aggregates: value }).length === 0;
|
|
1717
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
|
+
}
|
|
1718
1946
|
/* --- reusable nodes ------------------------------------------------------ */
|
|
1719
1947
|
/**
|
|
1720
1948
|
* The node kinds that can be saved once and used in several graphs.
|
|
@@ -1780,6 +2008,11 @@ function isReusableNodeKind(value) {
|
|
|
1780
2008
|
* its *output* column set is the thing downstream nodes are validated against,
|
|
1781
2009
|
* so a shared node editable from elsewhere would silently change what another
|
|
1782
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.
|
|
1783
2016
|
*/
|
|
1784
2017
|
exports.NODE_KIND_IS_REUSABLE = {
|
|
1785
2018
|
source: true,
|
|
@@ -1790,6 +2023,7 @@ exports.NODE_KIND_IS_REUSABLE = {
|
|
|
1790
2023
|
filter: false,
|
|
1791
2024
|
rename: false,
|
|
1792
2025
|
aggregate: false,
|
|
2026
|
+
lookup: false,
|
|
1793
2027
|
};
|
|
1794
2028
|
/** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
|
|
1795
2029
|
function nodeKindIsReusable(kind) {
|
|
@@ -2224,6 +2458,29 @@ exports.WORKFLOW_ISSUE_CODES = [
|
|
|
2224
2458
|
* on. See {@link aggregateRefusals} for the sentences.
|
|
2225
2459
|
*/
|
|
2226
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',
|
|
2227
2484
|
/**
|
|
2228
2485
|
* A node naming a column that nothing upstream can produce.
|
|
2229
2486
|
*
|
|
@@ -2300,6 +2557,10 @@ function validateWorkflow(graph, knowledge) {
|
|
|
2300
2557
|
const roots = nodes.filter(runsWithoutInput);
|
|
2301
2558
|
const sinks = nodes.filter((node) => node.kind === 'sink');
|
|
2302
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);
|
|
2303
2564
|
checkEndpoints(originators, sinks, issues);
|
|
2304
2565
|
checkBranches(edges, byId, issues);
|
|
2305
2566
|
checkPlainCallOutputs(nodes, outgoing, byId, issues);
|
|
@@ -2695,6 +2956,8 @@ function nodeIsUnconfigured(node) {
|
|
|
2695
2956
|
return renameIsUnconfigured(node);
|
|
2696
2957
|
if (node.kind === 'aggregate')
|
|
2697
2958
|
return aggregateIsUnconfigured(node);
|
|
2959
|
+
if (node.kind === 'lookup')
|
|
2960
|
+
return lookupIsUnconfigured(node);
|
|
2698
2961
|
return undefined;
|
|
2699
2962
|
}
|
|
2700
2963
|
/**
|
|
@@ -2718,6 +2981,89 @@ function aggregateIsUnconfigured(node) {
|
|
|
2718
2981
|
message: `Aggregate "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
|
|
2719
2982
|
};
|
|
2720
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
|
+
}
|
|
2721
3067
|
/**
|
|
2722
3068
|
* A rename whose map this service will not store.
|
|
2723
3069
|
*
|
|
@@ -3250,25 +3596,8 @@ function workflowGraphHash(graph) {
|
|
|
3250
3596
|
}
|
|
3251
3597
|
/** The parts of a node that change what a run produces. */
|
|
3252
3598
|
function canonicalNode(node) {
|
|
3253
|
-
if (node.kind === 'source')
|
|
3254
|
-
return
|
|
3255
|
-
node.id,
|
|
3256
|
-
node.kind,
|
|
3257
|
-
node.sourceKind,
|
|
3258
|
-
node.connectionId ?? '',
|
|
3259
|
-
node.secretEnvVar ?? '',
|
|
3260
|
-
node.mode ?? 'full',
|
|
3261
|
-
// Sorted keys, so a canvas that rewrites the object in a different order
|
|
3262
|
-
// does not look like an edit.
|
|
3263
|
-
sortedEntries(node.config),
|
|
3264
|
-
// Appended only when there is a reference, exactly as `edge.branch` above
|
|
3265
|
-
// is appended only when there is a label, and for the same reason: adding
|
|
3266
|
-
// reusable nodes to this file must not renumber the version of a single
|
|
3267
|
-
// graph that did not change. Every source drawn before they existed
|
|
3268
|
-
// hashes to the string it always did.
|
|
3269
|
-
...canonicalReuse(node),
|
|
3270
|
-
]);
|
|
3271
|
-
}
|
|
3599
|
+
if (node.kind === 'source')
|
|
3600
|
+
return canonicalSource(node);
|
|
3272
3601
|
if (node.kind === 'transform') {
|
|
3273
3602
|
// The transform's *version as stored* is deliberately not in here, and that
|
|
3274
3603
|
// has not changed: editing a transform is recorded as a new transform
|
|
@@ -3330,6 +3659,8 @@ function canonicalNode(node) {
|
|
|
3330
3659
|
return canonicalRename(node);
|
|
3331
3660
|
if (node.kind === 'aggregate')
|
|
3332
3661
|
return canonicalAggregate(node);
|
|
3662
|
+
if (node.kind === 'lookup')
|
|
3663
|
+
return canonicalLookup(node);
|
|
3333
3664
|
if (node.kind === 'sink') {
|
|
3334
3665
|
return JSON.stringify([
|
|
3335
3666
|
node.id,
|
|
@@ -3341,6 +3672,34 @@ function canonicalNode(node) {
|
|
|
3341
3672
|
}
|
|
3342
3673
|
return unreachableNodeKind(node, 'workflowGraphHash');
|
|
3343
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
|
+
}
|
|
3344
3703
|
/**
|
|
3345
3704
|
* A rename, canonicalised.
|
|
3346
3705
|
*
|
|
@@ -3404,6 +3763,38 @@ function canonicalAggregate(node) {
|
|
|
3404
3763
|
...(node.maxGroups === undefined ? [] : [node.maxGroups]),
|
|
3405
3764
|
]);
|
|
3406
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
|
+
}
|
|
3407
3798
|
/**
|
|
3408
3799
|
* The reusable reference, as zero, one or two trailing hash components.
|
|
3409
3800
|
*
|
|
@@ -3588,7 +3979,7 @@ function workflowFilterColumns(predicate) {
|
|
|
3588
3979
|
* cyclic graph before it gets here, but the canvas calls this while a graph is
|
|
3589
3980
|
* being drawn and is entitled to a wrong-but-terminating answer.
|
|
3590
3981
|
*/
|
|
3591
|
-
function workflowKnownColumns(graph, nodeId, knowledge) {
|
|
3982
|
+
function workflowKnownColumns(graph, nodeId, knowledge, onlyFrom) {
|
|
3592
3983
|
const nodes = graph.nodes ?? [];
|
|
3593
3984
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
3594
3985
|
const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
|
|
@@ -3605,13 +3996,16 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
|
|
|
3605
3996
|
if (!node)
|
|
3606
3997
|
return undefined;
|
|
3607
3998
|
open.add(id);
|
|
3608
|
-
const produced = producedColumns(node, () => intoNode(id), knowledge);
|
|
3999
|
+
const produced = producedColumns(node, (only) => intoNode(id, only), knowledge);
|
|
3609
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.
|
|
3610
4004
|
answered.set(id, produced);
|
|
3611
4005
|
return produced;
|
|
3612
4006
|
};
|
|
3613
|
-
const intoNode = (id) => {
|
|
3614
|
-
const feeds = incoming.get(id) ?? [];
|
|
4007
|
+
const intoNode = (id, only) => {
|
|
4008
|
+
const feeds = (incoming.get(id) ?? []).filter((from) => only?.(from) ?? true);
|
|
3615
4009
|
if (feeds.length === 0)
|
|
3616
4010
|
return undefined;
|
|
3617
4011
|
const union = new Set();
|
|
@@ -3627,7 +4021,28 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
|
|
|
3627
4021
|
}
|
|
3628
4022
|
return union;
|
|
3629
4023
|
};
|
|
3630
|
-
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
|
+
};
|
|
3631
4046
|
}
|
|
3632
4047
|
/**
|
|
3633
4048
|
* What one node passes on, given what reaches it.
|
|
@@ -3642,17 +4057,10 @@ function workflowKnownColumns(graph, nodeId, knowledge) {
|
|
|
3642
4057
|
* *safe* wrong answer and would therefore never be noticed.
|
|
3643
4058
|
*/
|
|
3644
4059
|
function producedColumns(node, upstream, knowledge) {
|
|
3645
|
-
if (node.kind === '
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
if (known === undefined)
|
|
3650
|
-
return undefined;
|
|
3651
|
-
const renamed = new Set();
|
|
3652
|
-
for (const column of known)
|
|
3653
|
-
renamed.add(node.columns?.[column] ?? column);
|
|
3654
|
-
return renamed;
|
|
3655
|
-
}
|
|
4060
|
+
if (node.kind === 'lookup')
|
|
4061
|
+
return lookupProducedColumns(node, upstream);
|
|
4062
|
+
if (node.kind === 'rename')
|
|
4063
|
+
return renameProducedColumns(node, upstream);
|
|
3656
4064
|
// The one kind whose output set is *exact* rather than an upper bound, and it
|
|
3657
4065
|
// is exact without looking upstream at all: an aggregate emits its group-by
|
|
3658
4066
|
// columns and its named aggregates on every record it produces, whatever it
|
|
@@ -3676,6 +4084,50 @@ function producedColumns(node, upstream, knowledge) {
|
|
|
3676
4084
|
}
|
|
3677
4085
|
return unreachableNodeKind(node, 'workflowKnownColumns');
|
|
3678
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
|
+
}
|
|
3679
4131
|
/**
|
|
3680
4132
|
* What a source produces, per source kind.
|
|
3681
4133
|
*
|
|
@@ -3746,22 +4198,11 @@ function checkColumnsProduced(graph, issues, knowledge) {
|
|
|
3746
4198
|
// Narrowed off the union rather than tested with a property check, so a kind
|
|
3747
4199
|
// that starts naming columns without being answered for here is a type error
|
|
3748
4200
|
// at `missingColumnMessage` and not a check that silently passes.
|
|
3749
|
-
if (node.kind
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
const known = workflowKnownColumns(graph, node.id, knowledge);
|
|
3755
|
-
if (known === undefined)
|
|
3756
|
-
continue;
|
|
3757
|
-
const missing = named.filter((column) => column.length > 0 && !known.has(column));
|
|
3758
|
-
if (missing.length === 0)
|
|
3759
|
-
continue;
|
|
3760
|
-
issues.push({
|
|
3761
|
-
code: 'column-not-produced',
|
|
3762
|
-
nodeIds: [node.id],
|
|
3763
|
-
message: missingColumnMessage(node, missing, known),
|
|
3764
|
-
});
|
|
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
|
+
}
|
|
3765
4206
|
}
|
|
3766
4207
|
}
|
|
3767
4208
|
/**
|
|
@@ -3779,6 +4220,109 @@ function columnsNamedBy(node) {
|
|
|
3779
4220
|
return Object.keys(node.columns ?? {});
|
|
3780
4221
|
return workflowAggregateColumns(node);
|
|
3781
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)) {
|
|
4302
|
+
issues.push({
|
|
4303
|
+
code: 'column-not-produced',
|
|
4304
|
+
nodeIds: [node.id],
|
|
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.`,
|
|
4306
|
+
});
|
|
4307
|
+
}
|
|
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
|
+
}
|
|
3782
4326
|
/** The sentence {@link checkColumnsProduced} says, per kind. */
|
|
3783
4327
|
function missingColumnMessage(node, missing, known) {
|
|
3784
4328
|
const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
|
|
@@ -3851,13 +4395,25 @@ function isWorkflowNode(value) {
|
|
|
3851
4395
|
return isRenameNodeShape(value);
|
|
3852
4396
|
if (kind === 'aggregate')
|
|
3853
4397
|
return isAggregateNodeShape(value);
|
|
3854
|
-
if (kind === '
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
return
|
|
3858
|
-
}
|
|
4398
|
+
if (kind === 'lookup')
|
|
4399
|
+
return isLookupNodeShape(value);
|
|
4400
|
+
if (kind === 'source')
|
|
4401
|
+
return isSourceNodeShape(value);
|
|
3859
4402
|
return isWorkflowNodeKindUnhandled(kind);
|
|
3860
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
|
+
}
|
|
3861
4417
|
/**
|
|
3862
4418
|
* Everything a `rename` node carries.
|
|
3863
4419
|
*
|
|
@@ -3891,6 +4447,35 @@ function isAggregateNodeShape(value) {
|
|
|
3891
4447
|
maxGroups: Reflect.get(value, 'maxGroups'),
|
|
3892
4448
|
}).length === 0);
|
|
3893
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
|
+
}
|
|
3894
4479
|
/**
|
|
3895
4480
|
* Everything a `call` node carries, checked as strictly as a source's.
|
|
3896
4481
|
*
|