tranfi 0.0.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 -21
- package/NOTICE +8 -0
- package/README.md +627 -0
- package/app/assets/index-6quYZ5Ap.css +5 -0
- package/app/assets/index-BIAIKnrp.js +160 -0
- package/app/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
- package/app/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
- package/app/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
- package/app/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
- package/app/index.html +13 -0
- package/binding.gyp +121 -0
- package/csrc/arena.c +93 -0
- package/csrc/batch.c +976 -0
- package/csrc/buffer.c +154 -0
- package/csrc/cJSON.c +3386 -0
- package/csrc/cJSON.h +316 -0
- package/csrc/codec_csv.c +1951 -0
- package/csrc/codec_jsonl.c +1086 -0
- package/csrc/codec_table.c +248 -0
- package/csrc/codec_text.c +447 -0
- package/csrc/compiler.c +130 -0
- package/csrc/config.h +21 -0
- package/csrc/date_utils.h +94 -0
- package/csrc/dsl.c +5417 -0
- package/csrc/dsl.h +22 -0
- package/csrc/expr.c +1553 -0
- package/csrc/expr.h +58 -0
- package/csrc/internal.h +539 -0
- package/csrc/ir.c +166 -0
- package/csrc/ir.h +208 -0
- package/csrc/ir_schema.c +75 -0
- package/csrc/ir_serialize.c +166 -0
- package/csrc/ir_sql.c +1822 -0
- package/csrc/ir_validate.c +576 -0
- package/csrc/json_path.c +210 -0
- package/csrc/main.c +1241 -0
- package/csrc/memory_estimate.c +477 -0
- package/csrc/op_acf.c +283 -0
- package/csrc/op_across.c +477 -0
- package/csrc/op_anomaly.c +255 -0
- package/csrc/op_assert.c +761 -0
- package/csrc/op_bin.c +248 -0
- package/csrc/op_cast.c +523 -0
- package/csrc/op_clip.c +99 -0
- package/csrc/op_date_trunc.c +355 -0
- package/csrc/op_datetime.c +394 -0
- package/csrc/op_derive.c +216 -0
- package/csrc/op_diff.c +250 -0
- package/csrc/op_ewma.c +222 -0
- package/csrc/op_explode.c +206 -0
- package/csrc/op_fill_down.c +235 -0
- package/csrc/op_fill_null.c +268 -0
- package/csrc/op_filter.c +181 -0
- package/csrc/op_frequency.c +721 -0
- package/csrc/op_grep.c +181 -0
- package/csrc/op_group_agg.c +1956 -0
- package/csrc/op_hash.c +159 -0
- package/csrc/op_head.c +84 -0
- package/csrc/op_interpolate.c +445 -0
- package/csrc/op_join.c +2902 -0
- 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 +419 -0
- package/csrc/op_lag.c +181 -0
- package/csrc/op_lead.c +242 -0
- package/csrc/op_normalize.c +510 -0
- package/csrc/op_onehot.c +457 -0
- package/csrc/op_pivot.c +1754 -0
- package/csrc/op_quarantine.c +189 -0
- package/csrc/op_registry.c +3044 -0
- package/csrc/op_rename.c +129 -0
- package/csrc/op_replace.c +354 -0
- package/csrc/op_rleid.c +297 -0
- package/csrc/op_rowid.c +559 -0
- package/csrc/op_sample.c +158 -0
- package/csrc/op_schema.c +1341 -0
- package/csrc/op_schema_infer.c +252 -0
- package/csrc/op_select.c +340 -0
- package/csrc/op_set.c +3449 -0
- package/csrc/op_skip.c +95 -0
- package/csrc/op_sort.c +819 -0
- package/csrc/op_source_name.c +120 -0
- package/csrc/op_split.c +151 -0
- package/csrc/op_split_data.c +119 -0
- package/csrc/op_stack.c +271 -0
- package/csrc/op_stats.c +875 -0
- package/csrc/op_step.c +333 -0
- package/csrc/op_tail.c +105 -0
- package/csrc/op_tee.c +338 -0
- package/csrc/op_top.c +357 -0
- package/csrc/op_trim.c +138 -0
- package/csrc/op_unique.c +1343 -0
- package/csrc/op_unpivot.c +193 -0
- package/csrc/op_validate.c +648 -0
- package/csrc/op_window.c +591 -0
- package/csrc/path_policy.c +85 -0
- package/csrc/pipeline.c +1088 -0
- package/csrc/recipes.c +104 -0
- package/csrc/recipes.h +27 -0
- package/csrc/report.c +506 -0
- package/csrc/report.h +22 -0
- 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 +291 -0
- 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 +218 -0
- package/napi_api.c +534 -0
- package/napi_transform.c +1648 -0
- package/napi_transform.h +8 -0
- package/package.json +64 -59
- package/scripts/install-native.js +76 -0
- package/scripts/prepack.js +64 -0
- package/scripts/sync-csrc.js +23 -0
- package/src/cli.js +190 -0
- package/src/engines/duckdb.js +142 -0
- package/src/index.js +925 -0
- package/src/memory_policy.js +411 -0
- package/src/native.js +18 -0
- package/src/pipeline.js +709 -0
- package/src/recipe_json.js +80 -0
- package/src/server.js +279 -0
- package/src/transform.js +403 -0
- package/src/transform_error.js +10 -0
- package/src/wasm.js +21 -0
- package/wasm/index.js +732 -0
- package/wasm/package.json +1 -0
- package/wasm/tranfi_core.js +0 -0
- package/wasm/transform.js +1156 -0
- package/wasm/worker.js +786 -0
- package/dist/bundle.js +0 -1
- package/index.html +0 -18
- package/src/app.css +0 -169
- package/src/app.js +0 -203
- package/src/app.vue +0 -250
- package/src/bulma-input.vue +0 -110
- package/src/common-inputs.js +0 -28
- package/src/main.js +0 -20
- package/src/transforms.js +0 -166
- package/webpack.config.js +0 -108
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline.js — Pipeline builder and runner.
|
|
3
|
+
*
|
|
4
|
+
* Tries native N-API addon first, falls back to WASM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
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')
|
|
14
|
+
|
|
15
|
+
// Channel IDs (match tranfi.h)
|
|
16
|
+
const CHAN_MAIN = 0
|
|
17
|
+
const CHAN_ERRORS = 1
|
|
18
|
+
const CHAN_STATS = 2
|
|
19
|
+
const CHAN_SAMPLES = 3
|
|
20
|
+
|
|
21
|
+
const CHUNK_SIZE = 64 * 1024
|
|
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
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the backend (native or WASM).
|
|
74
|
+
* Returns an object with: createPipeline, push, finish, pull, free, version
|
|
75
|
+
*/
|
|
76
|
+
let _backend = null
|
|
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
|
+
|
|
104
|
+
async function getBackend() {
|
|
105
|
+
if (_backend) return _backend
|
|
106
|
+
|
|
107
|
+
// Try native first
|
|
108
|
+
if (nativeBinding) {
|
|
109
|
+
_backend = nativeBinding
|
|
110
|
+
return _backend
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fall back to WASM
|
|
114
|
+
const { loadWasm } = require('./wasm.js')
|
|
115
|
+
const wasm = await loadWasm()
|
|
116
|
+
|
|
117
|
+
_backend = {
|
|
118
|
+
isWasm: true,
|
|
119
|
+
compileDsl(dsl) {
|
|
120
|
+
const len = wasm.lengthBytesUTF8(dsl)
|
|
121
|
+
const ptr = wasm._malloc(len + 1)
|
|
122
|
+
wasm.stringToUTF8(dsl, ptr, len + 1)
|
|
123
|
+
const resultPtr = wasm.ccall('wasm_compile_dsl', 'number', ['number', 'number'], [ptr, len])
|
|
124
|
+
wasm._free(ptr)
|
|
125
|
+
if (!resultPtr) {
|
|
126
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [-1])
|
|
127
|
+
throw new Error(`DSL parse failed: ${err || 'unknown error'}`)
|
|
128
|
+
}
|
|
129
|
+
const json = wasm.UTF8ToString(resultPtr)
|
|
130
|
+
wasm._free(resultPtr)
|
|
131
|
+
return json
|
|
132
|
+
},
|
|
133
|
+
createPipeline(planJson, options = {}) {
|
|
134
|
+
const len = wasm.lengthBytesUTF8(planJson)
|
|
135
|
+
const ptr = wasm._malloc(len + 1)
|
|
136
|
+
wasm.stringToUTF8(planJson, ptr, len + 1)
|
|
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
|
+
}
|
|
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
|
+
}
|
|
160
|
+
if (handle < 0) {
|
|
161
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
162
|
+
throw new Error(`Failed to create pipeline: ${err || 'unknown error'}`)
|
|
163
|
+
}
|
|
164
|
+
return handle
|
|
165
|
+
},
|
|
166
|
+
push(handle, buffer) {
|
|
167
|
+
const len = buffer.length
|
|
168
|
+
const ptr = wasm._malloc(len)
|
|
169
|
+
wasm.HEAPU8.set(buffer, ptr)
|
|
170
|
+
const rc = wasm.ccall('wasm_pipeline_push', 'number', ['number', 'number', 'number'], [handle, ptr, len])
|
|
171
|
+
wasm._free(ptr)
|
|
172
|
+
if (rc !== 0) {
|
|
173
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
174
|
+
throw new Error(`Push failed: ${err || 'unknown error'}`)
|
|
175
|
+
}
|
|
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
|
+
},
|
|
196
|
+
finish(handle) {
|
|
197
|
+
const rc = wasm.ccall('wasm_pipeline_finish', 'number', ['number'], [handle])
|
|
198
|
+
if (rc !== 0) {
|
|
199
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
|
|
200
|
+
throw new Error(`Finish failed: ${err || 'unknown error'}`)
|
|
201
|
+
}
|
|
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
|
+
},
|
|
211
|
+
pull(handle, channel) {
|
|
212
|
+
const bufSize = 65536
|
|
213
|
+
const ptr = wasm._malloc(bufSize)
|
|
214
|
+
const chunks = []
|
|
215
|
+
for (;;) {
|
|
216
|
+
const n = wasm.ccall('wasm_pipeline_pull', 'number',
|
|
217
|
+
['number', 'number', 'number', 'number'], [handle, channel, ptr, bufSize])
|
|
218
|
+
if (n === 0) break
|
|
219
|
+
chunks.push(new Uint8Array(wasm.HEAPU8.buffer, ptr, n).slice())
|
|
220
|
+
}
|
|
221
|
+
wasm._free(ptr)
|
|
222
|
+
// Concatenate
|
|
223
|
+
const total = chunks.reduce((s, c) => s + c.length, 0)
|
|
224
|
+
const result = Buffer.alloc(total)
|
|
225
|
+
let offset = 0
|
|
226
|
+
for (const c of chunks) {
|
|
227
|
+
result.set(c, offset)
|
|
228
|
+
offset += c.length
|
|
229
|
+
}
|
|
230
|
+
return result
|
|
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
|
+
},
|
|
241
|
+
free(handle) {
|
|
242
|
+
wasm.ccall('wasm_pipeline_free', null, ['number'], [handle])
|
|
243
|
+
},
|
|
244
|
+
version() {
|
|
245
|
+
return wasm.ccall('wasm_version', 'string', [], [])
|
|
246
|
+
},
|
|
247
|
+
recipeCount() {
|
|
248
|
+
return wasm.ccall('wasm_recipe_count', 'number', [], [])
|
|
249
|
+
},
|
|
250
|
+
recipeName(index) {
|
|
251
|
+
return wasm.ccall('wasm_recipe_name', 'string', ['number'], [index])
|
|
252
|
+
},
|
|
253
|
+
recipeDsl(index) {
|
|
254
|
+
return wasm.ccall('wasm_recipe_dsl', 'string', ['number'], [index])
|
|
255
|
+
},
|
|
256
|
+
recipeDescription(index) {
|
|
257
|
+
return wasm.ccall('wasm_recipe_description', 'string', ['number'], [index])
|
|
258
|
+
},
|
|
259
|
+
recipeFindDsl(name) {
|
|
260
|
+
const len = wasm.lengthBytesUTF8(name)
|
|
261
|
+
const ptr = wasm._malloc(len + 1)
|
|
262
|
+
wasm.stringToUTF8(name, ptr, len + 1)
|
|
263
|
+
const result = wasm.ccall('wasm_recipe_find_dsl', 'string', ['number'], [ptr])
|
|
264
|
+
wasm._free(ptr)
|
|
265
|
+
return result || null
|
|
266
|
+
},
|
|
267
|
+
compileToSql(dsl) {
|
|
268
|
+
const len = wasm.lengthBytesUTF8(dsl)
|
|
269
|
+
const ptr = wasm._malloc(len + 1)
|
|
270
|
+
wasm.stringToUTF8(dsl, ptr, len + 1)
|
|
271
|
+
const resultPtr = wasm.ccall('wasm_compile_to_sql', 'number', ['number', 'number'], [ptr, len])
|
|
272
|
+
wasm._free(ptr)
|
|
273
|
+
if (!resultPtr) {
|
|
274
|
+
const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [-1])
|
|
275
|
+
throw new Error(`SQL compile failed: ${err || 'unknown error'}`)
|
|
276
|
+
}
|
|
277
|
+
const sql = wasm.UTF8ToString(resultPtr)
|
|
278
|
+
wasm._free(resultPtr)
|
|
279
|
+
return sql
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return _backend
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
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 {
|
|
462
|
+
constructor(output, errors, stats, samples) {
|
|
463
|
+
this.output = output
|
|
464
|
+
this.errors = errors
|
|
465
|
+
this.stats = stats
|
|
466
|
+
this.samples = samples
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
get outputText() {
|
|
470
|
+
return this.output.toString('utf-8')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
get statsText() {
|
|
474
|
+
return this.stats.toString('utf-8')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
toString() {
|
|
478
|
+
return `PipelineResult(output=${this.output.length} bytes, errors=${this.errors.length} bytes, stats=${this.stats.length} bytes)`
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class Pipeline {
|
|
484
|
+
constructor(steps, { planJson, engine } = {}) {
|
|
485
|
+
this._engine = engine || null
|
|
486
|
+
if (planJson) {
|
|
487
|
+
this._planJson = planJson
|
|
488
|
+
this._dsl = null
|
|
489
|
+
this._steps = null
|
|
490
|
+
} else if (typeof steps === 'string') {
|
|
491
|
+
this._dsl = steps
|
|
492
|
+
this._steps = null
|
|
493
|
+
this._planJson = null
|
|
494
|
+
} else {
|
|
495
|
+
this._dsl = null
|
|
496
|
+
this._steps = steps
|
|
497
|
+
this._planJson = null
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async _toPlanJson(backend) {
|
|
502
|
+
if (this._planJson) return this._planJson
|
|
503
|
+
if (this._dsl) {
|
|
504
|
+
// Check if it's a built-in recipe name (no pipes, no braces)
|
|
505
|
+
const s = this._dsl.trim()
|
|
506
|
+
if (!s.includes('|') && !s.startsWith('{')) {
|
|
507
|
+
const recipeDsl = backend.recipeFindDsl(s)
|
|
508
|
+
if (recipeDsl) return backend.compileDsl(recipeDsl)
|
|
509
|
+
}
|
|
510
|
+
return backend.compileDsl(this._dsl)
|
|
511
|
+
}
|
|
512
|
+
return JSON.stringify({ steps: this._steps })
|
|
513
|
+
}
|
|
514
|
+
|
|
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
|
+
}
|
|
523
|
+
if (this._engine && this._engine !== 'native') {
|
|
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 })
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const backend = await getBackend()
|
|
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 = []
|
|
540
|
+
|
|
541
|
+
try {
|
|
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
|
|
549
|
+
}
|
|
550
|
+
backend.push(handle, item.chunk)
|
|
551
|
+
await drainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
await finishAndDrainMain(backend, handle, chunkSize, outputChunks, onOutput, collectOutput)
|
|
555
|
+
|
|
556
|
+
const output = collectOutput ? concatBuffers(outputChunks) : Buffer.alloc(0)
|
|
557
|
+
const errors = backend.pull(handle, CHAN_ERRORS)
|
|
558
|
+
const stats = backend.pull(handle, CHAN_STATS)
|
|
559
|
+
const samples = backend.pull(handle, CHAN_SAMPLES)
|
|
560
|
+
|
|
561
|
+
return new PipelineResult(output, errors, stats, samples)
|
|
562
|
+
} finally {
|
|
563
|
+
backend.free(handle)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
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')
|
|
653
|
+
const engine = getEngine(this._engine)
|
|
654
|
+
const dsl = this._dsl
|
|
655
|
+
if (!dsl) throw new Error('DuckDB engine requires a DSL string pipeline')
|
|
656
|
+
return engine.run(dsl, { input, inputFile, memory, spillDir })
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function compileDsl(dsl) {
|
|
661
|
+
const backend = await getBackend()
|
|
662
|
+
return backend.compileDsl(dsl)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function compileToSql(dsl, options) {
|
|
666
|
+
normalizeSqlDialect(options)
|
|
667
|
+
const backend = await getBackend()
|
|
668
|
+
return backend.compileToSql(dsl)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function loadRecipe(source) {
|
|
672
|
+
let planJson
|
|
673
|
+
if (typeof source === 'object' && source.steps) {
|
|
674
|
+
planJson = JSON.stringify(source)
|
|
675
|
+
} else if (typeof source === 'string' && source.trimStart().startsWith('{')) {
|
|
676
|
+
planJson = source
|
|
677
|
+
} else {
|
|
678
|
+
planJson = await readFile(source, 'utf-8')
|
|
679
|
+
}
|
|
680
|
+
return new Pipeline(null, { planJson })
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function saveRecipe(steps, path) {
|
|
684
|
+
await writeFile(path, JSON.stringify({ steps }))
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function recipes() {
|
|
688
|
+
const backend = await getBackend()
|
|
689
|
+
const n = backend.recipeCount()
|
|
690
|
+
const result = []
|
|
691
|
+
for (let i = 0; i < n; i++) {
|
|
692
|
+
result.push({
|
|
693
|
+
name: backend.recipeName(i),
|
|
694
|
+
dsl: backend.recipeDsl(i),
|
|
695
|
+
description: backend.recipeDescription(i)
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
return result
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
Pipeline,
|
|
703
|
+
PipelineResult,
|
|
704
|
+
compileDsl,
|
|
705
|
+
compileToSql,
|
|
706
|
+
loadRecipe,
|
|
707
|
+
saveRecipe,
|
|
708
|
+
recipes
|
|
709
|
+
}
|