@dudousxd/nestjs-catalog 0.26.0 → 0.28.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.
@@ -0,0 +1,703 @@
1
+ "use strict";
2
+ /**
3
+ * The fold behind the `aggregate` node: one pass, one entry per group.
4
+ *
5
+ * ## The property this file exists to have
6
+ *
7
+ * A hash aggregate **consumes its input as a stream and holds only the groups.**
8
+ * It never needs the row it read two rows ago, and it never needs the row it is
9
+ * about to read. So the heap it occupies is a function of how many distinct
10
+ * groups there are, and not of how many rows there were.
11
+ *
12
+ * That is the entire argument for the node, and it is worth stating with the
13
+ * measurement that produced it. flip's `wo` derivation groups **44,720 rows into
14
+ * 16,119 groups** with about fifty aggregates over them. Today it is a
15
+ * whole-batch transform, and as a whole-batch transform it is one column away
16
+ * from dying:
17
+ *
18
+ * - the child is handed 65.51 MiB on stdin;
19
+ * - it answers with **one JSON line of 25.01 MiB — 78.2% of the hard 32 MiB
20
+ * output cap**, which the parent buffers as a single string before parsing;
21
+ * - and before any of that, the parent materialises all 44,720 rows through
22
+ * `readInputs`. A standalone equivalent peaked at 237 MiB heap, 406 MiB RSS.
23
+ *
24
+ * At **1.28×** that file it stops working, and it stops by being killed at the
25
+ * cap rather than by getting slower. SUBWO is already the largest file in the
26
+ * drop it comes from. Run through this file instead, the same derivation holds
27
+ * 16,119 accumulator rows and reads one staged batch at a time.
28
+ *
29
+ * ## Which is exactly why the bound is loud
30
+ *
31
+ * "Holds only the groups" is a *cheap* promise only while the groups are far
32
+ * fewer than the rows. Group by something near-unique and a hash aggregate holds
33
+ * the entire load, which is the thing being fixed rather than a corner of it. So
34
+ * {@link AggregateTable} carries a group ceiling and **refuses** when it is
35
+ * crossed, naming the columns it was grouping on. Peaking silently at the
36
+ * ceiling is the failure mode this node was written about.
37
+ *
38
+ * ## Every arithmetic decision in one place
39
+ *
40
+ * The node is the model; this is the semantics. Both are here rather than in the
41
+ * runner so that the canvas, a spec and a durable step get the same answer out
42
+ * of the same code, and so that the decisions below have one address:
43
+ *
44
+ * - **Comparison order for `min`/`max` over strings** — see {@link compareValues}.
45
+ * - **Summation error** — see {@link addToSum}.
46
+ * - **The bound on a `join`, and what happens at it** — see {@link appendJoin}.
47
+ * - **What makes two records the same group** — see {@link groupKeyOf}.
48
+ *
49
+ * None of them is inherited from MySQL, and where the answer differs from
50
+ * MySQL's the docblock says so and says why, because the alternative is a load
51
+ * that silently disagrees with the query it replaced.
52
+ */
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.AggregateTable = exports.WorkflowAggregateError = void 0;
55
+ exports.aggregateInputColumns = aggregateInputColumns;
56
+ exports.groupKeyOf = groupKeyOf;
57
+ exports.compareValues = compareValues;
58
+ exports.addToSum = addToSum;
59
+ exports.appendJoin = appendJoin;
60
+ exports.describeGroup = describeGroup;
61
+ exports.describeValue = describeValue;
62
+ const catalog_pipeline_1 = require("./catalog.pipeline");
63
+ /**
64
+ * A refusal raised while folding, rather than a wrong answer carried forward.
65
+ *
66
+ * Its own class so a caller can tell "this data cannot be aggregated the way the
67
+ * node says" from a bug, and turn the first into a 400 with the sentence intact.
68
+ * Every message it carries names the column, the group and the values, because a
69
+ * refusal at row ninety thousand that says only "incomparable" is a refusal
70
+ * somebody has to reproduce before they can act on it.
71
+ */
72
+ class WorkflowAggregateError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = 'WorkflowAggregateError';
76
+ }
77
+ }
78
+ exports.WorkflowAggregateError = WorkflowAggregateError;
79
+ /**
80
+ * The fold, as an object you push records into.
81
+ *
82
+ * An object rather than a `reduce` because the caller is a loop over staged
83
+ * batches with an `await` in it: the table has to survive between batches, and
84
+ * nothing about it may scale with how many batches there were.
85
+ *
86
+ * Built from the node, and it refuses a node the validator would refuse rather
87
+ * than folding under a configuration nobody could have saved — a graph can reach
88
+ * a runner from a database written by an older build, and `aggregateRefusals` is
89
+ * the one place that rule lives.
90
+ */
91
+ class AggregateTable {
92
+ node;
93
+ groups = new Map();
94
+ groupBy;
95
+ aggregates;
96
+ maxGroups;
97
+ /** Named columns not yet seen in any record. Emptied as they turn up. */
98
+ unseen;
99
+ rowsIn = 0;
100
+ coercedFromString = 0;
101
+ longestJoin = 0;
102
+ constructor(node) {
103
+ this.node = node;
104
+ const refusals = (0, catalog_pipeline_1.aggregateRefusals)(node);
105
+ if (refusals.length > 0) {
106
+ throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) cannot be run as it is. ${refusals.join(' ')}`);
107
+ }
108
+ this.groupBy = node.groupBy;
109
+ this.aggregates = node.aggregates;
110
+ this.maxGroups = (0, catalog_pipeline_1.workflowAggregateMaxGroups)(node);
111
+ this.unseen = new Set([...node.groupBy, ...aggregateInputColumns(node)]);
112
+ }
113
+ /**
114
+ * Fold one record in.
115
+ *
116
+ * Allocates nothing per record except when a record opens a group, which is
117
+ * the property that makes the whole thing a stream: the cost of a row is the
118
+ * group-key encoding plus one accumulator update per aggregate.
119
+ */
120
+ push(row) {
121
+ this.rowsIn += 1;
122
+ if (this.unseen.size > 0) {
123
+ for (const column of this.unseen) {
124
+ if (row[column] !== undefined)
125
+ this.unseen.delete(column);
126
+ }
127
+ }
128
+ const key = [];
129
+ for (const column of this.groupBy)
130
+ key.push(row[column]);
131
+ const id = groupKeyOf(key, this.groupBy, this.node);
132
+ let group = this.groups.get(id);
133
+ if (group === undefined) {
134
+ if (this.groups.size >= this.maxGroups) {
135
+ throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) has already held ${this.groups.size} distinct groups after ${this.rowsIn} records, which is the ceiling this node was given. A hash aggregate holds one entry per group, so grouping on ${this.groupBy.map((column) => JSON.stringify(column)).join(', ')} is holding the whole load rather than a summary of it — which is the thing this node exists to avoid. Group on fewer or coarser columns, or raise \`maxGroups\` deliberately and size the machine for it.`);
136
+ }
137
+ group = { key, acc: this.aggregates.map((each) => freshAccumulator(each)) };
138
+ this.groups.set(id, group);
139
+ }
140
+ for (let at = 0; at < this.aggregates.length; at += 1) {
141
+ const aggregate = this.aggregates[at];
142
+ if (aggregate === undefined)
143
+ continue;
144
+ this.accumulate(aggregate, group.acc[at], row, id);
145
+ }
146
+ }
147
+ /**
148
+ * One aggregate, one record. Narrowed off the function list, never asserted.
149
+ *
150
+ * A dispatch and four one-function methods rather than four branches inline,
151
+ * so each function's rule about nulls — which is the part that has to match
152
+ * SQL and the part that is easy to get subtly wrong — sits next to its own
153
+ * name. Ends in the exhaustiveness guard, so a seventh function is a compile
154
+ * error here rather than a value nobody computed.
155
+ */
156
+ accumulate(aggregate, slot, row, groupId) {
157
+ const fn = aggregate.fn;
158
+ if (fn === 'count') {
159
+ this.count(aggregate, slot, row);
160
+ return;
161
+ }
162
+ if (fn === 'sum' || fn === 'avg') {
163
+ this.total(aggregate, slot, row, groupId);
164
+ return;
165
+ }
166
+ if (fn === 'min' || fn === 'max') {
167
+ this.extremum(fn, aggregate, slot, row, groupId);
168
+ return;
169
+ }
170
+ if (fn === 'join') {
171
+ this.join(aggregate, slot, row, groupId);
172
+ return;
173
+ }
174
+ (0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'AggregateTable.accumulate');
175
+ }
176
+ /**
177
+ * `COUNT(*)` with no column and `COUNT(col)` with one.
178
+ *
179
+ * The two differ exactly where the data is sparse, which is where somebody is
180
+ * most likely to want to know — so both are offered rather than one being
181
+ * picked on the reader's behalf.
182
+ */
183
+ count(aggregate, slot, row) {
184
+ if (!isCountAcc(slot))
185
+ return;
186
+ if (aggregate.column === undefined) {
187
+ slot.n += 1;
188
+ return;
189
+ }
190
+ const value = row[aggregate.column];
191
+ if (value !== null && value !== undefined)
192
+ slot.n += 1;
193
+ }
194
+ /** `sum` and `avg`, which share an accumulator and differ only at the finish. */
195
+ total(aggregate, slot, row, groupId) {
196
+ if (!isSumAcc(slot) || aggregate.column === undefined)
197
+ return;
198
+ const numeric = this.toNumber(row[aggregate.column], aggregate, groupId);
199
+ if (numeric === undefined)
200
+ return;
201
+ addToSum(slot, numeric);
202
+ }
203
+ /** `min` and `max`. Nulls are skipped, as SQL skips them. */
204
+ extremum(fn, aggregate, slot, row, groupId) {
205
+ if (!isExtremumAcc(slot) || aggregate.column === undefined)
206
+ return;
207
+ const value = row[aggregate.column];
208
+ if (value === null || value === undefined)
209
+ return;
210
+ if (slot.value === undefined) {
211
+ slot.value = value;
212
+ return;
213
+ }
214
+ const order = compareValues(slot.value, value, aggregate.column, groupId);
215
+ if (fn === 'min' ? order > 0 : order < 0)
216
+ slot.value = value;
217
+ }
218
+ /** `join`. Nulls skipped, empty strings kept — which is what GROUP_CONCAT does. */
219
+ join(aggregate, slot, row, groupId) {
220
+ if (!isJoinAcc(slot) || aggregate.column === undefined)
221
+ return;
222
+ const value = row[aggregate.column];
223
+ if (value === null || value === undefined)
224
+ return;
225
+ appendJoin(slot, joinText(value, aggregate.column, groupId), aggregate, this.node, groupId);
226
+ if (slot.text.length > this.longestJoin)
227
+ this.longestJoin = slot.text.length;
228
+ }
229
+ /**
230
+ * A value on its way into a `sum` or an `avg`, or nothing.
231
+ *
232
+ * Three answers rather than two, and the middle one is the decision:
233
+ *
234
+ * - **null, undefined, or a string that is only whitespace** — skipped, and
235
+ * `n` does not move. A blank cell in a CSV is an absent number and not a
236
+ * zero, and SQL agrees: `SUM` over nothing but nulls is `NULL`, which is
237
+ * visibly different from a real total of zero.
238
+ * - **a string that parses as a finite number** — accepted, and counted in
239
+ * {@link AggregateTableStats.coercedFromString}. Refusing would make the
240
+ * node useless on the exact data it was written for, since a CSV delivers
241
+ * every number as text.
242
+ * - **anything else** — refused, naming the column, the group and the value.
243
+ * MySQL reads `'n/a'` as `0` and raises a warning MikroORM does not surface,
244
+ * which is how a total ends up quietly too small. This is that behaviour,
245
+ * not reproduced.
246
+ */
247
+ toNumber(raw, aggregate, groupId) {
248
+ if (raw === null || raw === undefined)
249
+ return undefined;
250
+ if (typeof raw === 'number') {
251
+ if (Number.isFinite(raw))
252
+ return raw;
253
+ throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) was asked to ${aggregate.fn} ${JSON.stringify(aggregate.column)} and found ${String(raw)} in the group ${describeGroup(groupId)}. There is no total that includes it, and carrying it would make every row of the group's answer ${String(raw)} without saying why.`);
254
+ }
255
+ if (typeof raw === 'bigint') {
256
+ this.coercedFromString += 0;
257
+ return Number(raw);
258
+ }
259
+ if (typeof raw === 'string') {
260
+ const trimmed = raw.trim();
261
+ if (trimmed.length === 0)
262
+ return undefined;
263
+ const parsed = Number(trimmed);
264
+ if (Number.isFinite(parsed)) {
265
+ this.coercedFromString += 1;
266
+ return parsed;
267
+ }
268
+ }
269
+ throw new WorkflowAggregateError(`Aggregate "${this.node.name}" (${this.node.id}) was asked to ${aggregate.fn} ${JSON.stringify(aggregate.column)} and found ${describeValue(raw)} in the group ${describeGroup(groupId)}, which is not a number. MySQL would read that as 0 and raise a warning nobody sees, so the total would come out too small and the run would come out green. Normalise the column in a transform above this node, or aggregate a different one.`);
270
+ }
271
+ /**
272
+ * Every group, as records, in the order the groups were first seen.
273
+ *
274
+ * **Insertion order rather than sorted**, and that is a decision about
275
+ * determinism rather than about cost. Sorting would need a total order over
276
+ * heterogeneous key values — the thing {@link compareValues} refuses to invent
277
+ * — and would buy an ordering nobody asked for. Insertion order is a function
278
+ * of the input order, and the input is a numbered list of staged batches, so
279
+ * two runs over the same staged rows emit the same rows in the same order.
280
+ * What it does **not** promise is stability across a source that returns its
281
+ * own rows in a different order; a `SELECT` without an `ORDER BY` promises
282
+ * nothing and this node cannot promise more than it was given.
283
+ *
284
+ * Every record carries **every** output column — the group keys, then the
285
+ * aggregates, in the node's order — including the ones whose answer is `null`.
286
+ * That is what makes an aggregate's output set *exact* rather than an upper
287
+ * bound, which is the claim `producedColumns` makes about this kind and about
288
+ * no other.
289
+ */
290
+ *emit() {
291
+ for (const group of this.groups.values()) {
292
+ const row = {};
293
+ for (let at = 0; at < this.groupBy.length; at += 1) {
294
+ const column = this.groupBy[at];
295
+ if (column === undefined)
296
+ continue;
297
+ const value = group.key[at];
298
+ // A record that did not carry the column grouped with the records whose
299
+ // column was null — see `groupKeyOf` — so it has to come out as null and
300
+ // not as absent, or the two halves of one group would disagree about
301
+ // whether the column exists.
302
+ row[column] = value === undefined ? null : value;
303
+ }
304
+ for (let at = 0; at < this.aggregates.length; at += 1) {
305
+ const aggregate = this.aggregates[at];
306
+ if (aggregate === undefined)
307
+ continue;
308
+ row[aggregate.as] = finishAccumulator(aggregate, group.acc[at]);
309
+ }
310
+ yield row;
311
+ }
312
+ }
313
+ /** What the run log says. Cheap: everything here was counted on the way past. */
314
+ stats() {
315
+ return {
316
+ rowsIn: this.rowsIn,
317
+ groups: this.groups.size,
318
+ unseenColumns: [...this.unseen],
319
+ coercedFromString: this.coercedFromString,
320
+ longestJoin: this.longestJoin,
321
+ };
322
+ }
323
+ }
324
+ exports.AggregateTable = AggregateTable;
325
+ /** The columns a node's aggregates read, deduplicated, `count(*)` excluded. */
326
+ function aggregateInputColumns(node) {
327
+ const columns = new Set();
328
+ for (const aggregate of node.aggregates ?? []) {
329
+ if (aggregate.column !== undefined && aggregate.column.length > 0)
330
+ columns.add(aggregate.column);
331
+ }
332
+ return [...columns];
333
+ }
334
+ /** An empty accumulator, per function. Ends in the exhaustiveness guard. */
335
+ function freshAccumulator(aggregate) {
336
+ const fn = aggregate.fn;
337
+ if (fn === 'count')
338
+ return { n: 0 };
339
+ if (fn === 'sum' || fn === 'avg')
340
+ return { sum: 0, compensation: 0, n: 0 };
341
+ if (fn === 'min' || fn === 'max')
342
+ return { value: undefined };
343
+ if (fn === 'join')
344
+ return { text: '', n: 0 };
345
+ return (0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'freshAccumulator');
346
+ }
347
+ /**
348
+ * An accumulator, read out as the value the group's record carries.
349
+ *
350
+ * `null` wherever nothing non-null arrived — never `0`, and never the empty
351
+ * string. SQL agrees on all four, and the difference is the one signal that
352
+ * tells "this column was in no record" apart from "this column really did sum to
353
+ * nothing": an empty string is a value somebody wrote down and a null is a value
354
+ * nobody did.
355
+ */
356
+ function finishAccumulator(aggregate, slot) {
357
+ const fn = aggregate.fn;
358
+ // Split three and three: the ones that answer with a number this node
359
+ // computed, and the ones that answer with a value the data already held. A
360
+ // seventh function is still a compile error — it belongs to neither union, so
361
+ // neither call below accepts it.
362
+ if (fn === 'count' || fn === 'sum' || fn === 'avg')
363
+ return finishedNumber(fn, slot);
364
+ return finishedValue(fn, slot);
365
+ }
366
+ /** `count`, `sum` and `avg`: a number this node derived, or null. */
367
+ function finishedNumber(fn, slot) {
368
+ if (fn === 'count')
369
+ return isCountAcc(slot) ? slot.n : 0;
370
+ const total = finishedSum(slot);
371
+ if (total === undefined)
372
+ return null;
373
+ return fn === 'sum' ? total.sum : total.sum / total.n;
374
+ }
375
+ /** `min`, `max` and `join`: a value the data held, or null. */
376
+ function finishedValue(fn, slot) {
377
+ if (fn === 'min' || fn === 'max')
378
+ return isExtremumAcc(slot) ? (slot.value ?? null) : null;
379
+ if (fn === 'join')
380
+ return isJoinAcc(slot) && slot.n > 0 ? slot.text : null;
381
+ return (0, catalog_pipeline_1.unreachableAggregateFunction)(fn, 'finishAccumulator');
382
+ }
383
+ /**
384
+ * The total and how many terms went into it, or nothing at all.
385
+ *
386
+ * The compensation is added back here and nowhere else, which is the point of
387
+ * the helper: `sum` and `avg` read the same accumulator, and one of them
388
+ * forgetting the low-order bits would be a difference nobody could see in a
389
+ * report and nobody could explain in a reconciliation.
390
+ */
391
+ function finishedSum(slot) {
392
+ if (!isSumAcc(slot) || slot.n === 0)
393
+ return undefined;
394
+ return { sum: slot.sum + slot.compensation, n: slot.n };
395
+ }
396
+ /* --- the four decisions -------------------------------------------------- */
397
+ /**
398
+ * What makes two records the same group.
399
+ *
400
+ * The key is the group-by values, encoded into one string so a `Map` can hold
401
+ * them. Every part is **length-prefixed and type-tagged**, which closes two
402
+ * holes that a naive `values.join('|')` leaves open:
403
+ *
404
+ * - `["a|b", "c"]` and `["a", "b|c"]` are different groups and must stay
405
+ * different. Length prefixes make the encoding unambiguous whatever the values
406
+ * contain.
407
+ * - `1` and `"1"` are different groups, and that is a **decision against SQL**,
408
+ * which would coerce them together. Merging them means merging two things the
409
+ * source considered distinct, on a rule nobody chose; within one load a column
410
+ * comes from one system and is one type, so the coercion buys nothing and
411
+ * risks a group that quietly swallowed another.
412
+ *
413
+ * **Missing and null are the same group**, and that is a decision *for* SQL.
414
+ * `GROUP BY` collects all NULLs into one group, and a record that simply lacks
415
+ * the column is a record whose column is null. The stage encoding makes "absent"
416
+ * and "null" a difference in physical layout rather than in meaning — see the
417
+ * shape dictionary in `catalog.stage-encoding.ts` — so grouping them apart would
418
+ * make the answer depend on which shape a row happened to land in, which is not
419
+ * a thing to build a load on.
420
+ *
421
+ * A value that is an object or an array is **refused**. Two objects are equal
422
+ * under some rule and unequal under others, every one of those rules is
423
+ * somebody's convention, and picking one silently decides how many rows a
424
+ * published type ends up holding.
425
+ */
426
+ function groupKeyOf(values, columns, node) {
427
+ let key = '';
428
+ for (let at = 0; at < values.length; at += 1) {
429
+ const part = keyPart(values[at], columns[at] ?? '', node);
430
+ key += `${part.length}${part}`;
431
+ }
432
+ return key;
433
+ }
434
+ function keyPart(value, column, node) {
435
+ if (value === null || value === undefined)
436
+ return '~';
437
+ if (typeof value === 'string')
438
+ return `s${value}`;
439
+ if (typeof value === 'number') {
440
+ if (!Number.isFinite(value)) {
441
+ throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found ${String(value)} in it. It is not a value two records can be judged equal on, so it cannot name a group.`);
442
+ }
443
+ return `n${value}`;
444
+ }
445
+ if (typeof value === 'boolean')
446
+ return `b${value ? 1 : 0}`;
447
+ if (typeof value === 'bigint')
448
+ return `n${value}`;
449
+ if (value instanceof Date) {
450
+ const time = value.getTime();
451
+ if (Number.isNaN(time)) {
452
+ throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found an invalid date in it. Every invalid date is equal to no other date including itself, so it cannot name a group.`);
453
+ }
454
+ return `d${time}`;
455
+ }
456
+ throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) groups on ${JSON.stringify(column)} and found ${describeValue(value)} in it. Two of those are equal under some rule and unequal under others, and picking one here would silently decide how many rows this load commits. Group on a column of plain values, or flatten it in a transform above this node.`);
457
+ }
458
+ /**
459
+ * The order `min` and `max` compare in, and the one place it is decided.
460
+ *
461
+ * ## Strings compare by **code point**, and that is not MySQL's answer
462
+ *
463
+ * The column this node most often replaces sits in MySQL under
464
+ * `utf8mb4_0900_ai_ci` — case-insensitive and accent-insensitive — where
465
+ * `'apple' > 'Banana'` is false. JavaScript's `<` compares UTF-16 code points,
466
+ * where `'B'` is 66 and `'a'` is 97, so `'apple' > 'Banana'` is true. On the real
467
+ * SUBWO data the two answers agree on most columns and **differ on two**: 18 of
468
+ * 16,119 groups for `lastUpdatedBy` and 23 for `maintenanceLocation`, both of
469
+ * which hold mixed-case usernames and location codes.
470
+ *
471
+ * Code point order is implemented anyway, deliberately:
472
+ *
473
+ * - It is **total, stable and machine-independent.** The same rows produce the
474
+ * same answer on every Node build, in every locale, with no ICU data loaded.
475
+ * - The alternative is not "MySQL's answer" — it is *an approximation of one
476
+ * deployment's collation*. `Intl.Collator` implements the Unicode collation
477
+ * algorithm, MySQL implements its own table per collation, and a graph can
478
+ * read from a source that is not MySQL at all. Matching would mean carrying a
479
+ * collation name on the node and reimplementing it, which is a database inside
480
+ * a pipeline node.
481
+ * - Being *near* MySQL is worse than being clearly different. A comparison that
482
+ * agrees 99.8% of the time is one nobody checks and everybody trusts.
483
+ *
484
+ * So it is code point order, it is written down here, and the run log says
485
+ * nothing about it because there is nothing conditional to report — what a
486
+ * reader needs is this paragraph, which is why it is this long.
487
+ *
488
+ * ## Types are not compared across classes
489
+ *
490
+ * Numbers with numbers, strings with strings, dates with dates, booleans with
491
+ * booleans. A `max` over a column holding both `12` and `"12"` is **refused**,
492
+ * naming the column, the group and both values. There is no ordering between a
493
+ * number and a string that is not somebody's coercion rule, and every such rule
494
+ * produces a maximum that depends on which row arrived first — a wrong answer
495
+ * that reports success, which is the shape of failure this node was written
496
+ * about. The fix is one node upstream: normalise the column in a transform.
497
+ */
498
+ function compareValues(left, right, column, groupId) {
499
+ if (typeof left === 'number' && typeof right === 'number')
500
+ return sign(left, right);
501
+ // Code point order. See the docblock: not MySQL's collation, on purpose.
502
+ if (typeof left === 'string' && typeof right === 'string')
503
+ return sign(left, right);
504
+ if (left instanceof Date && right instanceof Date)
505
+ return sign(left.getTime(), right.getTime());
506
+ if (typeof left === 'boolean' && typeof right === 'boolean') {
507
+ return sign(left ? 1 : 0, right ? 1 : 0);
508
+ }
509
+ if (typeof left === 'bigint' && typeof right === 'bigint')
510
+ return sign(left, right);
511
+ throw new WorkflowAggregateError(`Column ${JSON.stringify(column)} holds ${describeValue(left)} and ${describeValue(right)} in the group ${describeGroup(groupId)}, and there is no order between them that is not somebody's coercion rule. Whichever this node picked, the answer would depend on which record arrived first and the run would still report success. Normalise the column in a transform above this node.`);
512
+ }
513
+ /**
514
+ * Add one value to a running total, with the low-order bits kept.
515
+ *
516
+ * ## The measurement this is here for
517
+ *
518
+ * A prior comparison of flip's `wo` derivation against a JavaScript
519
+ * reimplementation found **17 of 16,119 groups differing in the last float64
520
+ * ulp** — `6442.999999999999` against `6443` — purely from the order the terms
521
+ * were added in. The grand totals reconciled exactly (212,192,113 on both
522
+ * sides), so the difference was tolerable. It was also nobody's decision, and
523
+ * that is the part worth fixing: a load that is off by an ulp because of an
524
+ * accident is one nobody can reason about the next time it is off by more.
525
+ *
526
+ * ## What is implemented, and what it does and does not promise
527
+ *
528
+ * **Neumaier summation.** One extra float per accumulator carries the bits the
529
+ * running total could not hold, and they are added back once at the end. On
530
+ * decimal money and hours — which is what every column this node sums actually
531
+ * is — the result is the correctly-rounded sum, so `6443` comes out as `6443`.
532
+ *
533
+ * What it promises:
534
+ *
535
+ * - The answer is **far** closer to the exact sum than `+=` is, and for values
536
+ * with a couple of decimal places it is the exact sum rounded once.
537
+ * - It costs one number per accumulator and two floating-point operations per
538
+ * row. At 44,720 rows that is unmeasurable.
539
+ *
540
+ * What it does **not** promise:
541
+ *
542
+ * - **Order independence.** Nothing short of exact arithmetic gives that, and
543
+ * exact arithmetic means holding a big decimal per accumulator, which trades
544
+ * the property the node is built on for a rounding difference nobody can
545
+ * observe in a report.
546
+ * - **Bit-for-bit agreement with MySQL.** MySQL sums in its own order and, for a
547
+ * `DECIMAL` column, not in binary floating point at all. Where the source
548
+ * column is `DECIMAL` the honest answer is that these are two different
549
+ * arithmetics and the difference is bounded by one rounding, not that they
550
+ * agree.
551
+ */
552
+ function addToSum(slot, value) {
553
+ const total = slot.sum + value;
554
+ // Neumaier's refinement of Kahan: whichever of the two was larger in
555
+ // magnitude keeps its bits, and the other's lost low-order bits go to the
556
+ // compensation term. This is the branch Kahan's original gets wrong when the
557
+ // incoming value is the larger of the two.
558
+ slot.compensation +=
559
+ Math.abs(slot.sum) >= Math.abs(value) ? slot.sum - total + value : value - total + slot.sum;
560
+ slot.sum = total;
561
+ slot.n += 1;
562
+ }
563
+ /**
564
+ * Append one value to a joined string, and refuse rather than truncate.
565
+ *
566
+ * ## The behaviour this is designed against, which is live today
567
+ *
568
+ * flip's `wo` derivation uses `GROUP_CONCAT(... SEPARATOR '; ')`. In that
569
+ * deployment `group_concat_max_len` is **1024**. Real values reach **1,700 and
570
+ * 1,883 characters**, and **5 of 16,119 groups exceed the limit on each of two
571
+ * columns**. MySQL truncates at the limit and raises a warning, and MikroORM
572
+ * does not surface the warning. So five rows per column have been silently
573
+ * missing their tail, in committed data, with a green run every time.
574
+ *
575
+ * That is not a MySQL bug and it is not a configuration slip anybody would
576
+ * notice: the default is 1024, the query does not mention it, and a truncated
577
+ * string is a perfectly plausible string.
578
+ *
579
+ * ## What this does instead
580
+ *
581
+ * **Refuses, at the bound, naming the group and the length.** Not truncation
582
+ * with a log line, and not an unbounded string.
583
+ *
584
+ * - Truncating loudly was the other candidate and it loses on the same argument
585
+ * as everything else here: the run would go green, the snapshot would commit,
586
+ * and the log line would be one of twenty on a node nobody reads unless
587
+ * something already went wrong. The value would still be wrong in the
588
+ * warehouse.
589
+ * - Unbounded loses on the node's own thesis. A `join` is the one accumulator
590
+ * whose size is not bounded by the group count, so an unbounded one is a hole
591
+ * straight through "holds only the groups".
592
+ *
593
+ * The default bound is {@link WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH} — 65,535
594
+ * characters, which is what one MySQL `TEXT` column holds, because a value the
595
+ * target column cannot store is the same defect one layer further down. flip's
596
+ * real maximum of 1,883 is 2.9% of it, so the derivation this node was written
597
+ * for runs untouched and would have refused loudly at 35× the data instead of
598
+ * quietly at 1.5×.
599
+ *
600
+ * An author may lower it (a column that should never exceed 200 characters is
601
+ * worth saying so about) or raise it up to a hard ceiling, which is on
602
+ * {@link workflowAggregateJoinMaxLength}.
603
+ */
604
+ function appendJoin(slot, text, aggregate, node, groupId) {
605
+ const separator = (0, catalog_pipeline_1.workflowAggregateSeparator)(aggregate);
606
+ const addition = slot.n === 0 ? text : separator + text;
607
+ const limit = (0, catalog_pipeline_1.workflowAggregateJoinMaxLength)(aggregate);
608
+ const would = slot.text.length + addition.length;
609
+ if (would > limit) {
610
+ throw new WorkflowAggregateError(`Aggregate "${node.name}" (${node.id}) joins ${JSON.stringify(aggregate.column)} into ${JSON.stringify(aggregate.as)}, and the group ${describeGroup(groupId)} has reached ${would} characters against a limit of ${limit}. It is refused rather than truncated: MySQL's GROUP_CONCAT truncates at group_concat_max_len and raises a warning most drivers never surface, which is how a committed column ends up quietly missing its tail. Raise \`maxLength\` on this aggregate if the whole value is wanted, or count the rows instead of joining them.`);
611
+ }
612
+ slot.text += addition;
613
+ slot.n += 1;
614
+ }
615
+ /**
616
+ * The order of two values of one type, as -1, 0 or 1.
617
+ *
618
+ * Generic over the comparable primitives rather than taking `unknown`, so
619
+ * {@link compareValues} does the narrowing once per class and this cannot be
620
+ * called on a pair whose comparison would be a coercion.
621
+ */
622
+ function sign(left, right) {
623
+ if (left < right)
624
+ return -1;
625
+ if (left > right)
626
+ return 1;
627
+ return 0;
628
+ }
629
+ /** One value on its way into a `join`. Refuses what has no obvious spelling. */
630
+ function joinText(value, column, groupId) {
631
+ if (typeof value === 'string')
632
+ return value;
633
+ if (typeof value === 'number' || typeof value === 'bigint')
634
+ return String(value);
635
+ if (typeof value === 'boolean')
636
+ return value ? 'true' : 'false';
637
+ if (value instanceof Date)
638
+ return value.toISOString();
639
+ throw new WorkflowAggregateError(`Column ${JSON.stringify(column)} holds ${describeValue(value)} in the group ${describeGroup(groupId)}, and there is no one spelling of it to join. MySQL would render it as whatever its own cast produced; this node would rather be told which text was meant, in a transform above it.`);
640
+ }
641
+ /* --- narrowing, so nothing here is a type assertion ---------------------- */
642
+ function isCountAcc(slot) {
643
+ return typeof slot === 'object' && slot !== null && typeof Reflect.get(slot, 'n') === 'number';
644
+ }
645
+ function isSumAcc(slot) {
646
+ if (typeof slot !== 'object' || slot === null)
647
+ return false;
648
+ return (typeof Reflect.get(slot, 'sum') === 'number' &&
649
+ typeof Reflect.get(slot, 'compensation') === 'number' &&
650
+ typeof Reflect.get(slot, 'n') === 'number');
651
+ }
652
+ function isExtremumAcc(slot) {
653
+ return typeof slot === 'object' && slot !== null && 'value' in slot;
654
+ }
655
+ function isJoinAcc(slot) {
656
+ if (typeof slot !== 'object' || slot === null)
657
+ return false;
658
+ return (typeof Reflect.get(slot, 'text') === 'string' && typeof Reflect.get(slot, 'n') === 'number');
659
+ }
660
+ /* --- sentences ----------------------------------------------------------- */
661
+ /**
662
+ * A group key, back in something a person can look up.
663
+ *
664
+ * The key is a length-prefixed encoding, which is unreadable on purpose and
665
+ * useless in an error message. This walks it back into the values it was built
666
+ * from, so a refusal names the row somebody has to go and find.
667
+ */
668
+ function describeGroup(key) {
669
+ const parts = [];
670
+ let at = 0;
671
+ while (at < key.length) {
672
+ const boundary = key.indexOf('', at);
673
+ if (boundary < 0)
674
+ break;
675
+ const length = Number(key.slice(at, boundary));
676
+ if (!Number.isInteger(length) || length < 0)
677
+ break;
678
+ const part = key.slice(boundary + 1, boundary + 1 + length);
679
+ at = boundary + 1 + length;
680
+ parts.push(part === '~' ? 'null' : JSON.stringify(part.slice(1)));
681
+ }
682
+ return parts.length === 0 ? '(unreadable)' : `(${parts.join(', ')})`;
683
+ }
684
+ /** A value, short enough for an error message and honest about its type. */
685
+ function describeValue(value) {
686
+ if (value === null)
687
+ return 'null';
688
+ if (value === undefined)
689
+ return 'nothing';
690
+ if (typeof value === 'string') {
691
+ const shown = value.length > 40 ? `${value.slice(0, 40)}…` : value;
692
+ return `the text ${JSON.stringify(shown)}`;
693
+ }
694
+ if (typeof value === 'number' || typeof value === 'bigint')
695
+ return `the number ${String(value)}`;
696
+ if (typeof value === 'boolean')
697
+ return `the boolean ${String(value)}`;
698
+ if (value instanceof Date)
699
+ return `the date ${value.toISOString()}`;
700
+ if (Array.isArray(value))
701
+ return `a list of ${value.length}`;
702
+ return 'an object';
703
+ }