@xeplr/actions 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/index.js +51 -0
  3. package/lib/builtins/db/fetch.js +142 -0
  4. package/lib/builtins/db/list-columns.js +39 -0
  5. package/lib/builtins/db/list-procedures.js +36 -0
  6. package/lib/builtins/db/list-tables.js +38 -0
  7. package/lib/builtins/db/list-views.js +35 -0
  8. package/lib/builtins/db/push.js +107 -0
  9. package/lib/builtins/db/query.js +5 -0
  10. package/lib/builtins/file/move.js +8 -0
  11. package/lib/builtins/file/upload.js +123 -0
  12. package/lib/builtins/http-request.js +3 -0
  13. package/lib/builtins/index.js +40 -0
  14. package/lib/builtins/send-email.js +8 -0
  15. package/lib/builtins/spawn-program.js +4 -0
  16. package/lib/drivers/db/index.js +41 -0
  17. package/lib/drivers/db/mongo.js +8 -0
  18. package/lib/drivers/db/mssql.js +464 -0
  19. package/lib/drivers/db/mysql.js +425 -0
  20. package/lib/drivers/db/postgres.js +372 -0
  21. package/lib/drivers/file/google.js +8 -0
  22. package/lib/drivers/file/index.js +42 -0
  23. package/lib/drivers/file/local.js +68 -0
  24. package/lib/drivers/file/sftp.js +9 -0
  25. package/lib/drivers/file/sharepoint.js +9 -0
  26. package/lib/errors.js +37 -0
  27. package/lib/formats/_xlsx-stream.js +355 -0
  28. package/lib/formats/csv.js +54 -0
  29. package/lib/formats/excel.js +87 -0
  30. package/lib/formats/index.js +38 -0
  31. package/lib/formats/json.js +42 -0
  32. package/lib/formats/txt.js +6 -0
  33. package/lib/registry.js +75 -0
  34. package/lib/runner.js +81 -0
  35. package/lib/streaming/checkpoint.js +46 -0
  36. package/lib/streaming/index.js +10 -0
  37. package/lib/streaming/resume.js +35 -0
  38. package/lib/streaming/spool.js +253 -0
  39. package/lib/streaming/utils.js +82 -0
  40. package/lib/uploader/attach.js +69 -0
  41. package/lib/uploader/index.js +298 -0
  42. package/lib/uploader/infer.js +76 -0
  43. package/lib/uploader/meta-store-knex.js +136 -0
  44. package/lib/uploader/meta-store-noop.js +11 -0
  45. package/lib/uploader/reconcile.js +88 -0
  46. package/package.json +90 -0
