@dudousxd/nestjs-catalog 0.18.0 → 0.20.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,14 +9,19 @@
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_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.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_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
+ exports.isSourceFormat = isSourceFormat;
15
+ exports.unreachableSourceFormat = unreachableSourceFormat;
14
16
  exports.isTransformLanguage = isTransformLanguage;
15
17
  exports.isWorkflowSkipReason = isWorkflowSkipReason;
16
18
  exports.isWorkflowNodeKind = isWorkflowNodeKind;
17
19
  exports.unreachableNodeKind = unreachableNodeKind;
18
20
  exports.workflowColumnX = workflowColumnX;
19
21
  exports.workflowRowY = workflowRowY;
22
+ exports.isWorkflowCallMode = isWorkflowCallMode;
23
+ exports.unreachableCallMode = unreachableCallMode;
24
+ exports.workflowCallMode = workflowCallMode;
20
25
  exports.isWorkflowPredicateKind = isWorkflowPredicateKind;
21
26
  exports.unreachablePredicateKind = unreachablePredicateKind;
22
27
  exports.isWorkflowIfPredicate = isWorkflowIfPredicate;
@@ -34,6 +39,7 @@ exports.applyReusableNode = applyReusableNode;
34
39
  exports.isReusableNodeBody = isReusableNodeBody;
35
40
  exports.reusableNodeBodyOf = reusableNodeBodyOf;
36
41
  exports.describeVersionPin = describeVersionPin;
42
+ exports.describeLiveVersion = describeLiveVersion;
37
43
  exports.isWorkflowBranchLabel = isWorkflowBranchLabel;
38
44
  exports.isWorkflowStatus = isWorkflowStatus;
39
45
  exports.isWorkflowExecutionMode = isWorkflowExecutionMode;
@@ -47,6 +53,8 @@ exports.workflowGraphHash = workflowGraphHash;
47
53
  exports.isWorkflowNode = isWorkflowNode;
48
54
  exports.isWorkflowEdge = isWorkflowEdge;
49
55
  exports.supportsWorkflows = supportsWorkflows;
56
+ exports.supportsWorkflowReleases = supportsWorkflowReleases;
57
+ exports.liveWorkflowVersion = liveWorkflowVersion;
50
58
  exports.supportsTransformRevisions = supportsTransformRevisions;
51
59
  exports.supportsTransformPins = supportsTransformPins;
52
60
  exports.supportsReusableNodes = supportsReusableNodes;
