@dudousxd/nestjs-catalog 0.22.0 → 0.24.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,11 +9,16 @@
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_MODES = 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;
16
16
  exports.isTransformLanguage = isTransformLanguage;
17
+ exports.isTransformMode = isTransformMode;
18
+ exports.unreachableTransformMode = unreachableTransformMode;
19
+ exports.transformMode = transformMode;
20
+ exports.recordModeRefusal = recordModeRefusal;
21
+ exports.supportsTransformStreaming = supportsTransformStreaming;
17
22
  exports.isWorkflowSkipReason = isWorkflowSkipReason;
18
23
  exports.isWorkflowNodeKind = isWorkflowNodeKind;
19
24
  exports.unreachableNodeKind = unreachableNodeKind;
@@ -32,6 +37,11 @@ exports.unreachableFilterOperator = unreachableFilterOperator;
32
37
  exports.isWorkflowFilterValue = isWorkflowFilterValue;
33
38
  exports.isWorkflowFilterPredicate = isWorkflowFilterPredicate;
34
39
  exports.workflowFilterMatches = workflowFilterMatches;
40
+ exports.isWorkflowRenameUnnamed = isWorkflowRenameUnnamed;
41
+ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
42
+ exports.workflowRenameUnnamed = workflowRenameUnnamed;
43
+ exports.renameColumnRefusals = renameColumnRefusals;
44
+ exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
35
45
  exports.isReusableNodeKind = isReusableNodeKind;
36
46
  exports.nodeKindIsReusable = nodeKindIsReusable;
37
47
  exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
@@ -50,6 +60,8 @@ exports.workflowNarrowedTypes = workflowNarrowedTypes;
50
60
  exports.workflowRunOrder = workflowRunOrder;
51
61
  exports.workflowNodeRuns = workflowNodeRuns;
52
62
  exports.workflowGraphHash = workflowGraphHash;
63
+ exports.workflowFilterColumns = workflowFilterColumns;
64
+ exports.workflowKnownColumns = workflowKnownColumns;
53
65
  exports.isWorkflowNode = isWorkflowNode;
54
66
  exports.isWorkflowEdge = isWorkflowEdge;
55
67
  exports.supportsWorkflows = supportsWorkflows;
@@ -59,6 +71,7 @@ exports.supportsTransformRevisions = supportsTransformRevisions;
59
71
  exports.supportsTransformPins = supportsTransformPins;
60
72
  exports.supportsReusableNodes = supportsReusableNodes;
61
73
  exports.supportsWorkflowStages = supportsWorkflowStages;
74
+ exports.supportsStagePayloads = supportsStagePayloads;
62
75
  exports.supportsLoadExpectations = supportsLoadExpectations;
63
76
  exports.isPipelineStore = isPipelineStore;
64
77
  // The revision shape is declared beside the audit trail rather than here,
@@ -67,6 +80,11 @@ exports.isPipelineStore = isPipelineStore;
67
80
  // edge only ever points this way: `catalog.workspace.ts` knows nothing about
68
81
  // pipelines.
69
82
  const catalog_workspace_1 = require("./catalog.workspace");
