@xeplr/actions 1.1.1 → 1.1.5

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.
@@ -0,0 +1,646 @@
1
+ // DuckDB driver — same interface as postgres/mysql/mssql, one difference that
2
+ // governs everything else: DuckDB is not a server. It is a FILE that a process
3
+ // maps into itself, so the rules about who may open it are this driver's
4
+ // problem rather than a DBA's.
5
+ //
6
+ // Peer dep: @duckdb/node-api (declared optional in package.json).
7
+ //
8
+ // ── THE ONE RULE ─────────────────────────────────────────────────────────
9
+ //
10
+ // Measured, not assumed (three processes against one file, DuckDB 1.5.5):
11
+ //
12
+ // many processes read-only, nobody writing → ALL OPEN
13
+ // a second process read-write → BLOCKED
14
+ // a reader read-only WHILE a writer holds it → BLOCKED
15
+ //
16
+ // That third line is the one that bites, and it bites hardest under Node
17
+ // clustering: four app workers plus a scheduler is five processes, and one
18
+ // holding the file read-write locks out the other four completely. The failure
19
+ // is at OPEN, not at write — so a worker that never writes still dies.
20
+ //
21
+ // ── READ-ONLY BY DEFAULT. WRITING IS ASKED FOR, AT THE CALL SITE ─────────
22
+ //
23
+ // This used to be decided per PROCESS by an environment variable, and that was
24
+ // the wrong shape. It meant a deployment had to nominate one special process,
25
+ // the same code behaved differently depending on where it happened to run, and
26
+ // nothing at the point of a write said whether it was allowed to happen. A
27
+ // setting that far from the code it governs is a setting that gets out of step
28
+ // with it.
29
+ //
30
+ // So: connect({ file }) → READ-ONLY. Always. No exceptions.
31
+ // connect({ file, access: 'rw' }) → a write, said out loud, in the code
32
+ // doing the writing.
33
+ //
34
+ // Nothing needs to be configured to deploy this, no process is special, and
35
+ // "who can write" is answerable by grepping for `access: 'rw'`.
36
+ //
37
+ // ── HOLD THE LOCK BRIEFLY, AND QUEUE BEHIND IT ───────────────────────────
38
+ //
39
+ // The reason read-only can be the default is that a writer no longer keeps the
40
+ // file. It opens read-write, does its work, and closes — so the exclusive
41
+ // window is the length of one write rather than the lifetime of a process.
42
+ //
43
+ // Everything else WAITS instead of failing. `openTimeoutMs` retries on the
44
+ // lock error until it clears, which makes the file's own lock the mutex: two
45
+ // processes that both want to write do not need to know about each other, and
46
+ // a reader arriving mid-write blocks for the write rather than erroring. No
47
+ // designated writer, no coordination, no variable.
48
+ //
49
+ // The cost is honest and worth stating: while a big load holds the lock, reads
50
+ // of THAT FILE wait. It is bounded by making the loads incremental and by the
51
+ // split below.
52
+ //
53
+ // ── WHICH IS WHY CUBES ARE SEPARATE FILES ────────────────────────────────
54
+ //
55
+ // The replica takes a long write when it loads. A cube file is written once,
56
+ // closed, and never modified — so dashboards reading cubes are never behind a
57
+ // replica load at all. Only drill-down against the replica itself waits, and
58
+ // only while a load is running.
59
+ //
60
+ // Interface — identical to the other drivers, see postgres.js for the contract.
61
+
62
+ var INSTANCE_CACHE = {}; // absolute path → { instance, access, pool }
63
+
64
+ // Framework-managed column. Note there is NO __xeplr_id__ here, unlike the
65
+ // server dialects: it is a surrogate key nothing outside those drivers reads
66
+ // (grep says so), DuckDB has no SERIAL so it would cost a sequence, and on a
67
+ // 20M-row replica it is a whole extra column written for nobody's benefit.
68
+ var INTERNAL_ID_COL = null;
69
+ var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
70
+
71
+ // Type mapping. Two departures from the Postgres driver, both deliberate:
72
+ //
73
+ // number → DOUBLE, not NUMERIC. In DuckDB a bare NUMERIC means DECIMAL(18,3)
74
+ // — a silent truncation to three decimal places and an overflow above ~10^15.
75
+ // DOUBLE is what an analytical store is built to aggregate and what the cube
76
+ // arithmetic assumes.
77
+ //
78
+ // object/array → JSON, DuckDB's own type. There is no JSONB.
79
+ var TYPE_MAP = {
80
+ string: 'VARCHAR',
81
+ number: 'DOUBLE',
82
+ boolean: 'BOOLEAN',
83
+ date: 'DATE',
84
+ datetime: 'TIMESTAMPTZ',
85
+ object: 'JSON',
86
+ array: 'JSON'
87
+ };
88
+
89
+ var DUCK_TO_LOGICAL = {
90
+ 'VARCHAR': 'string', 'CHAR': 'string', 'TEXT': 'string', 'BLOB': 'string',
91
+ 'UUID': 'string', 'BIT': 'string', 'ENUM': 'string',
92
+ 'BOOLEAN': 'boolean',
93
+ 'TINYINT': 'number', 'SMALLINT': 'number', 'INTEGER': 'number', 'BIGINT': 'number',
94
+ 'HUGEINT': 'number', 'UTINYINT': 'number', 'USMALLINT': 'number', 'UINTEGER': 'number',
95
+ 'UBIGINT': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'DECIMAL': 'number',
96
+ 'DATE': 'date',
97
+ 'TIMESTAMP': 'datetime', 'TIMESTAMP WITH TIME ZONE': 'datetime',
98
+ 'TIMESTAMP_S': 'datetime', 'TIMESTAMP_MS': 'datetime', 'TIMESTAMP_NS': 'datetime',
99
+ 'TIME': 'datetime', 'TIME WITH TIME ZONE': 'datetime', 'INTERVAL': 'string',
100
+ 'JSON': 'object', 'STRUCT': 'object', 'MAP': 'object',
101
+ 'LIST': 'array'
102
+ };
103
+
104
+ // ── access mode ──────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * What this CALL is asking to do — not what this process is.
108
+ *
109
+ * Read-only unless the caller says otherwise, in the call itself. There is no
110
+ * environment variable and deliberately so: a process-wide setting means the
111
+ * same code behaves differently depending on where it runs, nothing at the
112
+ * point of a write says whether it is allowed, and a deployment has to
113
+ * nominate a special process. `access: 'rw'` sitting in the code that writes
114
+ * is the whole mechanism, and it is greppable.
115
+ */
116
+ function accessMode(config) {
117
+ var raw = String((config && config.access) || 'ro').toLowerCase();
118
+ if (raw === 'ro' || raw === 'read_only' || raw === 'readonly') return 'ro';
119
+ if (raw === 'rw' || raw === 'read_write' || raw === 'readwrite') return 'rw';
120
+ throw new Error(
121
+ 'duckdb: access must be "rw" (this call writes) or "ro" (the default, reads only) — ' +
122
+ 'got "' + raw + '".'
123
+ );
124
+ }
125
+
126
+ function isLockError(err) {
127
+ var msg = String((err && err.message) || err);
128
+ return msg.indexOf('Could not set lock') !== -1 || msg.indexOf('Conflicting lock') !== -1;
129
+ }
130
+
131
+ /**
132
+ * Turn DuckDB's lock error into one that says what actually happened.
133
+ *
134
+ * Only reached after waiting — see openWithWait. The native message names a
135
+ * PID and links to the concurrency docs, which is accurate and useless at 3am.
136
+ */
137
+ function explainLockError(err, path, access, waitedMs) {
138
+ if (!isLockError(err)) return err;
139
+
140
+ var e = new Error(
141
+ 'duckdb: gave up waiting for "' + path + '" after ' + waitedMs + 'ms — another ' +
142
+ 'process is still holding it.\n' +
143
+ ' This call asked for ' + (access === 'ro' ? 'READ-ONLY' : 'READ-WRITE') + ' access.\n' +
144
+ ' One process may hold the file read-write, and no reader can open it while that ' +
145
+ 'lasts — so a long write blocks everything else on the same file.\n' +
146
+ ' Either the write is taking longer than openTimeoutMs, or something opened it ' +
147
+ 'read-write and did not close it.\n' +
148
+ ' Original: ' + String((err && err.message) || err)
149
+ );
150
+ e.cause = err;
151
+ e.code = 'DUCKDB_LOCKED';
152
+ return e;
153
+ }
154
+
155
+ /**
156
+ * Open, waiting out whoever is holding the file rather than failing at them.
157
+ *
158
+ * This is what lets the file's own lock be the mutex. Two callers that both
159
+ * want to write need no knowledge of each other: one gets it, the other waits
160
+ * and then gets it. A reader arriving mid-write waits for the write instead of
161
+ * erroring. Nothing has to be designated, configured or coordinated.
162
+ *
163
+ * Bounded, because waiting forever turns a stuck writer into a hung request
164
+ * with nothing in the log. When the bound is hit the error says which of the
165
+ * two things went wrong.
166
+ */
167
+ async function openWithWait(api, key, options, access, timeoutMs) {
168
+ var started = Date.now();
169
+ var delay = 25;
170
+ for (;;) {
171
+ try {
172
+ return await api.DuckDBInstance.create(key, options);
173
+ } catch (err) {
174
+ if (!isLockError(err)) throw err;
175
+ var waited = Date.now() - started;
176
+ if (waited >= timeoutMs) throw explainLockError(err, key, access, waited);
177
+ await new Promise(function(r) { setTimeout(r, delay); });
178
+ // Backing off rather than hammering: a load holds the file for minutes,
179
+ // and a 25ms poll for that long is thousands of pointless syscalls.
180
+ delay = Math.min(delay * 2, 1000);
181
+ }
182
+ }
183
+ }
184
+
185
+ // ── connection lifecycle ─────────────────────────────────────────────────
186
+
187
+ /**
188
+ * A pool, in the shape the uploader expects — but DuckDB connections are not
189
+ * sockets. They are handles onto ONE in-process instance, so the expensive
190
+ * thing is the instance and it must be shared: two DuckDBInstance objects for
191
+ * the same path in one process is the same conflicting-lock error as two
192
+ * processes, self-inflicted.
193
+ *
194
+ * config.file / config.database path to the .duckdb file
195
+ * config.access 'rw' to write. Omitted means READ-ONLY.
196
+ * config.openTimeoutMs how long to wait out another holder
197
+ * (default 60s — a load can be slow)
198
+ * config.maxConnections default 4
199
+ *
200
+ * READ-ONLY UNLESS ASKED. A caller that forgets `access: 'rw'` gets an error
201
+ * on its first write, in the code doing the writing — not a silent success on
202
+ * a handle it was never entitled to.
203
+ */
204
+ async function connect(config) {
205
+ config = config || {};
206
+ var api = require('@duckdb/node-api');
207
+ var path = require('path');
208
+
209
+ var file = config.file || config.database || config.path;
210
+ if (!file) throw new Error('duckdb: no file given — set config.file to the .duckdb path.');
211
+ // ':memory:' is legitimate (tests, a scratch cube) and is not a path.
212
+ var key = file === ':memory:' ? ':memory:' : path.resolve(file);
213
+
214
+ var access = accessMode(config);
215
+ var existing = INSTANCE_CACHE[key];
216
+
217
+ if (existing) {
218
+ // Already open in this process. A read-only ASK against a read-write
219
+ // instance is fine — same process, same instance, no second lock — so it
220
+ // is served rather than refused. The reverse is not: handing out write
221
+ // access on a handle opened read-only fails later, at the write, in
222
+ // whatever code happened to be running.
223
+ if (access === 'rw' && existing.access === 'ro') {
224
+ throw new Error(
225
+ 'duckdb: "' + key + '" is already open READ-ONLY in this process, so this write ' +
226
+ 'cannot proceed. Close the read-only handle first, or open it with access: "rw" ' +
227
+ 'from the start — one file cannot be both at once inside one process.'
228
+ );
229
+ }
230
+ existing.refs++;
231
+ return existing.pool;
232
+ }
233
+
234
+ var options = access === 'ro' ? { access_mode: 'READ_ONLY' } : {};
235
+ // Waits rather than failing — see openWithWait. This is what makes the
236
+ // file's own lock the mutex and removes any need to designate a writer.
237
+ var instance = await openWithWait(api, key, options, access,
238
+ config.openTimeoutMs == null ? 60000 : config.openTimeoutMs);
239
+
240
+ var entry = {
241
+ instance: instance,
242
+ access: access,
243
+ key: key,
244
+ refs: 1,
245
+ free: [],
246
+ waiters: [],
247
+ open: 0,
248
+ max: Math.max(1, parseInt(config.maxConnections || 4, 10))
249
+ };
250
+ entry.pool = { __duck: entry, access: access, file: key };
251
+ INSTANCE_CACHE[key] = entry;
252
+ return entry.pool;
253
+ }
254
+
255
+ /** Check out a connection, creating one lazily up to `max`, else queueing. */
256
+ async function acquire(entry) {
257
+ if (entry.free.length) return entry.free.pop();
258
+ if (entry.open < entry.max) {
259
+ entry.open++;
260
+ try {
261
+ return await entry.instance.connect();
262
+ } catch (err) {
263
+ entry.open--;
264
+ throw err;
265
+ }
266
+ }
267
+ return new Promise(function(resolve) { entry.waiters.push(resolve); });
268
+ }
269
+
270
+ function release(entry, conn) {
271
+ var waiter = entry.waiters.shift();
272
+ if (waiter) waiter(conn);
273
+ else entry.free.push(conn);
274
+ }
275
+
276
+ function entryOf(pool) {
277
+ var entry = pool && pool.__duck;
278
+ if (!entry) throw new Error('duckdb: not a duckdb pool — was connect() called with this config?');
279
+ return entry;
280
+ }
281
+
282
+ /**
283
+ * Reference-counted, because one process legitimately opens the same warehouse
284
+ * from several places at once (a build, a dashboard query, a listing) and the
285
+ * first one to finish must not close the file out from under the others.
286
+ */
287
+ async function close(pool) {
288
+ if (!pool || !pool.__duck) return;
289
+ var entry = pool.__duck;
290
+ if (--entry.refs > 0) return;
291
+
292
+ entry.free.forEach(function(c) { try { c.closeSync(); } catch (_) {} });
293
+ entry.free = [];
294
+ try { entry.instance.closeSync(); } catch (_) {}
295
+ delete INSTANCE_CACHE[entry.key];
296
+ }
297
+
298
+ // ── query ────────────────────────────────────────────────────────────────
299
+
300
+ // BigInt is what DuckDB hands back for BIGINT/HUGEINT, and it poisons anything
301
+ // downstream that does arithmetic or JSON.stringify on a result. Narrowed to a
302
+ // number where that is lossless, left alone where it is not — silently losing
303
+ // precision on a genuinely huge value would be worse than an awkward type.
304
+ function unBig(v) {
305
+ if (typeof v !== 'bigint') return v;
306
+ return (v <= 9007199254740991n && v >= -9007199254740991n) ? Number(v) : v;
307
+ }
308
+
309
+ /**
310
+ * Run one statement. Returns the shape the other drivers return — `rows` as
311
+ * objects, `rowCount`, and `columns` describing the OUTPUT regardless of
312
+ * whether any row matched.
313
+ *
314
+ * ── rowCount, and why it is not `rows.length` ────────────────────────────
315
+ *
316
+ * DuckDB answers an INSERT or DELETE with a one-row result holding the number
317
+ * of rows changed. So a DELETE that removed two thousand rows comes back with
318
+ * rows.length === 1, and anything reading that as the row count reports 1 —
319
+ * quietly, with no error anywhere. rollbackMovement is exactly such a caller,
320
+ * and "rolled back 1 row" when it rolled back two thousand is the kind of
321
+ * wrong number that gets believed.
322
+ *
323
+ * The result's OWN return type is what distinguishes the two cases, so that is
324
+ * what this reads. CHANGED_ROWS is DML; QUERY_RESULT is a select.
325
+ */
326
+ async function query(pool, sql, params) {
327
+ var entry = entryOf(pool);
328
+ var conn = await acquire(entry);
329
+ try {
330
+ var api = require('@duckdb/node-api');
331
+ var reader = await conn.runAndReadAll(sql, params && params.length ? params : undefined);
332
+
333
+ if (reader.returnType === api.ResultReturnType.CHANGED_ROWS) {
334
+ return { rows: [], rowCount: unBig(reader.rowsChanged) || 0, columns: [] };
335
+ }
336
+
337
+ var columns = reader.columnNames();
338
+ var rows = reader.getRowObjectsJS().map(function(row) {
339
+ var out = {};
340
+ for (var k in row) out[k] = unBig(row[k]);
341
+ return out;
342
+ });
343
+ return { rows: rows, rowCount: rows.length, columns: columns };
344
+ } catch (err) {
345
+ throw explainLockError(err, entry.key, entry.access, 0);
346
+ } finally {
347
+ release(entry, conn);
348
+ }
349
+ }
350
+
351
+ // ── SQL builders ─────────────────────────────────────────────────────────
352
+
353
+ function toPgType(type) { return TYPE_MAP[type] || 'VARCHAR'; }
354
+
355
+ function quoteIdent(name) {
356
+ return '"' + String(name).replace(/"/g, '""') + '"';
357
+ }
358
+
359
+ // See INTERNAL_ID_COL above for why there is no surrogate key column here.
360
+ function buildCreateTableSql(tableName, columns, _primaryKeys) {
361
+ var defs = [quoteIdent(INTERNAL_MOVEMENT_COL) + ' VARCHAR NOT NULL'];
362
+ for (var i = 0; i < columns.length; i++) {
363
+ defs.push(quoteIdent(columns[i].name) + ' ' + toPgType(columns[i].type));
364
+ }
365
+ return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(tableName) +
366
+ ' (\n ' + defs.join(',\n ') + '\n)';
367
+ }
368
+
369
+ function buildCreateErrorTableSql(tableName) {
370
+ var errTable = tableName + '_import_errors';
371
+ return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(errTable) + ' (\n' +
372
+ ' movement_id VARCHAR NOT NULL,\n' +
373
+ ' row_num INTEGER,\n' +
374
+ ' error_description VARCHAR,\n' +
375
+ ' underlying_sql VARCHAR,\n' +
376
+ ' raw_row JSON,\n' +
377
+ ' recorded_at TIMESTAMPTZ DEFAULT now()\n' +
378
+ ')';
379
+ }
380
+
381
+ // The UNIQUE index is not optional decoration on DuckDB — ON CONFLICT has no
382
+ // conflict target without it, so an upsert silently becomes an error.
383
+ function buildUpsertIndexSql(tableName, primaryKeys) {
384
+ if (!primaryKeys || primaryKeys.length === 0) return null;
385
+ return 'CREATE UNIQUE INDEX IF NOT EXISTS ' + quoteIdent(tableName + '_upsert_uniq') +
386
+ ' ON ' + quoteIdent(tableName) + ' (' + primaryKeys.map(quoteIdent).join(', ') + ')';
387
+ }
388
+
389
+ function buildAlterTableAddSql(tableName, columns) {
390
+ return columns.map(function(c) {
391
+ return 'ALTER TABLE ' + quoteIdent(tableName) +
392
+ ' ADD COLUMN IF NOT EXISTS ' + quoteIdent(c.name) + ' ' + toPgType(c.type);
393
+ });
394
+ }
395
+
396
+ function buildInsertSql(tableName, rows, columns, movementId, primaryKeys) {
397
+ var colNames = columns.map(function(c) { return c.name; });
398
+ var allCols = [INTERNAL_MOVEMENT_COL].concat(colNames);
399
+
400
+ var params = [];
401
+ var groups = [];
402
+ var idx = 1;
403
+
404
+ for (var r = 0; r < rows.length; r++) {
405
+ var row = rows[r];
406
+ var placeholders = ['$' + idx];
407
+ params.push(movementId); idx++;
408
+ for (var c = 0; c < colNames.length; c++) {
409
+ placeholders.push('$' + idx);
410
+ params.push(coerceValue(row[colNames[c]], columns[c].type));
411
+ idx++;
412
+ }
413
+ groups.push('(' + placeholders.join(',') + ')');
414
+ }
415
+
416
+ var sql = 'INSERT INTO ' + quoteIdent(tableName) +
417
+ ' (' + allCols.map(quoteIdent).join(', ') + ') VALUES ' + groups.join(',');
418
+
419
+ if (primaryKeys && primaryKeys.length > 0) {
420
+ var updateCols = colNames.filter(function(c) { return primaryKeys.indexOf(c) === -1; });
421
+ var setClause = updateCols.concat([INTERNAL_MOVEMENT_COL]).map(function(c) {
422
+ return quoteIdent(c) + ' = EXCLUDED.' + quoteIdent(c);
423
+ }).join(', ');
424
+ sql += ' ON CONFLICT (' + primaryKeys.map(quoteIdent).join(', ') +
425
+ ') DO UPDATE SET ' + setClause;
426
+ }
427
+
428
+ return { sql: sql, params: params };
429
+ }
430
+
431
+ // A calendar date as YYYY-MM-DD with no timezone conversion — see the note in
432
+ // postgres.js; the bug it avoids is identical here.
433
+ function toDateOnly(v) {
434
+ if (v === null || v === undefined) return v;
435
+ if (v instanceof Date) {
436
+ if (isNaN(v.getTime())) return null;
437
+ return v.getFullYear() + '-' +
438
+ String(v.getMonth() + 1).padStart(2, '0') + '-' +
439
+ String(v.getDate()).padStart(2, '0');
440
+ }
441
+ var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
442
+ return m ? m[1] : v;
443
+ }
444
+
445
+ // Coerce a JS value to the effective column type. Same contract as the other
446
+ // drivers: a value that cannot fit passes through unchanged so DuckDB rejects
447
+ // it and SqlQueue's bisection isolates the row, rather than throwing here and
448
+ // failing the whole batch.
449
+ //
450
+ // Everything leaves as a string, number, boolean or null — never a Date and
451
+ // never a BigInt — because those are the values the bindings map cleanly.
452
+ function coerceValue(v, type) {
453
+ if (v === undefined || v === null) return null;
454
+
455
+ switch (type) {
456
+ case 'string':
457
+ if (v instanceof Date) return v.toISOString();
458
+ if (typeof v === 'bigint') return v.toString();
459
+ if (typeof v === 'object') return JSON.stringify(v);
460
+ return String(v);
461
+
462
+ case 'number':
463
+ if (typeof v === 'number') return v;
464
+ if (typeof v === 'bigint') return unBig(v);
465
+ if (typeof v === 'string') {
466
+ var n = Number(v);
467
+ return isNaN(n) ? v : n;
468
+ }
469
+ return v;
470
+
471
+ case 'boolean':
472
+ if (typeof v === 'boolean') return v;
473
+ if (typeof v === 'string') {
474
+ var lower = v.toLowerCase().trim();
475
+ if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'y') return true;
476
+ if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'n') return false;
477
+ }
478
+ if (typeof v === 'number') return v !== 0;
479
+ return v;
480
+
481
+ case 'date':
482
+ return toDateOnly(v);
483
+
484
+ case 'datetime':
485
+ if (v instanceof Date) return v.toISOString();
486
+ return v;
487
+
488
+ case 'object':
489
+ case 'array':
490
+ if (typeof v === 'string') return v;
491
+ return JSON.stringify(v);
492
+
493
+ default:
494
+ return v;
495
+ }
496
+ }
497
+
498
+ // DuckDB reports parameterised types as "DECIMAL(18,3)" / "STRUCT(...)", so the
499
+ // lookup is on the head of the string rather than the whole of it.
500
+ function dataTypeToLogical(dataType) {
501
+ if (!dataType) return 'string';
502
+ var head = String(dataType).toUpperCase().split('(')[0].trim();
503
+ return DUCK_TO_LOGICAL[head] || DUCK_TO_LOGICAL[String(dataType).toUpperCase()] || 'string';
504
+ }
505
+
506
+ // ── idempotent DDL ───────────────────────────────────────────────────────
507
+
508
+ async function ensureMovementColumn(pool, tableName) {
509
+ await query(pool, 'ALTER TABLE ' + quoteIdent(tableName) +
510
+ ' ADD COLUMN IF NOT EXISTS ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' VARCHAR');
511
+ }
512
+
513
+ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
514
+ var sql = buildUpsertIndexSql(tableName, primaryKeys);
515
+ if (sql) await query(pool, sql);
516
+ }
517
+
518
+ // ── introspection ────────────────────────────────────────────────────────
519
+
520
+ // 'main' rather than 'public' — that is DuckDB's default schema, and passing
521
+ // 'public' here returns an empty column list for a table that plainly exists.
522
+ async function getTableSchema(pool, tableName, schema) {
523
+ var r = await query(pool,
524
+ 'SELECT column_name, data_type FROM information_schema.columns ' +
525
+ 'WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position',
526
+ [schema || 'main', tableName]);
527
+ return r.rows.map(function(row) {
528
+ return { name: row.column_name, dataType: row.data_type, udtName: row.data_type };
529
+ });
530
+ }
531
+
532
+ async function listTables(pool, schema) {
533
+ var r = await query(pool,
534
+ "SELECT table_name FROM information_schema.tables " +
535
+ "WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name",
536
+ [schema || 'main']);
537
+ return r.rows.map(function(row) { return row.table_name; });
538
+ }
539
+
540
+ async function listViews(pool, schema) {
541
+ var r = await query(pool,
542
+ 'SELECT table_name FROM information_schema.views WHERE table_schema = $1 ORDER BY table_name',
543
+ [schema || 'main']);
544
+ return r.rows.map(function(row) { return row.table_name; });
545
+ }
546
+
547
+ // DuckDB has no stored procedures. Empty rather than an error, so a UI that
548
+ // offers "a stored procedure" against every connection gets an empty list and
549
+ // says so, instead of a failed request.
550
+ async function listProcedures(_pool, _schema) {
551
+ return [];
552
+ }
553
+
554
+ // ── streaming read ───────────────────────────────────────────────────────
555
+
556
+ /**
557
+ * Stream a result set without buffering it — chunk at a time, so a 20M-row
558
+ * read costs one chunk of memory rather than twenty million rows of it.
559
+ *
560
+ * `fetchChunk()` is the backpressure: the next chunk is only pulled when the
561
+ * consumer asks for the next row. `readAll()` on a reader would defeat this
562
+ * entirely by materialising the lot first.
563
+ */
564
+ async function* fetchStream(pool, opts) {
565
+ var entry = entryOf(pool);
566
+ var conn = await acquire(entry);
567
+ try {
568
+ var result = await conn.stream(opts.sql, (opts.params && opts.params.length) ? opts.params : undefined);
569
+ var names = result.columnNames();
570
+ while (true) {
571
+ var chunk = await result.fetchChunk();
572
+ if (!chunk || chunk.rowCount === 0) break;
573
+ var rows = chunk.getRowObjects(names);
574
+ for (var i = 0; i < rows.length; i++) {
575
+ var row = rows[i];
576
+ var out = {};
577
+ for (var k in row) out[k] = unBig(row[k]);
578
+ yield out;
579
+ }
580
+ }
581
+ } catch (err) {
582
+ throw explainLockError(err, entry.key, entry.access, 0);
583
+ } finally {
584
+ release(entry, conn);
585
+ }
586
+ }
587
+
588
+ // ── rollback ─────────────────────────────────────────────────────────────
589
+
590
+ async function rollbackMovement(pool, tableName, movementId) {
591
+ var result = { mainDeleted: 0, errorDeleted: 0 };
592
+
593
+ var r1 = await query(pool,
594
+ 'DELETE FROM ' + quoteIdent(tableName) +
595
+ ' WHERE ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' = $1', [movementId]);
596
+ result.mainDeleted = r1.rowCount || 0;
597
+
598
+ try {
599
+ var r2 = await query(pool,
600
+ 'DELETE FROM ' + quoteIdent(tableName + '_import_errors') + ' WHERE movement_id = $1',
601
+ [movementId]);
602
+ result.errorDeleted = r2.rowCount || 0;
603
+ } catch (err) {
604
+ // The error table only exists once a movement has actually errored, so
605
+ // "it isn't there" is the normal case, not a failure.
606
+ if (!/does not exist|not found|Table with name/i.test(String(err.message))) throw err;
607
+ }
608
+
609
+ return result;
610
+ }
611
+
612
+ module.exports = {
613
+ requires: ['@duckdb/node-api'],
614
+
615
+ connect: connect,
616
+ query: query,
617
+ close: close,
618
+
619
+ toPgType: toPgType,
620
+ dataTypeToLogical: dataTypeToLogical,
621
+ quoteIdent: quoteIdent,
622
+ buildCreateTableSql: buildCreateTableSql,
623
+ buildCreateErrorTableSql: buildCreateErrorTableSql,
624
+ buildUpsertIndexSql: buildUpsertIndexSql,
625
+ buildAlterTableAddSql: buildAlterTableAddSql,
626
+ buildInsertSql: buildInsertSql,
627
+
628
+ ensureMovementColumn: ensureMovementColumn,
629
+ ensureUpsertIndex: ensureUpsertIndex,
630
+
631
+ getTableSchema: getTableSchema,
632
+ listTables: listTables,
633
+ listViews: listViews,
634
+ listProcedures: listProcedures,
635
+
636
+ fetchStream: fetchStream,
637
+
638
+ rollbackMovement: rollbackMovement,
639
+
640
+ INTERNAL_ID_COL: INTERNAL_ID_COL,
641
+ INTERNAL_MOVEMENT_COL: INTERNAL_MOVEMENT_COL,
642
+
643
+ // Exposed for the warehouse layer and for tests — "is this process the
644
+ // writer" is a question worth being able to ask without opening a file.
645
+ accessMode: accessMode
646
+ };
@@ -7,7 +7,10 @@ var DRIVERS = {
7
7
  postgres: function() { return require('./postgres'); },
8
8
  mysql: function() { return require('./mysql'); },
9
9
  mssql: function() { return require('./mssql'); },
10
- mongo: function() { return require('./mongo'); }
10
+ mongo: function() { return require('./mongo'); },
11
+ // Not a server — a file this process opens. See duckdb.js for what that
12
+ // means for who is allowed to open it, which is the whole of the difference.
13
+ duckdb: function() { return require('./duckdb'); }
11
14
  };
12
15
 
13
16
  function getDriver(type) {