@dudousxd/nestjs-catalog 0.15.0 → 0.16.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,185 @@
1
+ /**
2
+ * How a staged batch is written down.
3
+ *
4
+ * `catalog_workflow_stage.rows` is where every row between two nodes sits, and
5
+ * it was a JSON array of row objects — which means every property name is
6
+ * written out again for every row. On the deployment that reads back as 9.04 GB
7
+ * across ~16,233 staged batches in a week, for graphs that are two nodes long.
8
+ *
9
+ * ## Why not just stop staging
10
+ *
11
+ * Because the stage is not a cache. The durable engine checkpoints a step's
12
+ * output so that a crash resumes instead of re-reading the source, and a
13
+ * two-node graph is exactly the case where re-reading the source is the
14
+ * expensive thing. Fusing the nodes and handing the array over in memory is
15
+ * measurably faster and it spends the resume guarantee to get there. This file
16
+ * makes the same guarantee cheaper instead.
17
+ *
18
+ * ## What the measurement said
19
+ *
20
+ * With the shipped code paths, 50,000 rows, the deployment's own column lists
21
+ * and `BATCH_SIZE = 500`: `JSON.stringify` costs 488 ms and `JSON.parse` 285 ms,
22
+ * against 6,302 ms of `INSERT`. So the encoding is 9.4% of the bill *as CPU* and
23
+ * MySQL is 90.6% — which reads like an argument that the encoding does not
24
+ * matter, and is the opposite. `INSERT` time is linear in bytes, and writing the
25
+ * names once per batch rather than once per row halves the bytes. The saving
26
+ * arrives through the `INSERT`, not through the parse.
27
+ *
28
+ * ## The shape
29
+ *
30
+ * A **shape dictionary**, not a single column list:
31
+ *
32
+ * ```json
33
+ * { "enc": "columnar", "v": 1,
34
+ * "shapes": [["id", "name"], ["id", "name", "note"]],
35
+ * "shapeOf": [0, 0, 1, 0],
36
+ * "values": [[1, "a"], [2, null], [3, "c", "x"], [4, "d"]] }
37
+ * ```
38
+ *
39
+ * `shapes` holds each **distinct key-set** in the batch, once. `shapeOf[i]`
40
+ * says which one row `i` uses, and `values[i]` runs parallel to it.
41
+ *
42
+ * A single global column list with padding would have been simpler and is
43
+ * wrong, because it has no way to say **absent**. The pipeline's rows are
44
+ * `Record<string, unknown>` and a transform may emit differing shapes; a row
45
+ * that lacks `note` and a row whose `note` is `null` are different facts, and a
46
+ * padded array collapses them into the same `null`. That difference decides
47
+ * whether a sink writes a column or leaves it alone, so collapsing it would
48
+ * change committed data silently. Every sentinel that could stand for "absent"
49
+ * inside a JSON array is also a value a row is entitled to hold, so there is no
50
+ * sentinel available; naming each row's own key-set is the encoding that has
51
+ * the distinction built in rather than bolted on.
52
+ *
53
+ * It also degrades gracefully rather than off a cliff. A batch of 500 rows with
54
+ * 500 different key-sets stores 500 key-sets — the same names a row-oriented
55
+ * encoding would have written, plus one small integer per row. A padded union
56
+ * column list, on the same batch, would store a 500-way-wider row for every row
57
+ * and be far *worse* than what it replaced.
58
+ *
59
+ * ## Key order
60
+ *
61
+ * Preserved exactly, which is **more** than the old encoding managed, and that
62
+ * was worth checking before changing anything.
63
+ *
64
+ * A MySQL `JSON` column does not store the text it was given. It stores a
65
+ * normalised binary document in which an object's members are sorted — by key
66
+ * length, then by bytes — so a row staged as `{zebra, a, Middle_Name, b}` reads
67
+ * back as `{a, b, zebra, Middle_Name}`. Verified against the local server, not
68
+ * assumed. Every staged row has therefore been coming back reordered since the
69
+ * stage existed.
70
+ *
71
+ * Here the names live in `shapes`, a JSON **array**, and arrays keep their
72
+ * order in the same binary format. So a decoded row enumerates in the order the
73
+ * producing node emitted it.
74
+ *
75
+ * Nothing downstream depended on either behaviour, which is why this is safe to
76
+ * change in the fidelity direction. The sink path picks columns from the object
77
+ * type's declared properties and reads each one by name — `MySqlWarehouseStore`
78
+ * builds its `INSERT` column list from `type.properties`, the ClickHouse store
79
+ * builds a fresh name-keyed record per row, and reads normalise back to
80
+ * declared order regardless of what went in. The three places in the codebase
81
+ * where a row's key order does decide something — schema discovery's proposed
82
+ * column order, `csvLines` called without an explicit column list, and the
83
+ * ClickHouse ad-hoc query fallback — none of them read a staged batch: schema
84
+ * discovery samples the connector's fetcher directly, and the export route
85
+ * passes its columns.
86
+ *
87
+ * ## `undefined`, functions, symbols
88
+ *
89
+ * Dropped, key and all — which is what `JSON.stringify` did to them under the
90
+ * old encoding, and what `codeContext` does deliberately for the same reason:
91
+ * `{"k": undefined}` and a missing `k` are the same thing through JSON and
92
+ * different things through a deep-equality check. So a key whose value is one
93
+ * of those three does not enter the row's shape, and the row decodes without
94
+ * it, exactly as it did before.
95
+ *
96
+ * The one case this does not reproduce is a value carrying a `toJSON()` that
97
+ * returns `undefined` — `JSON.stringify` drops that key, and here the key is in
98
+ * the shape and its value serialises to `null`. Reproducing it would mean
99
+ * invoking `toJSON` a second time, on caller code, to find out; the boundary is
100
+ * documented rather than papered over, and no source in this codebase produces
101
+ * such a value (rows arrive from SQL drivers, HTTP JSON, or a transform's
102
+ * return value).
103
+ */
104
+ /** The tag every batch written by this build carries. */
105
+ export declare const STAGE_ENCODING = "columnar";
106
+ /**
107
+ * Bumped when {@link ColumnarStageBatch} changes shape in a way an older reader
108
+ * would misread. An older pod meeting a newer version refuses loudly rather
109
+ * than decoding half of it — see {@link classifyStagePayload}.
110
+ */
111
+ export declare const STAGE_ENCODING_VERSION = 1;
112
+ /** A staged batch, columnar. See the file comment for the argument. */
113
+ export interface ColumnarStageBatch {
114
+ readonly enc: typeof STAGE_ENCODING;
115
+ readonly v: typeof STAGE_ENCODING_VERSION;
116
+ /** The distinct key-sets in this batch, each in its rows' own key order. */
117
+ readonly shapes: ReadonlyArray<readonly string[]>;
118
+ /** Per row, an index into {@link shapes}. */
119
+ readonly shapeOf: readonly number[];
120
+ /** Per row, the values, parallel to `shapes[shapeOf[i]]`. */
121
+ readonly values: ReadonlyArray<readonly unknown[]>;
122
+ }
123
+ /**
124
+ * What was found in the column, named.
125
+ *
126
+ * A closed union, and {@link decodeStageRows} switches over it exhaustively —
127
+ * so adding a third encoding here without handling it there is a compile error
128
+ * rather than a batch that decodes to nothing at three in the morning.
129
+ */
130
+ export type StagePayload = {
131
+ /** The original encoding: a JSON array of row objects, carrying no tag. */
132
+ readonly encoding: 'row-oriented/v0';
133
+ readonly rows: readonly unknown[];
134
+ } | {
135
+ readonly encoding: 'columnar/v1';
136
+ readonly batch: ColumnarStageBatch;
137
+ };
138
+ /**
139
+ * Rows in, one columnar batch out.
140
+ *
141
+ * The last shape is remembered and compared before anything is hashed, because
142
+ * a batch whose rows all have the same keys — which is nearly every batch — then
143
+ * costs one array comparison per row and builds no fingerprint string at all.
144
+ * Only a row that differs from its predecessor pays for the dictionary lookup.
145
+ */
146
+ export declare function encodeStageRows(rows: ReadonlyArray<Record<string, unknown>>): ColumnarStageBatch;
147
+ /**
148
+ * A fully checked columnar batch — structure, and every index in range.
149
+ *
150
+ * Checked rather than trusted for the reason `readStage` always gave about the
151
+ * old encoding: this is JSON that came out of a database, and the alternative to
152
+ * validating it here is discovering it one `undefined` at a time somewhere that
153
+ * cannot say what went wrong. Everything the decoder indexes into is proved
154
+ * present by this function, which is why the decoder has no recovery paths.
155
+ */
156
+ export declare function isColumnarStageBatch(value: unknown): value is ColumnarStageBatch;
157
+ /**
158
+ * Name what is in the column, or refuse.
159
+ *
160
+ * The two encodings are disjoint **JSON types** — the old writer only ever
161
+ * called `JSON.stringify` on an `Array<Record<string, unknown>>`, so anything it
162
+ * produced parses back as an array, and this one only ever writes a tagged
163
+ * object. A top-level JSON value cannot be both, so there is no batch for which
164
+ * both branches match and nothing here is inferred from the *contents* of a
165
+ * row. That is what makes the discrimination total rather than a guess: it does
166
+ * not ask what the rows look like, it asks which of two mutually exclusive JSON
167
+ * types the payload is, and then, for the object, reads the tag it was written
168
+ * with.
169
+ *
170
+ * Anything else throws, and does not return an empty batch. An unreadable stage
171
+ * decoded to zero rows would reach `runSink` as "this node produced nothing",
172
+ * and for a full load the sink's own guard turns that into a refusal — but for
173
+ * an *incremental* load it is a legitimate answer, and carry-forward would
174
+ * commit a snapshot that quietly dropped whatever the batch held. The loud
175
+ * failure is the cheap one.
176
+ */
177
+ export declare function classifyStagePayload(stored: unknown): StagePayload;
178
+ /**
179
+ * Back into row records, whichever way the batch was written.
180
+ *
181
+ * The switch is exhaustive over {@link StagePayload} and the default branch
182
+ * assigns to `never`, so a third encoding added to that union stops the build
183
+ * here instead of falling through to a silent `[]`.
184
+ */
185
+ export declare function decodeStageRows(stored: unknown): Array<Record<string, unknown>>;
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ /**
3
+ * How a staged batch is written down.
4
+ *
5
+ * `catalog_workflow_stage.rows` is where every row between two nodes sits, and
6
+ * it was a JSON array of row objects — which means every property name is
7
+ * written out again for every row. On the deployment that reads back as 9.04 GB
8
+ * across ~16,233 staged batches in a week, for graphs that are two nodes long.
9
+ *
10
+ * ## Why not just stop staging
11
+ *
12
+ * Because the stage is not a cache. The durable engine checkpoints a step's
13
+ * output so that a crash resumes instead of re-reading the source, and a
14
+ * two-node graph is exactly the case where re-reading the source is the
15
+ * expensive thing. Fusing the nodes and handing the array over in memory is
16
+ * measurably faster and it spends the resume guarantee to get there. This file
17
+ * makes the same guarantee cheaper instead.
18
+ *
19
+ * ## What the measurement said
20
+ *
21
+ * With the shipped code paths, 50,000 rows, the deployment's own column lists
22
+ * and `BATCH_SIZE = 500`: `JSON.stringify` costs 488 ms and `JSON.parse` 285 ms,
23
+ * against 6,302 ms of `INSERT`. So the encoding is 9.4% of the bill *as CPU* and
24
+ * MySQL is 90.6% — which reads like an argument that the encoding does not
25
+ * matter, and is the opposite. `INSERT` time is linear in bytes, and writing the
26
+ * names once per batch rather than once per row halves the bytes. The saving
27
+ * arrives through the `INSERT`, not through the parse.
28
+ *
29
+ * ## The shape
30
+ *
31
+ * A **shape dictionary**, not a single column list:
32
+ *
33
+ * ```json
34
+ * { "enc": "columnar", "v": 1,
35
+ * "shapes": [["id", "name"], ["id", "name", "note"]],
36
+ * "shapeOf": [0, 0, 1, 0],
37
+ * "values": [[1, "a"], [2, null], [3, "c", "x"], [4, "d"]] }
38
+ * ```
39
+ *
40
+ * `shapes` holds each **distinct key-set** in the batch, once. `shapeOf[i]`
41
+ * says which one row `i` uses, and `values[i]` runs parallel to it.
42
+ *
43
+ * A single global column list with padding would have been simpler and is
44
+ * wrong, because it has no way to say **absent**. The pipeline's rows are
45
+ * `Record<string, unknown>` and a transform may emit differing shapes; a row
46
+ * that lacks `note` and a row whose `note` is `null` are different facts, and a
47
+ * padded array collapses them into the same `null`. That difference decides
48
+ * whether a sink writes a column or leaves it alone, so collapsing it would
49
+ * change committed data silently. Every sentinel that could stand for "absent"
50
+ * inside a JSON array is also a value a row is entitled to hold, so there is no
51
+ * sentinel available; naming each row's own key-set is the encoding that has
52
+ * the distinction built in rather than bolted on.
53
+ *
54
+ * It also degrades gracefully rather than off a cliff. A batch of 500 rows with
55
+ * 500 different key-sets stores 500 key-sets — the same names a row-oriented
56
+ * encoding would have written, plus one small integer per row. A padded union
57
+ * column list, on the same batch, would store a 500-way-wider row for every row
58
+ * and be far *worse* than what it replaced.
59
+ *
60
+ * ## Key order
61
+ *
62
+ * Preserved exactly, which is **more** than the old encoding managed, and that
63
+ * was worth checking before changing anything.
64
+ *
65
+ * A MySQL `JSON` column does not store the text it was given. It stores a
66
+ * normalised binary document in which an object's members are sorted — by key
67
+ * length, then by bytes — so a row staged as `{zebra, a, Middle_Name, b}` reads
68
+ * back as `{a, b, zebra, Middle_Name}`. Verified against the local server, not
69
+ * assumed. Every staged row has therefore been coming back reordered since the
70
+ * stage existed.
71
+ *
72
+ * Here the names live in `shapes`, a JSON **array**, and arrays keep their
73
+ * order in the same binary format. So a decoded row enumerates in the order the
74
+ * producing node emitted it.
75
+ *
76
+ * Nothing downstream depended on either behaviour, which is why this is safe to
77
+ * change in the fidelity direction. The sink path picks columns from the object
78
+ * type's declared properties and reads each one by name — `MySqlWarehouseStore`
79
+ * builds its `INSERT` column list from `type.properties`, the ClickHouse store
80
+ * builds a fresh name-keyed record per row, and reads normalise back to
81
+ * declared order regardless of what went in. The three places in the codebase
82
+ * where a row's key order does decide something — schema discovery's proposed
83
+ * column order, `csvLines` called without an explicit column list, and the
84
+ * ClickHouse ad-hoc query fallback — none of them read a staged batch: schema
85
+ * discovery samples the connector's fetcher directly, and the export route
86
+ * passes its columns.
87
+ *
88
+ * ## `undefined`, functions, symbols
89
+ *
90
+ * Dropped, key and all — which is what `JSON.stringify` did to them under the
91
+ * old encoding, and what `codeContext` does deliberately for the same reason:
92
+ * `{"k": undefined}` and a missing `k` are the same thing through JSON and
93
+ * different things through a deep-equality check. So a key whose value is one
94
+ * of those three does not enter the row's shape, and the row decodes without
95
+ * it, exactly as it did before.
96
+ *
97
+ * The one case this does not reproduce is a value carrying a `toJSON()` that
98
+ * returns `undefined` — `JSON.stringify` drops that key, and here the key is in
99
+ * the shape and its value serialises to `null`. Reproducing it would mean
100
+ * invoking `toJSON` a second time, on caller code, to find out; the boundary is
101
+ * documented rather than papered over, and no source in this codebase produces
102
+ * such a value (rows arrive from SQL drivers, HTTP JSON, or a transform's
103
+ * return value).
104
+ */
105
+ Object.defineProperty(exports, "__esModule", { value: true });
106
+ exports.STAGE_ENCODING_VERSION = exports.STAGE_ENCODING = void 0;
107
+ exports.encodeStageRows = encodeStageRows;
108
+ exports.isColumnarStageBatch = isColumnarStageBatch;
109
+ exports.classifyStagePayload = classifyStagePayload;
110
+ exports.decodeStageRows = decodeStageRows;
111
+ /** The tag every batch written by this build carries. */
112
+ exports.STAGE_ENCODING = 'columnar';
113
+ /**
114
+ * Bumped when {@link ColumnarStageBatch} changes shape in a way an older reader
115
+ * would misread. An older pod meeting a newer version refuses loudly rather
116
+ * than decoding half of it — see {@link classifyStagePayload}.
117
+ */
118
+ exports.STAGE_ENCODING_VERSION = 1;
119
+ function isRowRecord(value) {
120
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
121
+ }
122
+ /**
123
+ * Whether `JSON.stringify` would keep a property holding this value.
124
+ *
125
+ * The three it silently drops. Kept in one predicate so the encoder and the
126
+ * documentation cannot drift apart about which three.
127
+ */
128
+ function isSerialisableValue(value) {
129
+ return value !== undefined && typeof value !== 'function' && typeof value !== 'symbol';
130
+ }
131
+ /** The keys that will survive serialisation, in the row's own order. */
132
+ function serialisableKeys(row) {
133
+ const keys = Object.keys(row);
134
+ // Scanned before filtering, so the overwhelmingly common batch — no
135
+ // `undefined` anywhere in it — pays one pass and allocates nothing extra.
136
+ for (const key of keys) {
137
+ if (!isSerialisableValue(row[key])) {
138
+ return keys.filter((each) => isSerialisableValue(row[each]));
139
+ }
140
+ }
141
+ return keys;
142
+ }
143
+ function sameKeys(left, right) {
144
+ if (left.length !== right.length)
145
+ return false;
146
+ for (let index = 0; index < left.length; index += 1) {
147
+ if (left[index] !== right[index])
148
+ return false;
149
+ }
150
+ return true;
151
+ }
152
+ /**
153
+ * Rows in, one columnar batch out.
154
+ *
155
+ * The last shape is remembered and compared before anything is hashed, because
156
+ * a batch whose rows all have the same keys — which is nearly every batch — then
157
+ * costs one array comparison per row and builds no fingerprint string at all.
158
+ * Only a row that differs from its predecessor pays for the dictionary lookup.
159
+ */
160
+ function encodeStageRows(rows) {
161
+ const shapes = [];
162
+ const byFingerprint = new Map();
163
+ const shapeOf = [];
164
+ const values = [];
165
+ let lastKeys;
166
+ let lastIndex = 0;
167
+ for (const row of rows) {
168
+ const keys = serialisableKeys(row);
169
+ let at;
170
+ if (lastKeys !== undefined && sameKeys(keys, lastKeys)) {
171
+ at = lastIndex;
172
+ }
173
+ else {
174
+ // The fingerprint is `JSON.stringify` of the key list rather than a join
175
+ // on some separator, because every separator is a character a property
176
+ // name is entitled to contain, and two different key-sets colliding into
177
+ // one fingerprint would hand one row's values another row's names.
178
+ const fingerprint = JSON.stringify(keys);
179
+ const known = byFingerprint.get(fingerprint);
180
+ if (known === undefined) {
181
+ at = shapes.length;
182
+ shapes.push(keys);
183
+ byFingerprint.set(fingerprint, at);
184
+ }
185
+ else {
186
+ at = known;
187
+ }
188
+ lastIndex = at;
189
+ lastKeys = shapes[at];
190
+ }
191
+ shapeOf.push(at);
192
+ const shape = lastKeys ?? keys;
193
+ const out = new Array(shape.length);
194
+ for (let index = 0; index < shape.length; index += 1) {
195
+ const name = shape[index];
196
+ out[index] = name === undefined ? null : row[name];
197
+ }
198
+ values.push(out);
199
+ }
200
+ return { enc: exports.STAGE_ENCODING, v: exports.STAGE_ENCODING_VERSION, shapes, shapeOf, values };
201
+ }
202
+ function isStringArray(value) {
203
+ return Array.isArray(value) && value.every((each) => typeof each === 'string');
204
+ }
205
+ /**
206
+ * A fully checked columnar batch — structure, and every index in range.
207
+ *
208
+ * Checked rather than trusted for the reason `readStage` always gave about the
209
+ * old encoding: this is JSON that came out of a database, and the alternative to
210
+ * validating it here is discovering it one `undefined` at a time somewhere that
211
+ * cannot say what went wrong. Everything the decoder indexes into is proved
212
+ * present by this function, which is why the decoder has no recovery paths.
213
+ */
214
+ function isColumnarStageBatch(value) {
215
+ if (!isRowRecord(value))
216
+ return false;
217
+ if (value.enc !== exports.STAGE_ENCODING)
218
+ return false;
219
+ if (value.v !== exports.STAGE_ENCODING_VERSION)
220
+ return false;
221
+ const { shapes, shapeOf, values: cells } = value;
222
+ if (!Array.isArray(shapes) || !shapes.every(isStringArray))
223
+ return false;
224
+ if (!Array.isArray(shapeOf) || !Array.isArray(cells))
225
+ return false;
226
+ if (shapeOf.length !== cells.length)
227
+ return false;
228
+ return shapeOf.every((at, index) => namesAShape(shapes, at) && fitsShape(shapes, at, cells[index]));
229
+ }
230
+ /** Row `index` points at a shape this batch actually carries. */
231
+ function namesAShape(shapes, at) {
232
+ return typeof at === 'number' && Number.isInteger(at) && shapes[at] !== undefined;
233
+ }
234
+ /** …and carries exactly one value per name in it. Not one fewer, not one more. */
235
+ function fitsShape(shapes, at, cells) {
236
+ const shape = shapes[at];
237
+ return shape !== undefined && Array.isArray(cells) && cells.length === shape.length;
238
+ }
239
+ /**
240
+ * Name what is in the column, or refuse.
241
+ *
242
+ * The two encodings are disjoint **JSON types** — the old writer only ever
243
+ * called `JSON.stringify` on an `Array<Record<string, unknown>>`, so anything it
244
+ * produced parses back as an array, and this one only ever writes a tagged
245
+ * object. A top-level JSON value cannot be both, so there is no batch for which
246
+ * both branches match and nothing here is inferred from the *contents* of a
247
+ * row. That is what makes the discrimination total rather than a guess: it does
248
+ * not ask what the rows look like, it asks which of two mutually exclusive JSON
249
+ * types the payload is, and then, for the object, reads the tag it was written
250
+ * with.
251
+ *
252
+ * Anything else throws, and does not return an empty batch. An unreadable stage
253
+ * decoded to zero rows would reach `runSink` as "this node produced nothing",
254
+ * and for a full load the sink's own guard turns that into a refusal — but for
255
+ * an *incremental* load it is a legitimate answer, and carry-forward would
256
+ * commit a snapshot that quietly dropped whatever the batch held. The loud
257
+ * failure is the cheap one.
258
+ */
259
+ function classifyStagePayload(stored) {
260
+ if (Array.isArray(stored))
261
+ return { encoding: 'row-oriented/v0', rows: stored };
262
+ if (isColumnarStageBatch(stored))
263
+ return { encoding: 'columnar/v1', batch: stored };
264
+ const tag = isRowRecord(stored) ? stored.enc : undefined;
265
+ const version = isRowRecord(stored) ? stored.v : undefined;
266
+ if (tag === exports.STAGE_ENCODING) {
267
+ throw new Error(`This staged batch is written in "${exports.STAGE_ENCODING}" encoding version ${String(version)}, and this build reads version ${exports.STAGE_ENCODING_VERSION}. It was written by a newer deployment; a rolled-back pod cannot resume a run that a newer one staged.`);
268
+ }
269
+ throw new Error(`A staged batch is in an encoding this build does not know: ${tag === undefined
270
+ ? `a JSON ${stored === null ? 'null' : typeof stored} with no "enc" tag`
271
+ : `"${String(tag)}"`}. A batch is either a JSON array (the original row-oriented encoding) or an object tagged "${exports.STAGE_ENCODING}".`);
272
+ }
273
+ /**
274
+ * Back into row records, whichever way the batch was written.
275
+ *
276
+ * The switch is exhaustive over {@link StagePayload} and the default branch
277
+ * assigns to `never`, so a third encoding added to that union stops the build
278
+ * here instead of falling through to a silent `[]`.
279
+ */
280
+ function decodeStageRows(stored) {
281
+ const payload = classifyStagePayload(stored);
282
+ switch (payload.encoding) {
283
+ case 'row-oriented/v0':
284
+ // Narrowed rather than trusted, exactly as the original reader did: a
285
+ // staged batch is JSON a transform produced, and anything that is not a
286
+ // plain object could not have been written as a row.
287
+ return payload.rows.filter(isRowRecord);
288
+ case 'columnar/v1':
289
+ return fromColumnar(payload.batch);
290
+ default: {
291
+ const unreachable = payload;
292
+ throw new Error(`Unhandled staged batch encoding: ${JSON.stringify(unreachable)}`);
293
+ }
294
+ }
295
+ }
296
+ function fromColumnar(batch) {
297
+ const rows = [];
298
+ for (const [index, at] of batch.shapeOf.entries()) {
299
+ const shape = batch.shapes[at];
300
+ const cells = batch.values[index];
301
+ if (shape === undefined || cells === undefined) {
302
+ // Unreachable: `isColumnarStageBatch` proved both present before this ran.
303
+ // Loud rather than skipped, because a row silently dropped here is a row
304
+ // the sink never sees and nobody ever counts.
305
+ throw new Error(`Staged row ${index} names shape ${at}, which this batch does not carry. The batch failed to validate after it had already validated.`);
306
+ }
307
+ const row = {};
308
+ for (const [column, name] of shape.entries()) {
309
+ row[name] = cells[column];
310
+ }
311
+ rows.push(row);
312
+ }
313
+ return rows;
314
+ }
package/dist/client.d.ts CHANGED
@@ -153,8 +153,8 @@ export declare const catalogRoutes: {
153
153
  readonly traces: () => string;
154
154
  readonly trace: (id: string) => string;
155
155
  };
