@dudousxd/nestjs-catalog 0.22.0 → 0.23.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_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_LANGUAGES = exports.SOURCE_FORMATS = exports.CONNECTOR_KINDS = void 0;
12
+ exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_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_LANGUAGES = exports.SOURCE_FORMATS = exports.CONNECTOR_KINDS = void 0;
13
13
  exports.isConnectorKind = isConnectorKind;
14
14
  exports.isSourceFormat = isSourceFormat;
15
15
  exports.unreachableSourceFormat = unreachableSourceFormat;
@@ -32,6 +32,11 @@ exports.unreachableFilterOperator = unreachableFilterOperator;
32
32
  exports.isWorkflowFilterValue = isWorkflowFilterValue;
33
33
  exports.isWorkflowFilterPredicate = isWorkflowFilterPredicate;
34
34
  exports.workflowFilterMatches = workflowFilterMatches;
35
+ exports.isWorkflowRenameUnnamed = isWorkflowRenameUnnamed;
36
+ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
37
+ exports.workflowRenameUnnamed = workflowRenameUnnamed;
38
+ exports.renameColumnRefusals = renameColumnRefusals;
39
+ exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
35
40
  exports.isReusableNodeKind = isReusableNodeKind;
36
41
  exports.nodeKindIsReusable = nodeKindIsReusable;
37
42
  exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
@@ -50,6 +55,8 @@ exports.workflowNarrowedTypes = workflowNarrowedTypes;
50
55
  exports.workflowRunOrder = workflowRunOrder;
51
56
  exports.workflowNodeRuns = workflowNodeRuns;
52
57
  exports.workflowGraphHash = workflowGraphHash;
58
+ exports.workflowFilterColumns = workflowFilterColumns;
59
+ exports.workflowKnownColumns = workflowKnownColumns;
53
60
  exports.isWorkflowNode = isWorkflowNode;
54
61
  exports.isWorkflowEdge = isWorkflowEdge;
55
62
  exports.supportsWorkflows = supportsWorkflows;
@@ -59,6 +66,7 @@ exports.supportsTransformRevisions = supportsTransformRevisions;
59
66
  exports.supportsTransformPins = supportsTransformPins;
60
67
  exports.supportsReusableNodes = supportsReusableNodes;
61
68
  exports.supportsWorkflowStages = supportsWorkflowStages;
69
+ exports.supportsStagePayloads = supportsStagePayloads;
62
70
  exports.supportsLoadExpectations = supportsLoadExpectations;
63
71
  exports.isPipelineStore = isPipelineStore;
64
72
  // The revision shape is declared beside the audit trail rather than here,