83
+ // The one rule that tells a module-shaped transform from a bare body, used here
84
+ // to refuse a per-record transform written as a body. The edge only points this
85
+ // way: `transform-shape.ts` imports nothing at all, so it can be read on its own
86
+ // and cannot be dragged into a cycle.
87
+ const transform_shape_1 = require("./transform-shape");
70
88
  /**
71
89
  * Where a connector pulls from.
72
90
  *
@@ -175,6 +193,128 @@ exports.TRANSFORM_LANGUAGES = ['javascript', 'typescript', 'python'];
175
193
  function isTransformLanguage(value) {
176
194
  return exports.TRANSFORM_LANGUAGES.some((language) => language === value);
177
195
  }
196
+ /**
197
+ * Whether a transform is a function over the whole batch or over one record.
198
+ *
199
+ * ## Why this is declared and never inferred
200
+ *
201
+ * The two are not interchangeable and the difference is invisible in the code.
202
+ * `records.map(...)` and a body that returns one object read almost identically,
203
+ * and a detector that guessed from destructuring — `{ records }` versus
204
+ * `{ record }` — would be reading a *parameter name*, which is the author's to
205
+ * choose and which minification, a rename, or a rest parameter changes without
206
+ * changing what the function computes. Guess wrong towards `record` and an
207
+ * aggregation is called 102,520 times and returns 102,520 partial answers, none
208
+ * of which fails; guess wrong towards `batch` and a per-record function is handed
209
+ * an array and reads `undefined` off every property. Both commit. Neither errors.
210
+ * So the mode is a field somebody set, and the cost of setting it is one control
211
+ * in the editor.
212
+ *
213
+ * ## Why a closed list rather than `streaming?: boolean`
214
+ *
215
+ * The identical argument {@link WORKFLOW_CALL_MODES} makes one level up. A flag
216
+ * beside a future third calling convention — a windowed transform, a keyed one —
217
+ * is two optional booleans whose combinations nobody defined, and each reader
218
+ * invents its own rule for which wins. A closed list with an exhaustiveness guard
219
+ * ({@link unreachableTransformMode}) makes a third convention a compile error
220
+ * naming the files that have to answer for it: the harness that generates the
221
+ * call, the runner that chooses a transport, and the two runners that consume
222
+ * the result.
223
+ *
224
+ * ## What the default has to be, and why it is not a choice
225
+ *
226
+ * Absent means {@link CatalogTransform.mode} was never set, which is every
227
+ * transform stored before this field existed, and every one of them is a function
228
+ * over the whole batch — the harness handed it `records` and there was no other
229
+ * shape to write. Reading absence as anything else would silently change what a
230
+ * deployment's existing loads compute. Read it through {@link transformMode}
231
+ * rather than defaulting it a second time.
232
+ */
233
+ exports.TRANSFORM_MODES = [
234
+ /**
235
+ * The code is called **once**, with every record the node received.
236
+ *
237
+ * What every stored transform is, and what aggregating, deduplicating,
238
+ * sorting and joining require: none of them can be done a row at a time, and
239
+ * chunking the calls would not fail — it would return one answer per chunk and
240
+ * commit. The whole input is therefore in the running process's heap, which is
241
+ * the honest cost of the promise.
242
+ */
243
+ 'batch',
244
+ /**
245
+ * The code is called **once per record**, and never sees the batch.
246
+ *
247
+ * What a rename, a projection or a normalisation is by construction — and,
248
+ * since `WORKFLOW_FILTER_COLUMN_PATTERN` and `property-names.ts` both refuse a
249
+ * header with a space in it, what every DPAS file this catalog ingests needs
250
+ * before anything else can touch it. The records arrive as a stream and the
251
+ * rows leave as one, so nothing anywhere holds the dataset.
252
+ */
253
+ 'record',
254
+ ];
255
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
256
+ function isTransformMode(value) {
257
+ return exports.TRANSFORM_MODES.some((mode) => mode === value);
258
+ }
259
+ /**
260
+ * {@link unreachableCallMode}, for transforms, and for the identical reason.
261
+ *
262
+ * Every branch over {@link TransformMode} ends here, so a third calling
263
+ * convention added to the list without a harness to generate it, a transport to
264
+ * carry it and a consumer to read its output is a type error naming the file. It
265
+ * throws as well, because a mode arrives as JSON out of a column and a build
266
+ * older than the data is a thing that happens.
267
+ */
268
+ function unreachableTransformMode(mode, where) {
269
+ throw new Error(`${where} has no rule for the transform mode ${JSON.stringify(mode)}. It was added to TRANSFORM_MODES without teaching this code how to call user code written for it, and guessing would run somebody's transform under a contract they did not write it against.`);
270
+ }
271
+ /**
272
+ * The mode this transform runs in, with the default applied once.
273
+ *
274
+ * Absent means `'batch'` — see {@link TRANSFORM_MODES}. One function so that the
275
+ * store, the runner, the two consumers, the editor and the try pane cannot each
276
+ * carry their own `?? 'batch'` and have one of them drift.
277
+ */
278
+ function transformMode(transform) {
279
+ return transform.mode ?? 'batch';
280
+ }
281
+ /**
282
+ * Why this transform cannot run in the mode it declares, if it cannot.
283
+ *
284
+ * Two combinations are representable and neither can work, so both are refused
285
+ * at the point somebody presses save rather than at three in the morning when a
286
+ * schedule fires. `undefined` means there is nothing wrong.
287
+ *
288
+ * Asked in both places on purpose. The controller asks so the author is told
289
+ * while they are still looking at the code; the runner asks because a row can
290
+ * reach it that no controller in this build ever validated — promoted from
291
+ * another environment, restored from a backup, written by an older version — and
292
+ * the failure a runner must never have is the silent one where a per-record
293
+ * module is handed an array and quietly reads `undefined` off every property.
294
+ */
295
+ function recordModeRefusal(transform) {
296
+ if (transformMode(transform) !== 'record')
297
+ return undefined;
298
+ if (transform.language === 'python') {
299
+ return 'A per-record transform cannot be written in Python yet. The Python harness writes `def transform(records, context):` around the code, so a Python transform never states its own signature and there is no second `def` for the per-record shape — see TRANSFORM_MODES. Use javascript or typescript for a per-record transform, or leave this one as a whole-batch transform.';
300
+ }
301
+ if ((0, transform_shape_1.transformShape)(transform.code) === 'body') {
302
+ return 'A per-record transform must be a module that exports a function — `export default function transform({ record, context }) { … }` — and this code has no `export` at the start of a statement, so the catalog would run it as a bare function body. A body is handed `records`, the whole array, by the wrapper the catalog writes around it; there is no honest way to give it one record under a name it never wrote. Add the `export`, or set this transform back to whole-batch.';
303
+ }
304
+ return undefined;
305
+ }
306
+ /**
307
+ * Whether this runner can stream a per-record transform.
308
+ *
309
+ * The method rather than a flag, exactly as {@link supportsTransformRevisions}
310
+ * argues one interface along: a flag is a claim and a method is the thing
311
+ * itself. A runner that answers `false` still runs `'record'` transforms — the
312
+ * consumers buffer and call {@link TransformRunner.run} — so this is a question
313
+ * about *how much is held*, never about whether the load works.
314
+ */
315
+ function supportsTransformStreaming(runner) {
316
+ return typeof runner.runStream === 'function';
317
+ }
178
318
  exports.TRANSFORM_RUNNER = Symbol('TRANSFORM_RUNNER');