@@ -90,6 +98,57 @@ exports.CONNECTOR_KINDS = [
90
98
  function isConnectorKind(value) {
91
99
  return exports.CONNECTOR_KINDS.some((kind) => kind === value);
92
100
  }
101
+ /**
102
+ * How the bytes behind a `file` or `s3` connector are read as records.
103
+ *
104
+ * A list rather than a loose string, for the reason {@link CONNECTOR_KINDS} is
105
+ * one: this used to be compared against string literals in the parser and
106
+ * spelled out again in a dropdown, and the two had no way to disagree loudly.
107
+ * The parser's chain also *ended* in JSON, so a format it did not recognise was
108
+ * not refused — it was read as JSON, and a spreadsheet handed to `JSON.parse`
109
+ * fails with a syntax error that names a byte offset rather than the format.
110
+ *
111
+ * `xlsx` is the odd one and is named for what it is: the only member whose
112
+ * payload is binary. The other three are text, and everything that reads them
113
+ * decodes the bytes first. Anything deciding something *per format* narrows
114
+ * against this and answers {@link unreachableSourceFormat}.
115
+ */
116
+ exports.SOURCE_FORMATS = [
117
+ /** Delimited text with a header row. The delimiter is configurable. */
118
+ 'csv',
119
+ /** One JSON value per line. */
120
+ 'ndjson',
121
+ /** A JSON document, optionally with the array nested in an envelope. */
122
+ 'json',
123
+ /**
124
+ * A spreadsheet workbook — binary, and the only member that is.
125
+ *
126
+ * Named for the modern extension, but the reader identifies the container
127
+ * from its own bytes, so the legacy `.xls` and the macro-enabled `.xlsm` are
128
+ * this format too rather than three names for one decision.
129
+ */
130
+ 'xlsx',
131
+ ];
132
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
133
+ function isSourceFormat(value) {
134
+ return exports.SOURCE_FORMATS.some((format) => format === value);
135
+ }
136
+ /**
137
+ * The format that never compiles quietly.
138
+ *
139
+ * The {@link unreachableNodeKind} of formats, and it exists for the same reason:
140
+ * a member added to {@link SOURCE_FORMATS} without a branch in the parser should
141
+ * be a type error naming the file, not a connector that offers a format in a
142
+ * dropdown and then reads the file as JSON.
143
+ *
144
+ * It throws as well as failing to compile, because a connector config is JSON
145
+ * that outlives the build that wrote it: a `format` stored by a newer deployment
146
+ * and read by an older one is possible, and falling back to a default for it
147
+ * would be exactly the silent path this closes.
148
+ */
149
+ function unreachableSourceFormat(format, where) {
150
+ throw new Error(`${where} does not handle a source format of ${JSON.stringify(format)}. The format list and every decision made per format are meant to move together.`);
151
+ }
93
152
  /**
94
153
  * TypeScript is Node's own type stripping, so it costs no compiler and no build
95
154
  * step — and types are erased, never checked. A transform with a wrong type
@@ -318,6 +377,109 @@ function workflowColumnX(column) {
318
377
  function workflowRowY(row) {
319
378
  return row * (exports.WORKFLOW_NODE_HEIGHT + exports.WORKFLOW_ROW_GAP);
320
379
  }
380
+ /**
381
+ * What a {@link WorkflowCallNode} puts on the wire, and the whole of it.
382
+ *
383
+ * ## Why there is a second mode at all
384
+ *
385
+ * The catalog could not call a workflow that does not know about the catalog.
386
+ * A `call` node wraps the author's `config` in a {@link WorkflowCallEnvelope},
387
+ * so a workflow that already exists — one registered years before this package,
388
+ * whose body reads `data["proc"]` — receives `{catalog: {...}, input: {proc:
389
+ * ...}}` and dies on the first key it looks for. The only repair available was
390
+ * to edit the callee, which inverts the dependency exactly the wrong way round:
391
+ * every workflow anybody wanted to call would have to start depending on this
392
+ * package's contract, and a Python workflow registered in another repository
393
+ * would have to be changed to be reachable from a graph.
394
+ *
395
+ * ## Why the nesting is not being loosened instead
396
+ *
397
+ * The envelope nests for one stated reason, which is on {@link
398
+ * WorkflowCallEnvelope}: an author's parameter called `runId` must not be able
399
+ * to shadow the run id. That reason is sound and it is not being weakened. It
400
+ * simply does not reach the plain mode, because a plain call sends **no catalog
401
+ * metadata at all** — there is no `runId`, no `nodeId`, no contract number on
402
+ * the wire, so there is nothing a parameter could shadow. The flat payload is
403
+ * not the envelope with its guard removed; it is a different, smaller promise.
404
+ *
405
+ * ## What the plain mode costs, which is not small
406
+ *
407
+ * No `runId` and no `nodeId` means the callee has no key to stage rows under.
408
+ * Rows travel through the stage store addressed by `(runId, nodeId, batch)` —
409
+ * see {@link WorkflowStageRef} — and a callee that was told neither cannot
410
+ * write where the next node would read. So a plain call **cannot return rows to
411
+ * the graph**, and that is not a convention anybody could follow more carefully:
412
+ * it is arithmetic. `validateWorkflow` refuses a plain call with an outbound
413
+ * edge for exactly this reason (`call-plain-has-output`), and its return value
414
+ * is not read as a row count — see {@link readWorkflowCallOutput} for the shape
415
+ * that is deliberately *not* consulted on this path.
416
+ *
417
+ * A plain call is therefore for its **effect**: run the thing, and let something
418
+ * else in the graph produce what gets committed.
419
+ *
420
+ * ## Why a mode on the node rather than a second node kind or a boolean
421
+ *
422
+ * A boolean is the shape {@link WORKFLOW_PREDICATE_KINDS} argues against one
423
+ * level down, and for the reason it gives: a `plain?: boolean` beside a future
424
+ * third wire format is two optional flags whose combinations nobody defined, and
425
+ * every reader invents its own rule for which wins. A closed list with an
426
+ * exhaustiveness guard ({@link unreachableCallMode}) makes a third format a
427
+ * compile error naming the files that have to answer for it.
428
+ *
429
+ * A second node *kind* was the other candidate and it is too big. The kind list
430
+ * is deliberately small and every entry earns it by doing something no wiring
431
+ * can express (see {@link WORKFLOW_NODE_KINDS}). A plain call does the same
432
+ * thing a call does at the level the graph reasons about — it hands this
433
+ * position to a workflow somebody else registered, pinned by name and version.
434
+ * What differs is the payload. Splitting the kind would duplicate `callName`,
435
+ * `callVersion`, `config`, the pin check, the picker, the plan entry and the
436
+ * canvas node for a difference of one field, and every place that today writes
437
+ * `node.kind === 'call'` would have to remember to write both — which is the
438
+ * hand-maintained list going quiet that {@link NODE_KIND_IS_REUSABLE} exists to
439
+ * stop.
440
+ */
441
+ exports.WORKFLOW_CALL_MODES = [
442
+ /**
443
+ * The child is handed a {@link WorkflowCallEnvelope}: the catalog's metadata
444
+ * under `catalog`, the author's parameters under `input`. The callee can stage
445
+ * rows back for the graph, and it has to have been written for this catalog.
446
+ */
447
+ 'envelope',
448
+ /**
449
+ * The child is handed {@link WorkflowCallNode.config} verbatim, with nothing
450
+ * added and nothing wrapped. The callee needs to know nothing about the
451
+ * catalog — and cannot return rows to it.
452
+ */
453
+ 'plain',
454
+ ];
455
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
456
+ function isWorkflowCallMode(value) {
457
+ return exports.WORKFLOW_CALL_MODES.some((mode) => mode === value);
458
+ }
459
+ /**
460
+ * {@link unreachableNodeKind}, one level down, and for the identical reason.
461
+ *
462
+ * Every branch over {@link WorkflowCallMode} ends here, so a third wire format
463
+ * added to the list without a rule for building its payload, hashing it,
464
+ * validating it or reading its answer is a type error naming the file. It throws
465
+ * as well, because a mode arrives as JSON out of a column and a build older than
466
+ * the data is a thing that happens.
467
+ */
468
+ function unreachableCallMode(mode, where) {
469
+ throw new Error(`${where} has no rule for the call mode ${JSON.stringify(mode)}. It was added to WORKFLOW_CALL_MODES without teaching this code what to put on the wire for it, and guessing would send a workflow a payload nobody authored.`);
470
+ }
471
+ /**
472
+ * The mode this call node runs in, with the default applied once.
473
+ *
474
+ * Absent means `'envelope'` — that is what every node stored before the field
475
+ * existed is, and reading it as anything else would silently change what a
476
+ * deployment's graphs already do. One function so that the store, the runner,
477
+ * the hash and the canvas cannot each carry their own `?? 'envelope'` and have
478
+ * one of them drift.
479
+ */
480
+ function workflowCallMode(node) {
481
+ return node.callMode ?? 'envelope';
482
+ }
321
483
  /**
322
484
  * The kinds of test an {@link WorkflowIfNode} can make.
323
485
  *
@@ -977,6 +1139,44 @@ function describeVersionPin(version, subject) {
977
1139
  detail: `This node runs v${version} of ${subject} and stays there while it is edited elsewhere. Moving to a newer version is an edit to this graph, so it has a diff and a version of its own. A pin to a version that has been superseded more than ${catalog_workspace_1.CATALOG_REVISION_LIMIT} times can no longer be produced, and the run fails saying so rather than quietly using the latest.`,
978
1140
  };
979
1141
  }
1142
+ /**
1143
+ * The same question about a whole graph: does it follow its latest save, or does
1144
+ * it run a version somebody chose?
1145
+ *
1146
+ * Shares {@link VersionPinCopy} and its two labels with
1147
+ * {@link describeVersionPin} deliberately. A console that said "pinned to v6"
1148
+ * about a transform node and invented different words for a graph would be two
1149
+ * vocabularies for one idea, and the reader would have to work out whether they
1150
+ * meant the same thing — which is the confusion the whole notion of a pin exists
1151
+ * to remove.
1152
+ *
1153
+ * The *detail* differs, and only where the facts do. Two of them:
1154
+ *
1155
+ * - A node's pin can outlive the revision it names, because `catalog_revision`
1156
+ * is capped. A graph's cannot: releases are never evicted, precisely because
1157
+ * the one a live pointer names is the graph production is running. So this
1158
+ * copy makes no eviction caveat, and must not acquire one.
1159
+ * - Following the latest is *cheap* for a node — everybody moves together, which
1160
+ * is often the point. For a graph it means editing is deploying, which is the
1161
+ * hazard this field was added to remove. So the unpinned sentence here is a
1162
+ * warning where the node's is a trade-off.
1163
+ */
1164
+ function describeLiveVersion(workflow) {
1165
+ if (workflow.liveVersion === undefined) {
1166
+ return {
1167
+ pinned: false,
1168
+ label: 'follows the latest',
1169
+ detail: `This graph runs whatever its latest save holds, currently v${workflow.version}. Editing it is therefore the same act as deploying it: the next scheduled window runs what was last saved, with nobody having decided that it should. Releasing a version and setting it live is what separates the two.`,
1170
+ };
1171
+ }
1172
+ return {
1173
+ pinned: true,
1174
+ label: `running v${workflow.liveVersion}`,
1175
+ detail: workflow.liveVersion === workflow.version
1176
+ ? `This graph runs the released v${workflow.liveVersion}, which is also its latest save. Editing it from here changes nothing about what runs until a new version is released and set live.`
1177
+ : `This graph runs the released v${workflow.liveVersion} while its latest save is v${workflow.version}. The edits since then are stored and are not running; setting a newer release live is what deploys them, and setting an older one live is a rollback.`,
1178
+ };
1179
+ }
980
1180
  /**
981
1181
  * Which side of an {@link WorkflowIfNode} a wire leaves by.
982
1182
  *
@@ -1128,6 +1328,18 @@ exports.WORKFLOW_CALL_CONTRACT = 1;
1128
1328
  * schema for a workflow's output anywhere in the durable contract, and no way
1129
1329
  * to reach one if there were. So the check is here, at the one moment the
1130
1330
  * answer exists, and it names what it saw.
1331
+ *
1332
+ * ## Not called at all for a plain call, which is a decision and not an omission
1333
+ *
1334
+ * A plain call is handed no run id and no node id, so its callee could not have
1335
+ * staged anything under `(runId, nodeId, batch)` even if it wanted to. Running
1336
+ * this over its answer would be worse than pointless in both directions: a
1337
+ * callee that happens to return `{batches, rowCount}` meaning something else
1338
+ * entirely would have this graph go and read a stage that does not exist, and a
1339
+ * callee returning one of the two would fail the node over a key it was never
1340
+ * told about. So the plain path reports zero rows unconditionally and reads
1341
+ * nothing — see `WorkflowRunnerService.callOutput`, which is where that is
1342
+ * enforced rather than merely intended.
1131
1343
  */
1132
1344
  function readWorkflowCallOutput(value) {
1133
1345
  if (typeof value !== 'object' || value === null)
@@ -1182,6 +1394,13 @@ exports.WORKFLOW_ISSUE_CODES = [
1182
1394
  'dead-end',
1183
1395
  'transform-not-named',
1184
1396
  'call-not-named',
1397
+ /**
1398
+ * A plain call wired into something. See {@link WORKFLOW_CALL_MODES}: a plain
1399
+ * call is told no run id and no node id, so it has nowhere to stage rows and
1400
+ * always passes on none — and every node that can sit downstream of a call
1401
+ * consumes rows and nothing else.
1402
+ */
1403
+ 'call-plain-has-output',
1185
1404
  'if-not-named',
1186
1405
  'if-threshold-invalid',
1187
1406
  'if-needs-one-input',
@@ -1238,11 +1457,17 @@ function validateWorkflow(graph) {
1238
1457
  return issues;
1239
1458
  const { outgoing, incoming } = buildAdjacency(nodes, edges);
1240
1459
  const originators = nodes.filter(originatesRows);
1460
+ // Deliberately a *different* set from `originators` — see `runsWithoutInput`.
1461
+ const roots = nodes.filter(runsWithoutInput);
1241
1462
  const sinks = nodes.filter((node) => node.kind === 'sink');
1242
1463
  checkNodeWiring(nodes, incoming, outgoing, issues);
1243
1464
  checkEndpoints(originators, sinks, issues);
1244
1465
  checkBranches(edges, byId, issues);
1245
- checkFilterNarrowing({ nodes, edges }, originators, outgoing, issues);
1466
+ checkPlainCallOutputs(nodes, outgoing, byId, issues);
1467
+ // `roots` rather than `originators`, because this walks the graph forwards to
1468
+ // find which sinks a filter can narrow and a plain call is a perfectly
1469
+ // ordinary ancestor of nothing at all — it just never contributes rows.
1470
+ checkFilterNarrowing({ nodes, edges }, roots, outgoing, issues);
1246
1471
  const looped = findCycle(nodes, incoming, outgoing);
1247
1472
  if (looped) {
1248
1473
  issues.push({
@@ -1254,27 +1479,127 @@ function validateWorkflow(graph) {
1254
1479
  // only unreachable *because* of the cycle, which points at the wrong boxes.
1255
1480
  return issues;
1256
1481
  }
1257
- checkReachability(nodes, originators, sinks, incoming, outgoing, issues);
1482
+ checkReachability(nodes, roots, sinks, incoming, outgoing, issues);
1258
1483
  return issues;
1259
1484
  }
1485
+ /**
1486
+ * A plain call may not feed anything, and this is where that is refused.
1487
+ *
1488
+ * ## The rule, stated once
1489
+ *
1490
+ * **A call node in `plain` mode must have no outbound edge.** Not "must not feed
1491
+ * a sink", not "must not be the only thing feeding a sink" — no outbound edge at
1492
+ * all, and the reason it collapses that far is that there is no weaker version
1493
+ * of it that means anything. Every node kind that can sit downstream of a call —
1494
+ * transform, if, filter, sink — consumes rows and *only* rows. There is no
1495
+ * ordering-only wire in this model. So "a plain call with downstream nodes
1496
+ * expecting rows" and "a plain call with an outbound edge" are the same set.
1497
+ *
1498
+ * ## How it lands against the rest of the validator
1499
+ *
1500
+ * Three rules had to move for this one to be statable, and each moved in a way
1501
+ * that is narrower than it looks:
1502
+ *
1503
+ * - **`no-source`.** A plain call is no longer something that "reads": see
1504
+ * {@link originatesRows}. Without that, a graph of `plain call → sink` would
1505
+ * have passed the check that a graph has something producing rows, and then
1506
+ * committed an empty snapshot over whatever was live. It is refused here
1507
+ * first, and would be refused by `no-source` even if this check were deleted.
1508
+ * - **`dead-end` — every path reaches the sink.** A plain call reaches no sink
1509
+ * by construction, so it is exempt, and the exemption is exactly one node
1510
+ * wide: nothing can be *behind* a plain call, because a plain call has no
1511
+ * outbound edge, so no other node's route to the sink can run through one.
1512
+ * The message `dead-end` carries is "it would be computed and thrown away",
1513
+ * and that is precisely what a plain call is not — its effect is the point,
1514
+ * and it has already happened by the time the sink commits.
1515
+ * - **the one-sink rule.** Untouched. A graph still needs a sink and still needs
1516
+ * something that originates rows, and a plain call is now neither, so plain
1517
+ * calls cannot be a graph on their own — the rows come from a source or from
1518
+ * an envelope call, exactly as before.
1519
+ *
1520
+ * What a legal plain call looks like, then: `source → sink` with `source →
1521
+ * plainCall` beside it — the effect runs after the source and the load commits
1522
+ * the source's rows — or a plain call with nothing wired to it at all, which
1523
+ * runs at some point in the topological order and is reported like any other
1524
+ * node. Wiring a source into a plain call that is the source's *only* outbound
1525
+ * edge is still refused, by `dead-end`, pointed at the source: those rows really
1526
+ * would be fetched and dropped.
1527
+ */
1528
+ function checkPlainCallOutputs(nodes, outgoing, byId, issues) {
1529
+ for (const node of nodes) {
1530
+ if (node.kind !== 'call' || workflowCallMode(node) !== 'plain')
1531
+ continue;
1532
+ const fed = outgoing.get(node.id) ?? [];
1533
+ if (fed.length === 0)
1534
+ continue;
1535
+ issues.push({
1536
+ code: 'call-plain-has-output',
1537
+ nodeIds: [node.id, ...fed],
1538
+ message: `Call node "${node.name}" (${node.id}) is a plain call and is wired into ${listNodes(fed, byId)}, which cannot work. A plain call sends this node's parameters to ${node.callName || 'the workflow it calls'} verbatim and nothing else — no run id and no node id — so the workflow it calls is told no key to write rows under and this node always passes on zero. Everything downstream would run on an empty input and the load would commit an empty snapshot without anything failing. Unwire it: a plain call is run for its effect, and something else in this graph produces what gets committed. If the workflow you are calling is meant to produce rows for this graph, it needs the envelope instead, which is what tells it where to put them.`,
1539
+ });
1540
+ }
1541
+ }
1542
+ /** `"a" (n1) and "b" (n2)`, for a message that has to name several boxes. */
1543
+ function listNodes(ids, byId) {
1544
+ return ids
1545
+ .map((id) => {
1546
+ const node = byId.get(id);
1547
+ return node ? `"${node.name}" (${id})` : `"${id}"`;
1548
+ })
1549
+ .join(' and ');
1550
+ }
1260
1551
  /**
1261
1552
  * Whether a node can produce rows without anything wired into it.
1262
1553
  *
1263
- * A source obviously can. A **call** node can too, and this is the one rule the
1264
- * `call` kind changes rather than extends: the workflow it hands off to may
1265
- * itself read from a system, so a graph of `call → sink` is a real pipeline and
1266
- * refusing it for having "no source" would be false. What is not weakened is
1554
+ * A source obviously can. An **envelope call** node can too, and this is the one
1555
+ * rule the `call` kind changes rather than extends: the workflow it hands off to
1556
+ * may itself read from a system, so a graph of `call → sink` is a real pipeline
1557
+ * and refusing it for having "no source" would be false. What is not weakened is
1267
1558
  * that a graph still needs *something* that originates rows and *something*
1268
1559
  * that commits them — a graph of transforms alone is still refused.
1269
1560
  *
1270
- * Every call node counts, not only the ones with no inbound edge, and that is
1271
- * the conservative direction: it makes this the root set for reachability too,
1272
- * so a mid-graph call node cannot make everything downstream of it look
1273
- * unreachable when its own upstream is fine.
1561
+ * A **plain** call is not one of them, and that is the load-bearing half of this
1562
+ * function now. A plain call is told no run id and no node id, so it has no key
1563
+ * to stage rows under and cannot produce any see {@link WORKFLOW_CALL_MODES}.
1564
+ * Counting it here would let `plain call → sink` past `no-source` and commit an
1565
+ * empty snapshot over whatever was live, which is the exact silence this file is
1566
+ * arranged against.
1567
+ *
1568
+ * ## This used to be the reachability root set as well, and no longer is
1569
+ *
1570
+ * It said so, and the reason it gave was that it made the root set conservative.
1571
+ * The plain mode splits the two questions, because the answers genuinely differ:
1572
+ * a plain call **runs** with nothing wired into it (so it is a root, and calling
1573
+ * it unreachable would be false — it would run) and **produces nothing** (so it
1574
+ * is not something that reads). One function answering both would have to be
1575
+ * wrong about one of them. See {@link runsWithoutInput} for the other half.
1274
1576
  */
1275
1577
  function originatesRows(node) {
1578
+ if (node.kind === 'source')
1579
+ return true;
1580
+ return node.kind === 'call' && workflowCallMode(node) === 'envelope';
1581
+ }
1582
+ /**
1583
+ * Whether a node runs whether or not anything is wired into it.
1584
+ *
1585
+ * The root set for reachability, which used to be {@link originatesRows} and is
1586
+ * now its own question — see the note there. Every call node is one of these,
1587
+ * both modes: a call with no inbound edge sits at in-degree zero in the
1588
+ * topological order and is dispatched like anything else, so reporting it as
1589
+ * "not reachable from any source, so it would never run" would be a message that
1590
+ * is simply untrue.
1591
+ *
1592
+ * Every call node rather than only the unwired ones, for the reason the old
1593
+ * function gave: it keeps a mid-graph call node from making everything
1594
+ * downstream of it look unreachable when its own upstream is fine.
1595
+ */
1596
+ function runsWithoutInput(node) {
1276
1597
  return node.kind === 'source' || node.kind === 'call';
1277
1598
  }
1599
+ /** Whether this node is a call that can never hand rows back to the graph. */
1600
+ function isPlainCall(node) {
1601
+ return node.kind === 'call' && workflowCallMode(node) === 'plain';
1602
+ }
1278
1603
  /**
1279
1604
  * Index the nodes by id, reporting the ids that cannot be used as one.
1280
1605
  *
@@ -1811,12 +2136,17 @@ function peelTails(leftover, outgoing) {
1811
2136
  }
1812
2137
  return leftover;
1813
2138
  }
1814
- /** Nodes that nothing reading reaches, and nodes that reach no sink. */
1815
- function checkReachability(nodes, originators, sinks, incoming, outgoing, issues) {
1816
- const reachableFromSources = walk(originators.map((node) => node.id), outgoing);
2139
+ /**
2140
+ * Nodes that nothing reading reaches, and nodes that reach no sink.
2141
+ *
2142
+ * `roots` is {@link runsWithoutInput} and not {@link originatesRows}: this asks
2143
+ * what would *run*, and a plain call runs whether anything feeds it or not.
2144
+ */
2145
+ function checkReachability(nodes, roots, sinks, incoming, outgoing, issues) {
2146
+ const reachableFromSources = walk(roots.map((node) => node.id), outgoing);
1817
2147
  const reachesASink = walk(sinks.map((sink) => sink.id), incoming);
1818
2148
  for (const node of nodes) {
1819
- if (originators.length > 0 && !reachableFromSources.has(node.id)) {
2149
+ if (roots.length > 0 && !reachableFromSources.has(node.id)) {
1820
2150
  issues.push({
1821
2151
  code: 'unreachable',
1822
2152
  nodeIds: [node.id],
@@ -1824,6 +2154,15 @@ function checkReachability(nodes, originators, sinks, incoming, outgoing, issues
1824
2154
  });
1825
2155
  continue;
1826
2156
  }
2157
+ // The one exemption the plain mode buys, and it is exactly one node wide.
2158
+ // A plain call reaches no sink by construction — `call-plain-has-output`
2159
+ // refuses it an outbound edge — so `dead-end` would fire on every one of
2160
+ // them, with a message ("it would be computed and thrown away") that is
2161
+ // false about it: the effect it was run for has happened. Nothing can hide
2162
+ // behind this, because nothing can be downstream of a plain call, so no
2163
+ // other node's route to the sink can run through one.
2164
+ if (isPlainCall(node))
2165
+ continue;
1827
2166
  if (sinks.length > 0 && !reachesASink.has(node.id)) {
1828
2167
  issues.push({
1829
2168
  code: 'dead-end',
@@ -2059,6 +2398,13 @@ function canonicalNode(node) {
2059
2398
  node.callName,
2060
2399
  node.callVersion,
2061
2400
  sortedEntries(node.config),
2401
+ // Appended only for the non-default mode, exactly as `edge.branch` above
2402
+ // is appended only when there is a label. Every call node in every
2403
+ // deployment today is an envelope call — whether it says so or says
2404
+ // nothing — so every one of them hashes to the string it always did and
2405
+ // no stored graph is renumbered by picking up this release. See
2406
+ // `canonicalCallMode` for why the two spellings must fold together.
2407
+ ...canonicalCallMode(workflowCallMode(node)),
2062
2408
  ]);
2063
2409
  }
2064
2410
  if (node.kind === 'if') {
@@ -2103,6 +2449,27 @@ function canonicalNode(node) {
2103
2449
  * no version bump and no diff — which is precisely the silence this feature was
2104
2450
  * built to end.
2105
2451
  */
2452
+ /**
2453
+ * The call mode, as zero or one trailing hash component.
2454
+ *
2455
+ * Zero for `envelope`, and that is the opposite choice from {@link
2456
+ * canonicalReuse} beside it — for the opposite reason. There, absent and present
2457
+ * mean genuinely different things ("follow the latest" against "pinned to v1"),
2458
+ * so they must hash differently. Here absent and `'envelope'` are one behaviour
2459
+ * spelled two ways: a node that says `callMode: 'envelope'` puts precisely the
2460
+ * same bytes on the wire as a node that says nothing. A fingerprint that told
2461
+ * them apart would report an edit when a canvas normalised the field, which is
2462
+ * the cosmetic version bump {@link workflowGraphHash} exists to not do.
2463
+ *
2464
+ * `plain` earns its component, because it changes what the child receives.
2465
+ */
2466
+ function canonicalCallMode(mode) {
2467
+ if (mode === 'envelope')
2468
+ return [];
2469
+ if (mode === 'plain')
2470
+ return ['plain'];
2471
+ return unreachableCallMode(mode, 'workflowGraphHash');
2472
+ }
2106
2473
  function canonicalReuse(node) {
2107
2474
  if (node.useId === undefined)
2108
2475
  return [];
@@ -2211,18 +2578,8 @@ function isWorkflowNode(value) {
2211
2578
  if (kind === 'sink') {
2212
2579
  return typeof Reflect.get(value, 'targetType') === 'string';
2213
2580
  }
2214
- if (kind === 'call') {
2215
- // Both strings, and the config object, exactly as strictly as a source's:
2216
- // a stored call node missing its version is a node that would run whatever
2217
- // is registered today, which is the failure the pin exists to remove — and
2218
- // a graph that half-narrows is a load that runs nine nodes of ten.
2219
- const config = Reflect.get(value, 'config');
2220
- return (typeof Reflect.get(value, 'callName') === 'string' &&
2221
- typeof Reflect.get(value, 'callVersion') === 'string' &&
2222
- typeof config === 'object' &&
2223
- config !== null &&
2224
- !Array.isArray(config));
2225
- }
2581
+ if (kind === 'call')
2582
+ return isCallNodeShape(value);
2226
2583
  if (kind === 'if') {
2227
2584
  // The predicate in full, refused rather than defaulted: a gate read back
2228
2585
  // without a test it recognises would have to invent one, and inventing one
@@ -2241,6 +2598,34 @@ function isWorkflowNode(value) {
2241
2598
  }
2242
2599
  return isWorkflowNodeKindUnhandled(kind);
2243
2600
  }
2601
+ /**
2602
+ * Everything a `call` node carries, checked as strictly as a source's.
2603
+ *
2604
+ * Its own function rather than a branch of {@link isWorkflowNode}, which the
2605
+ * complexity bound will not hold any more of — and the split is where it should
2606
+ * be, because this is the kind with the most to check.
2607
+ *
2608
+ * A stored call node missing its version is a node that would run whatever is
2609
+ * registered today, which is the failure the pin exists to remove, and a graph
2610
+ * that half-narrows is a load that runs nine nodes of ten. A `callMode` that is
2611
+ * present and unrecognised is refused rather than dropped, for the reason an
2612
+ * unrecognised `edge.branch` is: reading it back as the default would turn a
2613
+ * plain call into an envelope call silently, and the callee would be handed a
2614
+ * payload nobody authored. Absent is accepted and always will be — it is what
2615
+ * every call node written before the field existed carries, and it means the
2616
+ * envelope, which is what those nodes have always sent.
2617
+ */
2618
+ function isCallNodeShape(value) {
2619
+ const callMode = Reflect.get(value, 'callMode');
2620
+ if (callMode !== undefined && !isWorkflowCallMode(callMode))
2621
+ return false;
2622
+ const config = Reflect.get(value, 'config');
2623
+ return (typeof Reflect.get(value, 'callName') === 'string' &&
2624
+ typeof Reflect.get(value, 'callVersion') === 'string' &&
2625
+ typeof config === 'object' &&
2626
+ config !== null &&
2627
+ !Array.isArray(config));
2628
+ }
2244
2629
  /**
2245
2630
  * Whether a stored `narrows` is one this build can read.
2246
2631
  *
@@ -2352,6 +2737,43 @@ function supportsWorkflows(store) {
2352
2737
  // is the surface a scheduling incident already came through once.
2353
2738
  typeof store.saveWorkflowSchedule === 'function');
2354
2739
  }
2740
+ /**
2741
+ * Whether this store can mint a release and be pointed at one.
2742
+ *
2743
+ * All four asked for by name, for the reason `supportsWorkflows` asks for
2744
+ * `publishWorkflow` and `saveWorkflowSchedule` by name rather than assuming they
2745
+ * arrive together: a store with the mint and not the pointer would narrow
2746
+ * cleanly here, let somebody release a graph, and then fail on the call that was
2747
+ * supposed to make it run.
2748
+ *
2749
+ * {@link getWorkflowAt} is the one whose absence is least visible and most
2750
+ * expensive. A store that could hold a `liveVersion` and not resolve it would
2751
+ * point a scheduled load at a version it cannot produce — and the only place
2752
+ * that shows up is a cron window that stops firing.
2753
+ */
2754
+ function supportsWorkflowReleases(store) {
2755
+ return (typeof store.releaseWorkflow === 'function' &&
2756
+ typeof store.listWorkflowReleases === 'function' &&
2757
+ typeof store.getWorkflowAt === 'function' &&
2758
+ typeof store.setLiveWorkflowVersion === 'function');
2759
+ }
2760
+ /**
2761
+ * Which version of this graph a run gets when the caller names none.
2762
+ *
2763
+ * The one implementation of "follow the latest unless something is live", shared
2764
+ * by the scheduler and by the manual run route so the two cannot disagree about
2765
+ * what a cron does and what the button next to it does. That divergence is not
2766
+ * hypothetical: the schedule used to live on the connector and on the workflow
2767
+ * at once, and the whole of `ConnectorScheduler`'s docblock is about what it
2768
+ * cost to have two copies of one answer.
2769
+ *
2770
+ * Not a fallback in the defensive sense. `liveVersion` absent is a stated
2771
+ * position — this graph follows its head — and this function is where that
2772
+ * position is turned into a number, not where a missing value is patched over.
2773
+ */
2774
+ function liveWorkflowVersion(workflow) {
2775
+ return workflow.liveVersion ?? workflow.version;
2776
+ }
2355
2777
  /**
2356
2778
  * Whether this store keeps a transform's history.
2357
2779
  *
package/dist/client.d.ts CHANGED
@@ -153,8 +153,8 @@ export declare const catalogRoutes: {
153
153
  readonly traces: () => string;
154
154
  readonly trace: (id: string) => string;
155
155
  };
156
- export type { CatalogConnection, CatalogConnector, ConnectionCheck, CatalogTransform, CatalogWorkflow, CatalogWorkflowCapabilities, ConnectorKind, ConnectorRun, TransformLanguage, TransformResult, CallableWorkflowRef, WorkflowBranchLabel, WorkflowCallEnvelope, WorkflowCallNode, WorkflowCallOutput, WorkflowEdge, WorkflowExecutionMode, WorkflowFilterAll, WorkflowFilterAny, WorkflowFilterComparison, WorkflowFilterGroup, WorkflowFilterNode, WorkflowFilterOneOf, WorkflowFilterOperator, WorkflowFilterPredicate, WorkflowFilterPredicateKind, WorkflowFilterPresence, WorkflowFilterValue, WorkflowGraph, WorkflowEnvPredicate, WorkflowIfNode, WorkflowIfPredicate, WorkflowIssueCode, WorkflowNode, WorkflowNodeKind, WorkflowNodeOutcome, WorkflowPredicateKind, WorkflowRowCountPredicate, WorkflowRunOrderEntry, WorkflowSinkNode, WorkflowSkipReason, WorkflowSourceNode, WorkflowStageRef, WorkflowTransformNode, WorkflowValidationIssue, CatalogReusableNode, CatalogReusableNodeUse, ReusableNodeBody, ReusableNodeKind, ReusableNodeRef, ReusableSinkBody, ReusableSourceBody, VersionPinCopy, } from './catalog.pipeline';
157
- export { CONNECTOR_KINDS, isConnectorKind, isTransformLanguage, isWorkflowEdge, isWorkflowNode, REDACTED_SECRET, TRANSFORM_LANGUAGES, readWorkflowCallOutput, WORKFLOW_CALL_CONTRACT, applyReusableNode, reusableNodeBodyOf, isReusableNodeBody, isReusableNodeKind, REUSABLE_NODE_KINDS, NODE_KIND_IS_REUSABLE, nodeKindIsReusable, unreachableReusableNodeKind, describeVersionPin, } from './catalog.pipeline';
156
+ export type { CatalogConnection, CatalogConnector, ConnectionCheck, CatalogTransform, CatalogWorkflow, CatalogWorkflowCapabilities, CatalogWorkflowRelease, ConnectorKind, ConnectorRun, TransformLanguage, TransformResult, CallableWorkflowRef, WorkflowBranchLabel, WorkflowCallEnvelope, WorkflowCallMode, WorkflowCallNode, WorkflowCallOutput, WorkflowEdge, WorkflowExecutionMode, WorkflowFilterAll, WorkflowFilterAny, WorkflowFilterComparison, WorkflowFilterGroup, WorkflowFilterNode, WorkflowFilterOneOf, WorkflowFilterOperator, WorkflowFilterPredicate, WorkflowFilterPredicateKind, WorkflowFilterPresence, WorkflowFilterValue, WorkflowGraph, WorkflowEnvPredicate, WorkflowIfNode, WorkflowIfPredicate, WorkflowIssueCode, WorkflowNode, WorkflowNodeKind, WorkflowNodeOutcome, WorkflowPredicateKind, WorkflowRowCountPredicate, WorkflowRunOrderEntry, WorkflowSinkNode, WorkflowSkipReason, WorkflowSourceNode, WorkflowStageRef, WorkflowTransformNode, WorkflowValidationIssue, CatalogReusableNode, CatalogReusableNodeUse, ReusableNodeBody, ReusableNodeKind, ReusableNodeRef, ReusableSinkBody, ReusableSourceBody, SourceFormat, VersionPinCopy, } from './catalog.pipeline';
157
+ export { CONNECTOR_KINDS, isConnectorKind, isSourceFormat, isTransformLanguage, SOURCE_FORMATS, isWorkflowEdge, isWorkflowNode, liveWorkflowVersion, REDACTED_SECRET, TRANSFORM_LANGUAGES, readWorkflowCallOutput, WORKFLOW_CALL_CONTRACT, WORKFLOW_CALL_MODES, isWorkflowCallMode, unreachableCallMode, workflowCallMode, applyReusableNode, reusableNodeBodyOf, isReusableNodeBody, isReusableNodeKind, REUSABLE_NODE_KINDS, NODE_KIND_IS_REUSABLE, nodeKindIsReusable, unreachableReusableNodeKind, describeLiveVersion, describeVersionPin, } from './catalog.pipeline';
158
158
  /**
159
159
  * The workflow validator, shipped to the browser deliberately.
160
160
  *