@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,376 @@
1
+ /**
2
+ * The fold behind the `aggregate` node: one pass, one entry per group.
3
+ *
4
+ * ## The property this file exists to have
5
+ *
6
+ * A hash aggregate **consumes its input as a stream and holds only the groups.**
7
+ * It never needs the row it read two rows ago, and it never needs the row it is
8
+ * about to read. So the heap it occupies is a function of how many distinct
9
+ * groups there are, and not of how many rows there were.
10
+ *
11
+ * That is the entire argument for the node, and it is worth stating with the
12
+ * measurement that produced it. flip's `wo` derivation groups **44,720 rows into
13
+ * 16,119 groups** with about fifty aggregates over them. Today it is a
14
+ * whole-batch transform, and as a whole-batch transform it is one column away
15
+ * from dying:
16
+ *
17
+ * - the child is handed 65.51 MiB on stdin;
18
+ * - it answers with **one JSON line of 25.01 MiB — 78.2% of the hard 32 MiB
19
+ * output cap**, which the parent buffers as a single string before parsing;
20
+ * - and before any of that, the parent materialises all 44,720 rows through
21
+ * `readInputs`. A standalone equivalent peaked at 237 MiB heap, 406 MiB RSS.
22
+ *
23
+ * At **1.28×** that file it stops working, and it stops by being killed at the
24
+ * cap rather than by getting slower. SUBWO is already the largest file in the
25
+ * drop it comes from. Run through this file instead, the same derivation holds
26
+ * 16,119 accumulator rows and reads one staged batch at a time.
27
+ *
28
+ * ## Which is exactly why the bound is loud
29
+ *
30
+ * "Holds only the groups" is a *cheap* promise only while the groups are far
31
+ * fewer than the rows. Group by something near-unique and a hash aggregate holds
32
+ * the entire load, which is the thing being fixed rather than a corner of it. So
33
+ * {@link AggregateTable} carries a group ceiling and **refuses** when it is
34
+ * crossed, naming the columns it was grouping on. Peaking silently at the
35
+ * ceiling is the failure mode this node was written about.
36
+ *
37
+ * ## Every arithmetic decision in one place
38
+ *
39
+ * The node is the model; this is the semantics. Both are here rather than in the
40
+ * runner so that the canvas, a spec and a durable step get the same answer out
41
+ * of the same code, and so that the decisions below have one address:
42
+ *
43
+ * - **Comparison order for `min`/`max` over strings** — see {@link compareValues}.
44
+ * - **Summation error** — see {@link addToSum}.
45
+ * - **The bound on a `join`, and what happens at it** — see {@link appendJoin}.
46
+ * - **What makes two records the same group** — see {@link groupKeyOf}.
47
+ *
48
+ * None of them is inherited from MySQL, and where the answer differs from
49
+ * MySQL's the docblock says so and says why, because the alternative is a load
50
+ * that silently disagrees with the query it replaced.
51
+ */
52
+ import { type WorkflowAggregate, type WorkflowAggregateNode } from './catalog.pipeline';
53
+ /**
54
+ * A refusal raised while folding, rather than a wrong answer carried forward.
55
+ *
56
+ * Its own class so a caller can tell "this data cannot be aggregated the way the
57
+ * node says" from a bug, and turn the first into a 400 with the sentence intact.
58
+ * Every message it carries names the column, the group and the values, because a
59
+ * refusal at row ninety thousand that says only "incomparable" is a refusal
60
+ * somebody has to reproduce before they can act on it.
61
+ */
62
+ export declare class WorkflowAggregateError extends Error {
63
+ constructor(message: string);
64
+ }
65
+ /** What the fold learned on the way through, for the run log. */
66
+ export interface AggregateTableStats {
67
+ /** Records handed to {@link AggregateTable.push}. */
68
+ rowsIn: number;
69
+ /** Distinct groups held. The number the memory bound is about. */
70
+ groups: number;
71
+ /**
72
+ * Columns the node names that were present in **no** record of the whole run.
73
+ *
74
+ * Almost always a typo in a header, and the symptom otherwise is a column of
75
+ * nulls and a green run — the same trap `renameStagePayload` reports and for
76
+ * the same reason. Not a refusal, because a source legitimately omits a column
77
+ * for a whole load, and `sum`/`min`/`max` over nothing answers `null` rather
78
+ * than `0`, which is visibly different from a real zero.
79
+ */
80
+ unseenColumns: string[];
81
+ /**
82
+ * How many values a `sum` or an `avg` had to read out of a string.
83
+ *
84
+ * A CSV delivers every number as text, so this is usually the whole column and
85
+ * is not a problem. It is reported because the one case that *is* a problem —
86
+ * a column that is numbers in one source and text in another — looks identical
87
+ * from the answer and different from here.
88
+ */
89
+ coercedFromString: number;
90
+ /** The longest value any `join` produced, in characters. See {@link appendJoin}. */
91
+ longestJoin: number;
92
+ }
93
+ /** `sum` and `avg`. See {@link addToSum} for why there are two numbers. */
94
+ interface SumAcc {
95
+ /** The running total. */
96
+ sum: number;
97
+ /** Neumaier's compensation term: the low-order bits `sum` could not hold. */
98
+ compensation: number;
99
+ /** Non-null values seen. Zero means the answer is `null`, never `0`. */
100
+ n: number;
101
+ }
102
+ /** `join`. See {@link appendJoin} for the bound and what happens at it. */
103
+ interface JoinAcc {
104
+ text: string;
105
+ n: number;
106
+ }
107
+ /**
108
+ * The fold, as an object you push records into.
109
+ *
110
+ * An object rather than a `reduce` because the caller is a loop over staged
111
+ * batches with an `await` in it: the table has to survive between batches, and
112
+ * nothing about it may scale with how many batches there were.
113
+ *
114
+ * Built from the node, and it refuses a node the validator would refuse rather
115
+ * than folding under a configuration nobody could have saved — a graph can reach
116
+ * a runner from a database written by an older build, and `aggregateRefusals` is
117
+ * the one place that rule lives.
118
+ */
119
+ export declare class AggregateTable {
120
+ private readonly node;
121
+ private readonly groups;
122
+ private readonly groupBy;
123
+ private readonly aggregates;
124
+ private readonly maxGroups;
125
+ /** Named columns not yet seen in any record. Emptied as they turn up. */
126
+ private readonly unseen;
127
+ private rowsIn;
128
+ private coercedFromString;
129
+ private longestJoin;
130
+ constructor(node: WorkflowAggregateNode);
131
+ /**
132
+ * Fold one record in.
133
+ *
134
+ * Allocates nothing per record except when a record opens a group, which is
135
+ * the property that makes the whole thing a stream: the cost of a row is the
136
+ * group-key encoding plus one accumulator update per aggregate.
137
+ */
138
+ push(row: Record<string, unknown>): void;
139
+ /**
140
+ * One aggregate, one record. Narrowed off the function list, never asserted.
141
+ *
142
+ * A dispatch and four one-function methods rather than four branches inline,
143
+ * so each function's rule about nulls — which is the part that has to match
144
+ * SQL and the part that is easy to get subtly wrong — sits next to its own
145
+ * name. Ends in the exhaustiveness guard, so a seventh function is a compile
146
+ * error here rather than a value nobody computed.
147
+ */
148
+ private accumulate;
149
+ /**
150
+ * `COUNT(*)` with no column and `COUNT(col)` with one.
151
+ *
152
+ * The two differ exactly where the data is sparse, which is where somebody is
153
+ * most likely to want to know — so both are offered rather than one being
154
+ * picked on the reader's behalf.
155
+ */
156
+ private count;
157
+ /** `sum` and `avg`, which share an accumulator and differ only at the finish. */
158
+ private total;
159
+ /** `min` and `max`. Nulls are skipped, as SQL skips them. */
160
+ private extremum;
161
+ /** `join`. Nulls skipped, empty strings kept — which is what GROUP_CONCAT does. */
162
+ private join;
163
+ /**
164
+ * A value on its way into a `sum` or an `avg`, or nothing.
165
+ *
166
+ * Three answers rather than two, and the middle one is the decision:
167
+ *
168
+ * - **null, undefined, or a string that is only whitespace** — skipped, and
169
+ * `n` does not move. A blank cell in a CSV is an absent number and not a
170
+ * zero, and SQL agrees: `SUM` over nothing but nulls is `NULL`, which is
171
+ * visibly different from a real total of zero.
172
+ * - **a string that parses as a finite number** — accepted, and counted in
173
+ * {@link AggregateTableStats.coercedFromString}. Refusing would make the
174
+ * node useless on the exact data it was written for, since a CSV delivers
175
+ * every number as text.
176
+ * - **anything else** — refused, naming the column, the group and the value.
177
+ * MySQL reads `'n/a'` as `0` and raises a warning MikroORM does not surface,
178
+ * which is how a total ends up quietly too small. This is that behaviour,
179
+ * not reproduced.
180
+ */
181
+ private toNumber;
182
+ /**
183
+ * Every group, as records, in the order the groups were first seen.
184
+ *
185
+ * **Insertion order rather than sorted**, and that is a decision about
186
+ * determinism rather than about cost. Sorting would need a total order over
187
+ * heterogeneous key values — the thing {@link compareValues} refuses to invent
188
+ * — and would buy an ordering nobody asked for. Insertion order is a function
189
+ * of the input order, and the input is a numbered list of staged batches, so
190
+ * two runs over the same staged rows emit the same rows in the same order.
191
+ * What it does **not** promise is stability across a source that returns its
192
+ * own rows in a different order; a `SELECT` without an `ORDER BY` promises
193
+ * nothing and this node cannot promise more than it was given.
194
+ *
195
+ * Every record carries **every** output column — the group keys, then the
196
+ * aggregates, in the node's order — including the ones whose answer is `null`.
197
+ * That is what makes an aggregate's output set *exact* rather than an upper
198
+ * bound, which is the claim `producedColumns` makes about this kind and about
199
+ * no other.
200
+ */
201
+ emit(): Generator<Record<string, unknown>>;
202
+ /** What the run log says. Cheap: everything here was counted on the way past. */
203
+ stats(): AggregateTableStats;
204
+ }
205
+ /** The columns a node's aggregates read, deduplicated, `count(*)` excluded. */
206
+ export declare function aggregateInputColumns(node: WorkflowAggregateNode): string[];
207
+ /**
208
+ * What makes two records the same group.
209
+ *
210
+ * The key is the group-by values, encoded into one string so a `Map` can hold
211
+ * them. Every part is **length-prefixed and type-tagged**, which closes two
212
+ * holes that a naive `values.join('|')` leaves open:
213
+ *
214
+ * - `["a|b", "c"]` and `["a", "b|c"]` are different groups and must stay
215
+ * different. Length prefixes make the encoding unambiguous whatever the values
216
+ * contain.
217
+ * - `1` and `"1"` are different groups, and that is a **decision against SQL**,
218
+ * which would coerce them together. Merging them means merging two things the
219
+ * source considered distinct, on a rule nobody chose; within one load a column
220
+ * comes from one system and is one type, so the coercion buys nothing and
221
+ * risks a group that quietly swallowed another.
222
+ *
223
+ * **Missing and null are the same group**, and that is a decision *for* SQL.
224
+ * `GROUP BY` collects all NULLs into one group, and a record that simply lacks
225
+ * the column is a record whose column is null. The stage encoding makes "absent"
226
+ * and "null" a difference in physical layout rather than in meaning — see the
227
+ * shape dictionary in `catalog.stage-encoding.ts` — so grouping them apart would
228
+ * make the answer depend on which shape a row happened to land in, which is not
229
+ * a thing to build a load on.
230
+ *
231
+ * A value that is an object or an array is **refused**. Two objects are equal
232
+ * under some rule and unequal under others, every one of those rules is
233
+ * somebody's convention, and picking one silently decides how many rows a
234
+ * published type ends up holding.
235
+ */
236
+ export declare function groupKeyOf(values: readonly unknown[], columns: readonly string[], node: {
237
+ id: string;
238
+ name: string;
239
+ }): string;
240
+ /**
241
+ * The order `min` and `max` compare in, and the one place it is decided.
242
+ *
243
+ * ## Strings compare by **code point**, and that is not MySQL's answer
244
+ *
245
+ * The column this node most often replaces sits in MySQL under
246
+ * `utf8mb4_0900_ai_ci` — case-insensitive and accent-insensitive — where
247
+ * `'apple' > 'Banana'` is false. JavaScript's `<` compares UTF-16 code points,
248
+ * where `'B'` is 66 and `'a'` is 97, so `'apple' > 'Banana'` is true. On the real
249
+ * SUBWO data the two answers agree on most columns and **differ on two**: 18 of
250
+ * 16,119 groups for `lastUpdatedBy` and 23 for `maintenanceLocation`, both of
251
+ * which hold mixed-case usernames and location codes.
252
+ *
253
+ * Code point order is implemented anyway, deliberately:
254
+ *
255
+ * - It is **total, stable and machine-independent.** The same rows produce the
256
+ * same answer on every Node build, in every locale, with no ICU data loaded.
257
+ * - The alternative is not "MySQL's answer" — it is *an approximation of one
258
+ * deployment's collation*. `Intl.Collator` implements the Unicode collation
259
+ * algorithm, MySQL implements its own table per collation, and a graph can
260
+ * read from a source that is not MySQL at all. Matching would mean carrying a
261
+ * collation name on the node and reimplementing it, which is a database inside
262
+ * a pipeline node.
263
+ * - Being *near* MySQL is worse than being clearly different. A comparison that
264
+ * agrees 99.8% of the time is one nobody checks and everybody trusts.
265
+ *
266
+ * So it is code point order, it is written down here, and the run log says
267
+ * nothing about it because there is nothing conditional to report — what a
268
+ * reader needs is this paragraph, which is why it is this long.
269
+ *
270
+ * ## Types are not compared across classes
271
+ *
272
+ * Numbers with numbers, strings with strings, dates with dates, booleans with
273
+ * booleans. A `max` over a column holding both `12` and `"12"` is **refused**,
274
+ * naming the column, the group and both values. There is no ordering between a
275
+ * number and a string that is not somebody's coercion rule, and every such rule
276
+ * produces a maximum that depends on which row arrived first — a wrong answer
277
+ * that reports success, which is the shape of failure this node was written
278
+ * about. The fix is one node upstream: normalise the column in a transform.
279
+ */
280
+ export declare function compareValues(left: unknown, right: unknown, column: string, groupId: string): number;
281
+ /**
282
+ * Add one value to a running total, with the low-order bits kept.
283
+ *
284
+ * ## The measurement this is here for
285
+ *
286
+ * A prior comparison of flip's `wo` derivation against a JavaScript
287
+ * reimplementation found **17 of 16,119 groups differing in the last float64
288
+ * ulp** — `6442.999999999999` against `6443` — purely from the order the terms
289
+ * were added in. The grand totals reconciled exactly (212,192,113 on both
290
+ * sides), so the difference was tolerable. It was also nobody's decision, and
291
+ * that is the part worth fixing: a load that is off by an ulp because of an
292
+ * accident is one nobody can reason about the next time it is off by more.
293
+ *
294
+ * ## What is implemented, and what it does and does not promise
295
+ *
296
+ * **Neumaier summation.** One extra float per accumulator carries the bits the
297
+ * running total could not hold, and they are added back once at the end. On
298
+ * decimal money and hours — which is what every column this node sums actually
299
+ * is — the result is the correctly-rounded sum, so `6443` comes out as `6443`.
300
+ *
301
+ * What it promises:
302
+ *
303
+ * - The answer is **far** closer to the exact sum than `+=` is, and for values
304
+ * with a couple of decimal places it is the exact sum rounded once.
305
+ * - It costs one number per accumulator and two floating-point operations per
306
+ * row. At 44,720 rows that is unmeasurable.
307
+ *
308
+ * What it does **not** promise:
309
+ *
310
+ * - **Order independence.** Nothing short of exact arithmetic gives that, and
311
+ * exact arithmetic means holding a big decimal per accumulator, which trades
312
+ * the property the node is built on for a rounding difference nobody can
313
+ * observe in a report.
314
+ * - **Bit-for-bit agreement with MySQL.** MySQL sums in its own order and, for a
315
+ * `DECIMAL` column, not in binary floating point at all. Where the source
316
+ * column is `DECIMAL` the honest answer is that these are two different
317
+ * arithmetics and the difference is bounded by one rounding, not that they
318
+ * agree.
319
+ */
320
+ export declare function addToSum(slot: SumAcc, value: number): void;
321
+ /**
322
+ * Append one value to a joined string, and refuse rather than truncate.
323
+ *
324
+ * ## The behaviour this is designed against, which is live today
325
+ *
326
+ * flip's `wo` derivation uses `GROUP_CONCAT(... SEPARATOR '; ')`. In that
327
+ * deployment `group_concat_max_len` is **1024**. Real values reach **1,700 and
328
+ * 1,883 characters**, and **5 of 16,119 groups exceed the limit on each of two
329
+ * columns**. MySQL truncates at the limit and raises a warning, and MikroORM
330
+ * does not surface the warning. So five rows per column have been silently
331
+ * missing their tail, in committed data, with a green run every time.
332
+ *
333
+ * That is not a MySQL bug and it is not a configuration slip anybody would
334
+ * notice: the default is 1024, the query does not mention it, and a truncated
335
+ * string is a perfectly plausible string.
336
+ *
337
+ * ## What this does instead
338
+ *
339
+ * **Refuses, at the bound, naming the group and the length.** Not truncation
340
+ * with a log line, and not an unbounded string.
341
+ *
342
+ * - Truncating loudly was the other candidate and it loses on the same argument
343
+ * as everything else here: the run would go green, the snapshot would commit,
344
+ * and the log line would be one of twenty on a node nobody reads unless
345
+ * something already went wrong. The value would still be wrong in the
346
+ * warehouse.
347
+ * - Unbounded loses on the node's own thesis. A `join` is the one accumulator
348
+ * whose size is not bounded by the group count, so an unbounded one is a hole
349
+ * straight through "holds only the groups".
350
+ *
351
+ * The default bound is {@link WORKFLOW_AGGREGATE_JOIN_MAX_LENGTH} — 65,535
352
+ * characters, which is what one MySQL `TEXT` column holds, because a value the
353
+ * target column cannot store is the same defect one layer further down. flip's
354
+ * real maximum of 1,883 is 2.9% of it, so the derivation this node was written
355
+ * for runs untouched and would have refused loudly at 35× the data instead of
356
+ * quietly at 1.5×.
357
+ *
358
+ * An author may lower it (a column that should never exceed 200 characters is
359
+ * worth saying so about) or raise it up to a hard ceiling, which is on
360
+ * {@link workflowAggregateJoinMaxLength}.
361
+ */
362
+ export declare function appendJoin(slot: JoinAcc, text: string, aggregate: WorkflowAggregate, node: {
363
+ id: string;
364
+ name: string;
365
+ }, groupId: string): void;
366
+ /**
367
+ * A group key, back in something a person can look up.
368
+ *
369
+ * The key is a length-prefixed encoding, which is unreadable on purpose and
370
+ * useless in an error message. This walks it back into the values it was built
371
+ * from, so a refusal names the row somebody has to go and find.
372
+ */
373
+ export declare function describeGroup(key: string): string;
374
+ /** A value, short enough for an error message and honest about its type. */
375
+ export declare function describeValue(value: unknown): string;
376
+ export {};