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.
Files changed (132) hide show
  1. package/LICENSE +177 -0
  2. package/NOTICE +8 -0
  3. package/README.md +272 -40
  4. package/app/assets/{index-pDFMluyz.js → index-BIAIKnrp.js} +1 -1
  5. package/app/index.html +1 -1
  6. package/binding.gyp +55 -3
  7. package/csrc/arena.c +7 -5
  8. package/csrc/batch.c +818 -71
  9. package/csrc/buffer.c +84 -8
  10. package/csrc/cJSON.c +262 -19
  11. package/csrc/cJSON.h +17 -1
  12. package/csrc/codec_csv.c +1074 -181
  13. package/csrc/codec_jsonl.c +830 -118
  14. package/csrc/codec_table.c +108 -78
  15. package/csrc/codec_text.c +286 -68
  16. package/csrc/compiler.c +31 -3
  17. package/csrc/config.h +21 -0
  18. package/csrc/dsl.c +4722 -485
  19. package/csrc/expr.c +363 -55
  20. package/csrc/expr.h +2 -0
  21. package/csrc/internal.h +316 -27
  22. package/csrc/ir.c +65 -18
  23. package/csrc/ir.h +41 -0
  24. package/csrc/ir_schema.c +20 -5
  25. package/csrc/ir_serialize.c +68 -6
  26. package/csrc/ir_sql.c +796 -185
  27. package/csrc/ir_validate.c +462 -6
  28. package/csrc/json_path.c +210 -0
  29. package/csrc/main.c +879 -30
  30. package/csrc/memory_estimate.c +477 -0
  31. package/csrc/op_acf.c +171 -21
  32. package/csrc/op_across.c +477 -0
  33. package/csrc/op_anomaly.c +167 -32
  34. package/csrc/op_assert.c +761 -0
  35. package/csrc/op_bin.c +168 -29
  36. package/csrc/op_cast.c +383 -55
  37. package/csrc/op_clip.c +30 -19
  38. package/csrc/op_date_trunc.c +208 -34
  39. package/csrc/op_datetime.c +259 -77
  40. package/csrc/op_derive.c +65 -97
  41. package/csrc/op_diff.c +146 -30
  42. package/csrc/op_ewma.c +149 -30
  43. package/csrc/op_explode.c +124 -26
  44. package/csrc/op_fill_down.c +125 -53
  45. package/csrc/op_fill_null.c +176 -31
  46. package/csrc/op_filter.c +89 -40
  47. package/csrc/op_frequency.c +571 -43
  48. package/csrc/op_grep.c +36 -18
  49. package/csrc/op_group_agg.c +1790 -119
  50. package/csrc/op_hash.c +48 -15
  51. package/csrc/op_head.c +21 -86
  52. package/csrc/op_interpolate.c +268 -62
  53. package/csrc/op_join.c +2700 -182
  54. package/csrc/op_json_extract.c +227 -0
  55. package/csrc/op_json_filter.c +384 -0
  56. package/csrc/op_json_flatten.c +293 -0
  57. package/csrc/op_json_schema.c +503 -0
  58. package/csrc/op_label_encode.c +328 -53
  59. package/csrc/op_lag.c +181 -0
  60. package/csrc/op_lead.c +141 -89
  61. package/csrc/op_normalize.c +363 -79
  62. package/csrc/op_onehot.c +345 -73
  63. package/csrc/op_pivot.c +1546 -162
  64. package/csrc/op_quarantine.c +189 -0
  65. package/csrc/op_registry.c +2062 -166
  66. package/csrc/op_rename.c +41 -50
  67. package/csrc/op_replace.c +270 -118
  68. package/csrc/op_rleid.c +297 -0
  69. package/csrc/op_rowid.c +559 -0
  70. package/csrc/op_sample.c +80 -23
  71. package/csrc/op_schema.c +1341 -0
  72. package/csrc/op_schema_infer.c +252 -0
  73. package/csrc/op_select.c +265 -65
  74. package/csrc/op_set.c +3449 -0
  75. package/csrc/op_skip.c +30 -87
  76. package/csrc/op_sort.c +670 -124
  77. package/csrc/op_source_name.c +120 -0
  78. package/csrc/op_split.c +65 -28
  79. package/csrc/op_split_data.c +41 -9
  80. package/csrc/op_stack.c +178 -222
  81. package/csrc/op_stats.c +206 -110
  82. package/csrc/op_step.c +217 -55
  83. package/csrc/op_tail.c +21 -12
  84. package/csrc/op_tee.c +338 -0
  85. package/csrc/op_top.c +260 -53
  86. package/csrc/op_trim.c +48 -19
  87. package/csrc/op_unique.c +1193 -150
  88. package/csrc/op_unpivot.c +100 -66
  89. package/csrc/op_validate.c +601 -24
  90. package/csrc/op_window.c +492 -51
  91. package/csrc/path_policy.c +85 -0
  92. package/csrc/pipeline.c +872 -99
  93. package/csrc/recipes.c +3 -1
  94. package/csrc/report.c +73 -30
  95. package/csrc/selector.c +1097 -0
  96. package/csrc/size_utils.c +348 -0
  97. package/csrc/spill.c +317 -0
  98. package/csrc/spill.h +21 -0
  99. package/csrc/tranfi.h +169 -1
  100. package/csrc/transform.h +209 -0
  101. package/csrc/transform_api.c +2237 -0
  102. package/csrc/transform_categorical.c +923 -0
  103. package/csrc/transform_internal.h +472 -0
  104. package/csrc/transform_json.c +3812 -0
  105. package/csrc/transform_numeric.c +1966 -0
  106. package/csrc/transform_sha256.c +154 -0
  107. package/csrc/transform_wasm.h +162 -0
  108. package/csrc/transform_wasm_api.c +1373 -0
  109. package/csrc/wasm_api.c +70 -9
  110. package/napi_api.c +219 -11
  111. package/napi_transform.c +1648 -0
  112. package/napi_transform.h +8 -0
  113. package/package.json +27 -11
  114. package/scripts/install-native.js +76 -0
  115. package/scripts/prepack.js +64 -0
  116. package/scripts/sync-csrc.js +23 -0
  117. package/src/cli.js +8 -11
  118. package/src/engines/duckdb.js +45 -12
  119. package/src/index.js +661 -42
  120. package/src/memory_policy.js +411 -0
  121. package/src/native.js +1 -5
  122. package/src/pipeline.js +454 -31
  123. package/src/recipe_json.js +80 -0
  124. package/src/server.js +10 -8
  125. package/src/transform.js +403 -0
  126. package/src/transform_error.js +10 -0
  127. package/src/wasm.js +6 -4
  128. package/wasm/index.js +498 -10
  129. package/wasm/tranfi_core.js +0 -0
  130. package/wasm/transform.js +1156 -0
  131. package/wasm/worker.js +786 -0
  132. 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
