@xeplr/actions 2.2.4 → 2.2.6

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 CHANGED
@@ -122,7 +122,7 @@ The input is checked exactly as a run checks it.
122
122
 
123
123
  | previewed (reads, up to 5) | example only (writes or sends; never run) |
124
124
  |---|---|
125
- | `db-fetch` (through the run's own cursor, then stops; nothing spooled), `db-list-tables` / `-views` / `-procedures` / `-columns`, `file-list`, `file-read`, `data-operation` (the run itself: it only computes), `email-read` (the mailbox opened **read-only**, IMAP `EXAMINE`, so nothing is marked read) | `file-move`, `file-delete`, `email-send`, `push-send`, `email-move`, `email-delete`, `email-download-email`, `email-download-attachments`, `export-table`, `export-pages`, `db-procedure` (a procedure may write) |
125
+ | `db-fetch` (through the run's own cursor, then stops; nothing spooled), `db-list-tables` / `-views` / `-procedures` / `-columns` / `-foreign-keys`, `file-list`, `file-read`, `data-operation` (the run itself: it only computes), `email-read` (the mailbox opened **read-only**, IMAP `EXAMINE`, so nothing is marked read) | `file-move`, `file-delete`, `email-send`, `push-send`, `email-move`, `email-delete`, `email-download-email`, `email-download-attachments`, `export-table`, `export-pages`, `db-procedure` (a procedure may write) |
126
126
 
127
127
  Neither: `db-push`, `db-move`, `http-request` (an outside system, which may change something), `spawn-program`, `file-upload`; a designer asks for an example by hand. `test/previews.test.js` checks it on a real folder, file and Postgres: five rows read and no file spooled, the real count kept, a deleting step's file still there.
128
128
 
@@ -144,6 +144,7 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
144
144
  | `dbListViews` | `db-list-views` | `{ views }`. |
145
145
  | `dbListProcedures` | `db-list-procedures` | `{ procedures }` (functions excluded). |
146
146
  | `dbListColumns` | `db-list-columns` | `{ columns }` — `name`, `dataType`, `udtName` and `maxLength` (characters; `-1` is SQL Server `(max)`, `null` when it has none) for one table. |
147
+ | `dbListForeignKeys` | `db-list-foreign-keys` | `{ foreignKeys }` — each foreign key between the tables of a schema/database: `{ name, table, columns, refTable, refColumns }`, a composite key's columns in key order. Only keys whose **both** tables are in the schema `db-list-tables` lists, so every name is one it returns. Postgres (`public`), MySQL (the connection's database), SQL Server (every schema unless `schema`), DuckDB (`main`). What the BI dataset canvas joins two tables on without asking. |
147
148
  | `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`) as a New table, Append or Replace (`mode`, see [Import modes](#import-modes)). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
148
149
  | `sendEmail` | `email-send` | Send one message. See [Email](#email). |
149
150
  | `pushSend` | `push-send` | A push notification on people's phones and computers, even with the app closed, by `emails` and/or `userIds`: `title` (required), `body`, `url` (a page of the app a tap opens), `tag`. Returns `{ devices, sent, removed, failed }`. Through [`@xeplr/push`](https://www.npmjs.com/package/@xeplr/push), which the host sets up. |
@@ -568,6 +569,8 @@ On Postgres, MySQL and SQL Server the staging table is shaped like the target (`
568
569
 
569
570
  Beside the tables it loads, the uploader makes three of its own: `<table>_import_errors` (refused rows), `<table>__xeplr_staging` (a staged load in progress) and, in a DuckDB file, `__xeplr_loads` (its ledger). `isInternalTable(name)` (also accepts a listing row `{ name }`) says whether a table is one of these, so an app listing a database's tables for people to pick from can leave them out. They still exist and are read where they belong.
570
571
 
572
+ It also adds its own columns to every table it loads: `__xeplr_id__` (Postgres, MySQL, SQL Server) and `__xeplr_movement_id__` (every target), the row's id and the load that wrote it. `isInternalColumn(name)` (or `{ name }`) says whether a column is one of these. **They are never carried into another table:** when a loaded table is loaded somewhere else (an import copied into a cube's warehouse, a move between databases), the uploader drops them from the target's columns, whether inferred from the rows or declared by the caller, and the new load stamps its own. Carried along, they clashed with the target's own column (`Column with name __xeplr_movement_id__ already exists`) and would have marked rows with a load that never wrote them.
573
+
571
574
  ### A written DuckDB file keeps no index
572
575
 
573
576
  DuckDB 1.5.4–1.5.6 has a bug ([duckdb/duckdb#26106](https://github.com/duckdb/duckdb/issues/26106), open upstream): when a process dies with rows still only in the WAL, the next open replays them into the table and the next checkpoint drops them from every index on it. The first `DELETE` that reaches one fails with *"Failed to delete all rows from index"*, which is **FATAL**: the whole file is unusable until the process restarts. Any index, unique or not. A table with no index is unaffected.
package/index.js CHANGED
@@ -48,6 +48,7 @@ module.exports = {
48
48
  uploader: uploader,
49
49
  // The uploader's own tables (_import_errors, __xeplr_staging, __xeplr_loads), to leave out of table pickers.
50
50
  isInternalTable: require('./lib/drivers/db/internalTables').isInternalTable,
51
+ isInternalColumn: require('./lib/drivers/db/internalTables').isInternalColumn,
51
52
  inferColumns: uploader.inferColumns,
52
53
  streaming: streaming,
53
54
  drivers: { db: dbDrivers, file: fileDrivers },
@@ -0,0 +1,41 @@
1
+ // db-list-foreign-keys action — the foreign keys between the tables of one
2
+ // schema/database, each with its columns in key order: { name, table,
3
+ // columns, refTable, refColumns }. What an app joins two tables on without
4
+ // asking (the BI dataset canvas does). Only keys whose both tables are in the
5
+ // schema db-list-tables lists. A small catalogue read, like db-list-tables.
6
+
7
+ var dbDrivers = require('../../drivers/db');
8
+
9
+ module.exports = {
10
+ name: 'db-list-foreign-keys',
11
+ description: 'List foreign keys between tables of a schema/database. Routes to the driver named by input.dbType.',
12
+ requires: [], // per-driver requires checked at runtime once dbType is known
13
+
14
+ inputSchema: [
15
+ { name: 'dbType', type: 'string', required: true, default: 'postgres', order: 1,
16
+ description: 'postgres | mysql | mssql | duckdb' },
17
+ { name: 'connection', type: 'object', required: true, order: 2,
18
+ description: 'Connection config: { host, port, user, password, database } (duckdb: { file })' },
19
+ { name: 'schema', type: 'string', order: 3,
20
+ description: 'Schema to read (postgres public, duckdb main, mssql every schema, mysql the connection database by default)' }
21
+ ],
22
+
23
+ // Only reads the database's own catalogue, so the preview is the run.
24
+ preview: async function(ctx) { return { output: await module.exports.execute(ctx) }; },
25
+
26
+ execute: async function(ctx) {
27
+ var input = ctx.input || {};
28
+ var driver = dbDrivers.getDriver(input.dbType);
29
+ dbDrivers.checkDriverRequires('db-list-foreign-keys', input.dbType, driver);
30
+ if (typeof driver.listForeignKeys !== 'function') {
31
+ throw new Error('db-list-foreign-keys: driver "' + input.dbType + '" does not implement listForeignKeys');
32
+ }
33
+
34
+ var pool = await driver.connect(input.connection);
35
+ try {
36
+ return { foreignKeys: await driver.listForeignKeys(pool, input.schema) };
37
+ } finally {
38
+ await driver.close(pool);
39
+ }
40
+ }
41
+ };
@@ -49,6 +49,8 @@ module.exports = {
49
49
  dbListViews: require('./db/list-views'),
50
50
  dbListProcedures: require('./db/list-procedures'),
51
51
  dbListColumns: require('./db/list-columns'),
52
+ // The foreign keys between a schema's tables, composite ones in key order.
53
+ dbListForeignKeys: require('./db/list-foreign-keys'),
52
54
 
53
55
  // File actions — factory-shaped for the same reason (meta store binding).
54
56
  fileUpload: require('./file/upload'),
@@ -354,6 +354,7 @@ async function acquire(entry) {
354
354
  // own connection, and the time limit is a timer that does the same. A query
355
355
  // still waiting for a free connection is let go of before it ever runs.
356
356
  var stop = require('./stop');
357
+ var foreignKeys = require('./foreignKeys');
357
358
 
358
359
  /** acquire(), unless the stop comes first; a connection that arrives too late goes straight back. */
359
360
  function acquireUnlessStopped(entry, signal) {
@@ -658,6 +659,28 @@ async function listTables(pool, schema) {
658
659
  return r.rows.map(function(row) { return row.table_name; });
659
660
  }
660
661
 
662
+ // Foreign keys between this schema's tables — see foreignKeys.js.
663
+ async function listForeignKeys(pool, schema) {
664
+ var r = await query(pool,
665
+ "SELECT constraint_index, table_name, constraint_column_names, referenced_table, referenced_column_names " +
666
+ "FROM duckdb_constraints() WHERE constraint_type = 'FOREIGN KEY' AND schema_name = $1 " +
667
+ 'ORDER BY table_name, constraint_index',
668
+ [schema || 'main']);
669
+ var rows = [];
670
+ r.rows.forEach(function(row) {
671
+ // LIST columns: this driver hands them back as arrays.
672
+ var cols = row.constraint_column_names || [];
673
+ var refs = row.referenced_column_names || [];
674
+ cols.forEach(function(col, i) {
675
+ rows.push({ name: row.table_name + '_fk_' + row.constraint_index, table: row.table_name,
676
+ refTable: row.referenced_table, column: col, refColumn: refs[i] });
677
+ });
678
+ });
679
+ return foreignKeys.group(rows);
680
+ }
681
+
682
+
683
+
661
684
  async function listViews(pool, schema) {
662
685
  var r = await query(pool,
663
686
  'SELECT table_name FROM information_schema.views WHERE table_schema = $1 ORDER BY table_name',
@@ -1039,6 +1062,7 @@ module.exports = {
1039
1062
 
1040
1063
  getTableSchema: getTableSchema,
1041
1064
  listTables: listTables,
1065
+ listForeignKeys: listForeignKeys,
1042
1066
  listViews: listViews,
1043
1067
  listProcedures: listProcedures,
1044
1068
 
@@ -0,0 +1,26 @@
1
+ // Foreign keys between the tables of one schema, as every SQL driver's
2
+ // listForeignKeys returns them — what the BI dataset canvas joins two tables
3
+ // on without asking. One entry per constraint, its columns in key order (a
4
+ // composite key keeps every pair): { name, table, columns, refTable,
5
+ // refColumns }. Each driver reads its own catalogue one row per column pair
6
+ // and hands the rows here; only keys whose BOTH tables are in the schema
7
+ // listTables lists, so every name is one listTables also returns.
8
+
9
+ /** Rows of { name, table, refTable, column, refColumn }, in key order → one entry per key. */
10
+ function group(rows) {
11
+ var byKey = {};
12
+ var out = [];
13
+ (rows || []).forEach(function(r) {
14
+ var key = r.table + '\u0000' + r.name;
15
+ var fk = byKey[key];
16
+ if (!fk) {
17
+ fk = byKey[key] = { name: r.name, table: r.table, columns: [], refTable: r.refTable, refColumns: [] };
18
+ out.push(fk);
19
+ }
20
+ fk.columns.push(r.column);
21
+ fk.refColumns.push(r.refColumn);
22
+ });
23
+ return out;
24
+ }
25
+
26
+ module.exports = { group: group };
@@ -13,6 +13,13 @@ var ERRORS_SUFFIX = '_import_errors';
13
13
  var STAGING_SUFFIX = require('./staging').SUFFIX;
14
14
  var LOADS_LEDGER = '__xeplr_loads';
15
15
 
16
+ // THE COLUMNS IT ADDS TO EVERY TABLE IT LOADS: a row's own id and the
17
+ // movement that wrote it. They describe a load into THAT table, so they are
18
+ // never carried into another one — loading a loaded table elsewhere (an
19
+ // import copied into a cube's warehouse, a move between databases) leaves
20
+ // them behind and the new load stamps its own (uploader/index.js).
21
+ var INTERNAL_COLUMNS = ['__xeplr_id__', '__xeplr_movement_id__'];
22
+
16
23
  function endsWith(s, suffix) {
17
24
  return s.length > suffix.length && s.slice(-suffix.length) === suffix;
18
25
  }
@@ -23,8 +30,16 @@ function isInternalTable(name) {
23
30
  return endsWith(s, ERRORS_SUFFIX) || endsWith(s, STAGING_SUFFIX) || s === LOADS_LEDGER;
24
31
  }
25
32
 
33
+ /** Whether a column is one the uploader adds itself. A name, or { name }. */
34
+ function isInternalColumn(column) {
35
+ var s = String(column == null ? '' : (typeof column === 'object' ? column.name || '' : column));
36
+ return INTERNAL_COLUMNS.indexOf(s) !== -1;
37
+ }
38
+
26
39
  module.exports = {
27
40
  isInternalTable: isInternalTable,
41
+ isInternalColumn: isInternalColumn,
42
+ INTERNAL_COLUMNS: INTERNAL_COLUMNS,
28
43
  ERRORS_SUFFIX: ERRORS_SUFFIX,
29
44
  STAGING_SUFFIX: STAGING_SUFFIX,
30
45
  LOADS_LEDGER: LOADS_LEDGER
@@ -57,6 +57,7 @@ var NATIVE_TO_LOGICAL = {
57
57
 
58
58
  // Dates and times: one shared rule for every driver (dateValues.js).
59
59
  var dateValues = require('./dateValues');
60
+ var foreignKeys = require('./foreignKeys');
60
61
 
61
62
  var INTERNAL_ID_COL = '__xeplr_id__';
62
63
  var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
@@ -348,6 +349,25 @@ async function listTables(pool, schema) {
348
349
  return r.rows.map(function(row) { return row.name; });
349
350
  }
350
351
 
352
+ // Foreign keys between this schema's tables — see foreignKeys.js.
353
+ async function listForeignKeys(pool, schema) {
354
+ var r = await query(pool,
355
+ 'SELECT fk.name AS name, t.name AS [table], rt.name AS refTable, ' +
356
+ 'c.name AS [column], rc.name AS refColumn ' +
357
+ 'FROM sys.foreign_keys fk ' +
358
+ 'JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id ' +
359
+ 'JOIN sys.tables t ON t.object_id = fk.parent_object_id ' +
360
+ 'JOIN sys.tables rt ON rt.object_id = fk.referenced_object_id ' +
361
+ 'JOIN sys.columns c ON c.object_id = fkc.parent_object_id AND c.column_id = fkc.parent_column_id ' +
362
+ 'JOIN sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id ' +
363
+ 'WHERE (@p0 IS NULL OR (SCHEMA_NAME(t.schema_id) = @p0 AND SCHEMA_NAME(rt.schema_id) = @p0)) ' +
364
+ 'ORDER BY t.name, fk.name, fkc.constraint_column_id',
365
+ [schema || null]
366
+ );
367
+ return foreignKeys.group(r.rows);
368
+ }
369
+
370
+
351
371
  async function listViews(pool, schema) {
352
372
  var r = await query(pool,
353
373
  'SELECT TABLE_NAME AS name FROM INFORMATION_SCHEMA.TABLES ' +
@@ -545,6 +565,7 @@ module.exports = {
545
565
  // Introspection (used by uploader.reconcile + the db-list-* actions) + rollback
546
566
  getTableSchema: getTableSchema,
547
567
  listTables: listTables,
568
+ listForeignKeys: listForeignKeys,
548
569
  listViews: listViews,
549
570
  listProcedures: listProcedures,
550
571
  rollbackMovement: rollbackMovement,
@@ -55,6 +55,7 @@ var NATIVE_TO_LOGICAL = {
55
55
 
56
56
  // Dates and times: one shared rule for every driver (dateValues.js).
57
57
  var dateValues = require('./dateValues');
58
+ var foreignKeys = require('./foreignKeys');
58
59
 
59
60
  var INTERNAL_ID_COL = '__xeplr_id__';
60
61
  var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
@@ -383,6 +384,21 @@ async function listTables(pool, schema) {
383
384
  return r.rows.map(function(row) { return row.name; });
384
385
  }
385
386
 
387
+ // Foreign keys between this schema's tables — see foreignKeys.js.
388
+ async function listForeignKeys(pool, schema) {
389
+ var r = await query(pool,
390
+ 'SELECT CONSTRAINT_NAME AS name, TABLE_NAME AS `table`, REFERENCED_TABLE_NAME AS refTable, ' +
391
+ 'COLUMN_NAME AS `column`, REFERENCED_COLUMN_NAME AS refColumn ' +
392
+ 'FROM information_schema.KEY_COLUMN_USAGE ' +
393
+ 'WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND REFERENCED_TABLE_NAME IS NOT NULL ' +
394
+ 'AND REFERENCED_TABLE_SCHEMA = TABLE_SCHEMA ' +
395
+ 'ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION',
396
+ [schema || null]
397
+ );
398
+ return foreignKeys.group(r.rows);
399
+ }
400
+
401
+
386
402
  async function listViews(pool, schema) {
387
403
  var r = await query(pool,
388
404
  "SELECT TABLE_NAME AS name FROM information_schema.TABLES " +
@@ -577,6 +593,7 @@ module.exports = {
577
593
  // Introspection (used by uploader.reconcile + the db-list-* actions) + rollback
578
594
  getTableSchema: getTableSchema,
579
595
  listTables: listTables,
596
+ listForeignKeys: listForeignKeys,
580
597
  listViews: listViews,
581
598
  listProcedures: listProcedures,
582
599
  rollbackMovement: rollbackMovement,
@@ -89,6 +89,7 @@ async function close(pool) {
89
89
  // time limit is statement_timeout, so Postgres enforces it even if this
90
90
  // process dies; our own timer, a little later, is only the backstop.
91
91
  var stop = require('./stop');
92
+ var foreignKeys = require('./foreignKeys');
92
93
  var QUERY_CANCELED = '57014';
93
94
  var BACKSTOP_MS = 2000;
94
95
 
@@ -381,6 +382,25 @@ async function listTables(pool, schema) {
381
382
  return r.rows.map(function(row) { return row.table_name; });
382
383
  }
383
384
 
385
+ // Foreign keys between this schema's tables — see foreignKeys.js.
386
+ async function listForeignKeys(pool, schema) {
387
+ var r = await pool.query(
388
+ 'SELECT c.conname AS name, t.relname AS "table", rt.relname AS "refTable", ' +
389
+ 'a.attname AS "column", ra.attname AS "refColumn" ' +
390
+ 'FROM pg_constraint c ' +
391
+ 'JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace ' +
392
+ 'JOIN pg_class rt ON rt.oid = c.confrelid JOIN pg_namespace rn ON rn.oid = rt.relnamespace ' +
393
+ 'CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(col, refcol, ord) ' +
394
+ 'JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.col ' +
395
+ 'JOIN pg_attribute ra ON ra.attrelid = c.confrelid AND ra.attnum = k.refcol ' +
396
+ "WHERE c.contype = 'f' AND n.nspname = $1 AND rn.nspname = $1 " +
397
+ 'ORDER BY t.relname, c.conname, k.ord',
398
+ [schema || 'public']
399
+ );
400
+ return foreignKeys.group(r.rows);
401
+ }
402
+
403
+
384
404
  async function listViews(pool, schema) {
385
405
  var r = await pool.query(
386
406
  "SELECT table_name FROM information_schema.views " +
@@ -546,6 +566,7 @@ module.exports = {
546
566
  // Introspection (used by uploader.reconcile + the db-list-* actions)
547
567
  getTableSchema: getTableSchema,
548
568
  listTables: listTables,
569
+ listForeignKeys: listForeignKeys,
549
570
  listViews: listViews,
550
571
  listProcedures: listProcedures,
551
572
 
@@ -59,6 +59,7 @@
59
59
  //
60
60
  // See project_xeplr_actions_streaming.md for the full contract.
61
61
 
62
+ var { isInternalColumn } = require('../drivers/db/internalTables');
62
63
  var path = require('path');
63
64
  var fsp = require('fs/promises');
64
65
 
@@ -149,6 +150,12 @@ async function upload(opts) {
149
150
  var scanLimit = Math.min(sample.length, opts.firstBatchScanRows);
150
151
  declared = inferColumns(sample.slice(0, scanLimit));
151
152
  }
153
+ // OUR OWN COLUMNS NEVER COME ALONG. A source that was itself loaded
154
+ // by the uploader (an import's table, a replica) has its own
155
+ // __xeplr_id__ and __xeplr_movement_id__; carried here they clashed
156
+ // with this table's ("already exists") and would have claimed rows
157
+ // for a load that never wrote them. This load stamps its own.
158
+ declared = declared.filter(function(c) { return !isInternalColumn(c); });
152
159
  columns = await runBootstrap(driver, pool, opts.targetTable, declared, opts.primaryKeys, opts.staged);
153
160
  if (opts.staged) {
154
161
  opts.writeTable = await driver.prepareStaging(pool, opts.targetTable);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
5
5
  "main": "index.js",
6
6
  "scripts": {