156
- export type { CatalogConnection, CatalogConnector, ConnectionCheck, CatalogTransform, CatalogWorkflow, CatalogWorkflowCapabilities, ConnectorKind, ConnectorRun, TransformLanguage, TransformResult, CallableWorkflowRef, WorkflowCallEnvelope, WorkflowCallNode, WorkflowCallOutput, WorkflowEdge, WorkflowExecutionMode, WorkflowGraph, WorkflowIssueCode, WorkflowNode, WorkflowNodeKind, WorkflowNodeOutcome, WorkflowSinkNode, WorkflowSourceNode, WorkflowStageRef, WorkflowTransformNode, WorkflowValidationIssue, } from './catalog.pipeline';
157
- export { CONNECTOR_KINDS, isConnectorKind, isTransformLanguage, isWorkflowEdge, isWorkflowNode, TRANSFORM_LANGUAGES, readWorkflowCallOutput, WORKFLOW_CALL_CONTRACT, } from './catalog.pipeline';
156
+ export type { CatalogConnection, CatalogConnector, ConnectionCheck, CatalogTransform, CatalogWorkflow, CatalogWorkflowCapabilities, ConnectorKind, ConnectorRun, TransformLanguage, TransformResult, CallableWorkflowRef, WorkflowBranchLabel, WorkflowCallEnvelope, WorkflowCallNode, WorkflowCallOutput, WorkflowEdge, WorkflowExecutionMode, WorkflowFilterAll, WorkflowFilterAny, WorkflowFilterComparison, WorkflowFilterGroup, WorkflowFilterNode, WorkflowFilterOneOf, WorkflowFilterOperator, WorkflowFilterPredicate, WorkflowFilterPredicateKind, WorkflowFilterPresence, WorkflowFilterValue, WorkflowGraph, WorkflowEnvPredicate, WorkflowIfNode, WorkflowIfPredicate, WorkflowIssueCode, WorkflowNode, WorkflowNodeKind, WorkflowNodeOutcome, WorkflowPredicateKind, WorkflowRowCountPredicate, WorkflowRunOrderEntry, WorkflowSinkNode, WorkflowSkipReason, WorkflowSourceNode, WorkflowStageRef, WorkflowTransformNode, WorkflowValidationIssue, } from './catalog.pipeline';
157
+ export { CONNECTOR_KINDS, isConnectorKind, isTransformLanguage, isWorkflowEdge, isWorkflowNode, REDACTED_SECRET, TRANSFORM_LANGUAGES, readWorkflowCallOutput, WORKFLOW_CALL_CONTRACT, } from './catalog.pipeline';
158
158
  /**
159
159
  * The workflow validator, shipped to the browser deliberately.
160
160
  *
@@ -172,8 +172,18 @@ export { CONNECTOR_KINDS, isConnectorKind, isTransformLanguage, isWorkflowEdge,
172
172
  * `WORKFLOW_NODE_ID_PATTERN` and `WORKFLOW_NODE_KINDS` are what a palette and an
173
173
  * id field should be built from rather than from a second copy that drifts.
174
174
  */
