@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.
- package/LICENSE +21 -0
- package/dist/catalog.pipeline.d.ts +442 -7
- package/dist/catalog.pipeline.js +646 -5
- package/dist/catalog.stage-encoding.d.ts +185 -0
- package/dist/catalog.stage-encoding.js +314 -0
- package/dist/catalog.workspace.d.ts +24 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.js +26 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +29 -3
- package/package.json +6 -6
package/dist/catalog.pipeline.js
CHANGED
|
@@ -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** —
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
*
|