@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.
- package/dist/catalog.events.d.ts +14 -0
- package/dist/catalog.pipeline.d.ts +1124 -75
- package/dist/catalog.pipeline.js +1150 -14
- package/dist/catalog.stage-encoding.d.ts +185 -0
- package/dist/catalog.stage-encoding.js +314 -0
- package/dist/client.d.ts +14 -4
- package/dist/client.js +85 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +48 -3
- package/dist/transform-runner.d.ts +19 -1
- package/dist/transform-runner.js +63 -7
- package/package.json +6 -6
- package/LICENSE +0 -21
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
/**
|