@dudousxd/nestjs-catalog 0.25.0 → 0.27.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/dist/catalog.aggregate.d.ts +376 -0
- package/dist/catalog.aggregate.js +703 -0
- package/dist/catalog.pipeline.d.ts +471 -9
- package/dist/catalog.pipeline.js +760 -33
- package/dist/catalog.store.d.ts +61 -0
- package/dist/catalog.store.js +7 -0
- package/dist/client.d.ts +4 -4
- package/dist/client.js +42 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +41 -4
- package/package.json +1 -1
package/dist/catalog.pipeline.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* systems each believing they decide when a load runs.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CONNECTOR_KINDS = void 0;
|
|
12
|
+
exports.REDACTED_SECRET = exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_CALL_CONTRACT = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_STATUSES = exports.WORKFLOW_BRANCH_LABELS = exports.NODE_KIND_IS_REUSABLE = exports.REUSABLE_NODE_KINDS = exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = exports.WORKFLOW_AGGREGATE_MAX_GROUPS = exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = exports.WORKFLOW_AGGREGATE_FUNCTIONS = exports.WORKFLOW_RENAME_MAX_COLUMNS = exports.WORKFLOW_RENAME_UNNAMED = exports.WORKFLOW_FILTER_MAX_VALUES = exports.WORKFLOW_FILTER_MAX_DEPTH = exports.WORKFLOW_FILTER_COLUMN_PATTERN = exports.WORKFLOW_FILTER_OPERATORS = exports.WORKFLOW_FILTER_PREDICATE_KINDS = exports.WORKFLOW_PREDICATE_KINDS = exports.WORKFLOW_CALL_MODES = exports.WORKFLOW_ROW_GAP = exports.WORKFLOW_COLUMN_GAP = exports.WORKFLOW_NODE_HEIGHT = exports.WORKFLOW_NODE_WIDTH = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.WORKFLOW_SKIP_REASONS = exports.CODE_CONTEXT_CONTRACT = exports.TRANSFORM_RUNNER = exports.TRANSFORM_MODES = exports.TRANSFORM_LANGUAGES = exports.SOURCE_FORMATS = exports.CATALOG_SOURCE_TYPE_KEY = exports.CONNECTOR_KINDS = void 0;
|
|
13
13
|
exports.isConnectorKind = isConnectorKind;
|
|
14
|
+
exports.unreachableConnectorKind = unreachableConnectorKind;
|
|
15
|
+
exports.workflowSourceObjectType = workflowSourceObjectType;
|
|
14
16
|
exports.isSourceFormat = isSourceFormat;
|
|
15
17
|
exports.unreachableSourceFormat = unreachableSourceFormat;
|
|
16
18
|
exports.isTransformLanguage = isTransformLanguage;
|
|
@@ -42,6 +44,16 @@ exports.unreachableRenameUnnamed = unreachableRenameUnnamed;
|
|
|
42
44
|
exports.workflowRenameUnnamed = workflowRenameUnnamed;
|
|
43
45
|
exports.renameColumnRefusals = renameColumnRefusals;
|
|
44
46
|
exports.isWorkflowRenameColumns = isWorkflowRenameColumns;
|
|
47
|
+
exports.isWorkflowAggregateFunction = isWorkflowAggregateFunction;
|
|
48
|
+
exports.unreachableAggregateFunction = unreachableAggregateFunction;
|
|
49
|
+
exports.workflowAggregateMaxGroups = workflowAggregateMaxGroups;
|
|
50
|
+
exports.workflowAggregateSeparator = workflowAggregateSeparator;
|
|
51
|
+
exports.workflowAggregateJoinMaxLength = workflowAggregateJoinMaxLength;
|
|
52
|
+
exports.workflowAggregateNeedsColumn = workflowAggregateNeedsColumn;
|
|
53
|
+
exports.workflowAggregateColumns = workflowAggregateColumns;
|
|
54
|
+
exports.workflowAggregateOutputColumns = workflowAggregateOutputColumns;
|
|
55
|
+
exports.aggregateRefusals = aggregateRefusals;
|
|
56
|
+
exports.isWorkflowAggregates = isWorkflowAggregates;
|
|
45
57
|
exports.isReusableNodeKind = isReusableNodeKind;
|
|
46
58
|
exports.nodeKindIsReusable = nodeKindIsReusable;
|
|
47
59
|
exports.unreachableReusableNodeKind = unreachableReusableNodeKind;
|
|
@@ -103,6 +115,45 @@ exports.CONNECTOR_KINDS = [
|
|
|
103
115
|
's3',
|
|
104
116
|
/** Records pasted into the config. For trying a transform against real shapes. */
|
|
105
117
|
'inline',
|
|
118
|
+
/**
|
|
119
|
+
* The catalog's own data: the **current snapshot** of one object type.
|
|
120
|
+
*
|
|
121
|
+
* ## Why this is a kind and not a `sql` connector with a clever query
|
|
122
|
+
*
|
|
123
|
+
* Because that is what it was, and it was silently wrong. A workflow that
|
|
124
|
+
* needed rows the catalog already holds had exactly one way to get them — a
|
|
125
|
+
* `sql` connector naming the physical table, `SELECT … FROM obj_subworeplica`
|
|
126
|
+
* — and the store **retains every committed snapshot in that table**. So the
|
|
127
|
+
* read is not the dataset, it is every load that has ever run, stacked.
|
|
128
|
+
*
|
|
129
|
+
* Measured, with two snapshots present: the source read 89,440 rows against a
|
|
130
|
+
* type holding 44,720; the run finished `succeeded`; the rows *written* were
|
|
131
|
+
* unchanged at 16,119 because the downstream `GROUP BY` collapsed the
|
|
132
|
+
* duplicates; and every `SUM` doubled — `actualLaborCost` from 212,192,113 to
|
|
133
|
+
* 424,384,226. The row count, which is the one number anybody checks, did not
|
|
134
|
+
* move. The graph had been correct once, by the accident of exactly one
|
|
135
|
+
* snapshot existing at the time it was first run.
|
|
136
|
+
*
|
|
137
|
+
* Three things follow, and each of them is why this is a kind of its own
|
|
138
|
+
* rather than a documented convention about which table to name:
|
|
139
|
+
*
|
|
140
|
+
* - **The author names a type, not a table.** `obj_<type>` and `_snapshot_id`
|
|
141
|
+
* are internal schema. A graph that spells them is coupled to a storage
|
|
142
|
+
* layout it does not own and — as measured — quietly wrong about it.
|
|
143
|
+
* - **There is no URL and no credential.** Every other kind that reaches a
|
|
144
|
+
* database needs an address and a secret, and pointing the catalog at its
|
|
145
|
+
* own database meant an operator putting a connection URL on the pod and on
|
|
146
|
+
* the secret allowlist: a credential that did not need to exist. This kind
|
|
147
|
+
* reads through the store the process already holds.
|
|
148
|
+
* - **"Current" is resolved when the run starts**, by asking the store which
|
|
149
|
+
* snapshot it serves, and a type with nothing committed is refused rather
|
|
150
|
+
* than read as zero rows. See `fetchCatalog` in the pipeline package.
|
|
151
|
+
*
|
|
152
|
+
* Not connectable — see `CONNECTION_KINDS` in the react package. A connection
|
|
153
|
+
* is an address and a credential shared by several loads, and this kind has
|
|
154
|
+
* neither.
|
|
155
|
+
*/
|
|
156
|
+
'catalog',
|
|
106
157
|
];
|
|
107
158
|
/**
|
|
108
159
|
* The type is derived from the list, not written beside it.
|
|
@@ -116,6 +167,59 @@ exports.CONNECTOR_KINDS = [
|
|
|
116
167
|
function isConnectorKind(value) {
|
|
117
168
|
return exports.CONNECTOR_KINDS.some((kind) => kind === value);
|
|
118
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The connector kind that never compiles quietly.
|
|
172
|
+
*
|
|
173
|
+
* The {@link unreachableNodeKind} of source kinds, and it arrived with
|
|
174
|
+
* `'catalog'` for the reason that kind arrived: two of the places that decide
|
|
175
|
+
* something *per source kind* — the fetcher map and the column question below —
|
|
176
|
+
* were keyed by `string` or answered for `source` as a whole, so a sixth kind
|
|
177
|
+
* could be added and be picked up by neither. A palette that offers a kind
|
|
178
|
+
* nothing can read is the failure {@link CONNECTOR_KINDS} opens by describing,
|
|
179
|
+
* one level down.
|
|
180
|
+
*
|
|
181
|
+
* It throws as well as failing to compile, for the reason its sibling does: a
|
|
182
|
+
* connector's `kind` is a string in a database row, so one written by a newer
|
|
183
|
+
* deployment and read by an older one is possible, and returning a default for
|
|
184
|
+
* it would read the wrong system entirely.
|
|
185
|
+
*/
|
|
186
|
+
function unreachableConnectorKind(kind, where) {
|
|
187
|
+
throw new Error(`${where} does not handle a source of kind ${JSON.stringify(kind)}. The kind list and every decision made per kind are meant to move together.`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The config key a `catalog` source names its object type in.
|
|
191
|
+
*
|
|
192
|
+
* A constant rather than a literal in four files, because it is the one field
|
|
193
|
+
* that kind has and it is written by a form, read by a fetcher, checked by a
|
|
194
|
+
* validator and hashed into the graph's fingerprint.
|
|
195
|
+
*/
|
|
196
|
+
exports.CATALOG_SOURCE_TYPE_KEY = 'objectType';
|
|
197
|
+
/**
|
|
198
|
+
* Which object type a source reads, when it is a `catalog` source that names one.
|
|
199
|
+
*
|
|
200
|
+
* `undefined` covers both "not that kind" and "that kind, unconfigured", and the
|
|
201
|
+
* two callers want the same thing from both: a source that does not name a type
|
|
202
|
+
* is not one whose columns are known, and it is one the validator refuses. The
|
|
203
|
+
* string is trimmed, because a name that differs from a published type only by
|
|
204
|
+
* surrounding whitespace is a load that resolves nothing at run time and a
|
|
205
|
+
* refusal nobody can see the cause of.
|
|
206
|
+
*/
|
|
207
|
+
function workflowSourceObjectType(node) {
|
|
208
|
+
// Both fields read reflectively rather than promised by the signature, for two
|
|
209
|
+
// reasons that point the same way: the parameter has to accept any node of the
|
|
210
|
+
// union — a sink carries neither field — and a node arrives out of a JSON
|
|
211
|
+
// column, where a `config` can be anything at all.
|
|
212
|
+
if (Reflect.get(node, 'sourceKind') !== 'catalog')
|
|
213
|
+
return undefined;
|
|
214
|
+
const config = Reflect.get(node, 'config');
|
|
215
|
+
if (typeof config !== 'object' || config === null)
|
|
216
|
+
return undefined;
|
|
217
|
+
const named = Reflect.get(config, exports.CATALOG_SOURCE_TYPE_KEY);
|
|
218
|
+
if (typeof named !== 'string')
|
|
219
|
+
return undefined;
|
|
220
|
+
const trimmed = named.trim();
|
|
221
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
222
|
+
}
|
|
119
223
|
/**
|
|
120
224
|
* How the bytes behind a `file` or `s3` connector are read as records.
|
|
121
225
|
*
|
|
@@ -438,6 +542,8 @@ exports.WORKFLOW_NODE_KINDS = [
|
|
|
438
542
|
'filter',
|
|
439
543
|
/** Renames columns, declaratively. See {@link WorkflowRenameNode}. */
|
|
440
544
|
'rename',
|
|
545
|
+
/** Groups records and summarises each group. See {@link WorkflowAggregateNode}. */
|
|
546
|
+
'aggregate',
|
|
441
547
|
];
|
|
442
548
|
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
443
549
|
function isWorkflowNodeKind(value) {
|
|
@@ -1212,6 +1318,403 @@ function isWorkflowRenameColumns(value) {
|
|
|
1212
1318
|
}
|
|
1213
1319
|
return false;
|
|
1214
1320
|
}
|
|
1321
|
+
/* --- aggregate ----------------------------------------------------------- */
|
|
1322
|
+
/**
|
|
1323
|
+
* The aggregate functions this node computes, and the rule that closes the list.
|
|
1324
|
+
*
|
|
1325
|
+
* A closed list with an exhaustiveness guard, for the reason every other list in
|
|
1326
|
+
* this file is one. What is different here is that the list has a **stated
|
|
1327
|
+
* admission rule**, because "we will keep it narrow" is a promise nobody can
|
|
1328
|
+
* check and a rule is:
|
|
1329
|
+
*
|
|
1330
|
+
* > A function is in if it can be computed from a **fixed-size accumulator**,
|
|
1331
|
+
* > and if its answer does not depend on a decision the config would have to
|
|
1332
|
+
* > carry.
|
|
1333
|
+
*
|
|
1334
|
+
* The first half is the node's whole reason to exist. A hash aggregate is cheap
|
|
1335
|
+
* because it holds one entry per group; an accumulator whose size grows with the
|
|
1336
|
+
* number of *rows* in a group puts the rows back in memory and gives up the
|
|
1337
|
+
* property. The second half is what keeps the config from becoming a small
|
|
1338
|
+
* language: a function that needs an extra field to say what it means is a
|
|
1339
|
+
* function whose meaning was not decided.
|
|
1340
|
+
*
|
|
1341
|
+
* What the rule excludes, so the omissions are on the record rather than
|
|
1342
|
+
* implied:
|
|
1343
|
+
*
|
|
1344
|
+
* - **`countDistinct`** — the sharpest one. It needs a set of the distinct
|
|
1345
|
+
* values *per group per column*, so its accumulator is O(distinct values) and
|
|
1346
|
+
* a high-cardinality column inside a group holds the load. It is the exact
|
|
1347
|
+
* thing this node was built to stop doing, wearing an aggregate's name. A
|
|
1348
|
+
* sketch (HyperLogLog) is fixed-size and is a different function — an
|
|
1349
|
+
* estimate — which is not something to ship under the word `distinct`.
|
|
1350
|
+
* - **`median`, percentiles, `stddev` of a stream** — all need the values, or a
|
|
1351
|
+
* digest that is an approximation with an error bound the config would have to
|
|
1352
|
+
* carry.
|
|
1353
|
+
* - **`first` / `last`** — fixed-size, and excluded on the other half of the
|
|
1354
|
+
* rule: they mean "in input order", and this node's input order is a
|
|
1355
|
+
* `SELECT` without an `ORDER BY`. An aggregate that returns a different value
|
|
1356
|
+
* on a rerun is a load nobody can diff. `min`/`max` are the order-independent
|
|
1357
|
+
* version and are what somebody reaching for `first` usually wants.
|
|
1358
|
+
* - **Conditional aggregation — `MAX(CASE WHEN … THEN … END)`** — deliberately
|
|
1359
|
+
* out of scope, and it is the one omission a reader of flip's `wo` query will
|
|
1360
|
+
* go looking for, because that query has a three-branch status ladder in it.
|
|
1361
|
+
* Admitting it means admitting a predicate *inside* an aggregate, which is a
|
|
1362
|
+
* second expression language nested in the first, evaluated per row per
|
|
1363
|
+
* aggregate. That is transform territory and the generic
|
|
1364
|
+
* {@link WorkflowTransformNode} still exists. What the ladder actually is, is
|
|
1365
|
+
* a priority ordering over a closed set of codes, and it composes: map the
|
|
1366
|
+
* code to a rank in a transform above this node, `min` the rank, map it back
|
|
1367
|
+
* below. Two cheap per-record steps instead of a language.
|
|
1368
|
+
* - **`avg` is in**, and it is in *because* of the rule rather than despite it.
|
|
1369
|
+
* It is `sum` and `count` in one accumulator, both of which are already here,
|
|
1370
|
+
* and SQL has exactly one answer for it. Excluding it would have made the list
|
|
1371
|
+
* an arbitrary set that happened to cover one query, which is the thing the
|
|
1372
|
+
* rule is for.
|
|
1373
|
+
*/
|
|
1374
|
+
exports.WORKFLOW_AGGREGATE_FUNCTIONS = [
|
|
1375
|
+
/** Rows in the group, or non-null values of a column. `COUNT(*)` / `COUNT(c)`. */
|
|
1376
|
+
'count',
|
|
1377
|
+
/** The total. See `addToSum` for the summation error and what is done about it. */
|
|
1378
|
+
'sum',
|
|
1379
|
+
/** The mean of the non-null values. `sum` and `count` in one accumulator. */
|
|
1380
|
+
'avg',
|
|
1381
|
+
/** The least value. See `compareValues` for the order, which is not MySQL's. */
|
|
1382
|
+
'min',
|
|
1383
|
+
/** The greatest value. Same comparison, same docblock. */
|
|
1384
|
+
'max',
|
|
1385
|
+
/** The values, concatenated. `GROUP_CONCAT`, with a bound that refuses. */
|
|
1386
|
+
'join',
|
|
1387
|
+
];
|
|
1388
|
+
/** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
|
|
1389
|
+
function isWorkflowAggregateFunction(value) {
|
|
1390
|
+
return exports.WORKFLOW_AGGREGATE_FUNCTIONS.some((fn) => fn === value);
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* {@link unreachableNodeKind}, one level down, and for the identical reason.
|
|
1394
|
+
*
|
|
1395
|
+
* Every branch over {@link WorkflowAggregateFunction} ends here, so a seventh
|
|
1396
|
+
* function added to the list without an accumulator, a finisher, a canonical
|
|
1397
|
+
* form and a sentence is a type error naming the file rather than a node that
|
|
1398
|
+
* saves, draws and then computes nothing. It throws as well, because a function
|
|
1399
|
+
* name arrives as JSON out of a column and a build older than the data is a
|
|
1400
|
+
* thing that happens.
|
|
1401
|
+
*/
|
|
1402
|
+
function unreachableAggregateFunction(fn, where) {
|
|
1403
|
+
throw new Error(`${where} has no rule for the aggregate function ${JSON.stringify(fn)}. It was added to WORKFLOW_AGGREGATE_FUNCTIONS without teaching this code how to compute it, and guessing would commit a number nobody derived.`);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* How many columns one node may group on.
|
|
1407
|
+
*
|
|
1408
|
+
* The same argument {@link WORKFLOW_RENAME_MAX_COLUMNS} makes, plus one specific
|
|
1409
|
+
* to this node: every extra group-by column can only ever *increase* the number
|
|
1410
|
+
* of groups, so a long list is the shape a high-cardinality grouping arrives in.
|
|
1411
|
+
* flip's real derivation groups on two.
|
|
1412
|
+
*/
|
|
1413
|
+
exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY = 16;
|
|
1414
|
+
/**
|
|
1415
|
+
* How many aggregates one node may compute.
|
|
1416
|
+
*
|
|
1417
|
+
* flip's `wo` derivation has 49, so the bound has to be comfortably above that
|
|
1418
|
+
* or the node does not do the job it was written for. Past a few hundred the
|
|
1419
|
+
* thing being expressed is a table definition rather than a summary, and the
|
|
1420
|
+
* cost is real: every aggregate is an accumulator held **per group**, so this
|
|
1421
|
+
* number multiplies {@link WORKFLOW_AGGREGATE_MAX_GROUPS} in the heap.
|
|
1422
|
+
*/
|
|
1423
|
+
exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES = 256;
|
|
1424
|
+
/**
|
|
1425
|
+
* The default ceiling on distinct groups, and the loud refusal that goes with
|
|
1426
|
+
* it.
|
|
1427
|
+
*
|
|
1428
|
+
* A hash aggregate is cheap **only while the groups are far fewer than the
|
|
1429
|
+
* rows**. Group on a near-unique column and it holds one accumulator row per
|
|
1430
|
+
* input row, which is the whole-batch behaviour this node replaces, arrived at
|
|
1431
|
+
* by a different route and with nothing on the canvas to point at. So the
|
|
1432
|
+
* ceiling exists, it is crossed loudly, and the message names the columns being
|
|
1433
|
+
* grouped on — because a bound that is merely reported is a bound that is
|
|
1434
|
+
* discovered by the machine running out of memory.
|
|
1435
|
+
*
|
|
1436
|
+
* A million is chosen against the measurement rather than as a round number:
|
|
1437
|
+
* flip's derivation holds 16,119, so the default is 62× the real case and no
|
|
1438
|
+
* author of a sane grouping ever meets it. What it catches is `groupBy:
|
|
1439
|
+
* ['combinedId']` on a 44,720-row type — a grouping that is *legal*, produces
|
|
1440
|
+
* one group per row, and is somebody having picked the wrong column.
|
|
1441
|
+
*
|
|
1442
|
+
* The number is a proxy and it is worth saying which part it cannot see: what a
|
|
1443
|
+
* group costs in bytes depends on how many aggregates the node has and how long
|
|
1444
|
+
* a `join` grows. The first is bounded by
|
|
1445
|
+
* {@link WORKFLOW_AGGREGATE_MAX_AGGREGATES}; the second has its own bound on the
|
|
1446
|
+
* aggregate, because it is the one accumulator whose size is not fixed by the
|
|
1447
|
+
* group count.
|
|
1448
|
+
*/
|
|
1449
|
+
exports.WORKFLOW_AGGREGATE_MAX_GROUPS = 1_000_000;
|
|
1450
|
+
/**
|
|
1451
|
+
* The highest ceiling an author may ask for.
|
|
1452
|
+
*
|
|
1453
|
+
* Configurable because "how many groups is too many" genuinely depends on the
|
|
1454
|
+
* machine and on how wide the node is, and a hard-coded limit would make the
|
|
1455
|
+
* node unusable for the one legitimate large grouping. Bounded because past this
|
|
1456
|
+
* the answer is not a bigger number — it is that the grouping belongs in the
|
|
1457
|
+
* source query, where the database already has spill-to-disk and this process
|
|
1458
|
+
* does not.
|
|
1459
|
+
*/
|
|
1460
|
+
exports.WORKFLOW_AGGREGATE_GROUPS_CEILING = 20_000_000;
|
|
1461
|
+
/**
|
|
1462
|
+
* The default bound on one joined value, in characters.
|
|
1463
|
+
*
|
|
1464
|
+
* 65,535 because that is what a MySQL `TEXT` column holds, and a value the
|
|
1465
|
+
* target column cannot store is the same defect one layer further down. See
|
|
1466
|
+
* `appendJoin` for the full argument, including the five groups per column that
|
|
1467
|
+
* are silently truncated in production today under a limit of 1,024.
|
|
1468
|
+
*/
|
|
1469
|
+
exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH = 65_535;
|
|
1470
|
+
/** The highest an author may raise a `join` bound to. One `MEDIUMTEXT`. */
|
|
1471
|
+
exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING = 16_777_215;
|
|
1472
|
+
/** The longest separator a `join` may use. Long enough for `" | "`, short enough not to be data. */
|
|
1473
|
+
exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR = 16;
|
|
1474
|
+
/** The separator a `join` uses when the aggregate does not name one. */
|
|
1475
|
+
exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR = ', ';
|
|
1476
|
+
/** {@link WorkflowAggregateNode.maxGroups}, resolved. One reader of the default. */
|
|
1477
|
+
function workflowAggregateMaxGroups(node) {
|
|
1478
|
+
const asked = node.maxGroups;
|
|
1479
|
+
if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
|
|
1480
|
+
return exports.WORKFLOW_AGGREGATE_MAX_GROUPS;
|
|
1481
|
+
}
|
|
1482
|
+
return Math.min(asked, exports.WORKFLOW_AGGREGATE_GROUPS_CEILING);
|
|
1483
|
+
}
|
|
1484
|
+
/** {@link WorkflowAggregate.separator}, resolved. One reader of the default. */
|
|
1485
|
+
function workflowAggregateSeparator(aggregate) {
|
|
1486
|
+
const asked = aggregate.separator;
|
|
1487
|
+
return typeof asked === 'string' ? asked : exports.WORKFLOW_AGGREGATE_DEFAULT_SEPARATOR;
|
|
1488
|
+
}
|
|
1489
|
+
/** {@link WorkflowAggregate.maxLength}, resolved. One reader of the default. */
|
|
1490
|
+
function workflowAggregateJoinMaxLength(aggregate) {
|
|
1491
|
+
const asked = aggregate.maxLength;
|
|
1492
|
+
if (typeof asked !== 'number' || !Number.isInteger(asked) || asked < 1) {
|
|
1493
|
+
return exports.WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH;
|
|
1494
|
+
}
|
|
1495
|
+
return Math.min(asked, exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING);
|
|
1496
|
+
}
|
|
1497
|
+
/** Whether this function reads a column. Only `count` may go without one. */
|
|
1498
|
+
function workflowAggregateNeedsColumn(fn) {
|
|
1499
|
+
return fn !== 'count';
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The columns an aggregate node **reads**: its group keys and its inputs.
|
|
1503
|
+
*
|
|
1504
|
+
* What `checkColumnsProduced` tests against what the graph can prove is there,
|
|
1505
|
+
* and what the run log reports as never-seen. Deduplicated and in a stable
|
|
1506
|
+
* order, because it goes into a sentence.
|
|
1507
|
+
*/
|
|
1508
|
+
function workflowAggregateColumns(node) {
|
|
1509
|
+
const columns = new Set();
|
|
1510
|
+
for (const column of node.groupBy ?? []) {
|
|
1511
|
+
if (typeof column === 'string' && column.length > 0)
|
|
1512
|
+
columns.add(column);
|
|
1513
|
+
}
|
|
1514
|
+
for (const aggregate of node.aggregates ?? []) {
|
|
1515
|
+
const column = aggregate?.column;
|
|
1516
|
+
if (typeof column === 'string' && column.length > 0)
|
|
1517
|
+
columns.add(column);
|
|
1518
|
+
}
|
|
1519
|
+
return [...columns];
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* The columns an aggregate node **produces**, which is all of them and nothing
|
|
1523
|
+
* else.
|
|
1524
|
+
*
|
|
1525
|
+
* Closed by the config, and closed *exactly* rather than as an upper bound —
|
|
1526
|
+
* every emitted record carries every one of these keys, whatever was upstream
|
|
1527
|
+
* and whatever the values turned out to be. That is a stronger claim than the
|
|
1528
|
+
* one `rename` introduced, and it is stronger for a structural reason: a rename
|
|
1529
|
+
* only produces a target where the input actually held the source column,
|
|
1530
|
+
* whereas an aggregate writes a group's answer whether or not anything in the
|
|
1531
|
+
* group had a value for it.
|
|
1532
|
+
*
|
|
1533
|
+
* The one thing it does not claim is that the values are useful. An aggregate
|
|
1534
|
+
* over a column that no record carried produces the column, holding `null`.
|
|
1535
|
+
*/
|
|
1536
|
+
function workflowAggregateOutputColumns(node) {
|
|
1537
|
+
const columns = [];
|
|
1538
|
+
for (const column of node.groupBy ?? []) {
|
|
1539
|
+
if (typeof column === 'string' && column.length > 0 && !columns.includes(column)) {
|
|
1540
|
+
columns.push(column);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
for (const aggregate of node.aggregates ?? []) {
|
|
1544
|
+
const as = aggregate?.as;
|
|
1545
|
+
if (typeof as === 'string' && as.length > 0 && !columns.includes(as))
|
|
1546
|
+
columns.push(as);
|
|
1547
|
+
}
|
|
1548
|
+
return columns;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Every reason an aggregate cannot be stored, as sentences, or empty.
|
|
1552
|
+
*
|
|
1553
|
+
* One function, called by {@link validateWorkflow}, by the HTTP boundary, by the
|
|
1554
|
+
* canvas and by the fold itself, for the reason {@link renameColumnRefusals} is
|
|
1555
|
+
* shared: a screen with its own copy of the identifier pattern is a screen that
|
|
1556
|
+
* accepts something the server refuses, halfway through a save.
|
|
1557
|
+
*
|
|
1558
|
+
* All of them rather than the first, exactly as
|
|
1559
|
+
* {@link refuseUnpublishablePropertyNames} argues: a node with forty aggregates
|
|
1560
|
+
* typed in one sitting is usually wrong about several in the same way.
|
|
1561
|
+
*/
|
|
1562
|
+
function aggregateRefusals(node) {
|
|
1563
|
+
const groupBy = Array.isArray(node.groupBy) ? node.groupBy : [];
|
|
1564
|
+
const aggregates = Array.isArray(node.aggregates) ? node.aggregates : [];
|
|
1565
|
+
const named = new Set();
|
|
1566
|
+
const refusals = [
|
|
1567
|
+
...groupByRefusals(groupBy, named),
|
|
1568
|
+
...aggregatesRefusals(aggregates, named),
|
|
1569
|
+
...ceilingRefusals(node.maxGroups),
|
|
1570
|
+
];
|
|
1571
|
+
return refusals;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* What the grouping half can be wrong about, and the names it accepted.
|
|
1575
|
+
*
|
|
1576
|
+
* The accepted set is threaded out rather than recomputed, because the sharpest
|
|
1577
|
+
* refusal in the file needs both halves: an aggregate writing into a column the
|
|
1578
|
+
* node also groups on is one name holding two values, and only a reader that has
|
|
1579
|
+
* seen the group-by list can see it.
|
|
1580
|
+
*/
|
|
1581
|
+
function groupByRefusals(groupBy, accepted) {
|
|
1582
|
+
const refusals = [];
|
|
1583
|
+
if (groupBy.length === 0) {
|
|
1584
|
+
refusals.push('It groups on nothing. With no group-by columns an aggregate returns exactly one row whether it read a billion records or none, so a run that summarised everything and a run that read an empty source commit the same thing. If a grand total is wanted, add a constant column in a transform above this node and group on it, so the one row is visible in the graph.');
|
|
1585
|
+
}
|
|
1586
|
+
if (groupBy.length > exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY) {
|
|
1587
|
+
refusals.push(`It groups on ${groupBy.length} columns, and at most ${exports.WORKFLOW_AGGREGATE_MAX_GROUP_BY} may be named. Every extra column can only increase the number of groups, and a long list is the shape a grouping that holds the whole load arrives in.`);
|
|
1588
|
+
}
|
|
1589
|
+
for (const column of groupBy) {
|
|
1590
|
+
if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
|
|
1591
|
+
refusals.push(`It groups on ${JSON.stringify(column)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A group key comes out under the name it went in under, so a name this service cannot carry downstream is one that loads NULL into every row and reports success. A source whose own headers are spelled like ${JSON.stringify('Work Order Id')} is what a rename node above this one is for.`);
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
if (accepted.has(column)) {
|
|
1595
|
+
refusals.push(`It groups on ${JSON.stringify(column)} twice. The second one changes no group and produces no second column, so it is either a typo or a column somebody meant to name instead.`);
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
accepted.add(column);
|
|
1599
|
+
}
|
|
1600
|
+
return refusals;
|
|
1601
|
+
}
|
|
1602
|
+
/** What the computing half can be wrong about, given the group keys already accepted. */
|
|
1603
|
+
function aggregatesRefusals(aggregates, groupedOn) {
|
|
1604
|
+
const refusals = [];
|
|
1605
|
+
if (aggregates.length === 0) {
|
|
1606
|
+
refusals.push('It computes nothing. An aggregate with no functions is a node that emits the distinct combinations of its group-by columns and drops every other column of every row — which is a real operation and a completely different one. Reaching it by deleting the last row of a form is how a published type loses forty columns.');
|
|
1607
|
+
}
|
|
1608
|
+
if (aggregates.length > exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES) {
|
|
1609
|
+
refusals.push(`It computes ${aggregates.length} aggregates, and at most ${exports.WORKFLOW_AGGREGATE_MAX_AGGREGATES} may be named in one node. Every one of them is an accumulator held per group, so this number multiplies the group ceiling in memory.`);
|
|
1610
|
+
}
|
|
1611
|
+
const names = new Map();
|
|
1612
|
+
for (const entry of aggregates) {
|
|
1613
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
1614
|
+
refusals.push(`One aggregate is ${JSON.stringify(entry)} rather than a function, a column and a name.`);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
refusals.push(...oneAggregateRefusals(entry, groupedOn, names));
|
|
1618
|
+
}
|
|
1619
|
+
for (const [as, count] of names) {
|
|
1620
|
+
if (count < 2)
|
|
1621
|
+
continue;
|
|
1622
|
+
refusals.push(`${count} aggregates are written out as ${JSON.stringify(as)}. Two columns cannot share one name, and picking a winner would be a rule about which of somebody's numbers survives.`);
|
|
1623
|
+
}
|
|
1624
|
+
return refusals;
|
|
1625
|
+
}
|
|
1626
|
+
/** One entry of the list: its name, its function, its column, and its two optional fields. */
|
|
1627
|
+
function oneAggregateRefusals(entry, groupedOn, names) {
|
|
1628
|
+
const refusals = [];
|
|
1629
|
+
const as = Reflect.get(entry, 'as');
|
|
1630
|
+
const fn = Reflect.get(entry, 'fn');
|
|
1631
|
+
const column = Reflect.get(entry, 'column');
|
|
1632
|
+
const label = typeof as === 'string' && as.length > 0 ? JSON.stringify(as) : 'One aggregate';
|
|
1633
|
+
if (typeof as !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(as)) {
|
|
1634
|
+
refusals.push(`${label} is written out as ${JSON.stringify(as)}, which is not a name a column can have: letters, digits and underscore, starting with a letter or an underscore. A load looks every field up as \`row[name]\`, so a column this service cannot name downstream is one that loads NULL into every row and reports success.`);
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
names.set(as, (names.get(as) ?? 0) + 1);
|
|
1638
|
+
if (groupedOn.has(as)) {
|
|
1639
|
+
refusals.push(`${label} writes into a column this node also groups on. One name cannot hold both the group key and a summary of the group, and picking a winner would be a rule about which of somebody's data survives.`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
if (!isWorkflowAggregateFunction(fn)) {
|
|
1643
|
+
refusals.push(`${label} uses the function ${JSON.stringify(fn)}, which this service cannot compute. The functions are ${exports.WORKFLOW_AGGREGATE_FUNCTIONS.join(', ')}; anything else is a transform.`);
|
|
1644
|
+
return refusals;
|
|
1645
|
+
}
|
|
1646
|
+
if (workflowAggregateNeedsColumn(fn)) {
|
|
1647
|
+
if (typeof column !== 'string' || !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(column)) {
|
|
1648
|
+
refusals.push(`${label} reads ${JSON.stringify(column)} with ${fn}, and that is not a column name: letters, digits and underscore, starting with a letter or an underscore. Only \`count\` may go without a column, where it means how many records landed in the group.`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
else if (column !== undefined && !exports.WORKFLOW_FILTER_COLUMN_PATTERN.test(String(column))) {
|
|
1652
|
+
refusals.push(`${label} counts ${JSON.stringify(column)}, and that is not a column name. Leave the column out to count the records in the group, or name one to count its non-null values.`);
|
|
1653
|
+
}
|
|
1654
|
+
refusals.push(...joinFieldRefusals(entry, fn, label));
|
|
1655
|
+
return refusals;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* The two fields only `join` reads.
|
|
1659
|
+
*
|
|
1660
|
+
* Refused on any other function rather than ignored, which is the rule the whole
|
|
1661
|
+
* config follows: a field that only some functions read is a field somebody sets
|
|
1662
|
+
* on the wrong one and never finds out.
|
|
1663
|
+
*/
|
|
1664
|
+
function joinFieldRefusals(entry, fn, label) {
|
|
1665
|
+
const refusals = [];
|
|
1666
|
+
const separator = Reflect.get(entry, 'separator');
|
|
1667
|
+
if (separator !== undefined) {
|
|
1668
|
+
if (fn !== 'join') {
|
|
1669
|
+
refusals.push(`${label} carries a separator and computes ${fn}, which has nothing to separate. A field that only some functions read is a field somebody will set on the wrong one and never find out.`);
|
|
1670
|
+
}
|
|
1671
|
+
else if (typeof separator !== 'string' ||
|
|
1672
|
+
separator.length > exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR) {
|
|
1673
|
+
refusals.push(`${label} joins with ${JSON.stringify(separator)}, and a separator has to be text of at most ${exports.WORKFLOW_AGGREGATE_MAX_SEPARATOR} characters. Past that it is data rather than punctuation, and it is data repeated once per record.`);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
const maxLength = Reflect.get(entry, 'maxLength');
|
|
1677
|
+
if (maxLength === undefined)
|
|
1678
|
+
return refusals;
|
|
1679
|
+
if (fn !== 'join') {
|
|
1680
|
+
refusals.push(`${label} carries a maximum length and computes ${fn}, which produces no text to bound.`);
|
|
1681
|
+
return refusals;
|
|
1682
|
+
}
|
|
1683
|
+
if (typeof maxLength !== 'number' ||
|
|
1684
|
+
!Number.isInteger(maxLength) ||
|
|
1685
|
+
maxLength < 1 ||
|
|
1686
|
+
maxLength > exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING) {
|
|
1687
|
+
refusals.push(`${label} bounds its joined value at ${JSON.stringify(maxLength)}, and the bound has to be a whole number of characters between 1 and ${exports.WORKFLOW_AGGREGATE_JOIN_LENGTH_CEILING}. Past that the value cannot be stored in the column it is going into, which is the same failure one layer further down.`);
|
|
1688
|
+
}
|
|
1689
|
+
return refusals;
|
|
1690
|
+
}
|
|
1691
|
+
/** The group ceiling, when the node set one. See {@link WORKFLOW_AGGREGATE_MAX_GROUPS}. */
|
|
1692
|
+
function ceilingRefusals(maxGroups) {
|
|
1693
|
+
if (maxGroups === undefined)
|
|
1694
|
+
return [];
|
|
1695
|
+
if (typeof maxGroups === 'number' &&
|
|
1696
|
+
Number.isInteger(maxGroups) &&
|
|
1697
|
+
maxGroups >= 1 &&
|
|
1698
|
+
maxGroups <= exports.WORKFLOW_AGGREGATE_GROUPS_CEILING) {
|
|
1699
|
+
return [];
|
|
1700
|
+
}
|
|
1701
|
+
return [
|
|
1702
|
+
`It caps itself at ${JSON.stringify(maxGroups)} groups, and the cap has to be a whole number between 1 and ${exports.WORKFLOW_AGGREGATE_GROUPS_CEILING}. Past that the answer is not a bigger number — it is that the grouping belongs in the source query, where the database has spill-to-disk and this process does not.`,
|
|
1703
|
+
];
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Whether a stored aggregate list is one this build can run.
|
|
1707
|
+
*
|
|
1708
|
+
* Refused rather than repaired, the stance {@link isWorkflowRenameColumns} takes
|
|
1709
|
+
* and for the same reason one step further along: an aggregate list read back
|
|
1710
|
+
* with one entry silently dropped is a graph that commits a column of nulls
|
|
1711
|
+
* under a name somebody put in an object type on purpose.
|
|
1712
|
+
*/
|
|
1713
|
+
function isWorkflowAggregates(value) {
|
|
1714
|
+
if (!Array.isArray(value))
|
|
1715
|
+
return false;
|
|
1716
|
+
return aggregateRefusals({ groupBy: ['x'], aggregates: value }).length === 0;
|
|
1717
|
+
}
|
|
1215
1718
|
/* --- reusable nodes ------------------------------------------------------ */
|
|
1216
1719
|
/**
|
|
1217
1720
|
* The node kinds that can be saved once and used in several graphs.
|
|
@@ -1270,6 +1773,13 @@ function isReusableNodeKind(value) {
|
|
|
1270
1773
|
* *about* one drop of one file. `Mgmt Cd → mgmtCd` saved under a name and
|
|
1271
1774
|
* dropped into a graph reading a different system renames nothing at all, and
|
|
1272
1775
|
* the symptom is a column of NULLs rather than a failure.
|
|
1776
|
+
* - `aggregate` — the same again, and it fails in both directions at once. Its
|
|
1777
|
+
* group-by columns and its inputs name one type's columns, so a shared one
|
|
1778
|
+
* groups a graph it was not written for on a column that is not there — which
|
|
1779
|
+
* collapses every record into one null-keyed group rather than erroring. And
|
|
1780
|
+
* its *output* column set is the thing downstream nodes are validated against,
|
|
1781
|
+
* so a shared node editable from elsewhere would silently change what another
|
|
1782
|
+
* graph's sink is allowed to write.
|
|
1273
1783
|
*/
|
|
1274
1784
|
exports.NODE_KIND_IS_REUSABLE = {
|
|
1275
1785
|
source: true,
|
|
@@ -1279,6 +1789,7 @@ exports.NODE_KIND_IS_REUSABLE = {
|
|
|
1279
1789
|
if: false,
|
|
1280
1790
|
filter: false,
|
|
1281
1791
|
rename: false,
|
|
1792
|
+
aggregate: false,
|
|
1282
1793
|
};
|
|
1283
1794
|
/** Whether this kind can be saved as a reusable node. Reads {@link NODE_KIND_IS_REUSABLE}. */
|
|
1284
1795
|
function nodeKindIsReusable(kind) {
|
|
@@ -1667,6 +2178,16 @@ exports.WORKFLOW_ISSUE_CODES = [
|
|
|
1667
2178
|
'unreachable',
|
|
1668
2179
|
'dead-end',
|
|
1669
2180
|
'transform-not-named',
|
|
2181
|
+
/**
|
|
2182
|
+
* A `catalog` source that does not say which object type it reads.
|
|
2183
|
+
*
|
|
2184
|
+
* The sibling of `transform-not-named`, and it earns a code of its own for the
|
|
2185
|
+
* reason that one has one: the field is the *whole* of what the node does, and
|
|
2186
|
+
* a node missing it fails inside a durable step halfway through a load rather
|
|
2187
|
+
* than on the canvas. Refused rather than defaulted — there is no sensible
|
|
2188
|
+
* type to guess, and guessing would read somebody else's data.
|
|
2189
|
+
*/
|
|
2190
|
+
'source-type-not-named',
|
|
1670
2191
|
'call-not-named',
|
|
1671
2192
|
/**
|
|
1672
2193
|
* A plain call wired into something. See {@link WORKFLOW_CALL_MODES}: a plain
|
|
@@ -1690,6 +2211,19 @@ exports.WORKFLOW_ISSUE_CODES = [
|
|
|
1690
2211
|
* {@link renameColumnRefusals} for the sentences.
|
|
1691
2212
|
*/
|
|
1692
2213
|
'rename-invalid',
|
|
2214
|
+
/**
|
|
2215
|
+
* An aggregate this service will not store: grouping on nothing or on a name
|
|
2216
|
+
* a column cannot have, computing nothing, two aggregates sharing an output
|
|
2217
|
+
* name, a function this build cannot compute, a separator on something that
|
|
2218
|
+
* does not join.
|
|
2219
|
+
*
|
|
2220
|
+
* Every one of those is decidable from the node alone, and every one of them
|
|
2221
|
+
* is silent if it is let through — the two worst being an empty `groupBy`,
|
|
2222
|
+
* which commits exactly one row whether the source held everything or nothing,
|
|
2223
|
+
* and an empty `aggregates`, which drops every column the node does not group
|
|
2224
|
+
* on. See {@link aggregateRefusals} for the sentences.
|
|
2225
|
+
*/
|
|
2226
|
+
'aggregate-invalid',
|
|
1693
2227
|
/**
|
|
1694
2228
|
* A node naming a column that nothing upstream can produce.
|
|
1695
2229
|
*
|
|
@@ -1732,8 +2266,16 @@ exports.WORKFLOW_ISSUE_CODES = [
|
|
|
1732
2266
|
* point at nodes which do not exist produces a second page of consequences, and
|
|
1733
2267
|
* burying the one real problem under them is how a validation message stops
|
|
1734
2268
|
* being read.
|
|
2269
|
+
*
|
|
2270
|
+
* `knowledge` is optional and adds only refusals that could not otherwise be
|
|
2271
|
+
* made: with it, a filter or a rename below a `catalog` source can be told it
|
|
2272
|
+
* names a column the published type does not have. Omitting it is a supported
|
|
2273
|
+
* call and the answer is a subset, never a different one — see
|
|
2274
|
+
* {@link WorkflowColumnKnowledge}. The pure, dependency-free promise this
|
|
2275
|
+
* function opens with is unchanged: the lookup is the caller's, and this reaches
|
|
2276
|
+
* nothing.
|
|
1735
2277
|
*/
|
|
1736
|
-
function validateWorkflow(graph) {
|
|
2278
|
+
function validateWorkflow(graph, knowledge) {
|
|
1737
2279
|
const issues = [];
|
|
1738
2280
|
const nodes = graph.nodes ?? [];
|
|
1739
2281
|
const edges = graph.edges ?? [];
|
|
@@ -1779,7 +2321,7 @@ function validateWorkflow(graph) {
|
|
|
1779
2321
|
checkReachability(nodes, roots, sinks, incoming, outgoing, issues);
|
|
1780
2322
|
// After the cycle check has returned, so the walk it does cannot meet a loop
|
|
1781
2323
|
// on a graph this function has already accepted as acyclic.
|
|
1782
|
-
checkColumnsProduced({ nodes, edges }, issues);
|
|
2324
|
+
checkColumnsProduced({ nodes, edges }, issues, knowledge);
|
|
1783
2325
|
return issues;
|
|
1784
2326
|
}
|
|
1785
2327
|
/**
|
|
@@ -2116,10 +2658,10 @@ function checkBranches(edges, byId, issues) {
|
|
|
2116
2658
|
/**
|
|
2117
2659
|
* A node that names none of the thing it exists to run.
|
|
2118
2660
|
*
|
|
2119
|
-
* The
|
|
2120
|
-
*
|
|
2121
|
-
* way because they are the same mistake: a box on
|
|
2122
|
-
* behind it, which looks finished and fails at run time.
|
|
2661
|
+
* The kinds that point at something outside themselves — a transform at stored
|
|
2662
|
+
* code, a call at a registered workflow, a `catalog` source at an object type —
|
|
2663
|
+
* and they are reported the same way because they are the same mistake: a box on
|
|
2664
|
+
* the canvas with nothing behind it, which looks finished and fails at run time.
|
|
2123
2665
|
*/
|
|
2124
2666
|
function nodeIsUnconfigured(node) {
|
|
2125
2667
|
if (node.kind === 'transform' && node.transformId.length === 0) {
|
|
@@ -2129,6 +2671,20 @@ function nodeIsUnconfigured(node) {
|
|
|
2129
2671
|
message: `Transform node "${node.name}" (${node.id}) names no transform, so there is no code for it to run.`,
|
|
2130
2672
|
};
|
|
2131
2673
|
}
|
|
2674
|
+
if (node.kind === 'source' && node.sourceKind === 'catalog') {
|
|
2675
|
+
// Only this kind, and only from the config: every other kind's address is
|
|
2676
|
+
// allowed to arrive from a named connection, so a blank field on the node is
|
|
2677
|
+
// not evidence of anything. A `catalog` source has no connection to borrow
|
|
2678
|
+
// from — the type name is the whole configuration — so a blank one is
|
|
2679
|
+
// decidable here.
|
|
2680
|
+
if (workflowSourceObjectType(node) === undefined) {
|
|
2681
|
+
return {
|
|
2682
|
+
code: 'source-type-not-named',
|
|
2683
|
+
nodeIds: [node.id],
|
|
2684
|
+
message: `Source "${node.name}" (${node.id}) reads from the catalog but does not say which object type, so there is nothing for it to read. Name the type on the node; there is no default, because a default would read somebody else's data.`,
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2132
2688
|
if (node.kind === 'call')
|
|
2133
2689
|
return callIsUnnamed(node);
|
|
2134
2690
|
if (node.kind === 'if')
|
|
@@ -2137,8 +2693,31 @@ function nodeIsUnconfigured(node) {
|
|
|
2137
2693
|
return filterIsUnconfigured(node);
|
|
2138
2694
|
if (node.kind === 'rename')
|
|
2139
2695
|
return renameIsUnconfigured(node);
|
|
2696
|
+
if (node.kind === 'aggregate')
|
|
2697
|
+
return aggregateIsUnconfigured(node);
|
|
2140
2698
|
return undefined;
|
|
2141
2699
|
}
|
|
2700
|
+
/**
|
|
2701
|
+
* An aggregate this service will not store.
|
|
2702
|
+
*
|
|
2703
|
+
* The refusals come from {@link aggregateRefusals} rather than being restated
|
|
2704
|
+
* here, so the canvas, the HTTP boundary, the validator and the fold itself say
|
|
2705
|
+
* the same sentence about the same node. Every one of them is a *silent* failure
|
|
2706
|
+
* if it were let through, and the two most dangerous point in opposite
|
|
2707
|
+
* directions: no group-by columns commits exactly one row whatever the source
|
|
2708
|
+
* held, and no aggregates commits the distinct group keys with every other
|
|
2709
|
+
* column of every row gone.
|
|
2710
|
+
*/
|
|
2711
|
+
function aggregateIsUnconfigured(node) {
|
|
2712
|
+
const refusals = aggregateRefusals(node);
|
|
2713
|
+
if (refusals.length === 0)
|
|
2714
|
+
return undefined;
|
|
2715
|
+
return {
|
|
2716
|
+
code: 'aggregate-invalid',
|
|
2717
|
+
nodeIds: [node.id],
|
|
2718
|
+
message: `Aggregate "${node.name}" (${node.id}) cannot be stored as it is. ${refusals.join(' ')}`,
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2142
2721
|
/**
|
|
2143
2722
|
* A rename whose map this service will not store.
|
|
2144
2723
|
*
|
|
@@ -2749,6 +3328,8 @@ function canonicalNode(node) {
|
|
|
2749
3328
|
}
|
|
2750
3329
|
if (node.kind === 'rename')
|
|
2751
3330
|
return canonicalRename(node);
|
|
3331
|
+
if (node.kind === 'aggregate')
|
|
3332
|
+
return canonicalAggregate(node);
|
|
2752
3333
|
if (node.kind === 'sink') {
|
|
2753
3334
|
return JSON.stringify([
|
|
2754
3335
|
node.id,
|
|
@@ -2782,6 +3363,47 @@ function canonicalRename(node) {
|
|
|
2782
3363
|
...(workflowRenameUnnamed(node) === 'drop' ? ['drop'] : []),
|
|
2783
3364
|
]);
|
|
2784
3365
|
}
|
|
3366
|
+
/**
|
|
3367
|
+
* An aggregate, canonicalised.
|
|
3368
|
+
*
|
|
3369
|
+
* **`groupBy` is sorted and the aggregates are sorted by output name**, because
|
|
3370
|
+
* neither order changes what the node computes: a set of group-by columns
|
|
3371
|
+
* defines the same groups whatever order they are listed in, and two aggregates
|
|
3372
|
+
* are independent of each other. Order *is* what the output columns come out in,
|
|
3373
|
+
* and a record's key order is not something anything downstream reads — the sink
|
|
3374
|
+
* looks every property up by name. So reordering rows in the inspector is not an
|
|
3375
|
+
* edit and does not bump a version.
|
|
3376
|
+
*
|
|
3377
|
+
* Each aggregate's optional fields are appended **only when set**, exactly as
|
|
3378
|
+
* `edge.branch` and a rename's `unnamed` are, so the default has one spelling.
|
|
3379
|
+
* The point of that rule is the same one every time: adding a node kind to this
|
|
3380
|
+
* file must not renumber a graph that did not change. Here it holds for a
|
|
3381
|
+
* stronger reason as well — no stored graph contains an aggregate node at all,
|
|
3382
|
+
* because this release is the first one in which such a node can be saved, so
|
|
3383
|
+
* every existing graph's canonical string is byte-identical to what it was.
|
|
3384
|
+
*/
|
|
3385
|
+
function canonicalAggregate(node) {
|
|
3386
|
+
const groupBy = [...(node.groupBy ?? [])].sort();
|
|
3387
|
+
const aggregates = [...(node.aggregates ?? [])]
|
|
3388
|
+
.sort((left, right) => (left.as < right.as ? -1 : left.as > right.as ? 1 : 0))
|
|
3389
|
+
.map((each) => [
|
|
3390
|
+
each.as,
|
|
3391
|
+
each.fn,
|
|
3392
|
+
each.column ?? '',
|
|
3393
|
+
...(each.separator === undefined ? [] : [each.separator]),
|
|
3394
|
+
...(each.maxLength === undefined ? [] : [each.maxLength]),
|
|
3395
|
+
]);
|
|
3396
|
+
return JSON.stringify([
|
|
3397
|
+
node.id,
|
|
3398
|
+
node.kind,
|
|
3399
|
+
groupBy,
|
|
3400
|
+
aggregates,
|
|
3401
|
+
// In the fingerprint because it decides whether a run finishes or refuses,
|
|
3402
|
+
// which is a difference between two runs of "the same" graph worth being
|
|
3403
|
+
// able to point at. Appended only when set, so the default has one spelling.
|
|
3404
|
+
...(node.maxGroups === undefined ? [] : [node.maxGroups]),
|
|
3405
|
+
]);
|
|
3406
|
+
}
|
|
2785
3407
|
/**
|
|
2786
3408
|
* The reusable reference, as zero, one or two trailing hash components.
|
|
2787
3409
|
*
|
|
@@ -2938,9 +3560,23 @@ function workflowFilterColumns(predicate) {
|
|
|
2938
3560
|
* with some keys re-labelled, and its input is unknown unless something
|
|
2939
3561
|
* upstream closed it. So `undefined` propagates, and that is the honest
|
|
2940
3562
|
* answer rather than an empty set.
|
|
2941
|
-
* - **A
|
|
2942
|
-
*
|
|
2943
|
-
*
|
|
3563
|
+
* - **A transform and a call are always unknown.** A transform is a function
|
|
3564
|
+
* body; a call is a workflow this graph does not own.
|
|
3565
|
+
* - **A source is unknown, with one exception, and the exception needs a
|
|
3566
|
+
* lookup.** Every kind that reaches an outside system has a shape discovered
|
|
3567
|
+
* against that system rather than declared in the graph. A `catalog` source
|
|
3568
|
+
* is the one kind whose shape is *already published*: it names an object
|
|
3569
|
+
* type, and the type's properties are exactly the keys its records carry —
|
|
3570
|
+
* see `fetchCatalog`, which asks the store for those properties by name.
|
|
3571
|
+
*
|
|
3572
|
+
* But the properties are not in the graph either. The graph holds a type
|
|
3573
|
+
* **name**; the columns live in the catalog's registry, which this function is
|
|
3574
|
+
* pure and dependency-free in order not to reach. So the answer is a lookup
|
|
3575
|
+
* the caller supplies — {@link WorkflowColumnKnowledge} — and with no lookup
|
|
3576
|
+
* the answer stays `undefined`. That is the honest shape of the claim: a
|
|
3577
|
+
* caller that can see the catalog gets column checking through a source, which
|
|
3578
|
+
* nothing else in this file can offer, and a caller that cannot see it is told
|
|
3579
|
+
* nothing rather than told an empty set.
|
|
2944
3580
|
* - **It says nothing about a sink's declared properties.** That is the check
|
|
2945
3581
|
* worth wanting — "this sink writes a property no upstream node produces" —
|
|
2946
3582
|
* and it is *not* available here: a {@link WorkflowSinkNode} carries a
|
|
@@ -2952,7 +3588,7 @@ function workflowFilterColumns(predicate) {
|
|
|
2952
3588
|
* cyclic graph before it gets here, but the canvas calls this while a graph is
|
|
2953
3589
|
* being drawn and is entitled to a wrong-but-terminating answer.
|
|
2954
3590
|
*/
|
|
2955
|
-
function workflowKnownColumns(graph, nodeId) {
|
|
3591
|
+
function workflowKnownColumns(graph, nodeId, knowledge) {
|
|
2956
3592
|
const nodes = graph.nodes ?? [];
|
|
2957
3593
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
2958
3594
|
const { incoming } = buildAdjacency(nodes, graph.edges ?? []);
|
|
@@ -2969,7 +3605,7 @@ function workflowKnownColumns(graph, nodeId) {
|
|
|
2969
3605
|
if (!node)
|
|
2970
3606
|
return undefined;
|
|
2971
3607
|
open.add(id);
|
|
2972
|
-
const produced = producedColumns(node, () => intoNode(id));
|
|
3608
|
+
const produced = producedColumns(node, () => intoNode(id), knowledge);
|
|
2973
3609
|
open.delete(id);
|
|
2974
3610
|
answered.set(id, produced);
|
|
2975
3611
|
return produced;
|
|
@@ -3005,7 +3641,7 @@ function workflowKnownColumns(graph, nodeId) {
|
|
|
3005
3641
|
* here is a compile error rather than a silent `undefined` — which would be the
|
|
3006
3642
|
* *safe* wrong answer and would therefore never be noticed.
|
|
3007
3643
|
*/
|
|
3008
|
-
function producedColumns(node, upstream) {
|
|
3644
|
+
function producedColumns(node, upstream, knowledge) {
|
|
3009
3645
|
if (node.kind === 'rename') {
|
|
3010
3646
|
if (workflowRenameUnnamed(node) === 'drop')
|
|
3011
3647
|
return new Set(Object.values(node.columns ?? {}));
|
|
@@ -3017,30 +3653,84 @@ function producedColumns(node, upstream) {
|
|
|
3017
3653
|
renamed.add(node.columns?.[column] ?? column);
|
|
3018
3654
|
return renamed;
|
|
3019
3655
|
}
|
|
3656
|
+
// The one kind whose output set is *exact* rather than an upper bound, and it
|
|
3657
|
+
// is exact without looking upstream at all: an aggregate emits its group-by
|
|
3658
|
+
// columns and its named aggregates on every record it produces, whatever it
|
|
3659
|
+
// was handed and whatever the values turned out to be. See
|
|
3660
|
+
// {@link workflowAggregateOutputColumns} for the one thing it does not claim.
|
|
3661
|
+
if (node.kind === 'aggregate')
|
|
3662
|
+
return new Set(workflowAggregateOutputColumns(node));
|
|
3020
3663
|
// Neither of these touches a column: a filter decides which *rows* survive and
|
|
3021
3664
|
// an `if` decides which *nodes* run. Both hand on exactly the shape they were
|
|
3022
3665
|
// given, which is what makes a closed set survive one.
|
|
3023
3666
|
if (node.kind === 'filter' || node.kind === 'if')
|
|
3024
3667
|
return upstream();
|
|
3025
|
-
// A source
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3668
|
+
// A source is the one kind whose answer depends on which *source* kind it is.
|
|
3669
|
+
if (node.kind === 'source')
|
|
3670
|
+
return sourceProducedColumns(node, knowledge);
|
|
3671
|
+
// A transform's shape is inside a function body, a call's belongs to a workflow
|
|
3672
|
+
// this graph does not own, and nothing reads a sink's output. See
|
|
3673
|
+
// {@link workflowKnownColumns}.
|
|
3674
|
+
if (node.kind === 'transform' || node.kind === 'call' || node.kind === 'sink') {
|
|
3032
3675
|
return undefined;
|
|
3033
3676
|
}
|
|
3034
3677
|
return unreachableNodeKind(node, 'workflowKnownColumns');
|
|
3035
3678
|
}
|
|
3679
|
+
/**
|
|
3680
|
+
* What a source produces, per source kind.
|
|
3681
|
+
*
|
|
3682
|
+
* Exhaustive over {@link CONNECTOR_KINDS} rather than one blanket `undefined`
|
|
3683
|
+
* for the whole node kind, and that is the point of the function existing: the
|
|
3684
|
+
* blanket answer was correct for five kinds and became wrong for the sixth
|
|
3685
|
+
* without anything failing to compile. Ending in
|
|
3686
|
+
* {@link unreachableConnectorKind} makes the seventh a build error here.
|
|
3687
|
+
*
|
|
3688
|
+
* Four of the five outside systems answer `undefined` for the same reason: what
|
|
3689
|
+
* an HTTP endpoint, a file, a bucket or a query produces is discovered against
|
|
3690
|
+
* the live system, and the graph holds an address rather than a shape.
|
|
3691
|
+
*
|
|
3692
|
+
* `inline` answers `undefined` too, and that one is a judgement rather than an
|
|
3693
|
+
* absence. The records are *in the config*, so their keys could be read off
|
|
3694
|
+
* them — but they are a sample somebody pasted to try a transform against, and
|
|
3695
|
+
* a set derived from a sample is not closed: the real load reads the same source
|
|
3696
|
+
* with more records in it and no reason for them to share a key set. Treating
|
|
3697
|
+
* three pasted objects as the definition of a column set would refuse a filter
|
|
3698
|
+
* that is going to be right.
|
|
3699
|
+
*
|
|
3700
|
+
* `catalog` is the one that answers, when a caller supplied the lookup. Its
|
|
3701
|
+
* records are the store's own rows, keyed by the property names of the type it
|
|
3702
|
+
* names — `fetchCatalog` asks for exactly those and the store returns exactly
|
|
3703
|
+
* those — so the set is closed in the sense {@link workflowKnownColumns}
|
|
3704
|
+
* requires: an upper bound that holds whatever is upstream, since nothing is.
|
|
3705
|
+
*/
|
|
3706
|
+
function sourceProducedColumns(node, knowledge) {
|
|
3707
|
+
const kind = node.sourceKind;
|
|
3708
|
+
if (kind === 'http' || kind === 'sql' || kind === 'file' || kind === 's3' || kind === 'inline') {
|
|
3709
|
+
return undefined;
|
|
3710
|
+
}
|
|
3711
|
+
if (kind === 'catalog') {
|
|
3712
|
+
const named = workflowSourceObjectType(node);
|
|
3713
|
+
if (named === undefined || knowledge === undefined)
|
|
3714
|
+
return undefined;
|
|
3715
|
+
const columns = knowledge.columnsOfType(named);
|
|
3716
|
+
// Absent means "this caller cannot see a type by that name", which is not
|
|
3717
|
+
// the same as a type with no columns and must not become an empty set — a
|
|
3718
|
+
// graph read by a console that has not loaded its types would otherwise have
|
|
3719
|
+
// every filter below the source refused.
|
|
3720
|
+
return columns === undefined ? undefined : new Set(columns);
|
|
3721
|
+
}
|
|
3722
|
+
return unreachableConnectorKind(kind, 'workflowKnownColumns');
|
|
3723
|
+
}
|
|
3036
3724
|
/**
|
|
3037
3725
|
* That no node names a column the graph can prove is not there.
|
|
3038
3726
|
*
|
|
3039
|
-
* Only where {@link workflowKnownColumns} answers
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3727
|
+
* Only where {@link workflowKnownColumns} answers: downstream of a rename that
|
|
3728
|
+
* drops what it does not name, or — when the caller supplied a
|
|
3729
|
+
* {@link WorkflowColumnKnowledge} — downstream of a `catalog` source, whose
|
|
3730
|
+
* columns are the named type's published properties. Everywhere else this is
|
|
3731
|
+
* silent, and that silence is correct rather than a gap being tolerated:
|
|
3732
|
+
* refusing a column the graph merely has no opinion about would make every
|
|
3733
|
+
* filter downstream of a transform unsaveable.
|
|
3044
3734
|
*
|
|
3045
3735
|
* A refusal rather than a warning, because both failures are silent and total.
|
|
3046
3736
|
* A filter on a column that cannot exist matches no row — a comparison against
|
|
@@ -3051,19 +3741,17 @@ function producedColumns(node, upstream) {
|
|
|
3051
3741
|
* commits NULL into every row. That is the exact shape `property-names.ts` was
|
|
3052
3742
|
* written about, one node upstream of where it can be caught.
|
|
3053
3743
|
*/
|
|
3054
|
-
function checkColumnsProduced(graph, issues) {
|
|
3744
|
+
function checkColumnsProduced(graph, issues, knowledge) {
|
|
3055
3745
|
for (const node of graph.nodes ?? []) {
|
|
3056
3746
|
// Narrowed off the union rather than tested with a property check, so a kind
|
|
3057
3747
|
// that starts naming columns without being answered for here is a type error
|
|
3058
3748
|
// at `missingColumnMessage` and not a check that silently passes.
|
|
3059
|
-
if (node.kind !== 'filter' && node.kind !== 'rename')
|
|
3749
|
+
if (node.kind !== 'filter' && node.kind !== 'rename' && node.kind !== 'aggregate')
|
|
3060
3750
|
continue;
|
|
3061
|
-
const named = node
|
|
3062
|
-
? workflowFilterColumns(node.predicate)
|
|
3063
|
-
: Object.keys(node.columns ?? {});
|
|
3751
|
+
const named = columnsNamedBy(node);
|
|
3064
3752
|
if (named.length === 0)
|
|
3065
3753
|
continue;
|
|
3066
|
-
const known = workflowKnownColumns(graph, node.id);
|
|
3754
|
+
const known = workflowKnownColumns(graph, node.id, knowledge);
|
|
3067
3755
|
if (known === undefined)
|
|
3068
3756
|
continue;
|
|
3069
3757
|
const missing = named.filter((column) => column.length > 0 && !known.has(column));
|
|
@@ -3076,13 +3764,33 @@ function checkColumnsProduced(graph, issues) {
|
|
|
3076
3764
|
});
|
|
3077
3765
|
}
|
|
3078
3766
|
}
|
|
3767
|
+
/**
|
|
3768
|
+
* The columns a node names, per kind that names any.
|
|
3769
|
+
*
|
|
3770
|
+
* Its own function so the union it takes and the union
|
|
3771
|
+
* {@link missingColumnMessage} takes are the same three kinds written down
|
|
3772
|
+
* twice — which is what makes a fourth kind that starts naming columns a type
|
|
3773
|
+
* error in both places rather than a check that silently passes.
|
|
3774
|
+
*/
|
|
3775
|
+
function columnsNamedBy(node) {
|
|
3776
|
+
if (node.kind === 'filter')
|
|
3777
|
+
return workflowFilterColumns(node.predicate);
|
|
3778
|
+
if (node.kind === 'rename')
|
|
3779
|
+
return Object.keys(node.columns ?? {});
|
|
3780
|
+
return workflowAggregateColumns(node);
|
|
3781
|
+
}
|
|
3079
3782
|
/** The sentence {@link checkColumnsProduced} says, per kind. */
|
|
3080
3783
|
function missingColumnMessage(node, missing, known) {
|
|
3081
3784
|
const quoted = (names) => [...names].map((column) => JSON.stringify(column)).join(', ');
|
|
3082
3785
|
const consequence = node.kind === 'filter'
|
|
3083
3786
|
? 'A test on a column that is not there matches no row — not even a "does not equal" test — so this load would come out empty and every node would report success.'
|
|
3084
|
-
:
|
|
3085
|
-
|
|
3787
|
+
: node.kind === 'rename'
|
|
3788
|
+
? 'A rename of a column that is not there does nothing, so the column it was meant to produce is absent and a sink writing it commits NULL into every row.'
|
|
3789
|
+
: // Both halves of an aggregate fail silently, and they fail differently,
|
|
3790
|
+
// which is why this sentence names both rather than picking one.
|
|
3791
|
+
'Grouping on a column that is not there puts every record into one null-keyed group, so a summary of sixteen thousand work orders comes out as a single row. Aggregating one that is not there answers null for every group, which a sink commits as a column of NULLs. Neither reports an error.';
|
|
3792
|
+
const label = node.kind === 'filter' ? 'Filter' : node.kind === 'rename' ? 'Rename' : 'Aggregate';
|
|
3793
|
+
return `${label} "${node.name}" (${node.id}) names ${quoted(missing)}, and nothing upstream produces ${missing.length === 1 ? 'that column' : 'those columns'}. Something above this node closes the column set — a rename that drops what it does not name, an aggregate, or a source reading a published object type — so what reaches here is exactly ${quoted(known)}. ${consequence}`;
|
|
3086
3794
|
}
|
|
3087
3795
|
function sortedEntries(config) {
|
|
3088
3796
|
return Object.keys(config)
|
|
@@ -3141,6 +3849,8 @@ function isWorkflowNode(value) {
|
|
|
3141
3849
|
}
|
|
3142
3850
|
if (kind === 'rename')
|
|
3143
3851
|
return isRenameNodeShape(value);
|
|
3852
|
+
if (kind === 'aggregate')
|
|
3853
|
+
return isAggregateNodeShape(value);
|
|
3144
3854
|
if (kind === 'source') {
|
|
3145
3855
|
const sourceKind = Reflect.get(value, 'sourceKind');
|
|
3146
3856
|
const config = Reflect.get(value, 'config');
|
|
@@ -3164,6 +3874,23 @@ function isRenameNodeShape(value) {
|
|
|
3164
3874
|
return false;
|
|
3165
3875
|
return isWorkflowRenameColumns(Reflect.get(value, 'columns'));
|
|
3166
3876
|
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Everything an `aggregate` node carries, through the one refusal list.
|
|
3879
|
+
*
|
|
3880
|
+
* The whole node rather than field by field, so a stored aggregate is read back
|
|
3881
|
+
* under exactly the rule that would have refused to store it. Refused rather
|
|
3882
|
+
* than repaired, the stance every guard in this file takes: an aggregate read
|
|
3883
|
+
* back with one entry silently dropped is a load that commits a column of nulls
|
|
3884
|
+
* under a name somebody put in an object type on purpose, and one read back with
|
|
3885
|
+
* its `groupBy` dropped is a load that commits a single row.
|
|
3886
|
+
*/
|
|
3887
|
+
function isAggregateNodeShape(value) {
|
|
3888
|
+
return (aggregateRefusals({
|
|
3889
|
+
groupBy: Reflect.get(value, 'groupBy'),
|
|
3890
|
+
aggregates: Reflect.get(value, 'aggregates'),
|
|
3891
|
+
maxGroups: Reflect.get(value, 'maxGroups'),
|
|
3892
|
+
}).length === 0);
|
|
3893
|
+
}
|
|
3167
3894
|
/**
|
|
3168
3895
|
* Everything a `call` node carries, checked as strictly as a source's.
|
|
3169
3896
|
*
|