tranfi 0.1.2 → 0.2.1

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 +443 -51
  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 +352 -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 +81 -41
  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
@@ -0,0 +1,8 @@
1
+ #ifndef TRANFI_NAPI_TRANSFORM_H
2
+ #define TRANFI_NAPI_TRANSFORM_H
3
+
4
+ #include <node_api.h>
5
+
6
+ napi_status tranfi_napi_define_transform(napi_env env, napi_value exports);
7
+
8
+ #endif
package/package.json CHANGED
@@ -1,15 +1,25 @@
1
1
  {
2
2
  "name": "tranfi",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Streaming ETL language + runtime",
5
- "type": "module",
5
+ "type": "commonjs",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "tranfi": "src/cli.js"
9
9
  },
10
10
  "exports": {
11
- ".": "./src/index.js",
12
- "./wasm": "./wasm/index.js"
11
+ ".": {
12
+ "require": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./wasm": {
16
+ "require": "./wasm/index.js",
17
+ "default": "./wasm/index.js"
18
+ },
19
+ "./wasm/worker": {
20
+ "require": "./wasm/worker.js",
21
+ "default": "./wasm/worker.js"
22
+ }
13
23
  },
14
24
  "files": [
15
25
  "src/",
@@ -17,14 +27,19 @@
17
27
  "csrc/",
18
28
  "app/",
19
29
  "napi_api.c",
30
+ "napi_transform.c",
31
+ "napi_transform.h",
20
32
  "binding.gyp",
21
- "README.md"
33
+ "scripts/",
34
+ "README.md",
35
+ "LICENSE",
36
+ "NOTICE"
22
37
  ],
23
38
  "scripts": {
24
- "prepack": "rm -rf csrc && mkdir -p csrc && cp ../src/*.c ../src/*.h csrc/ && rm -rf app && cp -r ../app/dist app && rm -rf app/wasm app/lib",
25
- "install": "node-gyp rebuild 2>/dev/null || true",
26
- "build:native": "rm -rf csrc && mkdir -p csrc && cp ../src/*.c ../src/*.h csrc/ && node-gyp rebuild",
27
- "test": "node ../test/test_node.js"
39
+ "prepack": "node scripts/prepack.js",
40
+ "install": "node scripts/install-native.js",
41
+ "build:native": "node scripts/sync-csrc.js && node-gyp rebuild",
42
+ "test": "node --test ../test/test_install_native.js ../test/test_cli_node.js && node ../test/test_node.js && node ../test/test_transform_node.js && node ../test/test_transform_wasm_node.js && node ../test/test_transform_worker_node.js"
28
43
  },
29
44
  "peerDependencies": {
30
45
  "duckdb": ">=1.0.0",
@@ -46,12 +61,13 @@
46
61
  "pipeline",
47
62
  "transform"
48
63
  ],
49
- "license": "MIT",
64
+ "license": "Apache-2.0",
50
65
  "repository": {
51
66
  "type": "git",
52
67
  "url": "https://github.com/tranfi/tranfi"
53
68
  },
