@xeplr/actions 1.1.12 → 1.1.13
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/README.md +3 -1
- package/lib/drivers/db/duckdb.js +134 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,6 +293,8 @@ await driver.query(pool, userSql, params) // reads only
|
|
|
293
293
|
| **Rollback is refused for a movement that used `primaryKeys`.** | Upsert stamps the movement id on rows it only updated, so deleting by it would remove rows that already existed. |
|
|
294
294
|
| Movement logs carry identifiers, counts, SQL and the error text — never parameter values or row contents; a failed batch shows one row's primary key, not its data. | Logs are copied into tickets and chats. |
|
|
295
295
|
| DuckDB opens **read-only unless the call says `access: 'rw'`**; no environment variable can grant write. A file already open read-only in the process refuses a write. Opening waits on the lock (`openTimeoutMs`, default 60 s). | One read-write process locks every other process out, even readers. Write access belongs at the call site that writes. |
|
|
296
|
+
| **Every DuckDB value comes out plain**, from `query` and `fetchStream` alike: a number, text, boolean, `Date`, array or object, never a BigInt or a DuckDB wrapper object. DECIMAL is a number; DATE and TIMESTAMP are `Date`s; a whole number too big to hold exactly (beyond ±9,007,199,254,740,991) is **text**, never rounded; lists, structs and maps are arrays and objects; TIME, INTERVAL, UUID and any other type are text. `fetchStream` has DuckDB do the conversion inside its engine by wrapping the query (about +3% on a million rows); a query with nothing to convert runs as given. | A streamed read used to hand DECIMAL and TIMESTAMP back as wrapper objects with a BigInt inside, and the first `JSON.stringify` downstream failed with "Do not know how to serialize a BigInt". Text for huge numbers is what the Postgres, MySQL and SQL Server drivers already return. |
|
|
297
|
+
| **A DuckDB timestamp without a zone is read as UTC**; one with a zone keeps its exact instant. | Data is kept in UTC; the viewer's time zone is applied when rendering, never in the driver. |
|
|
296
298
|
| `meta-store-knex` requires `service` and `applicationId`, and scopes every statement by `applicationId`. | `import_meta` is shared by every app; unattributed rows, or a rollback decided from another app's row, are worse than refusing. |
|
|
297
299
|
| Mail filenames are sanitised (`[^\w.-]` → `_`, all-dot names → `attachment`, max 180 chars) and prefixed with a random id. | Attachment names come from the sender and could escape the output directory; common names collide. |
|
|
298
300
|
| The system mail sender refuses `text`, `bcc`, `from`, `replyTo` rather than dropping them. | A silently lost bcc is a compliance problem found a year later. |
|
|
@@ -309,7 +311,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
309
311
|
| needs nothing | needs a database |
|
|
310
312
|
|---|---|
|
|
311
313
|
| `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
312
|
-
| `test/drivers/duckdb.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
|
|
314
|
+
| `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
|
|
313
315
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
314
316
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
|
315
317
|
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -299,11 +299,27 @@ async function close(pool) {
|
|
|
299
299
|
|
|
300
300
|
// BigInt is what DuckDB hands back for BIGINT/HUGEINT, and it poisons anything
|
|
301
301
|
// downstream that does arithmetic or JSON.stringify on a result. Narrowed to a
|
|
302
|
-
// number where that is lossless,
|
|
303
|
-
//
|
|
302
|
+
// number where that is lossless, and to TEXT where it is not: silently losing
|
|
303
|
+
// digits on a genuinely huge value would be wrong, and a BigInt left in a row
|
|
304
|
+
// crashes the first JSON.stringify that meets it. Text is also what Postgres,
|
|
305
|
+
// MySQL and SQL Server already return for such a value, so every source
|
|
306
|
+
// behaves alike.
|
|
304
307
|
function unBig(v) {
|
|
305
308
|
if (typeof v !== 'bigint') return v;
|
|
306
|
-
return (v <= 9007199254740991n && v >= -9007199254740991n) ? Number(v) : v;
|
|
309
|
+
return (v <= 9007199254740991n && v >= -9007199254740991n) ? Number(v) : v.toString();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The same, all the way down: a BigInt can sit INSIDE a value the normal read
|
|
313
|
+
// returns (an INTERVAL's micros, a list of BIGINTs). Arrays and plain objects
|
|
314
|
+
// keep their shape; a Date, a string or a number is left as it is.
|
|
315
|
+
function plainDeep(v) {
|
|
316
|
+
if (typeof v === 'bigint') return unBig(v);
|
|
317
|
+
if (v === null || typeof v !== 'object' || v instanceof Date) return v;
|
|
318
|
+
if (Array.isArray(v)) return v.map(plainDeep);
|
|
319
|
+
if (Object.getPrototypeOf(v) !== Object.prototype) return v;
|
|
320
|
+
var out = {};
|
|
321
|
+
for (var k in v) out[k] = plainDeep(v[k]);
|
|
322
|
+
return out;
|
|
307
323
|
}
|
|
308
324
|
|
|
309
325
|
/**
|
|
@@ -337,7 +353,7 @@ async function query(pool, sql, params) {
|
|
|
337
353
|
var columns = reader.columnNames();
|
|
338
354
|
var rows = reader.getRowObjectsJS().map(function(row) {
|
|
339
355
|
var out = {};
|
|
340
|
-
for (var k in row) out[k] =
|
|
356
|
+
for (var k in row) out[k] = plainDeep(row[k]);
|
|
341
357
|
return out;
|
|
342
358
|
});
|
|
343
359
|
return { rows: rows, rowCount: rows.length, columns: columns };
|
|
@@ -551,6 +567,108 @@ async function listProcedures(_pool, _schema) {
|
|
|
551
567
|
return [];
|
|
552
568
|
}
|
|
553
569
|
|
|
570
|
+
// ── plain values out of a stream ─────────────────────────────────────────
|
|
571
|
+
//
|
|
572
|
+
// A streamed chunk (getRowObjects) hands DECIMAL, TIMESTAMP, INTERVAL, UUID
|
|
573
|
+
// and the nested types back as DuckDB WRAPPER objects, several with a BigInt
|
|
574
|
+
// inside. They are not the values the rest of the code expects, and the first
|
|
575
|
+
// JSON.stringify that meets one fails ("Do not know how to serialize a
|
|
576
|
+
// BigInt") — which is how a report grouped by an amount or a timestamp from a
|
|
577
|
+
// cube used to die.
|
|
578
|
+
//
|
|
579
|
+
// Converting each wrapper in JavaScript doubles the cost of a streamed read.
|
|
580
|
+
// So DuckDB converts them inside its own engine instead, by wrapping the
|
|
581
|
+
// query: measured on 1M rows, +2% (+6% with the timestamp turned into a Date
|
|
582
|
+
// here), against +100% for the JS converter.
|
|
583
|
+
//
|
|
584
|
+
// passes through booleans, integers (BigInt narrowed by unBig), floats,
|
|
585
|
+
// text, enums
|
|
586
|
+
// DECIMAL DOUBLE: what the non-streamed read (getRowObjectsJS)
|
|
587
|
+
// returns too
|
|
588
|
+
// DATE, TIMESTAMP* epoch milliseconds, turned into a Date as the row is
|
|
589
|
+
// built — the same type Postgres and the normal read give.
|
|
590
|
+
// A timestamp WITHOUT a zone is read as UTC (data is kept
|
|
591
|
+
// in UTC; the viewer's zone is applied when rendering). One
|
|
592
|
+
// WITH a zone keeps its exact instant: epoch_ms is taken on
|
|
593
|
+
// it directly, never after a cast that would apply the
|
|
594
|
+
// session's zone.
|
|
595
|
+
// LIST, STRUCT, JSON, parsed back here: arrays and objects, the shape the
|
|
596
|
+
// MAP, ARRAY normal read gives
|
|
597
|
+
// anything else VARCHAR (TIME, INTERVAL, UUID, …). A type nobody has met
|
|
598
|
+
// yet becomes text rather than a crash.
|
|
599
|
+
//
|
|
600
|
+
// When no column needs converting, the query runs exactly as given.
|
|
601
|
+
var PASS_THROUGH_TYPES = null;
|
|
602
|
+
var DATE_TYPES = null;
|
|
603
|
+
var NESTED_TYPES = null;
|
|
604
|
+
function typeSets() {
|
|
605
|
+
if (PASS_THROUGH_TYPES) return;
|
|
606
|
+
var T = require('@duckdb/node-api').DuckDBTypeId;
|
|
607
|
+
PASS_THROUGH_TYPES = new Set([T.BOOLEAN, T.TINYINT, T.SMALLINT, T.INTEGER, T.BIGINT, T.HUGEINT,
|
|
608
|
+
T.UTINYINT, T.USMALLINT, T.UINTEGER, T.UBIGINT, T.UHUGEINT, T.FLOAT, T.DOUBLE,
|
|
609
|
+
T.VARCHAR, T.ENUM, T.SQLNULL].filter(function(x) { return x !== undefined; }));
|
|
610
|
+
DATE_TYPES = {};
|
|
611
|
+
DATE_TYPES[T.DATE] = 'plain';
|
|
612
|
+
DATE_TYPES[T.TIMESTAMP] = 'plain';
|
|
613
|
+
DATE_TYPES[T.TIMESTAMP_S] = 'plain';
|
|
614
|
+
DATE_TYPES[T.TIMESTAMP_MS] = 'plain';
|
|
615
|
+
DATE_TYPES[T.TIMESTAMP_NS] = 'plain';
|
|
616
|
+
DATE_TYPES[T.TIMESTAMP_TZ] = 'zoned';
|
|
617
|
+
NESTED_TYPES = new Set([T.LIST, T.STRUCT, T.MAP, T.ARRAY, T.UNION].filter(function(x) { return x !== undefined; }));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function stripTrailingSemicolon(sql) {
|
|
621
|
+
return String(sql).replace(/[\s;]+$/, '');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function plainValuesPlan(conn, sql) {
|
|
625
|
+
typeSets();
|
|
626
|
+
var prepared = await conn.prepare(sql);
|
|
627
|
+
var count = prepared.columnCount;
|
|
628
|
+
var select = [];
|
|
629
|
+
var inner = [];
|
|
630
|
+
var dateColumns = [];
|
|
631
|
+
var jsonColumns = [];
|
|
632
|
+
var changed = false;
|
|
633
|
+
for (var i = 0; i < count; i++) {
|
|
634
|
+
var name = prepared.columnName(i);
|
|
635
|
+
var typeId = prepared.columnTypeId(i);
|
|
636
|
+
var col = '"__c' + i + '"';
|
|
637
|
+
inner.push(col);
|
|
638
|
+
var expr;
|
|
639
|
+
if (PASS_THROUGH_TYPES.has(typeId)) {
|
|
640
|
+
expr = col;
|
|
641
|
+
} else if (DATE_TYPES[typeId] === 'zoned') {
|
|
642
|
+
expr = 'epoch_ms(' + col + ')';
|
|
643
|
+
dateColumns.push(name);
|
|
644
|
+
changed = true;
|
|
645
|
+
} else if (DATE_TYPES[typeId] === 'plain') {
|
|
646
|
+
expr = 'epoch_ms(CAST(' + col + ' AS TIMESTAMP))';
|
|
647
|
+
dateColumns.push(name);
|
|
648
|
+
changed = true;
|
|
649
|
+
} else if (typeId === require('@duckdb/node-api').DuckDBTypeId.DECIMAL) {
|
|
650
|
+
expr = 'CAST(' + col + ' AS DOUBLE)';
|
|
651
|
+
changed = true;
|
|
652
|
+
} else if (NESTED_TYPES.has(typeId)) {
|
|
653
|
+
expr = 'CAST(to_json(' + col + ') AS VARCHAR)';
|
|
654
|
+
jsonColumns.push(name);
|
|
655
|
+
changed = true;
|
|
656
|
+
} else {
|
|
657
|
+
expr = 'CAST(' + col + ' AS VARCHAR)';
|
|
658
|
+
changed = true;
|
|
659
|
+
}
|
|
660
|
+
select.push(expr + ' AS ' + quoteIdent(name));
|
|
661
|
+
}
|
|
662
|
+
if (!changed) return { sql: sql, dateColumns: [], jsonColumns: [] };
|
|
663
|
+
// Positional renames (__c0, __c1, …) so two source columns with one name
|
|
664
|
+
// stay two columns, whatever they are called.
|
|
665
|
+
return {
|
|
666
|
+
sql: 'SELECT ' + select.join(', ') + ' FROM (' + stripTrailingSemicolon(sql) + '\n) AS "__plain"(' + inner.join(', ') + ')',
|
|
667
|
+
dateColumns: dateColumns,
|
|
668
|
+
jsonColumns: jsonColumns
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
554
672
|
// ── streaming read ───────────────────────────────────────────────────────
|
|
555
673
|
|
|
556
674
|
/**
|
|
@@ -565,8 +683,11 @@ async function* fetchStream(pool, opts) {
|
|
|
565
683
|
var entry = entryOf(pool);
|
|
566
684
|
var conn = await acquire(entry);
|
|
567
685
|
try {
|
|
568
|
-
var
|
|
686
|
+
var plan = await plainValuesPlan(conn, opts.sql);
|
|
687
|
+
var result = await conn.stream(plan.sql, (opts.params && opts.params.length) ? opts.params : undefined);
|
|
569
688
|
var names = result.columnNames();
|
|
689
|
+
var dates = plan.dateColumns;
|
|
690
|
+
var jsons = plan.jsonColumns;
|
|
570
691
|
while (true) {
|
|
571
692
|
var chunk = await result.fetchChunk();
|
|
572
693
|
if (!chunk || chunk.rowCount === 0) break;
|
|
@@ -575,6 +696,14 @@ async function* fetchStream(pool, opts) {
|
|
|
575
696
|
var row = rows[i];
|
|
576
697
|
var out = {};
|
|
577
698
|
for (var k in row) out[k] = unBig(row[k]);
|
|
699
|
+
for (var d = 0; d < dates.length; d++) {
|
|
700
|
+
var v = out[dates[d]];
|
|
701
|
+
if (v !== null && v !== undefined) out[dates[d]] = new Date(v);
|
|
702
|
+
}
|
|
703
|
+
for (var j = 0; j < jsons.length; j++) {
|
|
704
|
+
var jv = out[jsons[j]];
|
|
705
|
+
if (typeof jv === 'string') out[jsons[j]] = JSON.parse(jv);
|
|
706
|
+
}
|
|
578
707
|
yield out;
|
|
579
708
|
}
|
|
580
709
|
}
|
package/package.json
CHANGED