175
- export { validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, WORKFLOW_STATUSES, workflowGraphHash, workflowRunOrder, isWorkflowExecutionMode, isWorkflowNodeKind, isWorkflowStatus, } from './catalog.pipeline';
176
- export type { WorkflowStatus } from './catalog.pipeline';
175
+ /**
176
+ * The canvas's geometry, from the same place the server lays a graph out.
177
+ *
178
+ * On this entry point rather than only on the package root because the two
179
+ * things that must agree are a browser component and a store: the node draws
180
+ * itself {@link WORKFLOW_NODE_WIDTH} wide, and `adoptConnector` spaces columns
181
+ * with {@link workflowColumnX}. A second copy of either number is how a graph
182
+ * ends up drawn with its boxes overlapping — which is exactly what happened.
183
+ */
184
+ export { WORKFLOW_COLUMN_GAP, WORKFLOW_NODE_HEIGHT, WORKFLOW_NODE_WIDTH, WORKFLOW_ROW_GAP, workflowColumnX, workflowRowY, } from './catalog.pipeline';
185
+ export { validateWorkflow, WORKFLOW_EXECUTION_MODES, WORKFLOW_ISSUE_CODES, WORKFLOW_NODE_ID_PATTERN, WORKFLOW_NODE_KINDS, WORKFLOW_BRANCH_LABELS, WORKFLOW_PREDICATE_KINDS, WORKFLOW_FILTER_COLUMN_PATTERN, WORKFLOW_FILTER_MAX_DEPTH, WORKFLOW_FILTER_MAX_VALUES, WORKFLOW_FILTER_OPERATORS, WORKFLOW_FILTER_PREDICATE_KINDS, WORKFLOW_SKIP_REASONS, isWorkflowBranchLabel, isWorkflowFilterOperator, isWorkflowFilterPredicate, isWorkflowFilterPredicateKind, isWorkflowFilterValue, isWorkflowIfPredicate, isWorkflowPredicateKind, isWorkflowSkipReason, workflowFilterMatches, workflowNarrowedTypes, workflowNodeRuns, unreachableFilterOperator, unreachableFilterPredicateKind, unreachableNodeKind, unreachablePredicateKind, WORKFLOW_STATUSES, workflowGraphHash, workflowRunOrder, isWorkflowExecutionMode, isWorkflowNodeKind, isWorkflowStatus, callableWorkflowBlock, } from './catalog.pipeline';
186
+ export type { CallableWorkflowBlock, CallableWorkflowDisagreement, WorkflowStatus, } from './catalog.pipeline';
177
187
  export type { CatalogTrace, CatalogTraceList, CatalogTraceOutcome, CatalogTraceSpan, TraceQuery, } from './catalog.workspace';
178
188
  export type { CatalogLoadExpectations, DeleteReconciliation, LoadExpectation, RowCountBound, StoredLoadExpectation, } from './catalog.pipeline';
179
189
  /**