54
69
  "devDependencies": {
55
70
  "duckdb": "^1.4.4"
56
- }
71
+ },
72
+ "author": "Anton Zemlyansky"
57
73
  }
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ const { spawnSync } = require('child_process')
4
+ const path = require('path')
5
+
6
+ function describeFailure(label, result, logger) {
7
+ if (result && result.error) {
8
+ logger.error(`tranfi: ${label} failed: ${result.error.message}`)
9
+ } else if (result && result.signal) {
10
+ logger.error(`tranfi: ${label} terminated by signal ${result.signal}`)
11
+ } else {
12
+ logger.error(`tranfi: ${label} exited with status ${result ? result.status : 'unknown'}`)
13
+ }
14
+ }
15
+
16
+ function successful(result) {
17
+ return Boolean(result) && result.error === undefined && result.signal === null
18
+ && result.status === 0
19
+ }
20
+
21
+ function installNative({
22
+ env = process.env,
23
+ execPath = process.execPath,
24
+ platform = process.platform,
25
+ spawn = spawnSync,
26
+ logger = console,
27
+ packageRoot = path.resolve(__dirname, '..')
28
+ } = {}) {
29
+ if (env.TRANFI_SKIP_NATIVE_BUILD === '1') {
30
+ logger.warn(
31
+ 'tranfi: skipping the native addon because TRANFI_SKIP_NATIVE_BUILD=1; ' +
32
+ "use require('tranfi/wasm') explicitly because prepared transforms on " +
33
+ "require('tranfi') need the native addon"
34
+ )
35
+ return 0
36
+ }
37
+
38
+ const syncResult = spawn(execPath, [path.join(__dirname, 'sync-csrc.js')], {
39
+ cwd: packageRoot,
40
+ stdio: 'inherit'
41
+ })
42
+ if (!successful(syncResult)) {
43
+ describeFailure('C-source synchronization', syncResult, logger)
44
+ return Number.isInteger(syncResult && syncResult.status) && syncResult.status !== 0
45
+ ? syncResult.status
46
+ : 1
47
+ }
48
+
49
+ const nodeGyp = env.npm_config_node_gyp || 'node-gyp'
50
+ const nodeGypIsScript = /\.[cm]?js$/i.test(nodeGyp)
51
+ const buildCommand = nodeGypIsScript ? execPath : nodeGyp
52
+ const buildArgs = nodeGypIsScript ? [nodeGyp, 'rebuild'] : ['rebuild']
53
+ const buildResult = spawn(buildCommand, buildArgs, {
54
+ cwd: packageRoot,
55
+ stdio: 'inherit',
56
+ shell: platform === 'win32' && !nodeGypIsScript
57
+ })
58
+ if (!successful(buildResult)) {
59
+ describeFailure('native addon build', buildResult, logger)
60
+ logger.error(
61
+ 'tranfi: installation requires a working native toolchain for the default ' +
62
+ "Node entry; set TRANFI_SKIP_NATIVE_BUILD=1 only for explicit 'tranfi/wasm' use"
63
+ )
64
+ return Number.isInteger(buildResult && buildResult.status) && buildResult.status !== 0
65
+ ? buildResult.status
66
+ : 1
67
+ }
68
+
69
+ return 0
70
+ }
71
+
72
+ if (require.main === module) {
73
+ process.exitCode = installNative()
74
+ }
75
+
76
+ module.exports = { installNative }
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { spawnSync } = require('child_process')
6
+
7
+ const scriptsDir = __dirname
8
+ const pkgDir = path.resolve(scriptsDir, '..')
9
+ const repoDir = path.resolve(pkgDir, '..')
10
+
11
+ function run(cmd, args, cwd, extraEnv = {}) {
12
+ const result = spawnSync(cmd, args, {
13
+ cwd,
14
+ stdio: 'inherit',
15
+ env: { ...process.env, ...extraEnv }
16
+ })
17
+ if (result.status !== 0) {
18
+ process.exit(result.status || 1)
19
+ }
20
+ }
21
+
22
+ run(process.execPath, [path.join(scriptsDir, 'sync-csrc.js')], pkgDir)
23
+
24
+ const wasmFile = path.join(pkgDir, 'wasm', 'tranfi_core.js')
25
+ if (!fs.existsSync(wasmFile)) {
26
+ const env = { EM_CACHE: process.env.EM_CACHE || path.join(repoDir, 'build-wasm', '.emcache') }
27
+ if (!process.env.EMSDK_PYTHON && fs.existsSync('/usr/bin/python3')) {
28
+ env.EMSDK_PYTHON = '/usr/bin/python3'
29
+ }
30
+ run('bash', [path.join(repoDir, 'scripts', 'build-wasm.sh')], repoDir, env)
31
+ }
32
+
33
+ const appDir = path.join(repoDir, 'app')
34
+ const appPublicWasmDir = path.join(appDir, 'public', 'wasm')
35
+ const appSource = path.join(appDir, 'dist')
36
+ const appDest = path.join(pkgDir, 'app')
37
+
38
+ fs.mkdirSync(appPublicWasmDir, { recursive: true })
39
+ fs.copyFileSync(wasmFile, path.join(appPublicWasmDir, 'tranfi_core.js'))
40
+
41
+ if (fs.existsSync(path.join(appDir, 'package.json'))) {
42
+ run('npm', ['run', 'build'], appDir)
43
+ }
44
+
45
+ if (fs.existsSync(appSource)) {
46
+ fs.rmSync(appDest, { recursive: true, force: true })
47
+ fs.cpSync(appSource, appDest, { recursive: true })
48
+ for (const extra of ['wasm', 'lib']) {
49
+ fs.rmSync(path.join(appDest, extra), { recursive: true, force: true })
50
+ }
51
+ }
52
+
53
+ const required = [
54
+ path.join(pkgDir, 'csrc'),
55
+ path.join(pkgDir, 'wasm', 'tranfi_core.js'),
56
+ path.join(pkgDir, 'app', 'index.html')
57
+ ]
58
+
59
+ for (const target of required) {
60
+ if (!fs.existsSync(target)) {
61
+ console.error('prepack: missing required publish artifact: ' + target)
62
+ process.exit(1)
63
+ }
64
+ }
@@ -0,0 +1,23 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const pkgDir = path.resolve(__dirname, '..')
7
+ const repoDir = path.resolve(pkgDir, '..')
8
+ const srcDir = path.join(repoDir, 'src')
9
+ const outDir = path.join(pkgDir, 'csrc')
10
+
11
+ if (!fs.existsSync(srcDir)) {
12
+ console.log('sync-csrc: src/ not found (published package), skipping')
13
+ process.exit(0)
14
+ }
15
+
16
+ fs.rmSync(outDir, { recursive: true, force: true })
17
+ fs.mkdirSync(outDir, { recursive: true })
18
+
19
+ for (const name of fs.readdirSync(srcDir)) {
20
+ if (name.endsWith('.c') || name.endsWith('.h')) {
21
+ fs.copyFileSync(path.join(srcDir, name), path.join(outDir, name))
22
+ }
23
+ }
package/src/cli.js CHANGED
@@ -9,13 +9,10 @@
9
9
  * tranfi profile < data.csv