@@ -0,0 +1,425 @@
1
+ // MySQL / MariaDB driver — parity with db/postgres.js. SqlQueue owns retries
2
+ // + bisection + abort, so the driver just does connect / query / close and
3
+ // provides the SQL builders the uploader composes into batch INSERTs.
4
+ //
5
+ // Peer dep: mysql2 (declared optional in @xeplr/actions/package.json).
6
+ //
7
+ // Dialect differences from Postgres worth calling out:
8
+ // • Placeholders are positional `?` (not `$1`).
9
+ // • Identifiers are backtick-quoted (not double-quoted).
10
+ // • MySQL 8 has NO `ADD COLUMN IF NOT EXISTS` and NO `CREATE INDEX IF NOT
11
+ // EXISTS`, so ensureMovementColumn / ensureUpsertIndex introspect
12
+ // information_schema first, then run the DDL only if absent.
13
+ // • mysql2's pool.query resolves to `[rows]` — query() normalizes it to the
14
+ // Postgres-shaped `{ rows, rowCount }` so the rest of the framework is
15
+ // dialect-agnostic.
16
+ //
17
+ // Type mapping — deliberately narrow, mirroring the Postgres driver's intent:
18
+ // string → LONGTEXT
19
+ // number → DECIMAL(38,10) (exact; > 10 fractional digits are rounded —
20
+ // pre-create a wider column if you need more)
21
+ // boolean → TINYINT(1)
22
+ // date → DATETIME(6) (user rule: always datetime, stored UTC)
23
+ // datetime→ DATETIME(6)
24
+ // object → JSON
25
+ // array → JSON
26
+
27
+ var TYPE_MAP = {
28
+ string: 'LONGTEXT',
29
+ number: 'DECIMAL(38,10)',
30
+ boolean: 'TINYINT(1)',
31
+ date: 'DATETIME(6)',
32
+ datetime: 'DATETIME(6)',
33
+ object: 'JSON',
34
+ array: 'JSON'
35
+ };
36
+
37
+ // information_schema DATA_TYPE (lowercase) → our logical types, for reconcile.
38
+ var NATIVE_TO_LOGICAL = {
39
+ varchar: 'string', char: 'string', text: 'string', tinytext: 'string',
40
+ mediumtext: 'string', longtext: 'string', enum: 'string', set: 'string',
41
+ int: 'number', integer: 'number', bigint: 'number', smallint: 'number',
42
+ mediumint: 'number', tinyint: 'number', decimal: 'number', numeric: 'number',
43
+ float: 'number', double: 'number', bit: 'boolean',
44
+ date: 'datetime', datetime: 'datetime', timestamp: 'datetime',
45
+ time: 'datetime', year: 'datetime',
46
+ json: 'object'
47
+ };
48
+
49
+ var INTERNAL_ID_COL = '__xeplr_id__';
50
+ var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
51
+
52
+ // MySQL's max placeholders per prepared statement is 65,535 — same ceiling as
53
+ // PG. Stay comfortably under it; the uploader divides by params-per-row.
54
+ var MAX_PARAMS = 60000;
55
+
56
+ async function connect(config) {
57
+ var mysql = require('mysql2/promise');
58
+ // timezone:'Z' makes mysql2 read/write DATETIME as UTC, matching how
59
+ // coerceValue serializes datetimes. dateStrings avoids local-tz Date shifts.
60
+ var pool = mysql.createPool({
61
+ host: config.host || 'localhost',
62
+ port: config.port || 3306,
63
+ user: config.user,
64
+ password: config.password,
65
+ database: config.database,
66
+ connectionLimit: config.maxConnections || 10,
67
+ timezone: 'Z',
68
+ dateStrings: true,
69
+ supportBigNumbers: true,
70
+ bigNumberStrings: true
71
+ });
72
+ return pool;
73
+ }
74
+
75
+ async function close(pool) {
76
+ if (pool) await pool.end();
77
+ }
78
+
79
+ // Normalize mysql2's `[rows|ResultSetHeader, fields]` to Postgres shape.
80
+ async function query(pool, sql, params) {
81
+ var res = await pool.query(sql, params || []);
82
+ var out = res[0];
83
+ if (Array.isArray(out)) {
84
+ return { rows: out, rowCount: out.length }; // SELECT
85
+ }
86
+ return { rows: [], rowCount: (out && out.affectedRows) || 0 }; // DML
87
+ }
88
+
89
+ function toMysqlType(type) { return TYPE_MAP[type] || 'LONGTEXT'; }
90
+
91
+ function dataTypeToLogical(dataType) {
92
+ return NATIVE_TO_LOGICAL[String(dataType).toLowerCase()] || 'string';
93
+ }
94
+
95
+ // Backtick-quote a MySQL identifier, escaping embedded backticks.
96
+ function quoteIdent(name) {
97
+ return '`' + String(name).replace(/`/g, '``') + '`';
98
+ }
99
+
100
+ function buildCreateTableSql(tableName, columns, primaryKeys) {
101
+ var defs = [];
102
+ defs.push(quoteIdent(INTERNAL_ID_COL) + ' BIGINT AUTO_INCREMENT PRIMARY KEY');
103
+ defs.push(quoteIdent(INTERNAL_MOVEMENT_COL) + ' VARCHAR(255) NOT NULL');
104
+ for (var i = 0; i < columns.length; i++) {
105
+ defs.push(quoteIdent(columns[i].name) + ' ' + columnDef(columns[i], primaryKeys));
106
+ }
107
+ return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(tableName) +
108
+ ' (\n ' + defs.join(',\n ') + '\n)';
109
+ }
110
+
111
+ // A string PK column can't be LONGTEXT — MySQL rejects TEXT/BLOB in an index
112
+ // key without a prefix length. UPSERT needs a UNIQUE index on the PK cols, so
113
+ // bound PK string columns to VARCHAR(255) (indexable; 255 utf8mb4 chars keeps
114
+ // composite keys under InnoDB's 3072-byte prefix limit). Pre-create the table
115
+ // with a wider bounded VARCHAR if your keys are longer.
116
+ function columnDef(col, primaryKeys) {
117
+ if (col.type === 'string' && primaryKeys && primaryKeys.indexOf(col.name) !== -1) {
118
+ return 'VARCHAR(255)';
119
+ }
120
+ return toMysqlType(col.type);
121
+ }
122
+
123
+ function buildCreateErrorTableSql(tableName) {
124
+ var errTable = tableName + '_import_errors';
125
+ return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(errTable) + ' (\n' +
126
+ ' ' + quoteIdent(INTERNAL_ID_COL) + ' BIGINT AUTO_INCREMENT PRIMARY KEY,\n' +
127
+ ' movement_id VARCHAR(255) NOT NULL,\n' +
128
+ ' row_num INT,\n' +
129
+ ' error_description LONGTEXT,\n' +
130
+ ' underlying_sql LONGTEXT,\n' +
131
+ ' raw_row JSON,\n' +
132
+ ' recorded_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)\n' +
133
+ ')';
134
+ }
135
+
136
+ // One plain `ALTER TABLE ADD COLUMN` per column. NOT idempotent (MySQL 8 has
137
+ // no IF NOT EXISTS for ADD COLUMN) — for idempotent movement-column handling
138
+ // use ensureMovementColumn().
139
+ function buildAlterTableAddSql(tableName, columns) {
140
+ return columns.map(function(c) {
141
+ return 'ALTER TABLE ' + quoteIdent(tableName) +
142
+ ' ADD COLUMN ' + quoteIdent(c.name) + ' ' + toMysqlType(c.type);
143
+ });
144
+ }
145
+
146
+ // Bulk INSERT (UPSERT via ON DUPLICATE KEY UPDATE when primaryKeys given).
147
+ // Parameterized with `?`; values never concatenated into SQL.
148
+ function buildInsertSql(tableName, rows, columns, movementId, primaryKeys) {
149
+ var colNames = columns.map(function(c) { return c.name; });
150
+ var allCols = [INTERNAL_MOVEMENT_COL].concat(colNames);
151
+
152
+ var params = [];
153
+ var groups = [];
154
+
155
+ for (var r = 0; r < rows.length; r++) {
156
+ var row = rows[r];
157
+ var placeholders = ['?'];
158
+ params.push(movementId);
159
+ for (var c = 0; c < colNames.length; c++) {
160
+ placeholders.push('?');
161
+ params.push(coerceValue(row[colNames[c]], columns[c].type));
162
+ }
163
+ groups.push('(' + placeholders.join(',') + ')');
164
+ }
165
+
166
+ var sql = 'INSERT INTO ' + quoteIdent(tableName) +
167
+ ' (' + allCols.map(quoteIdent).join(', ') + ') VALUES ' + groups.join(',');
168
+
169
+ if (primaryKeys && primaryKeys.length > 0) {
170
+ // Non-PK user columns update on conflict. __xeplr_movement_id__ ALWAYS
171
+ // updates so the latest movement "owns" the row and rollback finds it.
172
+ // VALUES() is deprecated in MySQL 8.0.20+ but has the widest version
173
+ // support across MySQL 5.7 / 8 / MariaDB — keep it for portability.
174
+ var updateCols = colNames.filter(function(c) { return primaryKeys.indexOf(c) === -1; });
175
+ var setColumns = updateCols.concat([INTERNAL_MOVEMENT_COL]);
176
+ var setClause = setColumns.map(function(c) {
177
+ return quoteIdent(c) + ' = VALUES(' + quoteIdent(c) + ')';
178
+ }).join(', ');
179
+ sql += ' ON DUPLICATE KEY UPDATE ' + setClause;
180
+ }
181
+
182
+ return { sql: sql, params: params };
183
+ }
184
+
185
+ // Coerce a JS value to the target column's effective type. Mirrors the
186
+ // Postgres driver; the only dialect divergence is datetime (UTC string) and
187
+ // boolean (0|1). Bad values pass through so MySQL rejects them and SqlQueue
188
+ // bisects the row into the error table.
189
+ function coerceValue(v, type) {
190
+ if (v === undefined || v === null) return null;
191
+
192
+ switch (type) {
193
+ case 'string':
194
+ if (v instanceof Date) return toMysqlDatetime(v);
195
+ if (Array.isArray(v)) return JSON.stringify(v);
196
+ if (typeof v === 'object') return JSON.stringify(v);
197
+ return String(v);
198
+
199
+ case 'number':
200
+ if (typeof v === 'number') return v;
201
+ if (typeof v === 'string') {
202
+ var n = Number(v);
203
+ return isNaN(n) ? v : n; // non-numeric string → let MySQL reject
204
+ }
205
+ return v;
206
+
207
+ case 'boolean':
208
+ if (typeof v === 'boolean') return v ? 1 : 0;
209
+ if (typeof v === 'string') {
210
+ var lower = v.toLowerCase().trim();
211
+ if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'y') return 1;
212
+ if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'n') return 0;
213
+ }
214
+ if (typeof v === 'number') return v !== 0 ? 1 : 0;
215
+ return v; // let MySQL reject
216
+
217
+ case 'date':
218
+ case 'datetime':
219
+ return toMysqlDatetime(v);
220
+
221
+ case 'object':
222
+ case 'array':
223
+ if (typeof v === 'string') return v; // assume valid JSON string
224
+ return JSON.stringify(v);
225
+
226
+ default:
227
+ return v;
228
+ }
229
+ }
230
+
231
+ // 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — the format MySQL DATETIME accepts.
232
+ // Unparseable input passes through so MySQL rejects it (→ error table).
233
+ function toMysqlDatetime(v) {
234
+ var d = (v instanceof Date) ? v : new Date(v);
235
+ if (isNaN(d.getTime())) return v;
236
+ return d.toISOString().slice(0, 23).replace('T', ' ');
237
+ }
238
+
239
+ async function getTableSchema(pool, tableName, schema) {
240
+ var r = await query(pool,
241
+ 'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName ' +
242
+ 'FROM information_schema.COLUMNS ' +
243
+ 'WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? ' +
244
+ 'ORDER BY ORDINAL_POSITION',
245
+ [schema || null, tableName]
246
+ );
247
+ return r.rows.map(function(row) {
248
+ return { name: row.name, dataType: row.dataType, udtName: row.udtName };
249
+ });
250
+ }
251
+
252
+ // Table/view/procedure listing — used by the db-list-* actions. MySQL has no
253
+ // separate schema concept from the database itself, so a null/omitted
254
+ // `schema` falls back to DATABASE() (the connection's own db), same as
255
+ // getTableSchema above.
256
+ async function listTables(pool, schema) {
257
+ var r = await query(pool,
258
+ "SELECT TABLE_NAME AS name FROM information_schema.TABLES " +
259
+ "WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_TYPE = 'BASE TABLE' " +
260
+ "ORDER BY TABLE_NAME",
261
+ [schema || null]
262
+ );
263
+ return r.rows.map(function(row) { return row.name; });
264
+ }
265
+
266
+ async function listViews(pool, schema) {
267
+ var r = await query(pool,
268
+ "SELECT TABLE_NAME AS name FROM information_schema.TABLES " +
269
+ "WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_TYPE = 'VIEW' " +
270
+ "ORDER BY TABLE_NAME",
271
+ [schema || null]
272
+ );
273
+ return r.rows.map(function(row) { return row.name; });
274
+ }
275
+
276
+ // ROUTINE_TYPE='PROCEDURE' only — excludes FUNCTION, same reasoning as the
277
+ // Postgres driver's listProcedures.
278
+ async function listProcedures(pool, schema) {
279
+ var r = await query(pool,
280
+ "SELECT ROUTINE_NAME AS name FROM information_schema.ROUTINES " +
281
+ "WHERE ROUTINE_SCHEMA = COALESCE(?, DATABASE()) AND ROUTINE_TYPE = 'PROCEDURE' " +
282
+ "ORDER BY ROUTINE_NAME",
283
+ [schema || null]
284
+ );
285
+ return r.rows.map(function(row) { return row.name; });
286
+ }
287
+
288
+ // Idempotently ensure __xeplr_movement_id__ exists (introspect, then ALTER).
289
+ async function ensureMovementColumn(pool, tableName) {
290
+ var exists = await query(pool,
291
+ 'SELECT 1 FROM information_schema.COLUMNS ' +
292
+ 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1',
293
+ [tableName, INTERNAL_MOVEMENT_COL]
294
+ );
295
+ if (exists.rows.length) return;
296
+ await query(pool,
297
+ 'ALTER TABLE ' + quoteIdent(tableName) +
298
+ ' ADD COLUMN ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' VARCHAR(255)'
299
+ );
300
+ }
301
+
302
+ // LOB types that MySQL refuses to index without an explicit prefix length.
303
+ var LOB_TYPES = {
304
+ tinytext: 1, text: 1, mediumtext: 1, longtext: 1,
305
+ tinyblob: 1, blob: 1, mediumblob: 1, longblob: 1
306
+ };
307
+
308
+ // Idempotently ensure the UNIQUE index that backs ON DUPLICATE KEY UPDATE.
309
+ // Introspects the PK columns' actual types so it works even against a
310
+ // pre-existing table whose key column is TEXT/BLOB (indexes a 255-char prefix
311
+ // in that case — MySQL won't index a LOB column without one).
312
+ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
313
+ if (!primaryKeys || primaryKeys.length === 0) return;
314
+ var idxName = upsertIndexName(tableName);
315
+ var exists = await query(pool,
316
+ 'SELECT 1 FROM information_schema.STATISTICS ' +
317
+ 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
318
+ [tableName, idxName]
319
+ );
320
+ if (exists.rows.length) return;
321
+
322
+ var schema = await getTableSchema(pool, tableName);
323
+ var typeByName = {};
324
+ schema.forEach(function(c) { typeByName[c.name] = String(c.dataType).toLowerCase(); });
325
+
326
+ var cols = primaryKeys.map(function(pk) {
327
+ return quoteIdent(pk) + (LOB_TYPES[typeByName[pk]] ? '(255)' : '');
328
+ }).join(', ');
329
+
330
+ await query(pool,
331
+ 'CREATE UNIQUE INDEX ' + quoteIdent(idxName) +
332
+ ' ON ' + quoteIdent(tableName) + ' (' + cols + ')'
333
+ );
334
+ }
335
+
336
+ // MySQL index names cap at 64 chars — truncate the table portion if needed.
337
+ function upsertIndexName(tableName) {
338
+ var suffix = '_upsert_uniq';
339
+ var room = 64 - suffix.length;
340
+ var base = tableName.length > room ? tableName.slice(0, room) : tableName;
341
+ return base + suffix;
342
+ }
343
+
344
+ // Stream a result set WITHOUT buffering it in memory — the read primitive
345
+ // db-fetch composes. mysql2 exposes a native row stream on the core connection;
346
+ // `for await` over it applies backpressure (highWaterMark bounds the rows
347
+ // buffered in the driver before it pauses reading from the socket).
348
+ //
349
+ // opts: { sql, params?, batchSize? } → async iterable<row>
350
+ async function* fetchStream(pool, opts) {
351
+ var batchSize = Math.max(1, parseInt(opts.batchSize || 1000, 10));
352
+ var conn = await pool.getConnection();
353
+ try {
354
+ // .stream() lives on the core (non-promise) connection under .connection.
355
+ var core = conn.connection;
356
+ var q = (opts.params && opts.params.length)
357
+ ? { sql: opts.sql, values: opts.params }
358
+ : opts.sql;
359
+ var stream = core.query(q).stream({ highWaterMark: batchSize });
360
+ for await (var row of stream) yield row;
361
+ } finally {
362
+ conn.release();
363
+ }
364
+ }
365
+
366
+ async function rollbackMovement(pool, tableName, movementId) {
367
+ var errTable = tableName + '_import_errors';
368
+ var result = { mainDeleted: 0, errorDeleted: 0 };
369
+
370
+ var r1 = await query(pool,
371
+ 'DELETE FROM ' + quoteIdent(tableName) +
372
+ ' WHERE ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' = ?',
373
+ [movementId]
374
+ );
375
+ result.mainDeleted = r1.rowCount || 0;
376
+
377
+ try {
378
+ var r2 = await query(pool,
379
+ 'DELETE FROM ' + quoteIdent(errTable) + ' WHERE movement_id = ?',
380
+ [movementId]
381
+ );
382
+ result.errorDeleted = r2.rowCount || 0;
383
+ } catch (err) {
384
+ if (err.code !== 'ER_NO_SUCH_TABLE') throw err; // table absent → ignore
385
+ }
386
+
387
+ return result;
388
+ }
389
+
390
+ module.exports = {
391
+ requires: ['mysql2'],
392
+ maxParams: MAX_PARAMS,
393
+
394
+ // Connection lifecycle
395
+ connect: connect,
396
+ query: query,
397
+ close: close,
398
+
399
+ // SQL builders (pure functions, no DB access)
400
+ toMysqlType: toMysqlType,
401
+ dataTypeToLogical: dataTypeToLogical,
402
+ quoteIdent: quoteIdent,
403
+ buildCreateTableSql: buildCreateTableSql,
404
+ buildCreateErrorTableSql: buildCreateErrorTableSql,
405
+ buildAlterTableAddSql: buildAlterTableAddSql,
406
+ buildInsertSql: buildInsertSql,
407
+
408
+ // Idempotent DDL helpers (async — MySQL lacks single-statement guards)
409
+ ensureMovementColumn: ensureMovementColumn,
410
+ ensureUpsertIndex: ensureUpsertIndex,
411
+
412
+ // Streaming read primitive (used by db-fetch)
413
+ fetchStream: fetchStream,
414
+
415
+ // Introspection (used by uploader.reconcile + the db-list-* actions) + rollback
416
+ getTableSchema: getTableSchema,
417
+ listTables: listTables,
418
+ listViews: listViews,
419
+ listProcedures: listProcedures,
420
+ rollbackMovement: rollbackMovement,
421
+
422
+ // Constants
423
+ INTERNAL_ID_COL: INTERNAL_ID_COL,
424
+ INTERNAL_MOVEMENT_COL: INTERNAL_MOVEMENT_COL
425
+ };