179
319
  /**
180
320
  * The number in {@link CatalogCodeContext.contract}.
@@ -296,6 +436,8 @@ exports.WORKFLOW_NODE_KINDS = [
296
436
  'if',
297
437
  /** Drops the rows that fail a declarative test. See {@link WorkflowFilterNode}. */
298
438
  'filter',
439
+ /** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
440
+ 'rename',
299
441
  ];
300
442
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
301
443
  function isWorkflowNodeKind(value) {
@@ -960,6 +1102,116 @@ function orderedHolds(operator, held, wanted) {
960
1102
  return held < wanted;
961
1103
  return held <= wanted;
962
1104
  }
1105
+ /* --- rename -------------------------------------------------------------- */
1106
+ /**
1107
+ * What happens to a column the rename does not name.
1108
+ *
1109
+ * Two words rather than a boolean, because the two are genuinely different
1110
+ * nodes and a boolean called `drop` would read as a modifier on one node. See
1111
+ * {@link WorkflowRenameNode.unnamed} for what each costs.
1112
+ */
1113
+ exports.WORKFLOW_RENAME_UNNAMED = [
1114
+ /** Passed through untouched. The default, and the metadata-only case. */
1115
+ 'keep',
1116
+ /** Removed. The output columns are exactly the map's targets. */
1117
+ 'drop',
1118
+ ];
1119
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1120
+ function isWorkflowRenameUnnamed(value) {
1121
+ return exports.WORKFLOW_RENAME_UNNAMED.some((each) => each === value);
1122
+ }
1123
+ /**
1124
+ * The exhaustiveness guard for {@link WORKFLOW_RENAME_UNNAMED}.
1125
+ *
1126
+ * {@link unreachableNodeKind}, one level down, and for the identical reason: the
1127
+ * two words decide whether a batch is rewritten or only re-labelled, and a third
1128
+ * one added without a branch would silently pick whichever the last `if` was.
1129
+ */
1130
+ function unreachableRenameUnnamed(value, where) {
1131
+ 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.`);
1132
+ }
1133
+ /**
1134
+ * How many columns one rename may name.
1135
+ *
1136
+ * The same argument {@link WORKFLOW_FILTER_MAX_VALUES} makes: the map travels in
1137
+ * the graph and into the graph fingerprint, and past a few hundred entries the
1138
+ * thing being expressed is a schema mapping that belongs in a stored object
1139
+ * rather than in a node. It is also the bound that keeps
1140
+ * {@link isWorkflowRenameColumns} — which is run on JSON out of a column — from
1141
+ * being a place to hand a service a million-key object.
1142
+ */
1143
+ exports.WORKFLOW_RENAME_MAX_COLUMNS = 500;
1144
+ /** {@link WorkflowRenameNode.unnamed}, resolved. One reader of the default. */
1145
+ function workflowRenameUnnamed(node) {
1146
+ return node.unnamed ?? 'keep';
1147
+ }
1148
+ /**
1149
+ * Every reason a rename map cannot be stored, as sentences, or empty.
1150
+ *
1151
+ * One function, called by {@link validateWorkflow}, by the HTTP boundary and by
1152
+ * the canvas, for the reason `validateWorkflow` itself is shared: a screen that
1153
+ * checked a target name against its own copy of the pattern is a screen that
1154
+ * eventually accepts something the server refuses, halfway through a save.
1155
+ *
1156
+ * All of them rather than the first, exactly as
1157
+ * {@link refuseUnpublishablePropertyNames} argues: a map of forty columns typed
1158
+ * in one sitting is usually wrong about several in the same way.
1159
+ */
1160
+ function renameColumnRefusals(columns) {
1161
+ const refusals = [];
1162
+ const entries = Object.entries(columns);
1163
+ if (entries.length === 0) {
1164
+ 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.');
1165
+ return refusals;
1166
+ }
1167
+ if (entries.length > exports.WORKFLOW_RENAME_MAX_COLUMNS) {
1168
+ 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.`);
1169
+ }
1170
+ const targets = new Map();
1171
+ for (const [from, to] of entries) {
1172
+ if (from.length === 0) {
1173
+ refusals.push('One entry renames a column with no name, so there is nothing for it to find.');
1174
+ continue;
1175
+ }
1176
+ if (typeof to !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(to)) {
1177
+ 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.`);
1178
+ continue;
1179
+ }
1180
+ targets.set(to, [...(targets.get(to) ?? []), from]);
1181
+ }
1182
+ for (const [to, sources] of targets) {
1183
+ if (sources.length < 2)
1184
+ continue;
1185
+ 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.`);
1186
+ }
1187
+ return refusals;
1188
+ }
1189
+ /**
1190
+ * Whether a stored rename map is one this build can run.
1191
+ *
1192
+ * Refused rather than repaired, the stance {@link isWorkflowFilterPredicate}
1193
+ * takes about a predicate and for the same reason one step further along: a
1194
+ * rename read back with one entry silently dropped is a graph that commits a
1195
+ * column of NULLs under a name nobody can now explain.
1196
+ *
1197
+ * `Object.entries` rather than a `for…in`, so an inherited key cannot enter the
1198
+ * map, and the values are checked one by one rather than trusted from the type.
1199
+ */
1200
+ function isWorkflowRenameColumns(value) {
1201
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1202
+ return false;
1203
+ const entries = Object.entries(value);
1204
+ if (entries.every(([, to]) => typeof to === 'string')) {
1205
+ const columns = {};
1206
+ for (const [from, to] of entries) {
1207
+ if (typeof to !== 'string')
1208
+ return false;
1209
+ columns[from] = to;
1210
+ }
1211
+ return renameColumnRefusals(columns).length === 0;
1212
+ }
1213
+ return false;
1214
+ }
963
1215
  /* --- reusable nodes ------------------------------------------------------ */