10
10
  */
11
11
 
12
- import { pipeline, compileDsl, recipes } from './index.js'
13
- import { readFileSync, writeFileSync, existsSync } from 'fs'
14
- import { resolve, dirname } from 'path'
15
- import { fileURLToPath } from 'url'
16
- import nativeBinding from './native.js'
17
-
18
- const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const { pipeline, compileDsl, compileToSql, recipes } = require('./index.js')
13
+ const { readFileSync, writeFileSync, existsSync, openSync, closeSync } = require('fs')
14
+ const { resolve, dirname } = require('path')
15
+ const nativeBinding = require('./native.js')
19
16
 
20
17
  function usage() {
21
18
  process.stderr.write(`Usage: tranfi [OPTIONS] PIPELINE
@@ -28,7 +25,7 @@ Examples:
28
25
  tranfi 'csv | filter "age > 25" | csv' # filter rows
29
26
  tranfi 'csv | select name,age | csv' # select columns
30
27
  tranfi 'csv | head 10 | csv' # first N rows
31
- tranfi 'csv | sort age | csv' # sort by column
28
+ tranfi 'csv | top-k 10 age | csv' # bounded top rows by column
32
29
  tranfi 'csv | stats | jsonl' # aggregate stats
33
30
  tranfi profile # built-in recipe
34
31
 
@@ -37,6 +34,13 @@ Options:
37
34
  -i FILE Read input from file instead of stdin
38
35
  -o FILE Write output to file instead of stdout
39
36
  -j Compile only, output JSON plan
37
+ --target native|json|sql Execute or compile a pipeline
38
+ --dialect NAME SQL dialect (duckdb)
39
+ --allow-blocking Permit full-input operations on known-small data
40
+ --fail-on-blocking Reject blocking operations (default)
41
+ --memory SIZE Native memory policy, e.g. max:64MB
42
+ --spill-dir DIR Existing private spill directory
43
+ --stats-json FILE Write stats NDJSON; - means stderr
40
44
  -q Quiet mode (suppress stats)
41
45
  -v Show version
42
46
  -R List built-in recipes
@@ -47,8 +51,8 @@ Options:
47
51
  function findAppDir () {
48
52
  // Try known locations relative to this file
49
53
  const candidates = [
50
- resolve(__dirname, '../../app/dist'), // dev: js/src/ → ../../app/dist
51
- resolve(__dirname, '../app'), // npm package: src/ → ../app/
54
+ resolve(__dirname, '../../app/dist'), // dev: js/src/ -> ../../app/dist
55
+ resolve(__dirname, '../app'), // npm package: src/ -> ../app/
52
56
  ]
53
57
  for (const dir of candidates) {
54
58
  if (existsSync(resolve(dir, 'index.html'))) return dir
@@ -57,7 +61,7 @@ function findAppDir () {
57
61
  }
58
62
 
59
63
  async function serveCommand (argv) {
60
- const { startServer } = await import('./server.js')
64
+ const { startServer } = require('./server.js')
61
65
  let dataDir = '.'
62
66
  let appDir = null
63
67
  let port = 3000
@@ -105,10 +109,21 @@ async function main() {
105
109
  let outputFile = null
106
110
  let jsonMode = false
107
111
  let quiet = false
112
+ let allowBlocking = false
113
+ let failOnBlocking = false
114
+ let memory, spillDir, statsFile
115
+ let target = 'native'
116
+ let dialect = 'duckdb'
117
+ function valueAt(i, inline, flag) {
118
+ const value = inline === undefined ? argv[i + 1] : inline
119
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`)
120
+ return value
121
+ }
108
122
 
