@dudousxd/nestjs-catalog 0.16.0-preview.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  * systems each believing they decide when a load runs.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.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.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;
13
13
  exports.isConnectorKind = isConnectorKind;
14
14
  exports.isTransformLanguage = isTransformLanguage;
15
15
  exports.isWorkflowSkipReason = isWorkflowSkipReason;
@@ -20,12 +20,20 @@ exports.workflowRowY = workflowRowY;
20
20
  exports.isWorkflowPredicateKind = isWorkflowPredicateKind;
21
21
  exports.unreachablePredicateKind = unreachablePredicateKind;
22
22
  exports.isWorkflowIfPredicate = isWorkflowIfPredicate;
23
+ exports.isWorkflowFilterPredicateKind = isWorkflowFilterPredicateKind;
24
+ exports.unreachableFilterPredicateKind = unreachableFilterPredicateKind;
25
+ exports.isWorkflowFilterOperator = isWorkflowFilterOperator;
26
+ exports.unreachableFilterOperator = unreachableFilterOperator;
27
+ exports.isWorkflowFilterValue = isWorkflowFilterValue;
28
+ exports.isWorkflowFilterPredicate = isWorkflowFilterPredicate;
29
+ exports.workflowFilterMatches = workflowFilterMatches;
23
30
  exports.isWorkflowBranchLabel = isWorkflowBranchLabel;
24
31
  exports.isWorkflowStatus = isWorkflowStatus;
25
32
  exports.isWorkflowExecutionMode = isWorkflowExecutionMode;
26
33
  exports.readWorkflowCallOutput = readWorkflowCallOutput;
27
34
  exports.callableWorkflowBlock = callableWorkflowBlock;
28
35
  exports.validateWorkflow = validateWorkflow;
36
+ exports.workflowNarrowedTypes = workflowNarrowedTypes;
29
37
  exports.workflowRunOrder = workflowRunOrder;
30
38
  exports.workflowNodeRuns = workflowNodeRuns;
31
39
  exports.workflowGraphHash = workflowGraphHash;