- import { readFile, writeFile } from 'fs/promises'
8
- import { existsSync } from 'fs'
9
- import nativeBinding from './native.js'
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 } = await import('./wasm.js')
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
- const handle = wasm.ccall('wasm_pipeline_create', 'number', ['number', 'number'], [ptr, len])
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
- export class PipelineResult {
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
- export class Pipeline {
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
- return this._runEngine({ input, inputFile })
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
- const planJson = await this._toPlanJson(backend)
211
- const handle = backend.createPipeline(planJson)
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
- if (inputFile) {
215
- const data = await readFile(inputFile)
216
- // Push in chunks
217
- for (let i = 0; i < data.length; i += CHUNK_SIZE) {
218
- const chunk = data.subarray(i, Math.min(i + CHUNK_SIZE, data.length))
219
- backend.push(handle, chunk)
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
- } else if (input) {
222
- const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : input
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.finish(handle)
554
+ await finishAndDrainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
227
555
 
228
- const output = backend.pull(handle, CHAN_MAIN)
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 _runEngine({ input, inputFile } = {}) {
240
- const { getEngine } = await import('./engines/duckdb.js')
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
- export async function compileDsl(dsl) {
660
+ async function compileDsl(dsl) {
249
661
  const backend = await getBackend()
250
662
  return backend.compileDsl(dsl)
251
663
  }
252
664
 
253
- export async function compileToSql(dsl) {
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
- export async function loadRecipe(source) {
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
- export async function saveRecipe(steps, path) {
683
+ async function saveRecipe(steps, path) {
271
684
  await writeFile(path, JSON.stringify({ steps }))
272
685
  }
273
686
 
274
- export async function recipes() {
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 }