964
1216
  /**
965
1217
  * The node kinds that can be saved once and used in several graphs.
@@ -1013,6 +1265,11 @@ function isReusableNodeKind(value) {
1013
1265
  * may not have, and a filter is worse: {@link WorkflowFilterNode.narrows} is
1014
1266
  * an acknowledgement about *this* graph's sinks, so a shared one would carry
1015
1267
  * somebody else's acknowledgement into a graph they never saw.
1268
+ * - `rename` — the same argument as `if` and `filter`, and the sharpest version
1269
+ * of it: a rename map names the source's own spelling of its columns, so it is
1270
+ * *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
1271
+ * dropped into a graph reading a different system renames nothing at all, and
1272
+ * the symptom is a column of NULLs rather than a failure.
1016
1273
  */
1017
1274
  exports.NODE_KIND_IS_REUSABLE = {
1018
1275
  source: true,
@@ -1021,6 +1278,7 @@ exports.NODE_KIND_IS_REUSABLE = {
1021
1278
  call: false,
1022
1279
  if: false,
1023
1280
  filter: false,
1281
+ rename: false,
1024
1282
  };
1025
1283
  /** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
1026
1284
  function nodeKindIsReusable(kind) {
@@ -1425,6 +1683,29 @@ exports.WORKFLOW_ISSUE_CODES = [
1425
1683
  'filter-predicate-invalid',
1426
1684
  'filter-narrows-unacknowledged',
1427
1685
  'filter-narrows-nothing',
1686
+ /**
1687
+ * A rename whose map cannot be stored: empty, too big, or naming a target that
1688
+ * is not a column name or that two source columns share. Every one of those is
1689
+ * detectable from the node alone, which is the point of the node — see
1690
+ * {@link renameColumnRefusals} for the sentences.
1691
+ */
1692
+ 'rename-invalid',
1693
+ /**
1694
+ * A node naming a column that nothing upstream can produce.
1695
+ *
1696
+ * The one thing a declarative rename buys the *validator*, and it is the whole
1697
+ * reason the node is data rather than code. A rename with
1698
+ * `unnamed: 'drop'` has an output column set that is known exactly from its
1699
+ * config — its targets, and nothing else, whatever it was handed. So a filter
1700
+ * or a second rename downstream of one, naming a column outside that set, is
1701
+ * provably wrong at the moment the graph is saved instead of at the moment the
1702
+ * load comes out empty.
1703
+ *
1704
+ * Reported only where the set is *closed*. See {@link workflowKnownColumns}
1705
+ * for exactly how far that reaches and for what it deliberately does not
1706
+ * claim.
1707
+ */
1708
+ 'column-not-produced',
1428
1709
  /**
1429
1710
  * A version pin that is not a version — `0`, `2.5`, `"3"`, `-1`.
1430
1711
  *
@@ -1496,6 +1777,9 @@ function validateWorkflow(graph) {
1496
1777
  return issues;
1497
1778
  }
1498
1779
  checkReachability(nodes, roots, sinks, incoming, outgoing, issues);
1780
+ // After the cycle check has returned, so the walk it does cannot meet a loop
1781
+ // on a graph this function has already accepted as acyclic.
1782
+ checkColumnsProduced({ nodes, edges }, issues);
1499
1783
  return issues;
1500
1784
  }
1501
1785
  /**
@@ -1851,8 +2135,31 @@ function nodeIsUnconfigured(node) {
1851
2135
  return ifIsUnconfigured(node);
1852
2136
  if (node.kind === 'filter')
1853
2137
  return filterIsUnconfigured(node);
2138
+ if (node.kind === 'rename')
2139
+ return renameIsUnconfigured(node);
1854
2140
  return undefined;
1855
2141
  }
2142
+ /**
2143
+ * A rename whose map this service will not store.
2144
+ *
2145
+ * The refusals come from {@link renameColumnRefusals} rather than being restated
2146
+ * here, so the canvas, the HTTP boundary and this validator say the same
2147
+ * sentence about the same map. Every one of them is a *silent* failure if it
2148
+ * were let through: an empty map is a node that does nothing or a node that
2149
+ * deletes every column, a target that is not a column name loads NULL into every
2150
+ * row and reports success, and two columns renamed onto one name means one of
2151
+ * them is gone and nothing says which.
2152
+ */
2153
+ function renameIsUnconfigured(node) {
2154
+ const refusals = renameColumnRefusals(node.columns ?? {});
2155
+ if (refusals.length === 0)
2156
+ return undefined;
2157
+ return {
2158
+ code: 'rename-invalid',
2159
+ nodeIds: [node.id],
2160
+ message: `Rename "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
2161
+ };
2162
+ }
1856
2163
  /**
1857
2164
  * A filter whose test cannot decide anything.
1858
2165
  *
@@ -2440,6 +2747,8 @@ function canonicalNode(node) {
2440
2747
  [...(node.narrows ?? [])].sort(),
2441
2748
  ]);
2442
2749
  }
2750
+ if (node.kind === 'rename')
2751
+ return canonicalRename(node);
2443
2752
  if (node.kind === 'sink') {
2444
2753
  return JSON.stringify([
2445
2754
  node.id,
@@ -2451,6 +2760,28 @@ function canonicalNode(node) {
2451
2760
  }
2452
2761
  return unreachableNodeKind(node, 'workflowGraphHash');
2453
2762
  }
2763
+ /**
2764
+ * A rename, canonicalised.
2765
+ *
2766
+ * Sorted by source column, so a canvas that rewrites the object in a different
2767
+ * order is not an edit — the rule `sortedEntries` applies to a source's config,
2768
+ * and it is safe here for a reason specific to this node: the map is applied
2769
+ * *simultaneously*, so its order changes nothing about the result.
2770
+ *
2771
+ * `unnamed` is appended only when it is `drop`, exactly as `edge.branch` is
2772
+ * appended only when there is a label. Every rename that keeps its unnamed
2773
+ * columns — whether it says so or says nothing — hashes to one string, so
2774
+ * normalising the field on a canvas cannot renumber a graph. It is in there at
2775
+ * all because it decides which columns reach the sink.
2776
+ */
2777
+ function canonicalRename(node) {
2778
+ return JSON.stringify([
2779
+ node.id,
2780
+ node.kind,
2781
+ sortedEntries(node.columns),
2782
+ ...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
2783
+ ]);
2784
+ }
2454
2785
  /**
2455
2786
  * The reusable reference, as zero, one or two trailing hash components.
2456
2787
  *
@@ -2552,6 +2883,207 @@ function canonicalFilterPredicate(predicate) {
2552
2883
  }
2553
2884
  return unreachableFilterPredicateKind(predicate, 'workflowGraphHash');
2554
2885
  }
2886
+ /* --- what the graph knows about columns ---------------------------------- */
2887
+ /**
2888
+ * Every column a filter predicate names, once each, in the order they appear.
2889
+ *
2890
+ * Its own function rather than a walk inlined into the validator, because two
2891
+ * things want it — the refusal below and anything on a screen that wants to say
2892
+ * which columns a node depends on — and a second copy of a tree walk is a second
2893
+ * copy that forgets the `oneOf` branch.
2894
+ */
2895
+ function workflowFilterColumns(predicate) {
2896
+ const found = [];
2897
+ const visit = (each) => {
2898
+ if (each.kind === 'all' || each.kind === 'any') {
2899
+ for (const child of each.children)
2900
+ visit(child);
2901
+ return;
2902
+ }
2903
+ if (!found.includes(each.column))
2904
+ found.push(each.column);
2905
+ };
2906
+ visit(predicate);
2907
+ return found;
2908
+ }
2909
+ /**
2910
+ * The columns that can reach this node, when the graph knows — and `undefined`
2911
+ * when it does not.
2912
+ *
2913
+ * ## What this is for
2914
+ *
2915
+ * It is the one thing a declarative rename buys that a transform cannot, and it
2916
+ * is worth being precise about how far it reaches rather than overselling it.
2917
+ *
2918
+ * With a JS transform, the catalog cannot know what columns come out — the
2919
+ * answer is inside a function body — which is why the property-name rule in
2920
+ * `property-names.ts` fires at publish time and why a mismatch between a
2921
+ * property and a record key is discovered as a column of NULLs. A rename is
2922
+ * **data**, so for one arrangement the answer is exact:
2923
+ *
2924
+ * > A rename with `unnamed: 'drop'` produces its targets and **nothing else**,
2925
+ * > whatever it was handed.
2926
+ *
2927
+ * That set is *closed* — an upper bound that holds regardless of what is
2928
+ * upstream — and it survives every node that does not touch columns. So a filter
2929
+ * or a second rename downstream of one can be told, at authoring time, that it
2930
+ * names a column which cannot be there.
2931
+ *
2932
+ * ## What it deliberately does not claim
2933
+ *
2934
+ * - **It is an upper bound, not the output.** A target only appears in a row
2935
+ * whose input actually held the source column. So a column *inside* the set
2936
+ * may still be absent, and nothing here says otherwise.
2937
+ * - **A `keep` rename tells you nothing on its own.** Its output is its input
2938
+ * with some keys re-labelled, and its input is unknown unless something
2939
+ * upstream closed it. So `undefined` propagates, and that is the honest
2940
+ * answer rather than an empty set.
2941
+ * - **A source, a transform and a call are always unknown.** A source's shape is
2942
+ * discovered against the live system rather than declared in the graph; a
2943
+ * transform is a function body; a call is a workflow this graph does not own.
2944
+ * - **It says nothing about a sink's declared properties.** That is the check
2945
+ * worth wanting — "this sink writes a property no upstream node produces" —
2946
+ * and it is *not* available here: a {@link WorkflowSinkNode} carries a
2947
+ * `targetType` and nothing else, so the property list would have to be
2948
+ * threaded into a validator that is pure and dependency-free on purpose. What
2949
+ * is built instead is the run log, which prints the columns a rename produced.
2950
+ *
2951
+ * Cycles answer `undefined` rather than looping. `validateWorkflow` refuses a
2952
+ * cyclic graph before it gets here, but the canvas calls this while a graph is
2953
+ * being drawn and is entitled to a wrong-but-terminating answer.
2954
+ */
2955
+ function workflowKnownColumns(graph, nodeId) {
2956
+ const nodes = graph.nodes ?? [];
2957
+ const byId = new Map(nodes.map((node) => [node.id, node]));
2958
+ const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
2959
+ const answered = new Map();
2960
+ const open = new Set();
2961
+ const outOf = (id) => {
2962
+ if (answered.has(id))
2963
+ return answered.get(id);
2964
+ // A loop. Answered as unknown and remembered, so the walk terminates and so
2965
+ // that a second question about the same node does not re-enter it.
2966
+ if (open.has(id))
2967
+ return undefined;
2968
+ const node = byId.get(id);
2969
+ if (!node)
2970
+ return undefined;
2971
+ open.add(id);
2972
+ const produced = producedColumns(node, () => intoNode(id));
2973
+ open.delete(id);
2974
+ answered.set(id, produced);
2975
+ return produced;
2976
+ };
2977
+ const intoNode = (id) => {
2978
+ const feeds = incoming.get(id) ?? [];
2979
+ if (feeds.length === 0)
2980
+ return undefined;
2981
+ const union = new Set();
2982
+ for (const from of feeds) {
2983
+ const upstream = outOf(from);
2984
+ // One unknown input makes the whole position unknown: the rows arrive
2985
+ // concatenated, so a column any one of them carries is a column this node
2986
+ // can see.
2987
+ if (upstream === undefined)
2988
+ return undefined;
2989
+ for (const column of upstream)
2990
+ union.add(column);
2991
+ }
2992
+ return union;
2993
+ };
2994
+ return intoNode(nodeId);
2995
+ }
2996
+ /**
2997
+ * What one node passes on, given what reaches it.
2998
+ *
2999
+ * The upstream set is a thunk rather than a value because the one case that
3000
+ * makes this function worth having does not need it: a rename that drops its
3001
+ * unnamed columns answers from its own config, so the walk stops there rather
3002
+ * than climbing to a source it would learn nothing from.
3003
+ *
3004
+ * Ends in {@link unreachableNodeKind}, so a node kind added without an answer
3005
+ * here is a compile error rather than a silent `undefined` — which would be the
3006
+ * *safe* wrong answer and would therefore never be noticed.
3007
+ */
3008
+ function producedColumns(node, upstream) {
3009
+ if (node.kind === 'rename') {
3010
+ if (workflowRenameUnnamed(node) === 'drop')
3011
+ return new Set(Object.values(node.columns ?? {}));
3012
+ const known = upstream();
3013
+ if (known === undefined)
3014
+ return undefined;
3015
+ const renamed = new Set();
3016
+ for (const column of known)
3017
+ renamed.add(node.columns?.[column] ?? column);
3018
+ return renamed;
3019
+ }
3020
+ // Neither of these touches a column: a filter decides which *rows* survive and
3021
+ // an `if` decides which *nodes* run. Both hand on exactly the shape they were
3022
+ // given, which is what makes a closed set survive one.
3023
+ if (node.kind === 'filter' || node.kind === 'if')
3024
+ return upstream();
3025
+ // A source's shape is discovered against the live system, a transform's is
3026
+ // inside a function body, a call's belongs to a workflow this graph does not
3027
+ // own, and nothing reads a sink's output. See {@link workflowKnownColumns}.
3028
+ if (node.kind === 'source' ||
3029
+ node.kind === 'transform' ||
3030
+ node.kind === 'call' ||
3031
+ node.kind === 'sink') {
3032
+ return undefined;
3033
+ }
3034
+ return unreachableNodeKind(node, 'workflowKnownColumns');
3035
+ }
3036
+ /**
3037
+ * That no node names a column the graph can prove is not there.
3038
+ *
3039
+ * Only where {@link workflowKnownColumns} answers, which is only downstream of a
3040
+ * rename that drops what it does not name. Everywhere else this is silent, and
3041
+ * that silence is correct rather than a gap being tolerated: refusing a column
3042
+ * the graph merely has no opinion about would make every filter downstream of a
3043
+ * transform unsaveable.
3044
+ *
3045
+ * A refusal rather than a warning, because both failures are silent and total.
3046
+ * A filter on a column that cannot exist matches no row — a comparison against
3047
+ * an absent column is false under the three-valued logic
3048
+ * {@link workflowFilterMatches} implements, *including the inverses* — so the
3049
+ * load comes out empty and every node reports success. A rename of a column that
3050
+ * cannot exist renames nothing, so the target is absent, and a sink writing it
3051
+ * commits NULL into every row. That is the exact shape `property-names.ts` was
3052
+ * written about, one node upstream of where it can be caught.
3053
+ */
3054
+ function checkColumnsProduced(graph, issues) {
3055
+ for (const node of graph.nodes ?? []) {
3056
+ // Narrowed off the union rather than tested with a property check, so a kind
3057
+ // that starts naming columns without being answered for here is a type error
3058
+ // at `missingColumnMessage` and not a check that silently passes.
3059
+ if (node.kind !== 'filter' && node.kind !== 'rename')
3060
+ continue;
3061
+ const named = node.kind === 'filter'
3062
+ ? workflowFilterColumns(node.predicate)
3063
+ : Object.keys(node.columns ?? {});
3064
+ if (named.length === 0)
3065
+ continue;
3066
+ const known = workflowKnownColumns(graph, node.id);
3067
+ if (known === undefined)
3068
+ continue;
3069
+ const missing = named.filter((column) => column.length > 0 && !known.has(column));
3070
+ if (missing.length === 0)
3071
+ continue;
3072
+ issues.push({
3073
+ code: 'column-not-produced',
3074
+ nodeIds: [node.id],
3075
+ message: missingColumnMessage(node, missing, known),
3076
+ });
3077
+ }
3078
+ }
3079
+ /** The sentence {@link checkColumnsProduced} says, per kind. */
3080
+ function missingColumnMessage(node, missing, known) {
3081
+ const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
3082
+ const consequence = node.kind === 'filter'
3083
+ ? 'A test on a column that is not there matches no row — not even a "does not equal" test — so this load would come out empty and every node would report success.'
3084
+ : 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.';
3085
+ return `${node.kind === 'filter' ? 'Filter' : 'Rename'} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. A rename above this node drops every column it does not name, so what reaches here is exactly ${quoted(known)}. ${consequence}`;
3086
+ }
2555
3087
  function sortedEntries(config) {
2556
3088
  return Object.keys(config)
2557
3089
  .sort()
@@ -2607,6 +3139,8 @@ function isWorkflowNode(value) {
2607
3139
  ? isWorkflowFilterPredicate(Reflect.get(value, 'predicate'))
2608
3140
  : false;
2609
3141
  }
3142
+ if (kind === 'rename')
3143
+ return isRenameNodeShape(value);
2610
3144
  if (kind === 'source') {
2611
3145
  const sourceKind = Reflect.get(value, 'sourceKind');
2612
3146
  const config = Reflect.get(value, 'config');
@@ -2614,6 +3148,22 @@ function isWorkflowNode(value) {
2614
3148
  }
2615
3149
  return isWorkflowNodeKindUnhandled(kind);
2616
3150
  }
3151
+ /**
3152
+ * Everything a `rename` node carries.
3153
+ *
3154
+ * `unnamed` absent is accepted and always will be — it is what every rename
3155
+ * written before the field existed carries, and it means `keep`. A value that is
3156
+ * present and unrecognised is refused rather than defaulted, for the reason an
3157
+ * unrecognised `edge.branch` is: reading it back as `keep` would turn a
3158
+ * projection into a pass-through silently, and the sink would commit every
3159
+ * column the author meant to remove.
3160
+ */
3161
+ function isRenameNodeShape(value) {
3162
+ const unnamed = Reflect.get(value, 'unnamed');
3163
+ if (unnamed !== undefined && !isWorkflowRenameUnnamed(unnamed))
3164
+ return false;
3165
+ return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
3166
+ }
2617
3167
  /**
2618
3168
  * Everything a `call` node carries, checked as strictly as a source's.
2619
3169
  *
@@ -2831,6 +3381,25 @@ function supportsReusableNodes(store) {
2831
3381
  function supportsWorkflowStages(store) {
2832
3382
  return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
2833
3383
  }
3384
+ /**
3385
+ * Whether this store will hand a staged batch over without decoding it.
3386
+ *
3387
+ * Both methods, never one: a rename that could read the payload and not write
3388
+ * one back would have to decode its own output to store it, which is the rebuild
3389
+ * the pair exists to avoid. The methods rather than a flag, the same argument
3390
+ * {@link supportsWorkflows} makes.
3391
+ *
3392
+ * A store that answers no is not broken and nothing degrades except speed — the
3393
+ * rename node falls back to `readStage`/`writeStage` and produces identical
3394
+ * rows. Which path ran is said in the run log, because "this rename was
3395
+ * metadata-only" is a claim, and a claim that could quietly stop being true is
3396
+ * worse than no claim.
3397
+ */
3398
+ function supportsStagePayloads(store) {
3399
+ return (supportsWorkflowStages(store) &&
3400
+ typeof store.readStagePayload === 'function' &&
3401
+ typeof store.writeStagePayload === 'function');
3402
+ }
2834
3403
  /**
2835
3404
  * Whether an operator can set a load expectation on this deployment at all.
2836
3405
  *