@@ -147,10 +155,22 @@ function isWorkflowSkipReason(value) {
147
155
  * The kinds that were considered and rejected, since a small vocabulary is only
148
156
  * defensible if the omissions are:
149
157
  *
150
- * - **filter** — a transform whose code returns a subset of what it was given.
151
- * It needs no new execution path, only a different body, and adding the kind
152
- * would mean two ways to drop rows and two places to look when rows go
153
- * missing.
158
+ * - **filter** — *this entry used to be a rejection, and it is worth leaving the
159
+ * reversal visible rather than editing the history out.* The argument was that
160
+ * a transform whose code returns a subset already filters, so the kind bought
161
+ * a second way to drop rows and a second place to look when rows went missing.
162
+ * That argument is sound about *code* and it is the reason
163
+ * {@link WorkflowFilterNode} does not take any: what changed is that the
164
+ * predicate is a **closed structure** rather than a body, and a closed
165
+ * structure can be read by something other than a JavaScript engine. Only a
166
+ * declarative predicate can be translated into a `WHERE` and pushed into the
167
+ * query the source already runs, and that is not a micro-optimisation — a
168
+ * transform filtering `obj_pribuybuylistdetail` reads all 7,637,391 rows off
169
+ * disk, over the network, and into JS objects of ~80 properties each before
170
+ * anything decides they were unwanted. See {@link WorkflowFilterNode} for what
171
+ * is actually built today (an in-memory, per-batch pass) and for where the
172
+ * pushdown seam is, which is a promise about a shape rather than a claim about
173
+ * a measurement.
154
174
  * - **branch / split (unconditional)** — already expressible, and still is: a
155
175
  * node with two outbound edges is read by both successors, each of which
156
176
  * filters differently. There is nothing for an *unconditional* split to do.
@@ -184,6 +204,8 @@ exports.WORKFLOW_NODE_KINDS = [
184
204
  'call',
185
205
  /** Sends the rows down one of its outbound branches. See {@link WorkflowIfNode}. */
186
206
  'if',
207
+ /** Drops the rows that fail a declarative test. See {@link WorkflowFilterNode}. */
208
+ 'filter',
187
209
  ];
188
210
  /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
189
211
  function isWorkflowNodeKind(value) {
@@ -356,6 +378,395 @@ function isWorkflowPredicateKindUnhandled(kind) {
356
378
  void kind;
357
379
  return false;
358
380
  }
381
+ /* ---------------------------------------------------------------------------
382
+ * The filter node, and the predicate language it drops rows by.
383
+ * ------------------------------------------------------------------------- */
384
+ /**
385
+ * The shapes a {@link WorkflowFilterPredicate} can take.
386
+ *
387
+ * Two leaf kinds plus a presence test plus two ways to combine them, and the
388
+ * list is closed for the same reason {@link WORKFLOW_PREDICATE_KINDS} is: every
389
+ * decision made per kind ends in {@link unreachableFilterPredicateKind}, so a
390
+ * sixth shape is a build failure naming the files that owe it an answer rather
391
+ * than a filter that saves, draws, and quietly keeps everything.
392
+ *
393
+ * **There is deliberately no `not`.** Every leaf carries its own inverse
394
+ * ({@link WORKFLOW_FILTER_OPERATORS} pairs each operator with one, `oneOf` has
395
+ * `notIn`, `present` has `isNotNull`) and `all`/`any` are duals, so De Morgan
396
+ * already writes any negation with the kinds here. A `not` node would be a
397
+ * second spelling of every predicate — two shapes to read when a load comes out
398
+ * short, and a UI with a checkbox nobody agrees on the placement of. The one
399
+ * case it does not cover is stated on {@link workflowFilterMatches}: a value the
400
+ * test cannot compare fails *both* a form and its inverse, on purpose.
401
+ *
402
+ * **There is deliberately no free-form expression and no code.** That is the
403
+ * whole argument for the node existing — see {@link WorkflowFilterNode}.
404
+ */
405
+ exports.WORKFLOW_FILTER_PREDICATE_KINDS = [
406
+ /** One column against one value. See {@link WorkflowFilterComparison}. */
407
+ 'compare',
408
+ /** One column against a list. See {@link WorkflowFilterOneOf}. */
409
+ 'oneOf',
410
+ /** Whether a column has a value at all. See {@link WorkflowFilterPresence}. */
411
+ 'present',
412
+ /** Every child holds. See {@link WorkflowFilterGroup}. */
413
+ 'all',
414
+ /** At least one child holds. See {@link WorkflowFilterGroup}. */
415
+ 'any',
416
+ ];
417
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
418
+ function isWorkflowFilterPredicateKind(value) {
419
+ return exports.WORKFLOW_FILTER_PREDICATE_KINDS.some((kind) => kind === value);
420
+ }
421
+ /**
422
+ * {@link unreachablePredicateKind}, for the filter language, and for the
423
+ * identical reason.
424
+ *
425
+ * It throws as well as failing to compile: a predicate arrives as JSON out of a
426
+ * column, so a graph saved by a newer build and read by an older one is a thing
427
+ * that happens, and returning a default for a shape this build has no rule for
428
+ * would mean silently keeping — or silently dropping — every row.
429
+ */
430
+ function unreachableFilterPredicateKind(predicate, where) {
431
+ const kind = typeof predicate === 'string' ? predicate : Reflect.get(Object(predicate), 'kind');
432
+ throw new Error(`${where} does not handle a filter predicate of kind ${JSON.stringify(kind)}. The predicate kinds and every decision made per kind are meant to move together.`);
433
+ }
434
+ /**
435
+ * What a {@link WorkflowFilterComparison} does with its column and its value.
436
+ *
437
+ * Ten, in five inverse pairs, and the pairing is the reason there is no `not`
438
+ * kind. Each one has an obvious single-expression form in both dialects this
439
+ * repository speaks, which is not a coincidence — it is the constraint the list
440
+ * was chosen under, so that the day a predicate is pushed into a source query
441
+ * the translation is a `switch` and not a design.
442
+ *
443
+ * The omissions, since a closed list is only defensible if they are:
444
+ *
445
+ * - **`between`** — `all` of a `greaterThanOrEqual` and a `lessThanOrEqual`, in
446
+ * one more click and with no second shape to validate, hash and translate.
447
+ * - **`endsWith`** — asked for far less than the other two, and unlike them it
448
+ * has no index that could ever serve it in either dialect, so offering it in a
449
+ * palette would advertise something that is a full scan by construction.
450
+ * `contains` covers it at the same cost.
451
+ * - **regular expressions** — the dialects disagree about the syntax, the
452
+ * engines disagree about the semantics, and a predicate whose meaning depends
453
+ * on which database answered it is a predicate that cannot be pushed down
454
+ * without changing what the load returns. That is the one property this list
455
+ * exists to hold.
456
+ * - **case-insensitive variants** — collation is a property of the column in
457
+ * both dialects, so a `equalsIgnoreCase` evaluated in memory and the same
458
+ * predicate evaluated in a `WHERE` would legitimately disagree. Normalise in a
459
+ * transform, where the disagreement is visible.
460
+ */
461
+ exports.WORKFLOW_FILTER_OPERATORS = [
462
+ 'equals',
463
+ 'notEquals',
464
+ 'greaterThan',
465
+ 'lessThanOrEqual',
466
+ 'greaterThanOrEqual',
467
+ 'lessThan',
468
+ 'contains',
469
+ 'notContains',
470
+ 'startsWith',
471
+ 'notStartsWith',
472
+ ];
473
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
474
+ function isWorkflowFilterOperator(value) {
475
+ return exports.WORKFLOW_FILTER_OPERATORS.some((operator) => operator === value);
476
+ }
477
+ /**
478
+ * {@link unreachableFilterPredicateKind}, one level further down.
479
+ *
480
+ * An operator added to the list without a rule for evaluating it, describing it
481
+ * or hashing it is a type error naming the file — not a comparison that silently
482
+ * answers false for every row, which is a filter that drops the whole dataset
483
+ * and reports success.
484
+ */
485
+ function unreachableFilterOperator(operator, where) {
486
+ throw new Error(`${where} does not handle the filter operator ${JSON.stringify(operator)}. The operator list and every decision made per operator are meant to move together.`);
487
+ }
488
+ /** Whether a stored value is one this language can compare against. */
489
+ function isWorkflowFilterValue(value) {
490
+ if (typeof value === 'string' || typeof value === 'boolean')
491
+ return true;
492
+ // `NaN` and the infinities are refused rather than kept: every comparison
493
+ // against `NaN` is false, so a predicate holding one is a filter that drops
494
+ // every row while looking perfectly well configured, and `Infinity` does not
495
+ // survive a JSON round trip at all — it comes back as `null`.
496
+ return typeof value === 'number' && Number.isFinite(value);
497
+ }
498
+ /**
499
+ * The column names a predicate may name.
500
+ *
501
+ * **The same pattern `boundStatement` requires of a watermark column**, and that
502
+ * is the point rather than a coincidence: an identifier cannot be bound by any
503
+ * driver, so pushing a predicate into a query means quoting the column into the
504
+ * SQL, and a name carrying a quote, a dot or a space is refused rather than
505
+ * escaped. Requiring it *now*, while the predicate is only ever evaluated in
506
+ * memory, is what stops a graph being authored today that could never be pushed
507
+ * down tomorrow.
508
+ *
509
+ * The cost, and it is real: a source whose column is called `Part Number` cannot
510
+ * be filtered directly. Rename it in a transform first — which is a node that
511
+ * already exists and whose output is a column this can name.
512
+ */
513
+ exports.WORKFLOW_FILTER_COLUMN_PATTERN = /^[A-Za-z_][A-Za-z0-9_$]*$/;
514
+ /**
515
+ * How deep `all`/`any` may nest.
516
+ *
517
+ * A bound rather than a trust, because a predicate arrives as JSON out of a
518
+ * column and every function that walks one is recursive: a graph carrying a
519
+ * thousand-deep tree would be a stack overflow inside a durable step rather than
520
+ * a refusal naming the node. Six is past anything a person builds in a form and
521
+ * far short of anything that costs a frame to walk.
522
+ */
523
+ exports.WORKFLOW_FILTER_MAX_DEPTH = 6;
524
+ /**
525
+ * How many values one {@link WorkflowFilterOneOf} may list.
526
+ *
527
+ * Bounded because the list travels in the graph, into the graph fingerprint, and
528
+ * eventually into an `IN (...)` — and past a few hundred entries all three of
529
+ * those stop being reasonable and the thing being expressed is a join against
530
+ * another dataset rather than a filter. Wire that dataset in as a second source
531
+ * and join it in a transform, which is the node that can.
532
+ */
533
+ exports.WORKFLOW_FILTER_MAX_VALUES = 500;
534
+ /**
535
+ * Narrow a stored filter predicate, refusing rather than repairing.
536
+ *
537
+ * The same contract {@link isWorkflowIfPredicate} has and for a sharper version
538
+ * of the same reason: a gate read back without its test picks a branch nobody
539
+ * authored, and a *filter* read back without its test decides which rows exist.
540
+ * Repairing a broken predicate — dropping an unreadable child out of an `all`,
541
+ * say — would silently widen or narrow what a load publishes, which is precisely
542
+ * the failure the node is built to make visible.
543
+ *
544
+ * Depth is carried rather than tracked globally so that the bound is on the
545
+ * *tree*, not on how many predicates have been checked: two sibling branches
546
+ * five deep are fine, and one branch seven deep is not.
547
+ */
548
+ function isWorkflowFilterPredicate(value, depth = 0) {
549
+ if (depth > exports.WORKFLOW_FILTER_MAX_DEPTH)
550
+ return false;
551
+ if (typeof value !== 'object' || value === null)
552
+ return false;
553
+ const kind = Reflect.get(value, 'kind');
554
+ if (!isWorkflowFilterPredicateKind(kind))
555
+ return false;
556
+ if (kind === 'all' || kind === 'any') {
557
+ const children = Reflect.get(value, 'children');
558
+ if (!Array.isArray(children) || children.length === 0)
559
+ return false;
560
+ return children.every((child) => isWorkflowFilterPredicate(child, depth + 1));
561
+ }
562
+ const column = Reflect.get(value, 'column');
563
+ if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column))
564
+ return false;
565
+ return isFilterLeaf(value, kind);
566
+ }
567
+ /**
568
+ * The half of {@link isWorkflowFilterPredicate} that is about one condition.
569
+ *
570
+ * Split off because the two halves have nothing to do with each other: above is
571
+ * a tree walk with a depth bound, and this is three shapes checked field by
572
+ * field. Reached only with the column already accepted, which is why it does not
573
+ * check one.
574
+ */
575
+ function isFilterLeaf(value, kind) {
576
+ const operator = Reflect.get(value, 'operator');
577
+ if (kind === 'present') {
578
+ return operator === 'isNull' || operator === 'isNotNull';
579
+ }
580
+ if (kind === 'oneOf') {
581
+ const values = Reflect.get(value, 'values');
582
+ if (operator !== 'in' && operator !== 'notIn')
583
+ return false;
584
+ return (Array.isArray(values) &&
585
+ values.length > 0 &&
586
+ values.length <= exports.WORKFLOW_FILTER_MAX_VALUES &&
587
+ values.every((entry) => isWorkflowFilterValue(entry)));
588
+ }
589
+ if (kind === 'compare') {
590
+ return isWorkflowFilterOperator(operator) && isWorkflowFilterValue(Reflect.get(value, 'value'));
591
+ }
592
+ return isWorkflowFilterPredicateKindUnhandled(kind);
593
+ }
594
+ /** The narrowing counterpart of {@link unreachableFilterPredicateKind}. */
595
+ function isWorkflowFilterPredicateKindUnhandled(kind) {
596
+ void kind;
597
+ return false;
598
+ }
599
+ /**
600
+ * Whether one row passes a predicate.
601
+ *
602
+ * Pure, allocation-free on the common path, and in core rather than in the
603
+ * runner so that the console can describe — and one day preview — exactly what
604
+ * the load will do, from the same function that does it. It is called once per
605
+ * row, so everything about it is arranged to be cheap: no closures built per
606
+ * row, no array built per row, and `note` is optional so a caller that does not
607
+ * want the diagnostics pays nothing for them.
608
+ *
609
+ * ## Null is unknown, and unknown does not pass
610
+ *
611
+ * A column that is `null`, `undefined`, or simply absent from the row fails
612
+ * **every** `compare` and **every** `oneOf`, *including the negative forms*.
613
+ * `status notEquals "CLOSED"` does not pass a row with no status.
614
+ *
615
+ * That is SQL's three-valued logic rather than JavaScript's, and it is chosen
616
+ * deliberately over the more intuitive JS answer for one reason: this predicate
617
+ * is meant to become a `WHERE`, and the day it does, the database will answer
618
+ * this way. A filter that kept those rows in memory and dropped them once pushed
619
+ * down would be a performance change that quietly altered what a type contains —
620
+ * which is the one thing a pushdown must never be able to do. {@link
621
+ * WorkflowFilterPresence} is how absence is tested on purpose.
622
+ *
623
+ * ## A value it cannot compare fails both a test and its inverse
624
+ *
625
+ * `qty greaterThan 10` against a `qty` holding `"n/a"` is not false because the
626
+ * string is small; there is no ordering between a string and a number that any
627
+ * two systems would agree on. Rather than invent one, the leaf answers false and
628
+ * calls `note` with the column, and the runner turns those counts into a line on
629
+ * the run: *"12,431 rows held a value in `qty` the test could not compare."*
630
+ *
631
+ * A row nothing can judge is therefore dropped rather than kept. That is the
632
+ * direction with a backstop — the sink's row-count bound sees the shrink, and a
633
+ * full sink refuses to commit nothing at all — whereas keeping unjudged rows
634
+ * would publish them into the type with nothing anywhere to notice.
635
+ */
636
+ function workflowFilterMatches(predicate, row, note) {
637
+ if (predicate.kind === 'all' || predicate.kind === 'any') {
638
+ // One loop for both, because they differ only in which answer is decisive:
639
+ // an `all` stops at the first false and an `any` stops at the first true.
640
+ //
641
+ // **Short-circuiting is why `note` may under-count**, and that is the right
642
+ // trade rather than an accident: an `any` that matched on its first child
643
+ // never looks at the second, so a value it could not have compared there is
644
+ // not reported. Evaluating every child to make the diagnostics complete
645
+ // would run every comparison against every row for a count nobody acts on
646
+ // per row — and this function is called seven million times.
647
+ const decisive = predicate.kind === 'any';
648
+ for (const child of predicate.children) {
649
+ if (workflowFilterMatches(child, row, note) === decisive)
650
+ return decisive;
651
+ }
652
+ return !decisive;
653
+ }
654
+ return matchFilterLeaf(predicate, row, note);
655
+ }
656
+ /** One condition against one row. Split from the recursion above, which is all it is. */
657
+ function matchFilterLeaf(predicate, row, note) {
658
+ // `Object.hasOwn` rather than a bare index, because `row` is somebody else's
659
+ // record and a column named `constructor` would otherwise reach up the
660
+ // prototype chain and compare against a function.
661
+ const held = Object.hasOwn(row, predicate.column)
662
+ ? Reflect.get(row, predicate.column)
663
+ : undefined;
664
+ if (predicate.kind === 'present') {
665
+ const missing = held === null || held === undefined;
666
+ return predicate.operator === 'isNull' ? missing : !missing;
667
+ }
668
+ // Three-valued logic — see the docblock above. Not noted, because an absent
669
+ // value is an ordinary fact about data rather than a predicate that does not
670
+ // fit it.
671
+ if (held === null || held === undefined)
672
+ return false;
673
+ if (predicate.kind === 'oneOf')
674
+ return matchFilterList(predicate, held, note);
675
+ if (predicate.kind === 'compare')
676
+ return compareFilterValue(predicate, held, note);
677
+ return unreachableFilterPredicateKind(predicate, 'workflowFilterMatches');
678
+ }
679
+ /** One `IN`/`NOT IN`, over a value already known to be present. */
680
+ function matchFilterList(predicate, held, note) {
681
+ if (!isWorkflowFilterValue(held)) {
682
+ note?.(predicate.column);
683
+ return false;
684
+ }
685
+ const found = predicate.values.includes(held);
686
+ return predicate.operator === 'in' ? found : !found;
687
+ }
688
+ /**
689
+ * One leaf comparison, with the type rules written out once.
690
+ *
691
+ * Split from {@link workflowFilterMatches} so the recursion above stays readable
692
+ * and so the ten operators are answered in one place that ends in
693
+ * {@link unreachableFilterOperator}.
694
+ */
695
+ function compareFilterValue(predicate, held, note) {
696
+ const operator = predicate.operator;
697
+ const wanted = predicate.value;
698
+ if (operator === 'equals' || operator === 'notEquals') {
699
+ // Same type or nothing. `"5" === 5` is false in JavaScript and `'5' = 5` is
700
+ // *true* in MySQL, so a cross-type equality is precisely a comparison whose
701
+ // answer would change under pushdown — reported rather than picked.
702
+ if (typeof held !== typeof wanted) {
703
+ note?.(predicate.column);
704
+ return false;
705
+ }
706
+ return operator === 'equals' ? held === wanted : held !== wanted;
707
+ }
708
+ if (operator === 'greaterThan' ||
709
+ operator === 'greaterThanOrEqual' ||
710
+ operator === 'lessThan' ||
711
+ operator === 'lessThanOrEqual') {
712
+ // Ordered types only, and both sides the same one. Booleans are excluded on
713
+ // purpose: `true > false` is an answer JavaScript will give and no reader of
714
+ // a filter ever meant to ask for.
715
+ const ordered = (typeof held === 'number' && typeof wanted === 'number') ||
716
+ (typeof held === 'string' && typeof wanted === 'string');
717
+ if (!ordered) {
718
+ note?.(predicate.column);
719
+ return false;
720
+ }
721
+ return orderedHolds(operator, held, wanted);
722
+ }
723
+ // The four string tests. A non-string on either side is not coerced: coercing
724
+ // would make `contains` match the digits of a number, which is a full scan
725
+ // producing rows nobody asked for rather than a comparison.
726
+ if (typeof held !== 'string' || typeof wanted !== 'string') {
727
+ note?.(predicate.column);
728
+ return false;
729
+ }
730
+ return textHolds(operator, held, wanted);
731
+ }
732
+ /**
733
+ * The four string tests, over two values already known to be strings.
734
+ *
735
+ * Split out for the same reason {@link orderedHolds} is: the type rule and the
736
+ * comparison are separate concerns, and keeping them in one function put a
737
+ * chain of ten operators and three type rules into one place the linter would
738
+ * not hold in one head — which is a fair description of how a comparison ends up
739
+ * silently answering the wrong way.
740
+ */
741
+ function textHolds(operator, held, wanted) {
742
+ if (operator === 'contains')
743
+ return held.includes(wanted);
744
+ if (operator === 'notContains')
745
+ return !held.includes(wanted);
746
+ if (operator === 'startsWith')
747
+ return held.startsWith(wanted);
748
+ if (operator === 'notStartsWith')
749
+ return !held.startsWith(wanted);
750
+ return unreachableFilterOperator(operator, 'workflowFilterMatches');
751
+ }
752
+ /**
753
+ * The four orderings, over two values already known to be the same ordered type.
754
+ *
755
+ * Its own function so the type check above and the comparison here are separate
756
+ * concerns rather than one branch doing both. Generic over the pair so that
757
+ * `held` and `wanted` are compared as the *same* type — the signature is what
758
+ * stops a future edit passing a string and a number to `>`, which is the exact
759
+ * silent coercion the caller went to trouble to rule out.
760
+ */
761
+ function orderedHolds(operator, held, wanted) {
762
+ if (operator === 'greaterThan')
763
+ return held > wanted;
764
+ if (operator === 'greaterThanOrEqual')
765
+ return held >= wanted;
766
+ if (operator === 'lessThan')
767
+ return held < wanted;
768
+ return held <= wanted;
769
+ }
359
770
  /**
360
771
  * Which side of an {@link WorkflowIfNode} a wire leaves by.
361
772
  *
@@ -566,6 +977,9 @@ exports.WORKFLOW_ISSUE_CODES = [
566
977
  'if-needs-one-input',
567
978
  'branch-not-labelled',
568
979
  'branch-on-plain-edge',
980
+ 'filter-predicate-invalid',
981
+ 'filter-narrows-unacknowledged',
982
+ 'filter-narrows-nothing',
569
983
  ];
570
984
  /**
571
985
  * Everything that makes a graph unrunnable, in one pure function.
@@ -609,6 +1023,7 @@ function validateWorkflow(graph) {
609
1023
  checkNodeWiring(nodes, incoming, outgoing, issues);
610
1024
  checkEndpoints(originators, sinks, issues);
611
1025
  checkBranches(edges, byId, issues);
1026
+ checkFilterNarrowing({ nodes, edges }, originators, outgoing, issues);
612
1027
  const looped = findCycle(nodes, incoming, outgoing);
613
1028
  if (looped) {
614
1029
  issues.push({
@@ -808,8 +1223,156 @@ function nodeIsUnconfigured(node) {
808
1223
  return callIsUnnamed(node);
809
1224
  if (node.kind === 'if')
810
1225
  return ifIsUnconfigured(node);
1226
+ if (node.kind === 'filter')
1227
+ return filterIsUnconfigured(node);
811
1228
  return undefined;
812
1229
  }
1230
+ /**
1231
+ * A filter whose test cannot decide anything.
1232
+ *
1233
+ * The whole predicate, checked by the same guard that reads one back out of a
1234
+ * database, rather than a field-by-field re-implementation here. That is not
1235
+ * only tidiness: the failures it catches are all *silent* ones and they point in
1236
+ * opposite directions — an empty `all` keeps every row so the filter does
1237
+ * nothing, an empty `any` drops every row so the load comes out empty, and a
1238
+ * predicate whose operator this build does not recognise would do neither
1239
+ * because {@link workflowFilterMatches} throws inside a step. A canvas has to
1240
+ * say so before any of that.
1241
+ */
1242
+ function filterIsUnconfigured(node) {
1243
+ if (isWorkflowFilterPredicate(node.predicate))
1244
+ return undefined;
1245
+ return {
1246
+ code: 'filter-predicate-invalid',
1247
+ nodeIds: [node.id],
1248
+ message: `Filter "${node.name}" (${node.id}) has no test this service can run. A filter tests bare column names (letters, digits and underscore, starting with a letter or underscore) against plain values, combined with "all" and "any"; a group has to have at least one condition in it, a list at least one value and no more than ${exports.WORKFLOW_FILTER_MAX_VALUES}, and the whole tree may nest ${exports.WORKFLOW_FILTER_MAX_DEPTH} deep. An empty "all" keeps every row and an empty "any" drops every row, which is why neither is stored.`,
1249
+ };
1250
+ }
1251
+ /**
1252
+ * That every filter names the published types it narrows, and names no others.
1253
+ *
1254
+ * See {@link WorkflowFilterNode.narrows} for the argument. This is the rule
1255
+ * that turns dragging a filter onto a working `source → sink` wire into a graph
1256
+ * that refuses to save until somebody writes down the name of the type they are
1257
+ * about to shrink.
1258
+ *
1259
+ * Run after the structural checks and after the cycle check would have returned,
1260
+ * so the reachability walks below are over a graph that has both ends and no
1261
+ * loop.
1262
+ */
1263
+ function checkFilterNarrowing(graph, originators, outgoing, issues) {
1264
+ const rootIds = originators.map((one) => one.id);
1265
+ for (const node of graph.nodes) {
1266
+ if (node.kind !== 'filter')
1267
+ continue;
1268
+ const required = workflowNarrowedTypes(graph, node.id, {
1269
+ originators: rootIds,
1270
+ outgoing,
1271
+ }).sort();
1272
+ const declared = [...new Set(node.narrows ?? [])].sort();
1273
+ const missing = required.filter((type) => !declared.includes(type));
1274
+ if (missing.length > 0)
1275
+ issues.push(filterNarrowsUnacknowledged(node, missing));
1276
+ const spurious = declared.filter((type) => !required.includes(type));
1277
+ if (spurious.length > 0)
1278
+ issues.push(filterNarrowsNothing(node, spurious));
1279
+ }
1280
+ }
1281
+ /** The refusal that stops a filter quietly shrinking a type somebody else reads. */
1282
+ function filterNarrowsUnacknowledged(node, missing) {
1283
+ const one = missing.length === 1;
1284
+ return {
1285
+ code: 'filter-narrows-unacknowledged',
1286
+ nodeIds: [node.id],
1287
+ message: `Filter "${node.name}" (${node.id}) is the only thing feeding the sink that commits ${listTypes(missing)}, and that sink replaces the whole snapshot. So whatever this filter drops disappears from ${one ? 'that type' : 'those types'} the moment this graph runs — and the run reports success, because from its point of view nothing went wrong. If that is what you mean, acknowledge it on the node by naming ${one ? 'the type' : 'the types'}. If you meant to build something new out of a subset, point the sink at a different object type; if you meant to add rows rather than replace them, set the sink to incremental.`,
1288
+ };
1289
+ }
1290
+ /** The opposite refusal: an acknowledgement the graph no longer backs up. */
1291
+ function filterNarrowsNothing(node, spurious) {
1292
+ const one = spurious.length === 1;
1293
+ return {
1294
+ code: 'filter-narrows-nothing',
1295
+ nodeIds: [node.id],
1296
+ message: `Filter "${node.name}" (${node.id}) says it narrows ${listTypes(spurious)}, and it does not: nothing it drops is missing from ${one ? 'that snapshot' : 'those snapshots'}, either because rows also reach the sink by another path or because the sink merges rather than replaces. An acknowledgement that nothing reads is worse than none, because the canvas draws it and the next reader believes it.`,
1297
+ };
1298
+ }
1299
+ /** `"A"`, `"A" and "B"`, `"A", "B" and "C"` — for a message, not for a machine. */
1300
+ function listTypes(types) {
1301
+ const quoted = types.map((type) => `"${type}"`);
1302
+ if (quoted.length <= 1)
1303
+ return quoted.join('');
1304
+ return `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
1305
+ }
1306
+ /**
1307
+ * The object types whose whole published snapshot a node stands in front of.
1308
+ *
1309
+ * A sink is in this list when it commits in `full` mode — replacing what is
1310
+ * served rather than merging into it — **and** removing the named node would
1311
+ * make it unreachable from everything that originates rows. That second half is
1312
+ * the load-bearing one: a filter on one of two paths into a sink narrows
1313
+ * nothing, because the other path still delivers, and a rule that ignored it
1314
+ * would demand an acknowledgement for a graph where nothing is lost.
1315
+ *
1316
+ * Removal-reachability rather than a dominator algorithm, deliberately. The
1317
+ * graphs here are a screenful of boxes, this runs once per filter node, and the
1318
+ * cheaper version would be a second, subtler implementation of "does this node
1319
+ * decide whether that one runs" living next to `checkReachability` — which is
1320
+ * exactly the kind of duplication that eventually disagrees with the walk the
1321
+ * rest of this file does.
1322
+ *
1323
+ * Exported because the console needs the same answer to offer the right
1324
+ * acknowledgements, and a canvas that computed its own would offer a set the
1325
+ * server then refuses.
1326
+ *
1327
+ * `precomputed` exists only so the validator, which has already built the
1328
+ * adjacency it needs, does not build it twice per filter node. Callers outside
1329
+ * this file pass a graph and nothing else.
1330
+ */
1331
+ function workflowNarrowedTypes(graph, nodeId, precomputed) {
1332
+ const nodes = graph.nodes ?? [];
1333
+ const outgoing = precomputed?.outgoing ?? buildAdjacency(nodes, graph.edges ?? []).outgoing;
1334
+ const originators = precomputed?.originators ?? nodes.filter((node) => originatesRows(node)).map((node) => node.id);
1335
+ // Everything still reachable once this node is taken out. A source that *is*
1336
+ // the node cannot originate anything, which falls out of the filter below
1337
+ // rather than needing its own case.
1338
+ const roots = originators.filter((id) => id !== nodeId);
1339
+ const reachableWithout = walk(roots, stripNode(outgoing, nodeId));
1340
+ const types = [];
1341
+ for (const node of nodes) {
1342
+ if (!narrowedByRemoval(node, reachableWithout))
1343
+ continue;
1344
+ const type = node.targetType.trim();
1345
+ if (type.length > 0 && !types.includes(type))
1346
+ types.push(type);
1347
+ }
1348
+ return types;
1349
+ }
1350
+ /**
1351
+ * Whether this node is a full-mode sink that the removed node was the only way
1352
+ * to reach.
1353
+ *
1354
+ * A sink with no explicit mode is a **full** sink — the same default `runSink`
1355
+ * applies, read the same way here, because a graph that validated under one
1356
+ * meaning of the absent field and ran under the other is precisely the failure
1357
+ * this whole check exists to prevent.
1358
+ */
1359
+ function narrowedByRemoval(node, reachableWithout) {
1360
+ if (node.kind !== 'sink')
1361
+ return false;
1362
+ if ((node.mode ?? 'full') === 'incremental')
1363
+ return false;
1364
+ return !reachableWithout.has(node.id);
1365
+ }
1366
+ /** The same adjacency with one node's outgoing edges cut, leaving the original alone. */
1367
+ function stripNode(outgoing, nodeId) {
1368
+ const without = new Map();
1369
+ for (const [from, targets] of outgoing) {
1370
+ if (from === nodeId)
1371
+ continue;
1372
+ without.set(from, targets.filter((to) => to !== nodeId));
1373
+ }
1374
+ return without;
1375
+ }
813
1376
  /**
814
1377
  * A gate whose test cannot decide anything.
815
1378
  *
@@ -1200,6 +1763,20 @@ function canonicalNode(node) {
1200
1763
  if (node.kind === 'if') {
1201
1764
  return JSON.stringify([node.id, node.kind, ...canonicalPredicate(node.predicate)]);
1202
1765
  }
1766
+ if (node.kind === 'filter') {
1767
+ // `narrows` is in here as well as the predicate, and that is a decision
1768
+ // rather than completeness. It changes nothing about what the node computes
1769
+ // — but it is the acknowledgement that a published type is about to become a
1770
+ // subset, and a run that happened before anybody acknowledged that must stay
1771
+ // distinguishable from one that happened after. Sorted, so ticking the same
1772
+ // two boxes in the other order is not an edit.
1773
+ return JSON.stringify([
1774
+ node.id,
1775
+ node.kind,
1776
+ canonicalFilterPredicate(node.predicate),
1777
+ [...(node.narrows ?? [])].sort(),
1778
+ ]);
1779
+ }
1203
1780
  if (node.kind === 'sink') {
1204
1781
  return JSON.stringify([node.id, node.kind, node.targetType, node.mode ?? 'full']);
1205
1782
  }
@@ -1226,6 +1803,46 @@ function canonicalPredicate(predicate) {
1226
1803
  }
1227
1804
  return unreachablePredicateKind(predicate, 'workflowGraphHash');
1228
1805
  }
1806
+ /**
1807
+ * The parts of a filter predicate that decide which rows survive.
1808
+ *
1809
+ * Which is all of them, so this is a faithful canonicalisation rather than a
1810
+ * selection — every field of every kind changes what a load publishes. What it
1811
+ * adds over `JSON.stringify` of the predicate is order-independence where order
1812
+ * is meaningless and order-*dependence* where it is not: the children of an
1813
+ * `all` are sorted, because `A and B` and `B and A` are the same filter and a
1814
+ * canvas that rewrites the array must not look like an edit; the `values` of a
1815
+ * `oneOf` are sorted for the same reason. The kind and the operator lead, so no
1816
+ * two shapes can canonicalise to the same string.
1817
+ *
1818
+ * `values` is deliberately *not* deduplicated. A duplicate changes nothing about
1819
+ * the result, but removing one here would make the hash disagree with what is
1820
+ * stored, and the fingerprint is meant to answer "is this the same graph" rather
1821
+ * than "is this an equivalent graph".
1822
+ */
1823
+ function canonicalFilterPredicate(predicate) {
1824
+ if (predicate.kind === 'all' || predicate.kind === 'any') {
1825
+ return JSON.stringify([
1826
+ predicate.kind,
1827
+ predicate.children.map((child) => canonicalFilterPredicate(child)).sort(),
1828
+ ]);
1829
+ }
1830
+ if (predicate.kind === 'oneOf') {
1831
+ return JSON.stringify([
1832
+ predicate.kind,
1833
+ predicate.column,
1834
+ predicate.operator,
1835
+ [...predicate.values].map((value) => JSON.stringify(value)).sort(),
1836
+ ]);
1837
+ }
1838
+ if (predicate.kind === 'present') {
1839
+ return JSON.stringify([predicate.kind, predicate.column, predicate.operator]);
1840
+ }
1841
+ if (predicate.kind === 'compare') {
1842
+ return JSON.stringify([predicate.kind, predicate.column, predicate.operator, predicate.value]);
1843
+ }
1844
+ return unreachableFilterPredicateKind(predicate, 'workflowGraphHash');
1845
+ }
1229
1846
  function sortedEntries(config) {
1230
1847
  return Object.keys(config)
1231
1848
  .sort()
@@ -1286,6 +1903,11 @@ function isWorkflowNode(value) {
1286
1903
  // means half the graph runs on a decision nobody made.
1287
1904
  return isWorkflowIfPredicate(Reflect.get(value, 'predicate'));
1288
1905
  }
1906
+ if (kind === 'filter') {
1907
+ return isNarrowsList(Reflect.get(value, 'narrows'))
1908
+ ? isWorkflowFilterPredicate(Reflect.get(value, 'predicate'))
1909
+ : false;
1910
+ }
1289
1911
  if (kind === 'source') {
1290
1912
  const sourceKind = Reflect.get(value, 'sourceKind');
1291
1913
  const config = Reflect.get(value, 'config');
@@ -1293,6 +1915,25 @@ function isWorkflowNode(value) {
1293
1915
  }
1294
1916
  return isWorkflowNodeKindUnhandled(kind);
1295
1917
  }
1918
+ /**
1919
+ * Whether a stored `narrows` is one this build can read.
1920
+ *
1921
+ * Checked as strictly as the predicate beside it, and it has to be:
1922
+ * {@link WorkflowFilterNode.narrows} is what stands between "filter into the
1923
+ * type that already exists" and a published snapshot quietly becoming a subset.
1924
+ * A value that is not a list of strings is refused rather than dropped, because
1925
+ * dropping it turns a graph somebody acknowledged into one that never was — and
1926
+ * `validateWorkflow` would then refuse it with a message about a field they did
1927
+ * fill in.
1928
+ *
1929
+ * Absent is accepted, because that is what every filter narrowing nothing looks
1930
+ * like and what every node stored before this field existed is.
1931
+ */
1932
+ function isNarrowsList(value) {
1933
+ if (value === undefined)
1934
+ return true;
1935
+ return Array.isArray(value) && value.every((type) => typeof type === 'string');
1936
+ }
1296
1937
  /**
1297
1938
  * The narrowing counterpart of {@link unreachableNodeKind}.
1298
1939
  *