@@ -296,6 +304,8 @@ exports.WORKFLOW_NODE_KINDS = [
296
304
  'if',
297
305
  /** Drops the rows that fail a declarative test. See {@link WorkflowFilterNode}. */
298
306
  'filter',
307
+ /** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
308
+ 'rename',
299
309
  ];
300
310
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
301
311
  function isWorkflowNodeKind(value) {
@@ -960,6 +970,116 @@ function orderedHolds(operator, held, wanted) {
960
970
  return held < wanted;
961
971
  return held <= wanted;
962
972
  }
973
+ /* --- rename -------------------------------------------------------------- */
974
+ /**
975
+ * What happens to a column the rename does not name.
976
+ *
977
+ * Two words rather than a boolean, because the two are genuinely different
978
+ * nodes and a boolean called `drop` would read as a modifier on one node. See
979
+ * {@link WorkflowRenameNode.unnamed} for what each costs.
980
+ */
981
+ exports.WORKFLOW_RENAME_UNNAMED = [
982
+ /** Passed through untouched. The default, and the metadata-only case. */
983
+ 'keep',
984
+ /** Removed. The output columns are exactly the map's targets. */
985
+ 'drop',
986
+ ];
987
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
988
+ function isWorkflowRenameUnnamed(value) {
989
+ return exports.WORKFLOW_RENAME_UNNAMED.some((each) => each === value);
990
+ }
991
+ /**
992
+ * The exhaustiveness guard for {@link WORKFLOW_RENAME_UNNAMED}.
993
+ *
994
+ * {@link unreachableNodeKind}, one level down, and for the identical reason: the
995
+ * two words decide whether a batch is rewritten or only re-labelled, and a third
996
+ * one added without a branch would silently pick whichever the last `if` was.
997
+ */
998
+ function unreachableRenameUnnamed(value, where) {
999
+ throw new Error(`${where} does not handle the rename disposition ${JSON.stringify(value)}. The list and every decision made per entry are meant to move together.`);
1000
+ }
1001
+ /**
1002
+ * How many columns one rename may name.
1003
+ *
1004
+ * The same argument {@link WORKFLOW_FILTER_MAX_VALUES} makes: the map travels in
1005
+ * the graph and into the graph fingerprint, and past a few hundred entries the
1006
+ * thing being expressed is a schema mapping that belongs in a stored object
1007
+ * rather than in a node. It is also the bound that keeps
1008
+ * {@link isWorkflowRenameColumns} — which is run on JSON out of a column — from
1009
+ * being a place to hand a service a million-key object.
1010
+ */
1011
+ exports.WORKFLOW_RENAME_MAX_COLUMNS = 500;
1012
+ /** {@link WorkflowRenameNode.unnamed}, resolved. One reader of the default. */
1013
+ function workflowRenameUnnamed(node) {
1014
+ return node.unnamed ?? 'keep';
1015
+ }
1016
+ /**
1017
+ * Every reason a rename map cannot be stored, as sentences, or empty.
1018
+ *
1019
+ * One function, called by {@link validateWorkflow}, by the HTTP boundary and by
1020
+ * the canvas, for the reason `validateWorkflow` itself is shared: a screen that
1021
+ * checked a target name against its own copy of the pattern is a screen that
1022
+ * eventually accepts something the server refuses, halfway through a save.
1023
+ *
1024
+ * All of them rather than the first, exactly as
1025
+ * {@link refuseUnpublishablePropertyNames} argues: a map of forty columns typed
1026
+ * in one sitting is usually wrong about several in the same way.
1027
+ */
1028
+ function renameColumnRefusals(columns) {
1029
+ const refusals = [];
1030
+ const entries = Object.entries(columns);
1031
+ if (entries.length === 0) {
1032
+ refusals.push('It renames nothing. An empty map is refused rather than stored: with unnamed columns kept it is a node that draws as configured and does nothing, and with them dropped it deletes every column of every row.');
1033
+ return refusals;
1034
+ }
1035
+ if (entries.length > exports.WORKFLOW_RENAME_MAX_COLUMNS) {
1036
+ refusals.push(`It names ${entries.length} columns, and at most ${exports.WORKFLOW_RENAME_MAX_COLUMNS} may be renamed in one node. Past that the thing being expressed is a schema mapping rather than a rename.`);
1037
+ }
1038
+ const targets = new Map();
1039
+ for (const [from, to] of entries) {
1040
+ if (from.length === 0) {
1041
+ refusals.push('One entry renames a column with no name, so there is nothing for it to find.');
1042
+ continue;
1043
+ }
1044
+ if (typeof to !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(to)) {
1045
+ refusals.push(`${JSON.stringify(from)} is renamed to ${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.`);
1046
+ continue;
1047
+ }
1048
+ targets.set(to, [...(targets.get(to) ?? []), from]);
1049
+ }
1050
+ for (const [to, sources] of targets) {
1051
+ if (sources.length < 2)
1052
+ continue;
1053
+ refusals.push(`${sources.map((from) => JSON.stringify(from)).join(' and ')} are both renamed to ${JSON.stringify(to)}. Two columns cannot share one name, and picking a winner would be a rule about which of somebody's data survives.`);
1054
+ }
1055
+ return refusals;
1056
+ }
1057
+ /**
1058
+ * Whether a stored rename map is one this build can run.
1059
+ *
1060
+ * Refused rather than repaired, the stance {@link isWorkflowFilterPredicate}
1061
+ * takes about a predicate and for the same reason one step further along: a
1062
+ * rename read back with one entry silently dropped is a graph that commits a
1063
+ * column of NULLs under a name nobody can now explain.
1064
+ *
1065
+ * `Object.entries` rather than a `for…in`, so an inherited key cannot enter the
1066
+ * map, and the values are checked one by one rather than trusted from the type.
1067
+ */
1068
+ function isWorkflowRenameColumns(value) {
1069
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1070
+ return false;
1071
+ const entries = Object.entries(value);
1072
+ if (entries.every(([, to]) => typeof to === 'string')) {
1073
+ const columns = {};
1074
+ for (const [from, to] of entries) {
1075
+ if (typeof to !== 'string')
1076
+ return false;
1077
+ columns[from] = to;
1078
+ }
1079
+ return renameColumnRefusals(columns).length === 0;
1080
+ }
1081
+ return false;
1082
+ }
963
1083
  /* --- reusable nodes ------------------------------------------------------ */
964
1084
  /**
965
1085
  * The node kinds that can be saved once and used in several graphs.
@@ -1013,6 +1133,11 @@ function isReusableNodeKind(value) {
1013
1133
  * may not have, and a filter is worse: {@link WorkflowFilterNode.narrows} is
1014
1134
  * an acknowledgement about *this* graph's sinks, so a shared one would carry
1015
1135
  * somebody else's acknowledgement into a graph they never saw.
1136
+ * - `rename` — the same argument as `if` and `filter`, and the sharpest version
1137
+ * of it: a rename map names the source's own spelling of its columns, so it is
1138
+ * *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
1139
+ * dropped into a graph reading a different system renames nothing at all, and
1140
+ * the symptom is a column of NULLs rather than a failure.
1016
1141
  */
1017
1142
  exports.NODE_KIND_IS_REUSABLE = {
1018
1143
  source: true,
@@ -1021,6 +1146,7 @@ exports.NODE_KIND_IS_REUSABLE = {
1021
1146
  call: false,
1022
1147
  if: false,
1023
1148
  filter: false,
1149
+ rename: false,
1024
1150
  };
1025
1151
  /** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
1026
1152
  function nodeKindIsReusable(kind) {
@@ -1425,6 +1551,29 @@ exports.WORKFLOW_ISSUE_CODES = [
1425
1551
  'filter-predicate-invalid',
1426
1552
  'filter-narrows-unacknowledged',
1427
1553
  'filter-narrows-nothing',
1554
+ /**
1555
+ * A rename whose map cannot be stored: empty, too big, or naming a target that
1556
+ * is not a column name or that two source columns share. Every one of those is
1557
+ * detectable from the node alone, which is the point of the node — see
1558
+ * {@link renameColumnRefusals} for the sentences.
1559
+ */
1560
+ 'rename-invalid',
1561
+ /**
1562
+ * A node naming a column that nothing upstream can produce.
1563
+ *
1564
+ * The one thing a declarative rename buys the *validator*, and it is the whole
1565
+ * reason the node is data rather than code. A rename with
1566
+ * `unnamed: 'drop'` has an output column set that is known exactly from its
1567
+ * config — its targets, and nothing else, whatever it was handed. So a filter
1568
+ * or a second rename downstream of one, naming a column outside that set, is
1569
+ * provably wrong at the moment the graph is saved instead of at the moment the
1570
+ * load comes out empty.
1571
+ *
1572
+ * Reported only where the set is *closed*. See {@link workflowKnownColumns}
1573
+ * for exactly how far that reaches and for what it deliberately does not
1574
+ * claim.
1575
+ */
1576
+ 'column-not-produced',
1428
1577
  /**
1429
1578
  * A version pin that is not a version — `0`, `2.5`, `"3"`, `-1`.
1430
1579
  *
@@ -1496,6 +1645,9 @@ function validateWorkflow(graph) {
1496
1645
  return issues;
1497
1646
  }
1498
1647
  checkReachability(nodes, roots, sinks, incoming, outgoing, issues);
1648
+ // After the cycle check has returned, so the walk it does cannot meet a loop
1649
+ // on a graph this function has already accepted as acyclic.
1650
+ checkColumnsProduced({ nodes, edges }, issues);
1499
1651
  return issues;
1500
1652
  }
1501
1653
  /**
@@ -1851,8 +2003,31 @@ function nodeIsUnconfigured(node) {
1851
2003
  return ifIsUnconfigured(node);
1852
2004
  if (node.kind === 'filter')
1853
2005
  return filterIsUnconfigured(node);
2006
+ if (node.kind === 'rename')
2007
+ return renameIsUnconfigured(node);
1854
2008
  return undefined;
1855
2009
  }
2010
+ /**
2011
+ * A rename whose map this service will not store.
2012
+ *
2013
+ * The refusals come from {@link renameColumnRefusals} rather than being restated
2014
+ * here, so the canvas, the HTTP boundary and this validator say the same
2015
+ * sentence about the same map. Every one of them is a *silent* failure if it
2016
+ * were let through: an empty map is a node that does nothing or a node that
2017
+ * deletes every column, a target that is not a column name loads NULL into every
2018
+ * row and reports success, and two columns renamed onto one name means one of
2019
+ * them is gone and nothing says which.
2020
+ */
2021
+ function renameIsUnconfigured(node) {
2022
+ const refusals = renameColumnRefusals(node.columns ?? {});
2023
+ if (refusals.length === 0)
2024
+ return undefined;
2025
+ return {
2026
+ code: 'rename-invalid',
2027
+ nodeIds: [node.id],
2028
+ message: `Rename "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
2029
+ };
2030
+ }
1856
2031
  /**
1857
2032
  * A filter whose test cannot decide anything.
1858
2033
  *
@@ -2440,6 +2615,8 @@ function canonicalNode(node) {
2440
2615
  [...(node.narrows ?? [])].sort(),
2441
2616
  ]);
2442
2617
  }
2618
+ if (node.kind === 'rename')
2619
+ return canonicalRename(node);
2443
2620
  if (node.kind === 'sink') {
2444
2621
  return JSON.stringify([
2445
2622
  node.id,
@@ -2451,6 +2628,28 @@ function canonicalNode(node) {
2451
2628
  }
2452
2629
  return unreachableNodeKind(node, 'workflowGraphHash');
2453
2630
  }
2631
+ /**
2632
+ * A rename, canonicalised.
2633
+ *
2634
+ * Sorted by source column, so a canvas that rewrites the object in a different
2635
+ * order is not an edit — the rule `sortedEntries` applies to a source's config,
2636
+ * and it is safe here for a reason specific to this node: the map is applied
2637
+ * *simultaneously*, so its order changes nothing about the result.
2638
+ *
2639
+ * `unnamed` is appended only when it is `drop`, exactly as `edge.branch` is
2640
+ * appended only when there is a label. Every rename that keeps its unnamed
2641
+ * columns — whether it says so or says nothing — hashes to one string, so
2642
+ * normalising the field on a canvas cannot renumber a graph. It is in there at
2643
+ * all because it decides which columns reach the sink.
2644
+ */
2645
+ function canonicalRename(node) {
2646
+ return JSON.stringify([
2647
+ node.id,
2648
+ node.kind,
2649
+ sortedEntries(node.columns),
2650
+ ...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
2651
+ ]);
2652
+ }
2454
2653
  /**
2455
2654
  * The reusable reference, as zero, one or two trailing hash components.
2456
2655
  *
@@ -2552,6 +2751,207 @@ function canonicalFilterPredicate(predicate) {
2552
2751
  }
2553
2752
  return unreachableFilterPredicateKind(predicate, 'workflowGraphHash');
2554
2753
  }
2754
+ /* --- what the graph knows about columns ---------------------------------- */
2755
+ /**
2756
+ * Every column a filter predicate names, once each, in the order they appear.
2757
+ *
2758
+ * Its own function rather than a walk inlined into the validator, because two
2759
+ * things want it — the refusal below and anything on a screen that wants to say
2760
+ * which columns a node depends on — and a second copy of a tree walk is a second
2761
+ * copy that forgets the `oneOf` branch.
2762
+ */
2763
+ function workflowFilterColumns(predicate) {
2764
+ const found = [];
2765
+ const visit = (each) => {
2766
+ if (each.kind === 'all' || each.kind === 'any') {
2767
+ for (const child of each.children)
2768
+ visit(child);
2769
+ return;
2770
+ }
2771
+ if (!found.includes(each.column))
2772
+ found.push(each.column);
2773
+ };
2774
+ visit(predicate);
2775
+ return found;
2776
+ }
2777
+ /**
2778
+ * The columns that can reach this node, when the graph knows — and `undefined`
2779
+ * when it does not.
2780
+ *
2781
+ * ## What this is for
2782
+ *
2783
+ * It is the one thing a declarative rename buys that a transform cannot, and it
2784
+ * is worth being precise about how far it reaches rather than overselling it.
2785
+ *
2786
+ * With a JS transform, the catalog cannot know what columns come out — the
2787
+ * answer is inside a function body — which is why the property-name rule in
2788
+ * `property-names.ts` fires at publish time and why a mismatch between a
2789
+ * property and a record key is discovered as a column of NULLs. A rename is
2790
+ * **data**, so for one arrangement the answer is exact:
2791
+ *
2792
+ * > A rename with `unnamed: 'drop'` produces its targets and **nothing else**,
2793
+ * > whatever it was handed.
2794
+ *
2795
+ * That set is *closed* — an upper bound that holds regardless of what is
2796
+ * upstream — and it survives every node that does not touch columns. So a filter
2797
+ * or a second rename downstream of one can be told, at authoring time, that it
2798
+ * names a column which cannot be there.
2799
+ *
2800
+ * ## What it deliberately does not claim
2801
+ *
2802
+ * - **It is an upper bound, not the output.** A target only appears in a row
2803
+ * whose input actually held the source column. So a column *inside* the set
2804
+ * may still be absent, and nothing here says otherwise.
2805
+ * - **A `keep` rename tells you nothing on its own.** Its output is its input
2806
+ * with some keys re-labelled, and its input is unknown unless something
2807
+ * upstream closed it. So `undefined` propagates, and that is the honest
2808
+ * answer rather than an empty set.
2809
+ * - **A source, a transform and a call are always unknown.** A source's shape is
2810
+ * discovered against the live system rather than declared in the graph; a
2811
+ * transform is a function body; a call is a workflow this graph does not own.
2812
+ * - **It says nothing about a sink's declared properties.** That is the check
2813
+ * worth wanting — "this sink writes a property no upstream node produces" —
2814
+ * and it is *not* available here: a {@link WorkflowSinkNode} carries a
2815
+ * `targetType` and nothing else, so the property list would have to be
2816
+ * threaded into a validator that is pure and dependency-free on purpose. What
2817
+ * is built instead is the run log, which prints the columns a rename produced.
2818
+ *
2819
+ * Cycles answer `undefined` rather than looping. `validateWorkflow` refuses a
2820
+ * cyclic graph before it gets here, but the canvas calls this while a graph is
2821
+ * being drawn and is entitled to a wrong-but-terminating answer.
2822
+ */
2823
+ function workflowKnownColumns(graph, nodeId) {
2824
+ const nodes = graph.nodes ?? [];
2825
+ const byId = new Map(nodes.map((node) => [node.id, node]));
2826
+ const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
2827
+ const answered = new Map();
2828
+ const open = new Set();
2829
+ const outOf = (id) => {
2830
+ if (answered.has(id))
2831
+ return answered.get(id);
2832
+ // A loop. Answered as unknown and remembered, so the walk terminates and so
2833
+ // that a second question about the same node does not re-enter it.
2834
+ if (open.has(id))
2835
+ return undefined;
2836
+ const node = byId.get(id);
2837
+ if (!node)
2838
+ return undefined;
2839
+ open.add(id);
2840
+ const produced = producedColumns(node, () => intoNode(id));
2841
+ open.delete(id);
2842
+ answered.set(id, produced);
2843
+ return produced;
2844
+ };
2845
+ const intoNode = (id) => {
2846
+ const feeds = incoming.get(id) ?? [];
2847
+ if (feeds.length === 0)
2848
+ return undefined;
2849
+ const union = new Set();
2850
+ for (const from of feeds) {
2851
+ const upstream = outOf(from);
2852
+ // One unknown input makes the whole position unknown: the rows arrive
2853
+ // concatenated, so a column any one of them carries is a column this node
2854
+ // can see.
2855
+ if (upstream === undefined)
2856
+ return undefined;
2857
+ for (const column of upstream)
2858
+ union.add(column);
2859
+ }
2860
+ return union;
2861
+ };
2862
+ return intoNode(nodeId);
2863
+ }
2864
+ /**
2865
+ * What one node passes on, given what reaches it.
2866
+ *
2867
+ * The upstream set is a thunk rather than a value because the one case that
2868
+ * makes this function worth having does not need it: a rename that drops its
2869
+ * unnamed columns answers from its own config, so the walk stops there rather
2870
+ * than climbing to a source it would learn nothing from.
2871
+ *
2872
+ * Ends in {@link unreachableNodeKind}, so a node kind added without an answer
2873
+ * here is a compile error rather than a silent `undefined` — which would be the
2874
+ * *safe* wrong answer and would therefore never be noticed.
2875
+ */
2876
+ function producedColumns(node, upstream) {
2877
+ if (node.kind === 'rename') {
2878
+ if (workflowRenameUnnamed(node) === 'drop')
2879
+ return new Set(Object.values(node.columns ?? {}));
2880
+ const known = upstream();
2881
+ if (known === undefined)
2882
+ return undefined;
2883
+ const renamed = new Set();
2884
+ for (const column of known)
2885
+ renamed.add(node.columns?.[column] ?? column);
2886
+ return renamed;
2887
+ }
2888
+ // Neither of these touches a column: a filter decides which *rows* survive and
2889
+ // an `if` decides which *nodes* run. Both hand on exactly the shape they were
2890
+ // given, which is what makes a closed set survive one.
2891
+ if (node.kind === 'filter' || node.kind === 'if')
2892
+ return upstream();
2893
+ // A source's shape is discovered against the live system, a transform's is
2894
+ // inside a function body, a call's belongs to a workflow this graph does not
2895
+ // own, and nothing reads a sink's output. See {@link workflowKnownColumns}.
2896
+ if (node.kind === 'source' ||
2897
+ node.kind === 'transform' ||
2898
+ node.kind === 'call' ||
2899
+ node.kind === 'sink') {
2900
+ return undefined;
2901
+ }
2902
+ return unreachableNodeKind(node, 'workflowKnownColumns');
2903
+ }
2904
+ /**
2905
+ * That no node names a column the graph can prove is not there.
2906
+ *
2907
+ * Only where {@link workflowKnownColumns} answers, which is only downstream of a
2908
+ * rename that drops what it does not name. Everywhere else this is silent, and
2909
+ * that silence is correct rather than a gap being tolerated: refusing a column
2910
+ * the graph merely has no opinion about would make every filter downstream of a
2911
+ * transform unsaveable.
2912
+ *
2913
+ * A refusal rather than a warning, because both failures are silent and total.
2914
+ * A filter on a column that cannot exist matches no row — a comparison against
2915
+ * an absent column is false under the three-valued logic
2916
+ * {@link workflowFilterMatches} implements, *including the inverses* — so the
2917
+ * load comes out empty and every node reports success. A rename of a column that
2918
+ * cannot exist renames nothing, so the target is absent, and a sink writing it
2919
+ * commits NULL into every row. That is the exact shape `property-names.ts` was
2920
+ * written about, one node upstream of where it can be caught.
2921
+ */
2922
+ function checkColumnsProduced(graph, issues) {
2923
+ for (const node of graph.nodes ?? []) {
2924
+ // Narrowed off the union rather than tested with a property check, so a kind
2925
+ // that starts naming columns without being answered for here is a type error
2926
+ // at `missingColumnMessage` and not a check that silently passes.
2927
+ if (node.kind !== 'filter' && node.kind !== 'rename')
2928
+ continue;
2929
+ const named = node.kind === 'filter'
2930
+ ? workflowFilterColumns(node.predicate)
2931
+ : Object.keys(node.columns ?? {});
2932
+ if (named.length === 0)
2933
+ continue;
2934
+ const known = workflowKnownColumns(graph, node.id);
2935
+ if (known === undefined)
2936
+ continue;
2937
+ const missing = named.filter((column) => column.length > 0 && !known.has(column));
2938
+ if (missing.length === 0)
2939
+ continue;
2940
+ issues.push({
2941
+ code: 'column-not-produced',
2942
+ nodeIds: [node.id],
2943
+ message: missingColumnMessage(node, missing, known),
2944
+ });
2945
+ }
2946
+ }
2947
+ /** The sentence {@link checkColumnsProduced} says, per kind. */
2948
+ function missingColumnMessage(node, missing, known) {
2949
+ const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
2950
+ const consequence = node.kind === 'filter'
2951
+ ? 'A test on a column that is not there matches no row — not even a "does not equal" test — so this load would come out empty and every node would report success.'
2952
+ : 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.';
2953
+ return `${node.kind === 'filter' ? 'Filter' : 'Rename'} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. A rename above this node drops every column it does not name, so what reaches here is exactly ${quoted(known)}. ${consequence}`;
2954
+ }
2555
2955
  function sortedEntries(config) {
2556
2956
  return Object.keys(config)
2557
2957
  .sort()
@@ -2607,6 +3007,8 @@ function isWorkflowNode(value) {
2607
3007
  ? isWorkflowFilterPredicate(Reflect.get(value, 'predicate'))
2608
3008
  : false;
2609
3009
  }
3010
+ if (kind === 'rename')
3011
+ return isRenameNodeShape(value);
2610
3012
  if (kind === 'source') {
2611
3013
  const sourceKind = Reflect.get(value, 'sourceKind');
2612
3014
  const config = Reflect.get(value, 'config');
@@ -2614,6 +3016,22 @@ function isWorkflowNode(value) {
2614
3016
  }
2615
3017
  return isWorkflowNodeKindUnhandled(kind);
2616
3018
  }
3019
+ /**
3020
+ * Everything a `rename` node carries.
3021
+ *
3022
+ * `unnamed` absent is accepted and always will be — it is what every rename
3023
+ * written before the field existed carries, and it means `keep`. A value that is
3024
+ * present and unrecognised is refused rather than defaulted, for the reason an
3025
+ * unrecognised `edge.branch` is: reading it back as `keep` would turn a
3026
+ * projection into a pass-through silently, and the sink would commit every
3027
+ * column the author meant to remove.
3028
+ */
3029
+ function isRenameNodeShape(value) {
3030
+ const unnamed = Reflect.get(value, 'unnamed');
3031
+ if (unnamed !== undefined && !isWorkflowRenameUnnamed(unnamed))
3032
+ return false;
3033
+ return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
3034
+ }
2617
3035
  /**
2618
3036
  * Everything a `call` node carries, checked as strictly as a source's.
2619
3037
  *
@@ -2831,6 +3249,25 @@ function supportsReusableNodes(store) {
2831
3249
  function supportsWorkflowStages(store) {
2832
3250
  return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
2833
3251
  }
3252
+ /**
3253
+ * Whether this store will hand a staged batch over without decoding it.
3254
+ *
3255
+ * Both methods, never one: a rename that could read the payload and not write
3256
+ * one back would have to decode its own output to store it, which is the rebuild
3257
+ * the pair exists to avoid. The methods rather than a flag, the same argument
3258
+ * {@link supportsWorkflows} makes.
3259
+ *
3260
+ * A store that answers no is not broken and nothing degrades except speed — the
3261
+ * rename node falls back to `readStage`/`writeStage` and produces identical
3262
+ * rows. Which path ran is said in the run log, because "this rename was
3263
+ * metadata-only" is a claim, and a claim that could quietly stop being true is
3264
+ * worse than no claim.
3265
+ */
3266
+ function supportsStagePayloads(store) {
3267
+ return (supportsWorkflowStages(store) &&
3268
+ typeof store.readStagePayload === 'function' &&
3269
+ typeof store.writeStagePayload === 'function');
3270
+ }
2834
3271
  /**
2835
3272
  * Whether an operator can set a load expectation on this deployment at all.
2836
3273
  *
@@ -183,3 +183,75 @@ export declare function classifyStagePayload(stored: unknown): StagePayload;
183
183
  * here instead of falling through to a silent `[]`.
184
184
  */
185
185
  export declare function decodeStageRows(stored: unknown): Array<Record<string, unknown>>;
186
+ /**
187
+ * A rename, reduced to what the encoding needs to know.
188
+ *
189
+ * A `Map` rather than the node's `Record`, because this runs once per shape and
190
+ * a `Map` lookup is what the loop below wants; and separate from
191
+ * `WorkflowRenameNode` so this file keeps knowing nothing about graphs.
192
+ */
193
+ export interface StageRenamePlan {
194
+ /** Old name → new name, applied simultaneously. */
195
+ readonly columns: ReadonlyMap<string, string>;
196
+ /** Whether a column the plan does not name survives. See the node's docblock. */
197
+ readonly dropUnnamed: boolean;
198
+ }
199
+ /** What {@link renameStagePayload} did, in enough detail for a run to report it. */
200
+ export interface StageRenameResult {
201
+ /** Ready to store, in the same column the batch came out of. */
202
+ readonly payload: ColumnarStageBatch;
203
+ readonly rows: number;
204
+ /**
205
+ * Whether **no value moved** — the batch came back columnar and only its
206
+ * `shapes` were rewritten.
207
+ *
208
+ * The claim the rename node is built on, reported rather than assumed: it is
209
+ * true for a pure rename over a columnar batch and false the moment unnamed
210
+ * columns are dropped, because dropping one removes a position from every
211
+ * `values` row. A run that says which one happened is a run whose cost can be
212
+ * explained afterwards.
213
+ */
214
+ readonly metadataOnly: boolean;
215
+ /** How many entries of `shapes` came out different from what went in. */
216
+ readonly shapesRewritten: number;
217
+ /** The plan's source columns that were present in at least one shape. */
218
+ readonly matched: ReadonlySet<string>;
219
+ }
220
+ /**
221
+ * Rename the columns of one staged batch.
222
+ *
223
+ * ## The metadata-only path, which is the whole point
224
+ *
225
+ * A columnar batch names its columns once per distinct key-set, in `shapes`, and
226
+ * carries the data in `values` as arrays that are *positional* — `values[i][3]`
227
+ * is whatever `shapes[shapeOf[i]][3]` is called. A positional array does not
228
+ * care what the key is called. So a pure rename rewrites `shapes` and hands back
229
+ * the **same `shapeOf` and the same `values` arrays, by reference**: a hundred
230
+ * thousand rows cost as many string comparisons as there are distinct key-sets,
231
+ * which for a real load is one or two.
232
+ *
233
+ * `dropUnnamed` breaks that and is allowed to. Removing a column removes a
234
+ * position, so every `values` row has to be rebuilt, and the result says so
235
+ * through {@link StageRenameResult.metadataOnly} rather than leaving the
236
+ * difference to be inferred from a stopwatch.
237
+ *
238
+ * ## Collisions
239
+ *
240
+ * A rename onto a name the shape already holds **throws**, naming both columns.
241
+ * The alternative is a shape with one name twice, which decodes to whichever
242
+ * value was written last — one of the author's two columns silently gone, with
243
+ * a green run. It is detected per shape rather than per row, so it fails on the
244
+ * first batch rather than at row ninety thousand.
245
+ *
246
+ * Under `dropUnnamed` there is nothing to collide with: a column the plan does
247
+ * not name is not in the output, so it cannot be occupying a name.
248
+ *
249
+ * ## A row-oriented batch
250
+ *
251
+ * Re-encoded first and then renamed by the one code path above, rather than
252
+ * given a second implementation that walks objects. Two implementations of "what
253
+ * does this rename mean" is how the fallback path ends up disagreeing with the
254
+ * fast one about a collision. The re-encode costs a pass and is reported as
255
+ * `metadataOnly: false`, which is the truth about the bytes.
256
+ */
257
+ export declare function renameStagePayload(stored: unknown, plan: StageRenamePlan): StageRenameResult;