@dudousxd/nestjs-catalog 0.15.0 → 0.16.0-preview.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.
@@ -406,12 +406,18 @@ export interface CatalogTransform {
406
406
  description?: string;
407
407
  language: TransformLanguage;
408
408
  /**
409
- * The body of a function over one batch. It receives `records` and returns
410
- * the rows to store.
409
+ * The body of a function over one batch. It receives `records` and
410
+ * `context`, and returns the rows to store.
411
411
  *
412
412
  * A batch rather than a record at a time: a transform that needs to look up,
413
413
  * deduplicate or aggregate cannot do it one row at a time, and paying one
414
414
  * process spawn per record would make any real load unusable.
415
+ *
416
+ * `context` is a {@link CatalogCodeContext} — the run, the node, the counts
417
+ * of what fed it, and the environment variables this deployment admits.
418
+ * Second rather than first, so that every transform written before it existed
419
+ * still runs: the harness supplies the parameter, and code that never names
420
+ * it is unaffected.
415
421
  */
416
422
  code: string;
417
423
  version: number;
@@ -460,6 +466,7 @@ export interface TransformResult {
460
466
  export interface TransformRunner {
461
467
  run(transform: Pick<CatalogTransform, 'language' | 'code'>, records: unknown[], options?: {
462
468
  timeoutMs?: number;
469
+ context?: CatalogCodeContext;
463
470
  }): Promise<TransformResult>;
464
471
  /** Languages this runner can actually execute in this environment. */
465
472
  available(): Promise<TransformLanguage[]>;
@@ -473,6 +480,123 @@ export interface TransformRunner {
473
480
  pythonPackages?(): Promise<string[]>;
474
481
  }
475
482
  export declare const TRANSFORM_RUNNER: unique symbol;
483
+ /**
484
+ * The number in {@link CatalogCodeContext.contract}.
485
+ *
486
+ * Its own version, separate from {@link WORKFLOW_CALL_CONTRACT}, because the
487
+ * two travel to different places for different reasons — a call envelope
488
+ * crosses to another SDK over the durable wire, a code context crosses to a
489
+ * child process this repository spawned — and a transform written against one
490
+ * has to be able to say which it read.
491
+ */
492
+ export declare const CODE_CONTEXT_CONTRACT = 1;
493
+ /**
494
+ * The second argument every code-bearing node's code is handed.
495
+ *
496
+ * ## Why this exists at all
497
+ *
498
+ * A transform is a function over a batch, and a batch is not the whole of what
499
+ * the code needs to know. It needs the credential for the API it enriches
500
+ * against; it needs to say which run it belongs to when it logs; and — the case
501
+ * that motivated this — a conditional node's predicate has no `records` at all
502
+ * and still has to answer "did the source return anything", which is the guard
503
+ * that stops an empty snapshot being committed over live data.
504
+ *
505
+ * ## Why it is not `process.env`
506
+ *
507
+ * The obvious answer to "code needs environment variables" is to stop trimming
508
+ * the child's environment. It is the wrong one, and this repository has already
509
+ * argued the case in `secret-env-allowlist.ts`: a `secretEnvVar` is chosen by
510
+ * whoever writes the connector, so `process.env[name]` with nothing in between
511
+ * let anyone holding `catalog:write` on one narrow object type read the host
512
+ * application's own `DATABASE_URL`. Transform code is *the same principal by a
513
+ * shorter route* — it is a string that principal saved, and it can print
514
+ * whatever it reads into `logs`, which land in the run record and are served at
515
+ * `catalog:read`.
516
+ *
517
+ * So {@link env} is the allow-listed environment and nothing else: the same
518
+ * policy, the same two levers, the same boot warning. One rule across the
519
+ * product rather than a second one that quietly undoes the first. What the
520
+ * child's *own* `process.env` holds is unchanged — still `{PATH, NODE_ENV}` —
521
+ * and the admitted values arrive as data on stdin instead.
522
+ *
523
+ * ## Replay
524
+ *
525
+ * Everything here is plain JSON, and everything except {@link env} and
526
+ * {@link environment} is derived from a durable step's checkpointed input: the
527
+ * run id, the node, the graph's version and the stage handles are byte-
528
+ * identical on every attempt and on every replay. Those two are reads of
529
+ * pod-local state, which for a transform is harmless — a transform runs inside
530
+ * a step whose *output* is checkpointed, so replay returns the answer rather
531
+ * than re-running the code — and for a predicate evaluated in a workflow body
532
+ * is not. A caller in that position must resolve the context inside a step and
533
+ * let the checkpoint carry it, so that a redeploy between the original run and
534
+ * the replay cannot move the branch. The shape is JSON precisely so that it can
535
+ * be.
536
+ */
537
+ export interface CatalogCodeContext {
538
+ /** {@link CODE_CONTEXT_CONTRACT} at the time the code ran. */
539
+ contract: number;
540
+ /**
541
+ * The run this code belongs to, which is also the snapshot id.
542
+ *
543
+ * Absent means there is no run: the editor's try pane executes code against
544
+ * sample records and stores nothing. Anything derived from this — an
545
+ * idempotency key, a filename — should refuse rather than invent one, and the
546
+ * absence is the signal that lets it.
547
+ */
548
+ runId?: string;
549
+ /** The graph, when the code is a node in one. Absent for a connector's single transform. */
550
+ workflow?: {
551
+ id: string;
552
+ name: string;
553
+ version: number;
554
+ };
555
+ /** The node, when the code is a node. Absent for a connector's single transform. */
556
+ node?: {
557
+ id: string;
558
+ name: string;
559
+ };
560
+ /** The connector, when the code runs as a connector's transform rather than in a graph. */
561
+ connectorId?: string;
562
+ /**
563
+ * The host's name for this copy of the world — `dev`, `prod` — when the host
564
+ * declared one.
565
+ *
566
+ * Nothing in the catalog can work this out for itself: a process may serve
567
+ * several environments and the choice arrives per request, so the only
568
+ * honest source is the host saying so. Present because branching on a named
569
+ * environment is legible in a way that sniffing a variable is not, and absent
570
+ * whenever the host stayed silent, which is a different thing from `dev`.
571
+ */
572
+ environment?: string;
573
+ /**
574
+ * How many records reached this code.
575
+ *
576
+ * Redundant with `records.length` for a transform and the whole payload for a
577
+ * predicate, which has no records. Stated as one number because the line a
578
+ * predicate is written to hold is "did anything arrive", and making that a
579
+ * sum over {@link inputs} invites `inputs[0].rowCount`, which is wrong the
580
+ * moment somebody draws a second edge.
581
+ */
582
+ rowCount: number;
583
+ /**
584
+ * Per inbound edge, in edge order — handles and counts, never the rows.
585
+ *
586
+ * The same {@link WorkflowStageRef} the call node hands a callee, deliberately
587
+ * rather than a second vocabulary for the same fact. Empty outside a graph.
588
+ */
589
+ inputs: WorkflowStageRef[];
590
+ /**
591
+ * The environment variables this deployment admits, and only those.
592
+ *
593
+ * Filtered by the credential allow-list — see `secret-env-allowlist.ts` — so
594
+ * a name nobody admitted is not here, and an empty object is the ordinary
595
+ * answer on a deployment that has declared no policy. The run's logs say
596
+ * which it was.
597
+ */
598
+ env: Record<string, string>;
599
+ }
476
600
  export interface ConnectorRun {
477
601
  id: string;
478
602
  connectorId: string;
@@ -539,18 +663,60 @@ export interface ConnectorRun {
539
663
  */
540
664
  nodeOutcomes?: Record<string, WorkflowNodeOutcome>;
541
665
  }
666
+ /**
667
+ * Why a `skipped` node did not run, when there is more to say than "the run
668
+ * stopped".
669
+ *
670
+ * **One entry, and the omission is the point.** `skipped` already meant one
671
+ * thing before branches existed — the run failed upstream and never reached
672
+ * this node — and every outcome ever stored records that meaning by *not*
673
+ * carrying a reason. Adding `run-stopped` to this list would not describe those
674
+ * rows, it would leave them describing an unknown reason, so the pre-existing
675
+ * meaning stays the absent one and this names only the new fact: the node is on
676
+ * a branch an {@link WorkflowIfNode} did not take.
677
+ *
678
+ * The distinction is not cosmetic. A sink skipped by a branch **committed
679
+ * nothing and left the live snapshot alone**, which is a correct, successful
680
+ * outcome; a sink skipped by a failure is part of a load that went wrong. A run
681
+ * panel that rendered both as "did not run" would answer "why is there no data
682
+ * in X" with the same shrug in both cases.
683
+ */
684
+ export declare const WORKFLOW_SKIP_REASONS: readonly ["branch-not-taken"];
685
+ export type WorkflowSkipReason = (typeof WORKFLOW_SKIP_REASONS)[number];
686
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
687
+ export declare function isWorkflowSkipReason(value: unknown): value is WorkflowSkipReason;
542
688
  /** What one node did during a run. Small by construction — counters, not rows. */
543
689
  export interface WorkflowNodeOutcome {
544
690
  /**
545
691
  * `skipped` exists for the nodes downstream of a failure. Without it, a
546
692
  * ten-node graph that died at node seven records three nodes with no entry at
547
693
  * all, which reads the same as three nodes nobody has looked at yet.
694
+ *
695
+ * It now carries a second, legitimate meaning as well — a node on the branch
696
+ * an `if` did not take — and {@link skippedBecause} is what tells the two
697
+ * apart.
548
698
  */
549
699
  status: 'succeeded' | 'failed' | 'skipped';
550
700
  /** Rows this node produced, or committed if it is the sink. */
551
701
  rows: number;
552
702
  /** For a transform node: which version of its code ran. */
553
703
  transformVersion?: number;
704
+ /**
705
+ * For an {@link WorkflowIfNode}: the branch this run took.
706
+ *
707
+ * **Written once, on the first evaluation, and read back on every replay.**
708
+ * This is the record the durable path replays from rather than re-evaluating:
709
+ * a predicate reads the environment, and an environment is pod-local, so a
710
+ * replay landing on another pod could otherwise take the other branch halfway
711
+ * through a run and load through a shape nobody chose. See
712
+ * `WorkflowRunnerService.runIf`.
713
+ *
714
+ * It is also the answer to "why did nothing load into X", which is the
715
+ * question a branch makes askable and nothing else on a run can answer.
716
+ */
717
+ branch?: WorkflowBranchLabel;
718
+ /** See {@link WORKFLOW_SKIP_REASONS}. Absent means the run stopped short. */
719
+ skippedBecause?: WorkflowSkipReason;
554
720
  elapsedMs?: number;
555
721
  error?: string;
556
722
  }
@@ -572,9 +738,12 @@ export interface WorkflowNodeOutcome {
572
738
  * It needs no new execution path, only a different body, and adding the kind
573
739
  * would mean two ways to drop rows and two places to look when rows go
574
740
  * missing.
575
- * - **branch / split** — already expressible: a node with two outbound edges is
576
- * read by both successors, each of which filters differently. There is
577
- * nothing for a branch node to *do*.
741
+ * - **branch / split (unconditional)** — already expressible, and still is: a
742
+ * node with two outbound edges is read by both successors, each of which
743
+ * filters differently. There is nothing for an *unconditional* split to do.
744
+ * {@link WorkflowIfNode} is the conditional one, and it earns its kind by
745
+ * doing something no wiring can express — deciding that one of those
746
+ * successors, and everything only it feeds, does not run at all.
578
747
  * - **merge / join** — a node with several inbound edges receives its inputs
579
748
  * concatenated in edge order (see {@link WorkflowEdge}). A keyed join is then
580
749
  * ordinary code inside the transform, which can already see every record.
@@ -591,10 +760,28 @@ export interface WorkflowNodeOutcome {
591
760
  * outside a run, which is why `call` names one and not a step. If a step is
592
761
  * what you want, the thing to call is a one-step workflow wrapping it.
593
762
  */
594
- export declare const WORKFLOW_NODE_KINDS: readonly ["source", "transform", "sink", "call"];
763
+ export declare const WORKFLOW_NODE_KINDS: readonly ["source", "transform", "sink", "call", "if"];
595
764
  export type WorkflowNodeKind = (typeof WORKFLOW_NODE_KINDS)[number];
596
765
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
597
766
  export declare function isWorkflowNodeKind(value: unknown): value is WorkflowNodeKind;
767
+ /**
768
+ * The kind that never compiles quietly.
769
+ *
770
+ * Every place that decides something *per kind* ends in a call to this, so a
771
+ * kind added to {@link WORKFLOW_NODE_KINDS} without a branch there is a type
772
+ * error naming the file rather than a graph that saves, validates, draws and
773
+ * then does the wrong thing. This codebase has been bitten by exactly that
774
+ * shape — a `toGraph` branch forgetting a field, a node-kind map missing a kind
775
+ * — and the fix each time was to make the omission impossible rather than to
776
+ * remember harder.
777
+ *
778
+ * It throws as well as failing to compile, because the narrowing that reaches
779
+ * it is over data that arrives as JSON: a node whose `kind` passed
780
+ * {@link isWorkflowNodeKind} in an older build and reaches a newer one is
781
+ * possible, and returning a default for it would be the silent path this exists
782
+ * to close.
783
+ */
784
+ export declare function unreachableNodeKind(node: never, where: string): never;
598
785
  /**
599
786
  * The longest a node id may be, and the alphabet it may use.
600
787
  *
@@ -618,12 +805,68 @@ interface WorkflowNodeBase {
618
805
  * a person arranged and the server then forgot is a canvas that loses work.
619
806
  * Excluded from the graph fingerprint for the same reason a rename is: moving
620
807
  * a box is not a new version of the graph.
808
+ *
809
+ * A position is in the units {@link WORKFLOW_NODE_WIDTH} is measured in, and
810
+ * anything **generating** one should space it with {@link workflowColumnX} and
811
+ * {@link workflowRowY} rather than by picking a number.
621
812
  */
622
813
  position?: {
623
814
  x: number;
624
815
  y: number;
625
816
  };
626
817
  }
818
+ /**
819
+ * How big a node is on the canvas, and therefore how far apart two of them have
820
+ * to be.
821
+ *
822
+ * ## Why the server owns a number about pixels
823
+ *
824
+ * Because the server *writes positions*. `adoptConnector` lays a connector out
825
+ * as a graph, and anything else that mints a graph without a person drawing it —
826
+ * a promotion, a template, a script — has the same job. A writer that does not
827
+ * know how wide a node is can only guess, and the guess was wrong: adoption used
828
+ * to place columns 220 apart against a node 224 wide, so every adopted graph
829
+ * drew each box overlapping the next by four pixels. Nothing was stacked and
830
+ * nothing was missing, so it read as boxes glued together rather than as a bug,
831
+ * and it survived until somebody opened thirteen of them.
832
+ *
833
+ * The fix is not a bigger number. 220 was not too small, it was **derived from
834
+ * nothing** — it had no relationship to the width it was supposed to clear, so
835
+ * it was only ever correct by luck and would go wrong again the next time the
836
+ * node's styling changed. These constants are the relationship, stated once, in
837
+ * the one package both the writer and the canvas already depend on.
838
+ *
839
+ * ## What pins them to the drawing
840
+ *
841
+ * {@link WORKFLOW_NODE_WIDTH} is not a description of the node — it is the
842
+ * *source of* the node's width. `WorkflowNodeBody` in
843
+ * `@dudousxd/nestjs-catalog-react` sets its own width from this constant rather
844
+ * than from a Tailwind class, so the two cannot drift: changing this changes the
845
+ * box, and there is no second number to forget.
846
+ */
847
+ export declare const WORKFLOW_NODE_WIDTH = 224;
848
+ /** How tall a node is. The other half of {@link WORKFLOW_NODE_WIDTH}'s contract. */
849
+ export declare const WORKFLOW_NODE_HEIGHT = 80;
850
+ /**
851
+ * Clear space between one column and the next, on top of the node's own width.
852
+ *
853
+ * Wide enough for the edge between two nodes to be read as a line with a
854
+ * direction rather than as a join. This is the part that is taste; the width it
855
+ * is added to is not.
856
+ */
857
+ export declare const WORKFLOW_COLUMN_GAP = 96;
858
+ /** Clear space between two nodes sharing a column. */
859
+ export declare const WORKFLOW_ROW_GAP = 32;
860
+ /**
861
+ * The x of the nth column, counting from zero.
862
+ *
863
+ * Every generator of a layout goes through this rather than multiplying by a
864
+ * literal, which is what makes "columns never overlap" a property of one
865
+ * function instead of a coincidence repeated at each call site.
866
+ */
867
+ export declare function workflowColumnX(column: number): number;
868
+ /** The y of the nth row within a column, counting from zero. */
869
+ export declare function workflowRowY(row: number): number;
627
870
  /**
628
871
  * Reads records out of a system.
629
872
  *
@@ -743,13 +986,229 @@ export interface WorkflowCallNode extends WorkflowNodeBase {
743
986
  /** Parameters the author typed, handed to the child under `input`. */
744
987
  config: Record<string, unknown>;
745
988
  }
989
+ /**
990
+ * The kinds of test an {@link WorkflowIfNode} can make.
991
+ *
992
+ * A second predicate shape was always going to arrive — the note on
993
+ * {@link WorkflowIfNode} says so about `code` — and the shape it arrives into is
994
+ * the decision worth arguing about, because the alternative was to keep both
995
+ * tests' fields flat on the node and mark them optional. That version types a
996
+ * gate as "an env var, maybe, and a threshold, maybe": a node carrying both is
997
+ * representable, a node carrying neither is representable, and every reader has
998
+ * to invent its own rule for which one wins. It is the same mistake the note on
999
+ * {@link WorkflowNode} refuses for node kinds, one level down.
1000
+ *
1001
+ * So the predicate is a union with a discriminant of its own, and every decision
1002
+ * made per predicate kind ends in {@link unreachablePredicateKind} — a third
1003
+ * shape is then a build failure listing the files that have to answer for it,
1004
+ * rather than a gate that saves, draws, and quietly always takes the `else`.
1005
+ */
1006
+ export declare const WORKFLOW_PREDICATE_KINDS: readonly ["env", "rowCount"];
1007
+ export type WorkflowPredicateKind = (typeof WORKFLOW_PREDICATE_KINDS)[number];
1008
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1009
+ export declare function isWorkflowPredicateKind(value: unknown): value is WorkflowPredicateKind;
1010
+ /**
1011
+ * {@link unreachableNodeKind}, one level down, and for the identical reason.
1012
+ *
1013
+ * Every branch over {@link WorkflowIfPredicate} ends here, so a predicate kind
1014
+ * added to the list without a rule for hashing it, validating it or evaluating
1015
+ * it is a type error naming the file — not a graph that runs and decides
1016
+ * something nobody authored. It throws as well, because predicates arrive as
1017
+ * JSON out of a column and a build older than the data is a thing that happens.
1018
+ */
1019
+ export declare function unreachablePredicateKind(predicate: never, where: string): never;
1020
+ /**
1021
+ * Tests a variable on the machine that runs the load.
1022
+ *
1023
+ * The original predicate, and the one the node was built for: a deployment that
1024
+ * has a ClickHouse has its URL and one that does not has nothing. It names the
1025
+ * variable and never its value — see the note on {@link WorkflowIfNode} for why
1026
+ * that is a safety property rather than a convenience.
1027
+ */
1028
+ export interface WorkflowEnvPredicate {
1029
+ kind: 'env';
1030
+ /**
1031
+ * The name of the environment variable to read on the machine that runs the
1032
+ * node. **The name, never the value** — nothing about a credential is stored
1033
+ * in a catalog.
1034
+ */
1035
+ envVar: string;
1036
+ /**
1037
+ * What it has to equal for the `then` branch to be taken.
1038
+ *
1039
+ * Absent means "is it set to anything non-empty", which is the ClickHouse
1040
+ * case: a deployment that has one has the URL, a deployment that does not has
1041
+ * nothing. Present means an exact string comparison, which is the
1042
+ * `DEPLOY_ENV = local` case.
1043
+ */
1044
+ equals?: string;
1045
+ }
1046
+ /**
1047
+ * Tests how many rows reached the gate.
1048
+ *
1049
+ * ## The case
1050
+ *
1051
+ * "Only run the sink if the source returned anything." A nightly export that
1052
+ * comes back empty because the upstream system is mid-maintenance is not a
1053
+ * failure — nothing is broken, there is simply nothing to load — but committing
1054
+ * it repoints the live view of a type at an empty snapshot, and the run reports
1055
+ * success while doing it. A gate in front of the sink turns that into a skip,
1056
+ * and a skipped node is never executed, so nothing commits. That is the same
1057
+ * guarantee the `else` branch already gives, pointed at the case that actually
1058
+ * happens.
1059
+ *
1060
+ * ## Which rows
1061
+ *
1062
+ * The ones on the single inbound edge. `validateWorkflow` refuses a gate with
1063
+ * more than one (`if-needs-one-input`) and refuses one with none as unreachable,
1064
+ * so "how many rows" has exactly one answer — and it is the count on the very
1065
+ * {@link WorkflowStageRef} the gate hands on, so the number tested and the rows
1066
+ * carried cannot disagree.
1067
+ *
1068
+ * ## Where the number comes from, which is the replay argument
1069
+ *
1070
+ * `WorkflowStageRef.rowCount`, off {@link WorkflowNodeStepInput.inputs}, which
1071
+ * is part of the step's checkpointed input and was itself produced by an
1072
+ * upstream step's checkpointed output. Nothing counts rows at evaluation time
1073
+ * and nothing reads the stage store: a resumed run on another pod sees the same
1074
+ * number the first attempt saw, and the branch it produced is read back off
1075
+ * {@link WorkflowNodeStepOutput.branch} anyway.
1076
+ *
1077
+ * ## Why a threshold and not "greater than zero"
1078
+ *
1079
+ * `atLeast: 1` *is* "did anything arrive", so the common case costs nothing to
1080
+ * express — and "a full export is never under ten thousand rows, so treat a
1081
+ * hundred as a broken upstream rather than as data" is the next thing anybody
1082
+ * asks for, and it would otherwise need a second predicate kind for one integer.
1083
+ *
1084
+ * One comparison and one direction, deliberately. `atMost`, `equals` and a
1085
+ * chosen operator were all considered and are all the same mistake the node's
1086
+ * own `negate` flag would have been: the inverse test is already expressible by
1087
+ * swapping which successor is on `then` and which is on `else`, and two ways to
1088
+ * say one thing is two places to look when a load takes the branch nobody
1089
+ * expected.
1090
+ */
1091
+ export interface WorkflowRowCountPredicate {
1092
+ kind: 'rowCount';
1093
+ /**
1094
+ * How many rows have to reach the gate for the `then` branch to be taken.
1095
+ *
1096
+ * A whole number of at least one, and `validateWorkflow` says so. Zero is
1097
+ * refused rather than treated as "always" because it is a gate that can only
1098
+ * ever answer one way — the `else` subtree would never run on any deployment,
1099
+ * which is the silent half-graph this node's whole design is arranged against.
1100
+ */
1101
+ atLeast: number;
1102
+ }
1103
+ /**
1104
+ * What an `if` node tests. See {@link WORKFLOW_PREDICATE_KINDS}.
1105
+ */
1106
+ export type WorkflowIfPredicate = WorkflowEnvPredicate | WorkflowRowCountPredicate;
1107
+ /**
1108
+ * Narrow a stored predicate, for the same reason {@link isWorkflowNode} narrows
1109
+ * a stored node: it arrives as JSON out of a column, and a gate read back
1110
+ * without its test is a gate that has to invent one.
1111
+ *
1112
+ * A row count that is not a whole number — `NaN` from a JSON round trip of an
1113
+ * unparsed field, an `Infinity` that serialised as `null` — is refused rather
1114
+ * than kept, because every comparison against it is false and the symptom is a
1115
+ * `then` branch that silently never runs again.
1116
+ */
1117
+ export declare function isWorkflowIfPredicate(value: unknown): value is WorkflowIfPredicate;
1118
+ /**
1119
+ * Sends the rows down one of its two outbound branches, and skips the other.
1120
+ *
1121
+ * The node the maintainer asked for, and the reason it is a node rather than a
1122
+ * flag on an edge: a deployment that has a ClickHouse and a deployment that does
1123
+ * not are the *same graph*, and the difference between them is one decision made
1124
+ * at run time. Modelling it as two workflows means two things to keep in step;
1125
+ * modelling it as an edge flag means the decision has nowhere to be recorded and
1126
+ * nothing on the canvas to open.
1127
+ *
1128
+ * ## What it does to the rows: nothing
1129
+ *
1130
+ * An `if` is a gate, not a stage. It passes its input through untouched — its
1131
+ * output ref *is* its input's ref — so a branch costs no copy of the dataset and
1132
+ * no second write into the stage store. That is also why it takes **exactly one
1133
+ * inbound edge**: a node hands its successors one {@link WorkflowStageRef}, so a
1134
+ * gate fed by two inputs could only either copy the rows to merge them (paying
1135
+ * for the whole dataset to make a decision that reads none of it) or silently
1136
+ * drop one. Merging is what a transform is for; put one in front.
1137
+ *
1138
+ * That holds for {@link WorkflowRowCountPredicate} too, which is the one
1139
+ * predicate that sounds like it reads the data and does not: the count it tests
1140
+ * is the number already written on the {@link WorkflowStageRef} it was handed,
1141
+ * so a gate still touches no rows — and with one inbound edge there is exactly
1142
+ * one count for "how many rows" to mean.
1143
+ *
1144
+ * ## The predicate is declarative, and that is the safety property
1145
+ *
1146
+ * It is one of {@link WORKFLOW_PREDICATE_KINDS} — a variable's name, or a count
1147
+ * off a checkpoint — and never a value and never code. Three reasons, in order
1148
+ * of how much they cost to get wrong:
1149
+ *
1150
+ * 1. **Replay.** The durable engine replays a run, possibly on another pod. A
1151
+ * predicate is by definition the thing whose answer decides which half of the
1152
+ * graph exists, so an answer that can differ between the run and its replay
1153
+ * is a run that loads through a shape nobody chose — and it would show up as
1154
+ * a non-determinism error two nodes later, naming neither the branch nor the
1155
+ * variable. The outcome is therefore recorded on first evaluation
1156
+ * ({@link WorkflowNodeOutcome.branch}) and read back afterwards, and the
1157
+ * declarative form is what keeps that record small enough to be a checkpoint.
1158
+ * Both predicate kinds are answerable from what a step was handed:
1159
+ * {@link WorkflowRowCountPredicate} reads a number that arrived on the step's
1160
+ * own checkpointed input rather than counting anything.
1161
+ * 2. **A predicate is not a place for a secret.** A name is stored; a value
1162
+ * never is. This is the same rule {@link WorkflowSourceNode.secretEnvVar}
1163
+ * follows and for the same reason.
1164
+ * 3. **Code would need a context to read, and this node does not own it.** What
1165
+ * code-bearing nodes may see — allow-listed variables, a `catalog` object —
1166
+ * is a question being answered elsewhere. A `code` predicate is additive the
1167
+ * day it lands: it becomes another member of {@link WorkflowIfPredicate}, and
1168
+ * everything that reads a branch reads it off the recorded outcome exactly as
1169
+ * it does now.
1170
+ *
1171
+ * To invert the test, swap which successor is on `then` and which is on `else`.
1172
+ * There is deliberately no `negate` flag: two ways to say one thing is two
1173
+ * places to look when a load takes the branch you did not expect.
1174
+ */
1175
+ export interface WorkflowIfNode extends WorkflowNodeBase {
1176
+ kind: 'if';
1177
+ /**
1178
+ * What it tests. A union rather than a field per test — see
1179
+ * {@link WORKFLOW_PREDICATE_KINDS} for why that is the whole point.
1180
+ */
1181
+ predicate: WorkflowIfPredicate;
1182
+ }
746
1183
  /**
747
1184
  * A discriminated union, so narrowing a node is `node.kind === "sink"` and
748
1185
  * never a type assertion. This is why the kind list is not simply a string on
749
1186
  * one node shape with every field optional: that shape lets a source node carry
750
1187
  * a `transformId` and nothing catches it.
751
1188
  */
752
- export type WorkflowNode = WorkflowSourceNode | WorkflowTransformNode | WorkflowSinkNode | WorkflowCallNode;
1189
+ export type WorkflowNode = WorkflowSourceNode | WorkflowTransformNode | WorkflowSinkNode | WorkflowCallNode | WorkflowIfNode;
1190
+ /**
1191
+ * Which side of an {@link WorkflowIfNode} a wire leaves by.
1192
+ *
1193
+ * **Two closed values rather than free-form labels**, which was the other design
1194
+ * and is worse in the one way that matters here: an unlabelled branch is a
1195
+ * branch that never runs, and a *misspelled* branch is one too. With a free
1196
+ * string, `thn` is a subtree that silently never executes and a graph that
1197
+ * validates perfectly; with these, it is refused at the boundary and is a type
1198
+ * error in the console. The failure a branch introduces is "nothing loaded and
1199
+ * nothing complained", so the vocabulary is the place to make it impossible.
1200
+ *
1201
+ * An N-way `switch` node was considered and is deliberately not this: it would
1202
+ * have to carry its own case list plus a default, the cases would have to be
1203
+ * validated against the labels, and an unmatched value would need a rule. That
1204
+ * is a different node, and it can be added later without changing this one —
1205
+ * because a boolean question is what a predicate answers, and an `if` is exactly
1206
+ * the shape of a boolean question.
1207
+ */
1208
+ export declare const WORKFLOW_BRANCH_LABELS: readonly ["then", "else"];
1209
+ export type WorkflowBranchLabel = (typeof WORKFLOW_BRANCH_LABELS)[number];
1210
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
1211
+ export declare function isWorkflowBranchLabel(value: unknown): value is WorkflowBranchLabel;
753
1212
  /**
754
1213
  * One wire.
755
1214
  *
@@ -766,6 +1225,22 @@ export type WorkflowNode = WorkflowSourceNode | WorkflowTransformNode | Workflow
766
1225
  export interface WorkflowEdge {
767
1226
  from: string;
768
1227
  to: string;
1228
+ /**
1229
+ * Which branch of an {@link WorkflowIfNode} this wire leaves by.
1230
+ *
1231
+ * **Optional, and absent on every edge that existed before branches did.** An
1232
+ * edge with no label is a plain wire that always carries rows when its source
1233
+ * ran — which is what every stored edge is, so nothing about an existing graph
1234
+ * changes, including its fingerprint. The label is *required* on an edge
1235
+ * leaving an `if` and *refused* on any other, both by `validateWorkflow`: a
1236
+ * label on a wire nothing branches at would look like a decision and be
1237
+ * ignored, and an unlabelled wire out of an `if` has no branch to belong to.
1238
+ *
1239
+ * Several wires may share a label. An `if` whose `then` side fans out to two
1240
+ * nodes is ordinary fan-out that happens to be conditional; what is refused is
1241
+ * two wires between the *same pair*, which was already refused as a duplicate.
1242
+ */
1243
+ branch?: WorkflowBranchLabel;
769
1244
  }
770
1245
  /** Just the executable part of a workflow, for validating a canvas draft. */
771
1246
  export interface WorkflowGraph {
@@ -987,6 +1462,17 @@ export interface WorkflowNodeStepOutput {
987
1462
  };
988
1463
  /** Which transform version ran, for a transform node. */
989
1464
  transformVersion?: number;
1465
+ /**
1466
+ * Which branch an `if` node took, for an `if` node.
1467
+ *
1468
+ * **On the step's output, which is the whole mechanism.** A step's output is
1469
+ * what the durable engine checkpoints and hands back on replay without running
1470
+ * the step again, so putting the evaluated branch here means the decision is
1471
+ * made exactly once, in the run's own history, and every later turn reads the
1472
+ * recorded one. A body that asked the predicate itself would be re-evaluating
1473
+ * a pod-local fact on a pod that may not be the same one.
1474
+ */
1475
+ branch?: WorkflowBranchLabel;
990
1476
  rows: number;
991
1477
  elapsedMs: number;
992
1478
  /**
@@ -1076,35 +1562,127 @@ export declare function readWorkflowCallOutput(value: unknown): WorkflowCallOutp
1076
1562
  /**
1077
1563
  * One workflow a call node could name.
1078
1564
  *
1079
- * Declared here with nothing producing it yet, and that is deliberate rather
1080
- * than premature: a picker needs a list, and **no such list exists**. The
1081
- * durable engine can answer `workflowBody(name, version)` for the process
1082
- * asking and nothing more — and a missing body is ambiguous, since it equally
1083
- * means a body registered in another SDK through `registerRemote` or a group
1084
- * resolved by convention against a live worker. Inferring the list from what
1085
- * one pod happens to know would produce a picker that omits exactly the
1086
- * cross-SDK workflows this node exists to call.
1087
- *
1088
- * So the node takes a name and a version as authored text today, and this is
1089
- * the shape a canvas should be handed the day a deployment can announce its
1090
- * registrations. One entry per **version**, never per name: a picker that
1091
- * listed names and resolved the version for you would undo the pin.
1565
+ * ## What this used to say, and what changed
1566
+ *
1567
+ * This shape was declared with nothing producing it, because a picker needs a
1568
+ * list and no list existed. The durable engine could answer
1569
+ * `workflowBody(name, version)` for the process asking and nothing more, and a
1570
+ * missing body is ambiguous by construction: it equally means "not registered",
1571
+ * "registered through `registerRemote` against another SDK", or "a group
1572
+ * resolved by convention against a live worker". A list inferred from that
1573
+ * would have omitted precisely the cross-SDK workflows this node exists to
1574
+ * call, and would have omitted them silently.
1575
+ *
1576
+ * `@dudousxd/nestjs-durable-core` **0.65.0** closed that with
1577
+ * `WorkflowEngine.announcedWorkflows()`: live workers publish what they can
1578
+ * execute on the worker-descriptor keyspace, and every pod folds the same
1579
+ * published statements, so the answer no longer depends on which replica
1580
+ * served the request. That is what fills this shape now — see
1581
+ * `WorkflowLauncher.callableWorkflows` in `@dudousxd/nestjs-catalog-pipeline`
1582
+ * for the adapter, and keep three properties of the aggregate in mind, because
1583
+ * every field below exists to carry one of them:
1584
+ *
1585
+ * - **It is what is runnable, not what is known about.** The announcer is
1586
+ * always the process that CONSUMES the queue, so a `registerRemote` entry is
1587
+ * never announced by the engine that declared it. An operator running bodies
1588
+ * inline with no queue announces nothing at all — its workflows are real and
1589
+ * startable, and simply not in this list.
1590
+ * - **It is a snapshot.** An announcement lives on a descriptor key with the
1591
+ * worker-heartbeat TTL, so a worker that dies takes its entries with it
1592
+ * within about one beat. Nothing built on this should be cached past that,
1593
+ * and nothing should present it as authoritative-forever.
1594
+ * - **Disagreements are kept, not merged.** Two live workers may claim the same
1595
+ * `name@version` from different groups. The aggregate refuses to pick a
1596
+ * winner, and so does this shape: see {@link disagreements}.
1597
+ *
1598
+ * One entry per **version**, never per name — a picker that listed names and
1599
+ * resolved the version for you would undo the pin the call node exists for.
1092
1600
  */
1093
1601
  export interface CallableWorkflowRef {
1094
1602
  name: string;
1095
- version: string;
1603
+ /**
1604
+ * The version to pin, and **absent is a real answer**.
1605
+ *
1606
+ * A worker that has not been upgraded announces a bare name with no version
1607
+ * and no group. Silence is not a claim, so no version is invented for it from
1608
+ * another announcer's — and an entry without one cannot satisfy the pin, which
1609
+ * is why {@link callableWorkflowBlock} refuses it rather than letting a picker
1610
+ * offer a name that would run whatever is newest on the day it runs.
1611
+ */
1612
+ version?: string;
1096
1613
  /** What it does, if the deployment publishes one. Shown beside the name. */
1097
1614
  description?: string;
1098
1615
  /**
1099
- * The worker group its turns are dispatched to, when it has one. The signal
1100
- * that says "this one's body is not in this process" — a Python workflow, or
1101
- * a separate TS worker — which is precisely what a caller cannot otherwise
1102
- * tell from a missing body.
1616
+ * The worker group its turns are dispatched to, when the live announcers
1617
+ * agree on exactly one. The signal that says "this one's body is not in this
1618
+ * process" — a Python workflow, or a separate TS worker — which is precisely
1619
+ * what a caller cannot otherwise tell from a missing body.
1620
+ *
1621
+ * Absent means either nobody stated one or the announcers disagree, and those
1622
+ * two are not the same: the second puts a `group` entry in
1623
+ * {@link disagreements} and the first does not.
1103
1624
  */
1104
1625
  group?: string;
1626
+ /**
1627
+ * How many live workers announce it. `1` is a single point of failure, and it
1628
+ * is never `0` — an entry exists only because somebody announced it.
1629
+ */
1630
+ workers?: number;
1631
+ /**
1632
+ * The axes the live announcers do not agree on, empty or absent when they
1633
+ * speak with one voice. Carried rather than resolved: the registry refuses to
1634
+ * guess and so does everything downstream of it.
1635
+ */
1636
+ disagreements?: CallableWorkflowDisagreement[];
1637
+ }
1638
+ /**
1639
+ * One axis on which the live announcers of a workflow differ.
1640
+ *
1641
+ * Mirrors the durable engine's own `Disagreement` rather than re-deriving it,
1642
+ * and `values` holds every distinct **declared** value: an announcer that stated
1643
+ * nothing on the axis contributes nothing, because silence is not a claim.
1644
+ */
1645
+ export interface CallableWorkflowDisagreement {
1646
+ axis: 'group' | 'origin' | 'requires';
1647
+ values: string[];
1105
1648
  }
1649
+ /**
1650
+ * Why an announced entry must not be committed onto a call node, or `undefined`
1651
+ * when it can be.
1652
+ *
1653
+ * Pure and exported from the browser entry point as well as this one, for the
1654
+ * reason {@link validateWorkflow} is: the canvas that greys the option out and
1655
+ * anything server-side that reasons about the same list must apply *the same*
1656
+ * rule. A picker with its own copy of it is a picker that eventually offers
1657
+ * something the rest of the system considers unusable.
1658
+ *
1659
+ * Two refusals, and they are refusals rather than warnings because in both cases
1660
+ * committing the entry would write a node whose meaning nobody can state:
1661
+ *
1662
+ * - `no-version` — an un-upgraded worker announced a bare name. The call node's
1663
+ * whole point is the pin; a node holding a name and no version follows
1664
+ * whatever gets deployed next, which is the failure the version field exists
1665
+ * to prevent. The name is still perfectly typeable by hand *with* a version
1666
+ * the author knows, so this refuses the one-click commit and not the workflow.
1667
+ * - `ambiguous-group` — two live workers claim this exact `name@version` from
1668
+ * different groups. Two groups means two queues, and nothing here can know
1669
+ * which one a run would land on, so the two bodies may not even be the same
1670
+ * code. Picking one on the author's behalf would be acting on a claim nobody
1671
+ * made.
1672
+ *
1673
+ * A disagreement on `origin` or `requires` is deliberately **not** a refusal. It
1674
+ * is worth showing — two packages declaring one name is a mess somebody should
1675
+ * clean up — but it does not change which queue the run goes to, and refusing on
1676
+ * it would block a pin that is otherwise exactly determined.
1677
+ */
1678
+ export interface CallableWorkflowBlock {
1679
+ code: 'no-version' | 'ambiguous-group';
1680
+ /** A full sentence, addressed to whoever is looking at the picker. */
1681
+ message: string;
1682
+ }
1683
+ export declare function callableWorkflowBlock(ref: CallableWorkflowRef): CallableWorkflowBlock | undefined;
1106
1684
  /** Every way a graph can be refused. Exported so a canvas can key off the code. */
1107
- export declare const WORKFLOW_ISSUE_CODES: readonly ["empty", "invalid-node-id", "duplicate-node-id", "edge-endpoint-missing", "self-edge", "duplicate-edge", "cycle", "no-source", "source-has-input", "no-sink", "duplicate-sink-type", "sink-has-output", "unreachable", "dead-end", "transform-not-named", "call-not-named"];
1685
+ export declare const WORKFLOW_ISSUE_CODES: readonly ["empty", "invalid-node-id", "duplicate-node-id", "edge-endpoint-missing", "self-edge", "duplicate-edge", "cycle", "no-source", "source-has-input", "no-sink", "duplicate-sink-type", "sink-has-output", "unreachable", "dead-end", "transform-not-named", "call-not-named", "if-not-named", "if-threshold-invalid", "if-needs-one-input", "branch-not-labelled", "branch-on-plain-edge"];
1108
1686
  export type WorkflowIssueCode = (typeof WORKFLOW_ISSUE_CODES)[number];
1109
1687
  export interface WorkflowValidationIssue {
1110
1688
  code: WorkflowIssueCode;
@@ -1147,10 +1725,68 @@ export declare function validateWorkflow(graph: WorkflowGraph): WorkflowValidati
1147
1725
  * order over a broken graph is a load that half-happens, which is harder to
1148
1726
  * recover from than one that never started.
1149
1727
  */
1150
- export declare function workflowRunOrder(graph: WorkflowGraph): Array<{
1728
+ export declare function workflowRunOrder(graph: WorkflowGraph): WorkflowRunOrderEntry[];
1729
+ /** One position in the order: which node, what feeds it, and on which branches. */
1730
+ export interface WorkflowRunOrderEntry {
1151
1731
  node: WorkflowNode;
1732
+ /** Upstream node ids, in inbound-edge order. */
1152
1733
  inputs: string[];
1153
- }>;
1734
+ /**
1735
+ * The branch label of each inbound edge that has one, keyed by the upstream
1736
+ * node id.
1737
+ *
1738
+ * A record rather than an array positionally aligned with {@link inputs},
1739
+ * which was the obvious shape and is unsafe for one specific reason: this
1740
+ * travels through a durable checkpoint as JSON, and `JSON.stringify` turns an
1741
+ * `undefined` hole in an array into `null`. A plain wire would come back from
1742
+ * a replay as `null` rather than absent, `null !== 'then'` would be false, and
1743
+ * the node would be treated as dead — the graph would silently stop running
1744
+ * half of itself on resumed runs only. A key that is simply not there
1745
+ * round-trips exactly. The key is unique because duplicate edges are refused.
1746
+ */
1747
+ inputBranches: Record<string, WorkflowBranchLabel>;
1748
+ }
1749
+ /**
1750
+ * Whether a node runs, given what the nodes before it did.
1751
+ *
1752
+ * ## The rule
1753
+ *
1754
+ * A node runs when **at least one wire into it is live**, where a wire is live
1755
+ * if its source ran and — when the wire carries a branch label — the source is
1756
+ * an `if` that took that branch. A node with no inbound wires always runs, which
1757
+ * is every source and every originating call.
1758
+ *
1759
+ * ## Why the obvious rule is wrong
1760
+ *
1761
+ * The naive version is "mark everything downstream of the untaken edge as
1762
+ * skipped", and it is wrong on the shape branches are most often drawn in:
1763
+ *
1764
+ * ```
1765
+ * ┌ then → A ┐
1766
+ * if ──┤ ├→ C → sink
1767
+ * └ else → B ┘
1768
+ * ```
1769
+ *
1770
+ * `C` is downstream of `B`. Take the `then` branch and the naive rule walks from
1771
+ * the untaken `else` edge, reaches `B`, reaches `C`, and skips it — so the sink
1772
+ * never runs and the load silently commits nothing, on a graph whose whole
1773
+ * purpose was that both branches converge. Reachability from the **taken** edges
1774
+ * gets it right: `C` is reached through `A`, so it runs, and `B` — reached only
1775
+ * through the untaken edge — does not. `C` then sees an empty stage ref for `B`,
1776
+ * which is exactly what `stageRefsFor` already does for an upstream that
1777
+ * produced nothing, so the positions a merge reads stay aligned with the wires
1778
+ * that were drawn.
1779
+ *
1780
+ * It is evaluated incrementally rather than as a graph walk because the answers
1781
+ * arrive as the run goes: `workflowRunOrder` is topological, so by the time a
1782
+ * node is reached every node feeding it has an outcome. That also means this
1783
+ * reads **only** what was recorded — no predicate is re-evaluated here, which is
1784
+ * the property the whole branch feature rests on.
1785
+ */
1786
+ export declare function workflowNodeRuns(entry: {
1787
+ inputs: readonly string[];
1788
+ inputBranches?: Readonly<Record<string, WorkflowBranchLabel>>;
1789
+ }, outcomes: Readonly<Record<string, WorkflowNodeOutcome>>): boolean;
1154
1790
  /**
1155
1791
  * A stable fingerprint of what a graph *does*.
1156
1792
  *
@@ -1313,49 +1949,6 @@ export interface CatalogWorkflowStore {
1313
1949
  schedule?: string;
1314
1950
  enabled?: boolean;
1315
1951
  }, changedBy: string): Promise<CatalogWorkflow>;
1316
- /**
1317
- * Wrap a connector that predates workflows into the graph it always was.
1318
- *
1319
- * **The upgrade path, and the reason removing `POST connectors` does not
1320
- * orphan anything.** A deployment upgrading into this change has connectors
1321
- * that were authored directly — a kind, a config, optionally one transform,
1322
- * and a target type — and no route can edit them any more. Three outcomes
1323
- * were possible: migrate them, leave them readable but frozen, or make
1324
- * somebody rebuild each by hand. This is the first, and it is chosen because
1325
- * the shape was already exactly representable: a connector *is* a
1326
- * single-source, single-sink graph with at most one transform in the middle,
1327
- * and it has been describable as one since `workflowId` existed.
1328
- *
1329
- * ## What is preserved, and why each matters
1330
- *
1331
- * - **The connector id.** It is the mutex key, the owner of every
1332
- * `ConnectorRun` and the holder of the watermark. Minting a fresh connector
1333
- * and leaving the old one would split one pipeline's history in two and
1334
- * leave nothing serialising the halves against each other.
1335
- * - **The watermark, re-keyed.** A plain connector kept a flat `state`; a
1336
- * graph keys it by source-node id. Carrying the blob over unchanged would
1337
- * leave the new source node with no watermark at all, and the first run
1338
- * after the upgrade would re-read an incremental source from the beginning —
1339
- * which is not data loss, but is a surprise measured in hours on a large
1340
- * source.
1341
- * - **The schedule**, moved to the graph, which is where it is authored now.
1342
- *
1343
- * ## What changes, stated plainly
1344
- *
1345
- * The graph is published as `ready` without a person having declared it
1346
- * finished, because refusing to publish would leave the pipeline not running —
1347
- * an upgrade that silently stops twelve loads is a worse outcome than one that
1348
- * carries a decision forward on the operator's behalf. It is validated first
1349
- * and the adoption is **refused** if the wrap does not validate, so nothing is
1350
- * published that could not have been drawn.
1351
- *
1352
- * Idempotent: a connector that already has a `workflowId` is left alone and
1353
- * answered `undefined`. That is what makes it safe to run at every boot.
1354
- */
1355
- adoptConnector(connectorId: string, adoptedBy: string): Promise<{
1356
- workflow: CatalogWorkflow;
1357
- connector: CatalogConnector;
1358
- } | undefined>;
1359
1952
  }
1360
1953
  /**
1361
1954
  * Where the rows between two nodes actually sit.
@@ -1612,6 +2205,27 @@ export interface CatalogConnection {
1612
2205
  lastCheckOk?: boolean;
1613
2206
  lastCheckError?: string;
1614
2207
  }
2208
+ /**
2209
+ * What a redacted password reads as, on the wire.
2210
+ *
2211
+ * Declared here rather than in `@dudousxd/nestjs-catalog-pipeline`, where the
2212
+ * redaction itself lives, because this literal is not an implementation detail
2213
+ * of the redaction: it is part of what `GET pipeline/connections` answers, and
2214
+ * a browser is the audience it was invented for. A form that lets somebody
2215
+ * paste an address has to be able to recognise the string it was shown — a
2216
+ * `url` whose password is exactly this came out of a read, and posting it back
2217
+ * as a NEW connection stores the word "REDACTED" as the password. There is no
2218
+ * stored row behind a create for `restoreRedactedSecrets` to put the real one
2219
+ * back from, so nothing downstream can catch it: the row saves, and the failure
2220
+ * arrives at the first scheduled load as an authentication error against a
2221
+ * password nobody typed.
2222
+ *
2223
+ * The pipeline package re-exports this rather than declaring its own, so the
2224
+ * two halves cannot drift. A fixed literal rather than a run of asterisks, so
2225
+ * it is greppable in a bug report and cannot be mistaken for a password
2226
+ * somebody actually chose.
2227
+ */
2228
+ export declare const REDACTED_SECRET = "REDACTED";
1615
2229
  /** What checking a connection found. */
1616
2230
  export interface ConnectionCheck {
1617
2231
  ok: boolean;