tranfi 0.1.2 → 0.2.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/LICENSE +177 -0
- package/NOTICE +8 -0
- package/README.md +272 -40
- package/app/assets/{index-pDFMluyz.js → index-BIAIKnrp.js} +1 -1
- package/app/index.html +1 -1
- package/binding.gyp +55 -3
- package/csrc/arena.c +7 -5
- package/csrc/batch.c +818 -71
- package/csrc/buffer.c +84 -8
- package/csrc/cJSON.c +262 -19
- package/csrc/cJSON.h +17 -1
- package/csrc/codec_csv.c +1074 -181
- package/csrc/codec_jsonl.c +830 -118
- package/csrc/codec_table.c +108 -78
- package/csrc/codec_text.c +286 -68
- package/csrc/compiler.c +31 -3
- package/csrc/config.h +21 -0
- package/csrc/dsl.c +4722 -485
- package/csrc/expr.c +363 -55
- package/csrc/expr.h +2 -0
- package/csrc/internal.h +316 -27
- package/csrc/ir.c +65 -18
- package/csrc/ir.h +41 -0
- package/csrc/ir_schema.c +20 -5
- package/csrc/ir_serialize.c +68 -6
- package/csrc/ir_sql.c +796 -185
- package/csrc/ir_validate.c +462 -6
- package/csrc/json_path.c +210 -0
- package/csrc/main.c +879 -30
- package/csrc/memory_estimate.c +477 -0
- package/csrc/op_acf.c +171 -21
- package/csrc/op_across.c +477 -0
- package/csrc/op_anomaly.c +167 -32
- package/csrc/op_assert.c +761 -0
- package/csrc/op_bin.c +168 -29
- package/csrc/op_cast.c +383 -55
- package/csrc/op_clip.c +30 -19
- package/csrc/op_date_trunc.c +208 -34
- package/csrc/op_datetime.c +259 -77
- package/csrc/op_derive.c +65 -97
- package/csrc/op_diff.c +146 -30
- package/csrc/op_ewma.c +149 -30
- package/csrc/op_explode.c +124 -26
- package/csrc/op_fill_down.c +125 -53
- package/csrc/op_fill_null.c +176 -31
- package/csrc/op_filter.c +89 -40
- package/csrc/op_frequency.c +571 -43
- package/csrc/op_grep.c +36 -18
- package/csrc/op_group_agg.c +1790 -119
- package/csrc/op_hash.c +48 -15
- package/csrc/op_head.c +21 -86
- package/csrc/op_interpolate.c +268 -62
- package/csrc/op_join.c +2700 -182
- package/csrc/op_json_extract.c +227 -0
- package/csrc/op_json_filter.c +384 -0
- package/csrc/op_json_flatten.c +293 -0
- package/csrc/op_json_schema.c +503 -0
- package/csrc/op_label_encode.c +328 -53
- package/csrc/op_lag.c +181 -0
- package/csrc/op_lead.c +141 -89
- package/csrc/op_normalize.c +363 -79
- package/csrc/op_onehot.c +345 -73
- package/csrc/op_pivot.c +1546 -162
- package/csrc/op_quarantine.c +189 -0
- package/csrc/op_registry.c +2062 -166
- package/csrc/op_rename.c +41 -50
- package/csrc/op_replace.c +270 -118
- package/csrc/op_rleid.c +297 -0
- package/csrc/op_rowid.c +559 -0
- package/csrc/op_sample.c +80 -23
- package/csrc/op_schema.c +1341 -0
- package/csrc/op_schema_infer.c +252 -0
- package/csrc/op_select.c +265 -65
- package/csrc/op_set.c +3449 -0
- package/csrc/op_skip.c +30 -87
- package/csrc/op_sort.c +670 -124
- package/csrc/op_source_name.c +120 -0
- package/csrc/op_split.c +65 -28
- package/csrc/op_split_data.c +41 -9
- package/csrc/op_stack.c +178 -222
- package/csrc/op_stats.c +206 -110
- package/csrc/op_step.c +217 -55
- package/csrc/op_tail.c +21 -12
- package/csrc/op_tee.c +338 -0
- package/csrc/op_top.c +260 -53
- package/csrc/op_trim.c +48 -19
- package/csrc/op_unique.c +1193 -150
- package/csrc/op_unpivot.c +100 -66
- package/csrc/op_validate.c +601 -24
- package/csrc/op_window.c +492 -51
- package/csrc/path_policy.c +85 -0
- package/csrc/pipeline.c +872 -99
- package/csrc/recipes.c +3 -1
- package/csrc/report.c +73 -30
- package/csrc/selector.c +1097 -0
- package/csrc/size_utils.c +348 -0
- package/csrc/spill.c +317 -0
- package/csrc/spill.h +21 -0
- package/csrc/tranfi.h +169 -1
- package/csrc/transform.h +209 -0
- package/csrc/transform_api.c +2237 -0
- package/csrc/transform_categorical.c +923 -0
- package/csrc/transform_internal.h +472 -0
- package/csrc/transform_json.c +3812 -0
- package/csrc/transform_numeric.c +1966 -0
- package/csrc/transform_sha256.c +154 -0
- package/csrc/transform_wasm.h +162 -0
- package/csrc/transform_wasm_api.c +1373 -0
- package/csrc/wasm_api.c +70 -9
- package/napi_api.c +219 -11
- package/napi_transform.c +1648 -0
- package/napi_transform.h +8 -0
- package/package.json +27 -11
- package/scripts/install-native.js +76 -0
- package/scripts/prepack.js +64 -0
- package/scripts/sync-csrc.js +23 -0
- package/src/cli.js +8 -11
- package/src/engines/duckdb.js +45 -12
- package/src/index.js +661 -42
- package/src/memory_policy.js +411 -0
- package/src/native.js +1 -5
- package/src/pipeline.js +454 -31
- package/src/recipe_json.js +80 -0
- package/src/server.js +10 -8
- package/src/transform.js +403 -0
- package/src/transform_error.js +10 -0
- package/src/wasm.js +6 -4
- package/wasm/index.js +498 -10
- package/wasm/tranfi_core.js +0 -0
- package/wasm/transform.js +1156 -0
- package/wasm/worker.js +786 -0
- package/csrc/plan.c +0 -206
package/src/pipeline.js
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
* Tries native N-API addon first, falls back to WASM.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const { readFile, writeFile } = require('fs/promises')
|
|
8
|
+
const { createReadStream } = require('fs')
|
|
9
|
+
const { Readable } = require('stream')
|
|
10
|
+
const { createGunzip } = require('zlib')
|
|
11
|
+
const { finished } = require('stream/promises')
|
|
12
|
+
const nativeBinding = require('./native.js')
|
|
13
|
+
const { prepareNativePlan, validateNativeMemoryPolicy } = require('./memory_policy.js')
|
|
10
14
|
|
|
11
15
|
// Channel IDs (match tranfi.h)
|
|
12
16
|
const CHAN_MAIN = 0
|
|
@@ -16,12 +20,87 @@ const CHAN_SAMPLES = 3
|
|
|
16
20
|
|
|
17
21
|
const CHUNK_SIZE = 64 * 1024
|
|
18
22
|
|
|
23
|
+
const HOSTED_AUDIT_PRIVACY_OPS = new Set([
|
|
24
|
+
'codec.csv.decode',
|
|
25
|
+
'filter',
|
|
26
|
+
'validate',
|
|
27
|
+
'assert',
|
|
28
|
+
'schema',
|
|
29
|
+
'json-schema',
|
|
30
|
+
'fill-null',
|
|
31
|
+
'replace',
|
|
32
|
+
'cast',
|
|
33
|
+
'normalize',
|
|
34
|
+
'frequency',
|
|
35
|
+
'tee',
|
|
36
|
+
'quarantine'
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
function normalizeSqlDialect(options) {
|
|
40
|
+
let dialect = 'duckdb'
|
|
41
|
+
if (typeof options === 'string') {
|
|
42
|
+
dialect = options
|
|
43
|
+
} else if (options && typeof options === 'object') {
|
|
44
|
+
dialect = options.dialect || options.sqlDialect || dialect
|
|
45
|
+
} else if (options !== undefined && options !== null) {
|
|
46
|
+
throw new TypeError('compileToSql options must be an object or dialect string')
|
|
47
|
+
}
|
|
48
|
+
if (typeof dialect !== 'string') throw new TypeError('SQL dialect must be a string')
|
|
49
|
+
const name = dialect.toLowerCase()
|
|
50
|
+
if (name === 'duckdb') return name
|
|
51
|
+
if (name === 'sqlite' || name === 'postgres') {
|
|
52
|
+
throw new Error(`SQL dialect '${name}' is recognized but not implemented yet; only duckdb lowering is available`)
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`unknown SQL dialect '${dialect}' (expected duckdb, sqlite, or postgres)`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function applyHostedAuditDefaults(planJson) {
|
|
58
|
+
const plan = JSON.parse(planJson)
|
|
59
|
+
if (!plan || !Array.isArray(plan.steps)) return planJson
|
|
60
|
+
let changed = false
|
|
61
|
+
for (const step of plan.steps) {
|
|
62
|
+
if (!step || typeof step !== 'object' || !HOSTED_AUDIT_PRIVACY_OPS.has(step.op)) continue
|
|
63
|
+
if (!step.args || typeof step.args !== 'object' || Array.isArray(step.args)) step.args = {}
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(step.args, 'audit_include_row')) {
|
|
65
|
+
step.args.audit_include_row = false
|
|
66
|
+
changed = true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return changed ? JSON.stringify(plan) : planJson
|
|
70
|
+
}
|
|
71
|
+
|
|
19
72
|
/**
|
|
20
73
|
* Get the backend (native or WASM).
|
|
21
74
|
* Returns an object with: createPipeline, push, finish, pull, free, version
|
|
22
75
|
*/
|
|
23
76
|
let _backend = null
|
|
24
77
|
|
|
78
|
+
function effectiveHostPolicy({ allowFs, allowRulesFile = false, workspaceRoot, spillDir, allowBlocking = true } = {}) {
|
|
79
|
+
return {
|
|
80
|
+
allowFs: allowFs === undefined ? Boolean(spillDir) : Boolean(allowFs),
|
|
81
|
+
allowSpill: Boolean(spillDir),
|
|
82
|
+
allowRulesFile: Boolean(allowRulesFile),
|
|
83
|
+
allowBlocking: Boolean(allowBlocking),
|
|
84
|
+
workspaceRoot
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function planUsesHostPaths(planJson) {
|
|
89
|
+
try {
|
|
90
|
+
const plan = JSON.parse(planJson)
|
|
91
|
+
const steps = Array.isArray(plan.steps) ? plan.steps : []
|
|
92
|
+
return steps.some((step) => {
|
|
93
|
+
const args = step && step.args
|
|
94
|
+
if (!args || typeof args !== 'object') return false
|
|
95
|
+
return ['file', 'rules_file', 'rulesFile', 'spill_dir', 'spillDir'].some((key) =>
|
|
96
|
+
typeof args[key] === 'string' && args[key].length > 0
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
} catch (_) {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
25
104
|
async function getBackend() {
|
|
26
105
|
if (_backend) return _backend
|
|
27
106
|
|
|
@@ -32,10 +111,11 @@ async function getBackend() {
|
|
|
32
111
|
}
|
|
33
112
|
|
|
34
113
|
// Fall back to WASM
|
|
35
|
-
const { loadWasm } =
|
|
114
|
+
const { loadWasm } = require('./wasm.js')
|
|
36
115
|
const wasm = await loadWasm()
|
|
37
116
|
|
|
38
117
|
_backend = {
|
|
118
|
+
isWasm: true,
|
|
39
119
|
compileDsl(dsl) {
|
|
40
120
|
const len = wasm.lengthBytesUTF8(dsl)
|
|
41
121
|
const ptr = wasm._malloc(len + 1)
|
|
@@ -50,12 +130,33 @@ async function getBackend() {
|
|
|
50
130
|
wasm._free(resultPtr)
|
|
51
131
|
return json
|
|
52
132
|
},
|
|
53
|
-
createPipeline(planJson) {
|
|
133
|
+
createPipeline(planJson, options = {}) {
|
|
54
134
|
const len = wasm.lengthBytesUTF8(planJson)
|
|
55
135
|
const ptr = wasm._malloc(len + 1)
|
|
56
136
|
wasm.stringToUTF8(planJson, ptr, len + 1)
|
|
57
|
-
|
|
137
|
+
let rootPtr = 0
|
|
138
|
+
if (options.workspaceRoot) {
|
|
139
|
+
const root = String(options.workspaceRoot)
|
|
140
|
+
const rootLen = wasm.lengthBytesUTF8(root)
|
|
141
|
+
rootPtr = wasm._malloc(rootLen + 1)
|
|
142
|
+
wasm.stringToUTF8(root, rootPtr, rootLen + 1)
|
|
143
|
+
}
|
|
144
|
+
const hasPolicyCreate = typeof wasm._wasm_pipeline_create_with_policy === 'function'
|
|
145
|
+
const needsPolicyCreate = options.allowFs || options.allowSpill || options.allowRulesFile || rootPtr || planUsesHostPaths(planJson)
|
|
146
|
+
let handle = -1
|
|
147
|
+
if (hasPolicyCreate) {
|
|
148
|
+
handle = wasm.ccall('wasm_pipeline_create_with_policy', 'number',
|
|
149
|
+
['number', 'number', 'number', 'number', 'number', 'number'],
|
|
150
|
+
[ptr, len, options.allowFs ? 1 : 0, options.allowSpill ? 1 : 0,
|
|
151
|
+
options.allowRulesFile ? 1 : 0, rootPtr])
|
|
152
|
+
} else if (!needsPolicyCreate) {
|
|
153
|
+
handle = wasm.ccall('wasm_pipeline_create', 'number', ['number', 'number'], [ptr, len])
|
|
154
|
+
}
|
|
58
155
|
wasm._free(ptr)
|
|
156
|
+
if (rootPtr) wasm._free(rootPtr)
|
|
157
|
+
if (!hasPolicyCreate && needsPolicyCreate) {
|
|
158
|
+
throw new Error('Failed to create pipeline: WASM host policy export unavailable for this plan; rebuild tranfi_core.js')
|
|
159
|
+
}
|
|
59
160
|
if (handle < 0) {
|
|
60
161
|
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
61
162
|
throw new Error(`Failed to create pipeline: ${err || 'unknown error'}`)
|
|
@@ -73,6 +174,25 @@ async function getBackend() {
|
|
|
73
174
|
throw new Error(`Push failed: ${err || 'unknown error'}`)
|
|
74
175
|
}
|
|
75
176
|
},
|
|
177
|
+
flushInput(handle) {
|
|
178
|
+
const rc = wasm.ccall('wasm_pipeline_flush_input', 'number', ['number'], [handle])
|
|
179
|
+
if (rc !== 0) {
|
|
180
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
181
|
+
throw new Error(`Input flush failed: ${err || 'unknown error'}`)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
setSourceName(handle, name) {
|
|
185
|
+
const source = name == null ? '' : String(name)
|
|
186
|
+
const len = wasm.lengthBytesUTF8(source)
|
|
187
|
+
const ptr = wasm._malloc(len + 1)
|
|
188
|
+
wasm.stringToUTF8(source, ptr, len + 1)
|
|
189
|
+
const rc = wasm.ccall('wasm_pipeline_set_source_name', 'number', ['number', 'number'], [handle, ptr])
|
|
190
|
+
wasm._free(ptr)
|
|
191
|
+
if (rc !== 0) {
|
|
192
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
193
|
+
throw new Error(`Set source name failed: ${err || 'unknown error'}`)
|
|
194
|
+
}
|
|
195
|
+
},
|
|
76
196
|
finish(handle) {
|
|
77
197
|
const rc = wasm.ccall('wasm_pipeline_finish', 'number', ['number'], [handle])
|
|
78
198
|
if (rc !== 0) {
|
|
@@ -80,6 +200,14 @@ async function getBackend() {
|
|
|
80
200
|
throw new Error(`Finish failed: ${err || 'unknown error'}`)
|
|
81
201
|
}
|
|
82
202
|
},
|
|
203
|
+
finishStep(handle) {
|
|
204
|
+
const rc = wasm.ccall('wasm_pipeline_finish_step', 'number', ['number'], [handle])
|
|
205
|
+
if (rc < 0) {
|
|
206
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
207
|
+
throw new Error(`Finish failed: ${err || 'unknown error'}`)
|
|
208
|
+
}
|
|
209
|
+
return rc === 1
|
|
210
|
+
},
|
|
83
211
|
pull(handle, channel) {
|
|
84
212
|
const bufSize = 65536
|
|
85
213
|
const ptr = wasm._malloc(bufSize)
|
|
@@ -101,6 +229,15 @@ async function getBackend() {
|
|
|
101
229
|
}
|
|
102
230
|
return result
|
|
103
231
|
},
|
|
232
|
+
pullChunk(handle, channel, chunkSize = 65536) {
|
|
233
|
+
if (chunkSize <= 0) throw new Error('chunkSize must be positive')
|
|
234
|
+
const ptr = wasm._malloc(chunkSize)
|
|
235
|
+
const n = wasm.ccall('wasm_pipeline_pull', 'number',
|
|
236
|
+
['number', 'number', 'number', 'number'], [handle, channel, ptr, chunkSize])
|
|
237
|
+
const result = Buffer.from(new Uint8Array(wasm.HEAPU8.buffer, ptr, n))
|
|
238
|
+
wasm._free(ptr)
|
|
239
|
+
return result
|
|
240
|
+
},
|
|
104
241
|
free(handle) {
|
|
105
242
|
wasm.ccall('wasm_pipeline_free', null, ['number'], [handle])
|
|
106
243
|
},
|
|
@@ -147,7 +284,181 @@ async function getBackend() {
|
|
|
147
284
|
}
|
|
148
285
|
|
|
149
286
|
|
|
150
|
-
|
|
287
|
+
function asInputBuffer(input) {
|
|
288
|
+
if (Buffer.isBuffer(input)) return input
|
|
289
|
+
if (typeof input === 'string') return Buffer.from(input, 'utf-8')
|
|
290
|
+
if (input instanceof ArrayBuffer) return Buffer.from(new Uint8Array(input))
|
|
291
|
+
return Buffer.from(input)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasInput(value) {
|
|
295
|
+
return value !== undefined && value !== null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function assertSingleInputSource({ input, inputFile, inputFiles, inputStream }) {
|
|
299
|
+
const count = (hasInput(input) ? 1 : 0) + (hasInput(inputFile) ? 1 : 0) + (hasInput(inputFiles) ? 1 : 0) + (hasInput(inputStream) ? 1 : 0)
|
|
300
|
+
if (count > 1) throw new Error('pass only one of input, inputFile, inputFiles, or inputStream')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeInputFiles(inputFiles) {
|
|
304
|
+
if (!hasInput(inputFiles)) return null
|
|
305
|
+
if (typeof inputFiles === 'string' || Buffer.isBuffer(inputFiles)) {
|
|
306
|
+
throw new TypeError('inputFiles must be an iterable of paths, not a single path')
|
|
307
|
+
}
|
|
308
|
+
if (typeof inputFiles[Symbol.iterator] !== 'function') {
|
|
309
|
+
throw new TypeError('inputFiles must be an iterable of paths')
|
|
310
|
+
}
|
|
311
|
+
const files = Array.from(inputFiles)
|
|
312
|
+
if (files.length === 0) throw new Error('inputFiles must not be empty')
|
|
313
|
+
return files
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function injectSourceNameStep(planJson, sourceColumn) {
|
|
317
|
+
if (sourceColumn === undefined || sourceColumn === null) return planJson
|
|
318
|
+
const result = String(sourceColumn)
|
|
319
|
+
if (!result) throw new Error('sourceColumn must be a non-empty column name')
|
|
320
|
+
const plan = JSON.parse(planJson)
|
|
321
|
+
if (!plan || !Array.isArray(plan.steps)) throw new Error('plan must contain a steps array')
|
|
322
|
+
let insertAt = 0
|
|
323
|
+
if (plan.steps.length > 0) {
|
|
324
|
+
const op = plan.steps[0] && plan.steps[0].op
|
|
325
|
+
if (typeof op === 'string' && op.startsWith('codec.') && op.endsWith('.decode')) insertAt = 1
|
|
326
|
+
}
|
|
327
|
+
plan.steps.splice(insertAt, 0, { op: 'source-name', args: { result } })
|
|
328
|
+
return JSON.stringify(plan)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeCompression(compression = 'auto') {
|
|
332
|
+
const mode = compression == null ? 'auto' : String(compression).toLowerCase()
|
|
333
|
+
if (!['auto', 'none', 'gzip'].includes(mode)) {
|
|
334
|
+
throw new Error("compression must be 'auto', 'none', or 'gzip'")
|
|
335
|
+
}
|
|
336
|
+
return mode
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function resolveFileCompression(inputFile, compression) {
|
|
340
|
+
const mode = normalizeCompression(compression)
|
|
341
|
+
if (mode === 'auto') {
|
|
342
|
+
return String(inputFile).toLowerCase().endsWith('.gz') ? 'gzip' : 'none'
|
|
343
|
+
}
|
|
344
|
+
return mode
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function *yieldChunkItems(chunk, chunkSize, sourceName) {
|
|
348
|
+
const buf = asInputBuffer(chunk)
|
|
349
|
+
for (let i = 0; i < buf.length; i += chunkSize) {
|
|
350
|
+
yield { chunk: buf.subarray(i, Math.min(i + chunkSize, buf.length)), sourceName }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function *yieldFileItems(inputFile, chunkSize, requestedCompression) {
|
|
355
|
+
const sourceName = String(inputFile)
|
|
356
|
+
const fileCompression = resolveFileCompression(inputFile, requestedCompression)
|
|
357
|
+
let stream = createReadStream(inputFile, { highWaterMark: chunkSize })
|
|
358
|
+
if (fileCompression === 'gzip') stream = stream.pipe(createGunzip())
|
|
359
|
+
for await (const chunk of stream) {
|
|
360
|
+
yield * yieldChunkItems(chunk, chunkSize, sourceName)
|
|
361
|
+
}
|
|
362
|
+
yield { flushInput: true, sourceName }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function *iterInputChunks({ input, inputFile, inputFiles, inputStream, chunkSize, compression = 'auto' }) {
|
|
366
|
+
const files = normalizeInputFiles(inputFiles)
|
|
367
|
+
assertSingleInputSource({ input, inputFile, inputFiles: files, inputStream })
|
|
368
|
+
const requestedCompression = normalizeCompression(compression)
|
|
369
|
+
|
|
370
|
+
if (files) {
|
|
371
|
+
for (const file of files) yield * yieldFileItems(file, chunkSize, requestedCompression)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (hasInput(inputFile)) {
|
|
376
|
+
yield * yieldFileItems(inputFile, chunkSize, requestedCompression)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (hasInput(inputStream)) {
|
|
381
|
+
const isIterable = typeof inputStream[Symbol.asyncIterator] === 'function' || typeof inputStream[Symbol.iterator] === 'function'
|
|
382
|
+
if (!isIterable || typeof inputStream.pipe !== 'function' && requestedCompression === 'gzip') {
|
|
383
|
+
throw new TypeError('inputStream must be a Node.js stream or an iterable of chunks')
|
|
384
|
+
}
|
|
385
|
+
let stream = inputStream
|
|
386
|
+
if (requestedCompression === 'gzip') stream = inputStream.pipe(createGunzip())
|
|
387
|
+
for await (const chunk of stream) {
|
|
388
|
+
yield * yieldChunkItems(chunk, chunkSize)
|
|
389
|
+
}
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (requestedCompression === 'gzip') throw new Error('compression=gzip requires inputFile, inputFiles, or inputStream')
|
|
394
|
+
|
|
395
|
+
if (hasInput(input)) {
|
|
396
|
+
const buf = asInputBuffer(input)
|
|
397
|
+
for (let i = 0; i < buf.length; i += chunkSize) {
|
|
398
|
+
yield { chunk: buf.subarray(i, Math.min(i + chunkSize, buf.length)) }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function concatBuffers(chunks) {
|
|
404
|
+
if (chunks.length === 0) return Buffer.alloc(0)
|
|
405
|
+
return Buffer.concat(chunks)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function waitForWritableDrain(writable) {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
function cleanup() {
|
|
411
|
+
writable.off('drain', onDrain)
|
|
412
|
+
writable.off('error', onError)
|
|
413
|
+
}
|
|
414
|
+
function onDrain() {
|
|
415
|
+
cleanup()
|
|
416
|
+
resolve()
|
|
417
|
+
}
|
|
418
|
+
function onError(err) {
|
|
419
|
+
cleanup()
|
|
420
|
+
reject(err)
|
|
421
|
+
}
|
|
422
|
+
writable.once('drain', onDrain)
|
|
423
|
+
writable.once('error', onError)
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function writeWritableChunk(writable, chunk) {
|
|
428
|
+
if (chunk.length === 0) return
|
|
429
|
+
if (!writable.write(chunk)) await waitForWritableDrain(writable)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function pullOneChunk(backend, handle, channel, chunkSize) {
|
|
433
|
+
if (typeof backend.pullChunk === 'function') {
|
|
434
|
+
return Buffer.from(backend.pullChunk(handle, channel, chunkSize))
|
|
435
|
+
}
|
|
436
|
+
return Buffer.from(backend.pull(handle, channel))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput) {
|
|
440
|
+
for (;;) {
|
|
441
|
+
const chunk = pullOneChunk(backend, handle, CHAN_MAIN, chunkSize)
|
|
442
|
+
if (chunk.length === 0) break
|
|
443
|
+
if (onOutput) await onOutput(chunk)
|
|
444
|
+
if (collectOutput) outputChunks.push(chunk)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function finishAndDrainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput) {
|
|
449
|
+
if (typeof backend.finishStep === 'function') {
|
|
450
|
+
for (;;) {
|
|
451
|
+
const done = backend.finishStep(handle)
|
|
452
|
+
await drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
453
|
+
if (done) break
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
backend.finish(handle)
|
|
457
|
+
await drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
class PipelineResult {
|
|
151
462
|
constructor(output, errors, stats, samples) {
|
|
152
463
|
this.output = output
|
|
153
464
|
this.errors = errors
|
|
@@ -169,7 +480,7 @@ export class PipelineResult {
|
|
|
169
480
|
}
|
|
170
481
|
|
|
171
482
|
|
|
172
|
-
|
|
483
|
+
class Pipeline {
|
|
173
484
|
constructor(steps, { planJson, engine } = {}) {
|
|
174
485
|
this._engine = engine || null
|
|
175
486
|
if (planJson) {
|
|
@@ -201,31 +512,48 @@ export class Pipeline {
|
|
|
201
512
|
return JSON.stringify({ steps: this._steps })
|
|
202
513
|
}
|
|
203
514
|
|
|
204
|
-
async run({ input, inputFile } = {}) {
|
|
515
|
+
async run({ input, inputFile, inputFiles, inputStream, sourceColumn, onOutput, collectOutput = true, chunkSize = CHUNK_SIZE, allowBlocking = false, memory, spillDir, allowFs, allowRulesFile = false, workspaceRoot, compression = 'auto', hostedAuditDefaults = false } = {}) {
|
|
516
|
+
if (chunkSize <= 0) throw new Error('chunkSize must be positive')
|
|
517
|
+
if (sourceColumn !== undefined && sourceColumn !== null && !hasInput(inputFile) && !hasInput(inputFiles)) {
|
|
518
|
+
throw new Error('sourceColumn requires inputFile or inputFiles')
|
|
519
|
+
}
|
|
520
|
+
if (onOutput !== undefined && typeof onOutput !== 'function') {
|
|
521
|
+
throw new TypeError('onOutput must be a function')
|
|
522
|
+
}
|
|
205
523
|
if (this._engine && this._engine !== 'native') {
|
|
206
|
-
|
|
524
|
+
if (onOutput || collectOutput === false || hasInput(inputStream) || hasInput(inputFiles) || sourceColumn !== undefined && sourceColumn !== null) {
|
|
525
|
+
throw new Error('streaming input/output, inputFiles, and sourceColumn require the native or WASM backend')
|
|
526
|
+
}
|
|
527
|
+
return this._runEngine({ input, inputFile, memory, spillDir })
|
|
207
528
|
}
|
|
208
529
|
|
|
209
530
|
const backend = await getBackend()
|
|
210
|
-
|
|
211
|
-
|
|
531
|
+
if (spillDir && backend.isWasm) {
|
|
532
|
+
throw new Error('spillDir is not supported by WASM native execution because browser/WASM spill storage would occupy WASM memory; use the Node native addon, CLI/direct C, or DuckDB')
|
|
533
|
+
}
|
|
534
|
+
let planJson = await this._toPlanJson(backend)
|
|
535
|
+
planJson = injectSourceNameStep(planJson, sourceColumn)
|
|
536
|
+
if (hostedAuditDefaults) planJson = applyHostedAuditDefaults(planJson)
|
|
537
|
+
planJson = prepareNativePlan(planJson, { allowBlocking, memory, spillDir })
|
|
538
|
+
const handle = backend.createPipeline(planJson, effectiveHostPolicy({ allowFs, allowRulesFile, workspaceRoot, spillDir, allowBlocking: true }))
|
|
539
|
+
const outputChunks = []
|
|
212
540
|
|
|
213
541
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
backend
|
|
542
|
+
for await (const item of iterInputChunks({ input, inputFile, inputFiles, inputStream, chunkSize, compression })) {
|
|
543
|
+
if (item.sourceName !== undefined && typeof backend.setSourceName === 'function') backend.setSourceName(handle, item.sourceName)
|
|
544
|
+
if (item.flushInput) {
|
|
545
|
+
if (typeof backend.flushInput !== 'function') throw new Error('inputFiles require backend.flushInput')
|
|
546
|
+
backend.flushInput(handle)
|
|
547
|
+
await drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
548
|
+
continue
|
|
220
549
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
backend.push(handle, buf)
|
|
550
|
+
backend.push(handle, item.chunk)
|
|
551
|
+
await drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
224
552
|
}
|
|
225
553
|
|
|
226
|
-
backend
|
|
554
|
+
await finishAndDrainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
227
555
|
|
|
228
|
-
const output =
|
|
556
|
+
const output = collectOutput ? concatBuffers(outputChunks) : Buffer.alloc(0)
|
|
229
557
|
const errors = backend.pull(handle, CHAN_ERRORS)
|
|
230
558
|
const stats = backend.pull(handle, CHAN_STATS)
|
|
231
559
|
const samples = backend.pull(handle, CHAN_SAMPLES)
|
|
@@ -236,26 +564,111 @@ export class Pipeline {
|
|
|
236
564
|
}
|
|
237
565
|
}
|
|
238
566
|
|
|
239
|
-
async
|
|
240
|
-
|
|
567
|
+
async *iterChunks({ input, inputFile, inputFiles, inputStream, sourceColumn, chunkSize = CHUNK_SIZE, allowBlocking = false, memory, spillDir, allowFs, allowRulesFile = false, workspaceRoot, compression = 'auto' } = {}) {
|
|
568
|
+
if (chunkSize <= 0) throw new Error('chunkSize must be positive')
|
|
569
|
+
if (sourceColumn !== undefined && sourceColumn !== null && !hasInput(inputFile) && !hasInput(inputFiles)) {
|
|
570
|
+
throw new Error('sourceColumn requires inputFile or inputFiles')
|
|
571
|
+
}
|
|
572
|
+
if (this._engine && this._engine !== 'native') {
|
|
573
|
+
throw new Error('streaming chunk iteration requires the native or WASM backend')
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const backend = await getBackend()
|
|
577
|
+
if (spillDir && backend.isWasm) {
|
|
578
|
+
throw new Error('spillDir is not supported by WASM native execution because browser/WASM spill storage would occupy WASM memory; use the Node native addon, CLI/direct C, or DuckDB')
|
|
579
|
+
}
|
|
580
|
+
let planJson = await this._toPlanJson(backend)
|
|
581
|
+
planJson = injectSourceNameStep(planJson, sourceColumn)
|
|
582
|
+
planJson = prepareNativePlan(planJson, { allowBlocking, memory, spillDir })
|
|
583
|
+
const handle = backend.createPipeline(planJson, effectiveHostPolicy({ allowFs, allowRulesFile, workspaceRoot, spillDir, allowBlocking: true }))
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
for await (const item of iterInputChunks({ input, inputFile, inputFiles, inputStream, chunkSize, compression })) {
|
|
587
|
+
if (item.sourceName !== undefined && typeof backend.setSourceName === 'function') backend.setSourceName(handle, item.sourceName)
|
|
588
|
+
if (item.flushInput) {
|
|
589
|
+
if (typeof backend.flushInput !== 'function') throw new Error('inputFiles require backend.flushInput')
|
|
590
|
+
backend.flushInput(handle)
|
|
591
|
+
} else {
|
|
592
|
+
backend.push(handle, item.chunk)
|
|
593
|
+
}
|
|
594
|
+
for (;;) {
|
|
595
|
+
const out = pullOneChunk(backend, handle, CHAN_MAIN, chunkSize)
|
|
596
|
+
if (out.length === 0) break
|
|
597
|
+
yield out
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (typeof backend.finishStep === 'function') {
|
|
602
|
+
for (;;) {
|
|
603
|
+
const done = backend.finishStep(handle)
|
|
604
|
+
for (;;) {
|
|
605
|
+
const out = pullOneChunk(backend, handle, CHAN_MAIN, chunkSize)
|
|
606
|
+
if (out.length === 0) break
|
|
607
|
+
yield out
|
|
608
|
+
}
|
|
609
|
+
if (done) break
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
backend.finish(handle)
|
|
613
|
+
for (;;) {
|
|
614
|
+
const out = pullOneChunk(backend, handle, CHAN_MAIN, chunkSize)
|
|
615
|
+
if (out.length === 0) break
|
|
616
|
+
yield out
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} finally {
|
|
620
|
+
backend.free(handle)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
toReadable({ highWaterMark, ...options } = {}) {
|
|
625
|
+
const streamOptions = { objectMode: false }
|
|
626
|
+
if (highWaterMark !== undefined) streamOptions.highWaterMark = highWaterMark
|
|
627
|
+
return Readable.from(this.iterChunks(options), streamOptions)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async writeTo(writable, { end = true, ...options } = {}) {
|
|
631
|
+
if (!writable || typeof writable.write !== 'function') {
|
|
632
|
+
throw new TypeError('writable must be a Node.js Writable stream')
|
|
633
|
+
}
|
|
634
|
+
const result = await this.run({
|
|
635
|
+
...options,
|
|
636
|
+
collectOutput: false,
|
|
637
|
+
onOutput: async chunk => {
|
|
638
|
+
await writeWritableChunk(writable, chunk)
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
if (end && typeof writable.end === 'function') {
|
|
642
|
+
writable.end()
|
|
643
|
+
await finished(writable)
|
|
644
|
+
}
|
|
645
|
+
return result
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async _runEngine({ input, inputFile, inputFiles, sourceColumn, memory, spillDir } = {}) {
|
|
649
|
+
if (hasInput(inputFiles) || sourceColumn !== undefined && sourceColumn !== null) {
|
|
650
|
+
throw new Error('inputFiles and sourceColumn require the native or WASM backend')
|
|
651
|
+
}
|
|
652
|
+
const { getEngine } = require('./engines/duckdb.js')
|
|
241
653
|
const engine = getEngine(this._engine)
|
|
242
654
|
const dsl = this._dsl
|
|
243
655
|
if (!dsl) throw new Error('DuckDB engine requires a DSL string pipeline')
|
|
244
|
-
return engine.run(dsl, { input, inputFile })
|
|
656
|
+
return engine.run(dsl, { input, inputFile, memory, spillDir })
|
|
245
657
|
}
|
|
246
658
|
}
|
|
247
659
|
|
|
248
|
-
|
|
660
|
+
async function compileDsl(dsl) {
|
|
249
661
|
const backend = await getBackend()
|
|
250
662
|
return backend.compileDsl(dsl)
|
|
251
663
|
}
|
|
252
664
|
|
|
253
|
-
|
|
665
|
+
async function compileToSql(dsl, options) {
|
|
666
|
+
normalizeSqlDialect(options)
|
|
254
667
|
const backend = await getBackend()
|
|
255
668
|
return backend.compileToSql(dsl)
|
|
256
669
|
}
|
|
257
670
|
|
|
258
|
-
|
|
671
|
+
async function loadRecipe(source) {
|
|
259
672
|
let planJson
|
|
260
673
|
if (typeof source === 'object' && source.steps) {
|
|
261
674
|
planJson = JSON.stringify(source)
|
|
@@ -267,11 +680,11 @@ export async function loadRecipe(source) {
|
|
|
267
680
|
return new Pipeline(null, { planJson })
|
|
268
681
|
}
|
|
269
682
|
|
|
270
|
-
|
|
683
|
+
async function saveRecipe(steps, path) {
|
|
271
684
|
await writeFile(path, JSON.stringify({ steps }))
|
|
272
685
|
}
|
|
273
686
|
|
|
274
|
-
|
|
687
|
+
async function recipes() {
|
|
275
688
|
const backend = await getBackend()
|
|
276
689
|
const n = backend.recipeCount()
|
|
277
690
|
const result = []
|
|
@@ -284,3 +697,13 @@ export async function recipes() {
|
|
|
284
697
|
}
|
|
285
698
|
return result
|
|
286
699
|
}
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
Pipeline,
|
|
703
|
+
PipelineResult,
|
|
704
|
+
compileDsl,
|
|
705
|
+
compileToSql,
|
|
706
|
+
loadRecipe,
|
|
707
|
+
saveRecipe,
|
|
708
|
+
recipes
|
|
709
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { TranfiTransformError } = require('./transform_error.js')
|
|
4
|
+
|
|
5
|
+
// The replacer runs before JSON.stringify emits each value. Charge the exact
|
|
6
|
+
// UTF-8 wire size there, so neither a giant string nor many small fields can
|
|
7
|
+
// allocate an over-budget JSON result. Getters/toJSON are evaluated only once.
|
|
8
|
+
function stringifyRecipe(recipe, limits) {
|
|
9
|
+
const limit = Math.min(limits.maxRecipeBytes, limits.maxAllocationBytes - 1)
|
|
10
|
+
let bytes = 0
|
|
11
|
+
const parents = []
|
|
12
|
+
const counts = []
|
|
13
|
+
function charge(n) {
|
|
14
|
+
bytes += n
|
|
15
|
+
if (bytes > limit) {
|
|
16
|
+
throw new TranfiTransformError(104, 'recipe exceeds its configured byte limit')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function string(value) {
|
|
20
|
+
charge(2)
|
|
21
|
+
for (let i = 0; i < value.length; i++) {
|
|
22
|
+
const c = value.charCodeAt(i)
|
|
23
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
24
|
+
const next = value.charCodeAt(++i)
|
|
25
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
26
|
+
throw new TypeError('recipe must not contain an unpaired UTF-16 surrogate')
|
|
27
|
+
}
|
|
28
|
+
charge(4)
|
|
29
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
30
|
+
throw new TypeError('recipe must not contain an unpaired UTF-16 surrogate')
|
|
31
|
+
} else if (c === 34 || c === 92 || (c >= 8 && c <= 10) || c === 12 || c === 13) charge(2)
|
|
32
|
+
else if (c < 32) charge(6)
|
|
33
|
+
else charge(c < 128 ? 1 : c < 2048 ? 2 : 3)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(recipe, function(key, value) {
|
|
37
|
+
while (parents.length && parents[parents.length - 1] !== this) {
|
|
38
|
+
parents.pop()
|
|
39
|
+
counts.pop()
|
|
40
|
+
}
|
|
41
|
+
// JSON's boxed primitives are scalars. Use its conversion semantics before
|
|
42
|
+
// counting; returning the scalar prevents a second custom coercion.
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
let boxed = false
|
|
45
|
+
try { Number.prototype.valueOf.call(value); boxed = true } catch (_) {}
|
|
46
|
+
if (boxed) value = Number(value)
|
|
47
|
+
else {
|
|
48
|
+
try { String.prototype.valueOf.call(value); boxed = true } catch (_) {}
|
|
49
|
+
if (boxed) value = String(value)
|
|
50
|
+
else {
|
|
51
|
+
try { value = Boolean.prototype.valueOf.call(value) } catch (_) {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const type = typeof value
|
|
56
|
+
const omitted = value === undefined || type === 'function' || type === 'symbol'
|
|
57
|
+
if (omitted && !Array.isArray(this)) return undefined
|
|
58
|
+
if (parents.length) {
|
|
59
|
+
const last = parents.length - 1
|
|
60
|
+
if (counts[last]++) charge(1)
|
|
61
|
+
if (!Array.isArray(this)) { string(key); charge(1) }
|
|
62
|
+
}
|
|
63
|
+
if (value === null || omitted) charge(4)
|
|
64
|
+
else if (type === 'string') string(value)
|
|
65
|
+
else if (type === 'number') charge(Number.isFinite(value) ? String(value).length : 4)
|
|
66
|
+
else if (type === 'boolean') charge(value ? 4 : 5)
|
|
67
|
+
else if (type === 'object') {
|
|
68
|
+
if (parents.includes(value)) throw new TypeError('circular recipe')
|
|
69
|
+
if (parents.length + 1 > limits.maxJsonDepth) {
|
|
70
|
+
throw new TranfiTransformError(104, 'recipe exceeds its configured depth limit')
|
|
71
|
+
}
|
|
72
|
+
charge(2)
|
|
73
|
+
parents.push(value)
|
|
74
|
+
counts.push(0)
|
|
75
|
+
}
|
|
76
|
+
return value
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { stringifyRecipe }
|