109
123
  // Parse args
110
124
  for (let i = 0; i < argv.length; i++) {
111
- const arg = argv[i]
125
+ const [arg, ...assigned] = argv[i].split('=')
126
+ const inline = assigned.length ? assigned.join('=') : undefined
112
127
  if (arg === '-h' || arg === '--help') { usage(); process.exit(0) }
113
128
  else if (arg === '-v' || arg === '--version') {
114
129
  const v = nativeBinding ? nativeBinding.version() : 'unknown'
@@ -124,18 +139,35 @@ async function main() {
124
139
  }
125
140
  process.exit(0)
126
141
  }
127
- else if (arg === '-j') jsonMode = true
128
- else if (arg === '-q') quiet = true
129
- else if (arg === '-f') { pipelineFile = argv[++i] }
130
- else if (arg === '-i') { inputFile = argv[++i] }
131
- else if (arg === '-o') { outputFile = argv[++i] }
132
- else if (!arg.startsWith('-')) { pipelineText = arg }
142
+ else if (arg === '-j' || arg === '--json') jsonMode = true
143
+ else if (arg === '-q' || arg === '--quiet') quiet = true
144
+ else if (arg === '--allow-blocking') allowBlocking = true
145
+ else if (arg === '--fail-on-blocking') failOnBlocking = true
146
+ else if (['-f', '--file', '-i', '--input', '-o', '--output', '--memory', '--spill-dir', '--stats-json', '--target', '--dialect'].includes(arg)) {
147
+ const value = valueAt(i, inline, arg)
148
+ if (inline === undefined) i++
149
+ if (arg === '-f' || arg === '--file') pipelineFile = value
150
+ else if (arg === '-i' || arg === '--input') inputFile = value
151
+ else if (arg === '-o' || arg === '--output') outputFile = value
152
+ else if (arg === '--memory') memory = value
153
+ else if (arg === '--spill-dir') spillDir = value
154
+ else if (arg === '--stats-json') statsFile = value
155
+ else if (arg === '--target') target = value
156
+ else if (arg === '--dialect') dialect = value
157
+ }
158
+ else if (!argv[i].startsWith('-')) {
159
+ if (pipelineText !== null) throw new Error('only one pipeline argument is allowed')
160
+ pipelineText = argv[i]
161
+ }
133
162
  else {
134
163
  process.stderr.write(`error: unknown option '${arg}'\n`)
135
164
  process.exit(1)
136
165
  }
137
166
  }
138
167
 
