@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.
@@ -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 / join** — a node with several inbound edges receives its inputs
515
- * concatenated in edge order (see {@link WorkflowEdge}). A keyed join is then
516
- * ordinary code inside the transform, which can already see every record.
517
- * A `merge` kind would have had to carry a strategy field whose values the
518
- * runner would have to implement one by one, and an unimplemented strategy in
519
- * a dropdown is the failure this list exists to avoid.
521
+ * - **merge (unkeyed)** — a node with several inbound edges already receives its
522
+ * inputs concatenated in edge order (see {@link WorkflowEdge}), so a `merge`
523
+ * kind would be a box that draws what the wires already say. It would also
524
+ * have had to carry a strategy field whose values the runner implements one by
525
+ * one, and an unimplemented strategy in a dropdown is the failure this list
526
+ * exists to avoid.
527
+ * - **join (keyed)** — *this half used to be refused with the entry above, and
528
+ * the reversal is left visible rather than edited out*, the way the `filter`
529
+ * entry leaves its own. The old argument was that a keyed join is ordinary
530
+ * code inside a transform, which can already see every record. Every word of
531
+ * that is true and it is exactly the problem: "can already see every record"
532
+ * is the same sentence as "holds the whole load", and it is why a transform
533
+ * makes `ConnectorRunnerService` log *"Held all N records in memory"*. A join
534
+ * does not need both sides held. It needs **one** side held — as a map, keyed
535
+ * — while the other streams past it, and that asymmetry is a property of the
536
+ * operation that a function over a batch cannot express and a runner therefore
537
+ * cannot exploit. {@link WorkflowLookupNode} is the keyed half, built narrow:
538
+ * one key, named enrichment fields, and a reference side that is bounded and
539
+ * refused loudly rather than held quietly.
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 JSON.stringify([
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 === 'rename') {
3646
- if (workflowRenameUnnamed(node) === 'drop')
3647
- return new Set(Object.values(node.columns ?? {}));
3648
- const known = upstream();
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 !== 'filter' && node.kind !== 'rename' && node.kind !== 'aggregate')
3750
- continue;
3751
- const named = columnsNamedBy(node);
3752
- if (named.length === 0)
3753
- continue;
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 === 'source') {
3855
- const sourceKind = Reflect.get(value, 'sourceKind');
3856
- const config = Reflect.get(value, 'config');
3857
- return isConnectorKind(sourceKind) && typeof config === 'object' && config !== null;
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
  *