168
+ if (allowBlocking && failOnBlocking) throw new Error('--allow-blocking conflicts with --fail-on-blocking')
169
+ if (!['native', 'json', 'sql'].includes(target)) throw new Error('unknown --target; expected native, json or sql')
170
+
139
171
  // Get pipeline text
140
172
  if (pipelineFile) {
141
173
  pipelineText = readFileSync(pipelineFile, 'utf-8')
@@ -146,26 +178,44 @@ async function main() {
146
178
  process.exit(1)
147
179
  }
148
180
 
149
- // JSON mode
150
- if (jsonMode) {
181
+ const named = (await recipes()).find(recipe => recipe.name === pipelineText.trim())
182
+ if (named) pipelineText = named.dsl
183
+ if (target === 'sql') {
184
+ process.stdout.write(await compileToSql(pipelineText, { dialect }) + '\n')
185
+ return
186
+ }
187
+
188
+ if (jsonMode || target === 'json') {
151
189
  const json = await compileDsl(pipelineText)
152
190
  process.stdout.write(json + '\n')
153
191
  process.exit(0)
154
192
  }
155
193
 
156
- // Create and run pipeline
157
194
  const p = pipeline(pipelineText)
158
- const input = inputFile ? undefined : await readStdin()
159
- const result = await p.run({
195
+ const options = {
160
196
  inputFile: inputFile || undefined,
161
- input
162
- })
163
-
164
- // Write output
197
+ inputStream: inputFile ? undefined : process.stdin,
198
+ allowBlocking, memory, spillDir, allowFs: true, allowRulesFile: true
199
+ }
200
+ let result
165
201
  if (outputFile) {
166
- writeFileSync(outputFile, result.output)
202
+ let fd
203
+ try {
204
+ result = await p.run({
205
+ ...options,
206
+ collectOutput: false,
207
+ onOutput(chunk) {
208
+ // Open only after plan validation; a rejected plan must not truncate output.
209
+ if (fd === undefined) fd = openSync(outputFile, 'w')
210
+ writeFileSync(fd, chunk)
211
+ }
212
+ })
213
+ if (fd === undefined) fd = openSync(outputFile, 'w')
214
+ } finally {
215
+ if (fd !== undefined) closeSync(fd)
216
+ }
167
217
  } else {
168
- process.stdout.write(result.output)
218
+ result = await p.writeTo(process.stdout, { ...options, end: false })
169
219
  }
170
220
 
171
221
  // Errors to stderr
@@ -173,18 +223,8 @@ async function main() {
173
223
  process.stderr.write(result.errors)
174
224
  }
175
225
 
176
- // Stats to stderr (unless quiet)
177
- if (!quiet && result.stats.length > 0) {
178
- process.stderr.write(result.stats)
179
- }
180
- }
181
-
182
- function readStdin() {
183
- return new Promise((resolve) => {
184
- const chunks = []
185
- process.stdin.on('data', chunk => chunks.push(chunk))
186
- process.stdin.on('end', () => resolve(Buffer.concat(chunks)))
187
- })
226
+ if (statsFile && statsFile !== '-') writeFileSync(statsFile, result.stats)
227
+ else if ((statsFile || !quiet) && result.stats.length > 0) process.stderr.write(result.stats)
188
228
  }
189
229
 
190
230
  main().catch(err => {
@@ -5,26 +5,27 @@
5
5
  * Install: npm install duckdb
6
6
  */
7
7
 
8
- import { writeFile, unlink } from 'fs/promises'
9
- import { tmpdir } from 'os'
10
- import { join } from 'path'
11
- import { randomBytes } from 'crypto'
12
- import { PipelineResult } from '../pipeline.js'
8
+ const { writeFile, unlink } = require('fs/promises')
9
+ const { tmpdir } = require('os')
10
+ const { join } = require('path')
11
+ const { randomBytes } = require('crypto')
12
+ const { PipelineResult } = require('../pipeline.js')
13
+ const { memoryForEngine } = require('../memory_policy.js')
13
14
 
14
15
  let _compileToSql = null
15
16
 
16
17
  async function getCompileToSql() {
17
18
  if (_compileToSql) return _compileToSql
18
- const { compileToSql } = await import('../pipeline.js')
19
+ const { compileToSql } = require('../pipeline.js')
19
20
  _compileToSql = compileToSql
20
21
  return _compileToSql
21
22
  }
22
23
 
23
24
  class DuckDBEngine {
24
- async run(dsl, { input, inputFile } = {}) {
25
+ async run(dsl, { input, inputFile, memory, spillDir } = {}) {
25
26
  let duckdb
26
27
  try {
27
- duckdb = (await import('duckdb')).default || await import('duckdb')
28
+ duckdb = require('duckdb')
28
29
  } catch {
29
30
  throw new Error(
30
31
  "DuckDB engine requires the 'duckdb' package. " +
@@ -40,13 +41,17 @@ class DuckDBEngine {
40
41
 
41
42
  let tmpPath = null
42
43
  try {
44
+ const memoryLimit = memoryForEngine(memory)
45
+ if (memoryLimit) await runSql(conn, `SET memory_limit = '${sqlString(memoryLimit)}'`)
46
+ if (spillDir) await runSql(conn, `SET temp_directory = '${sqlString(spillDir)}'`)
47
+
43
48
  if (inputFile) {
44
- sql = sql.replaceAll('input_data', `read_csv('${inputFile}')`)
49
+ sql = sql.replaceAll('input_data', `read_csv('${sqlString(inputFile)}')`)
45
50
  } else if (input) {
46
51
  const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : input
47
52
  tmpPath = join(tmpdir(), `tranfi_${randomBytes(8).toString('hex')}.csv`)
48
53
  await writeFile(tmpPath, buf)
49
- sql = sql.replaceAll('input_data', `read_csv('${tmpPath}')`)
54
+ sql = sql.replaceAll('input_data', `read_csv('${sqlString(tmpPath)}')`)
50
55
  } else {
51
56
  throw new Error('Either input or inputFile must be provided')
52
57
  }
@@ -70,6 +75,19 @@ class DuckDBEngine {
70
75
  }
71
76
  }
72
77
 
78
+ function sqlString(value) {
79
+ return String(value).replace(/'/g, "''")
80
+ }
81
+
82
+ function runSql(conn, sql) {
83
+ return new Promise((resolve, reject) => {
84
+ conn.run(sql, err => {
85
+ if (err) reject(err)
86
+ else resolve()
87
+ })
88
+ })
89
+ }
90
+
73
91
  function queryAll(conn, sql) {
74
92
  return new Promise((resolve, reject) => {
75
93
  conn.all(sql, (err, rows) => {
@@ -87,7 +105,7 @@ function rowsToCsv(rows) {
87
105
  const vals = cols.map(c => {
88
106
  const v = row[c]
89
107
  if (v === null || v === undefined) return ''
90
- const s = String(v)
108
+ const s = formatDuckValue(v)
91
109
  if (s.includes(',') || s.includes('"') || s.includes('\n')) {
92
110
  return '"' + s.replace(/"/g, '""') + '"'
93
111
  }
@@ -98,12 +116,27 @@ function rowsToCsv(rows) {
98
116
  return lines.join('\n') + '\n'
99
117
  }
100
118
 
119
+ function formatDuckValue(value) {
120
+ if (typeof value !== 'number') return String(value)
121
+ if (!Number.isFinite(value)) return String(value)
122
+ if (Number.isInteger(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER) {
123
+ return String(value)
124
+ }
125
+ let text = value.toPrecision(17)
126
+ if (!/[eE]/.test(text) && text.includes('.')) {
127
+ text = text.replace(/0+$/, '').replace(/\.$/, '')
128
+ }
129
+ return text
130
+ }
131
+
101
132
  let _engine = null
102
133
 
103
- export function getEngine(name) {
134
+ function getEngine(name) {
104
135
  if (name === 'duckdb') {
105
136
  if (!_engine) _engine = new DuckDBEngine()
106
137
  return _engine
107
138
  }
108
139
  throw new Error(`Unknown engine: '${name}'. Available: 'native', 'duckdb'`)
109
140
  }
141
+
142
+ module.exports = { getEngine }