@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,298 @@
1
+ // Uploader — spool + SqlQueue + driver orchestrator.
2
+ //
3
+ // const { upload, rollback } = require('@xeplr/actions').uploader;
4
+ //
5
+ // await upload({
6
+ // source, // async iterable of rows
7
+ // driver, // e.g. require('.../drivers/db/postgres')
8
+ // connection, // decrypted config object OR pool
9
+ // targetTable,
10
+ // primaryKeys, // optional — enables UPSERT
11
+ // movementId, // required — the correlation key
12
+ // queue, // required — a SqlQueue instance
13
+ // connectionName, // key used in queue.connections; defaults to targetTable
14
+ // batchSize: 5000,
15
+ // firstBatchScanRows: 1000,
16
+ // metaStore, // defaults to meta-store-noop
17
+ // mtId1..mtId4, // optional — forwarded to metaStore verbatim, tenant scoping
18
+ // details, // optional — forwarded to metaStore verbatim, app-specific context
19
+ // // (filename, connectionId, who ran it, ...) the engine itself
20
+ // // has no business knowing about
21
+ // errorTable, // defaults to `<targetTable>_import_errors`
22
+ // batchDir, // where NDJSON files spool to. Defaults to os.tmpdir()/xeplr-actions
23
+ // keepBatchFiles, // default false — clean up NDJSON files after success
24
+ // })
25
+ //
26
+ // EXECUTION MODEL (production-critical — read this):
27
+ //
28
+ // The source (Excel, CSV, DB cursor, etc.) is decoupled from the target
29
+ // DB via the streaming/spool primitive. Rows flow like this:
30
+ //
31
+ // [source rows] → spool writes NDJSON files → onBatch reads and pushes to SqlQueue → DB
32
+ //
33
+ // Why: a slow DB (or a big SqlQueue backlog) must NEVER back-pressure all
34
+ // the way to the source parser. Some source parsers (xlsx-stream-reader
35
+ // specifically) emit rows from a SAX event handler and don't respect
36
+ // downstream backpressure — an in-memory row pile grows unboundedly until
37
+ // V8 OOMs. Spool breaks that chain by landing rows on disk between the
38
+ // two stages.
39
+ //
40
+ // See project_xeplr_actions_streaming.md for the full contract.
41
+
42
+ var os = require('os');
43
+ var path = require('path');
44
+ var fsp = require('fs/promises');
45
+
46
+ var { inferColumns } = require('./infer');
47
+ var { spool, readBatchFile } = require('../streaming');
48
+
49
+ // Postgres protocol caps at 65,535 parameters per query. We stay comfortably
50
+ // under it — the effective batch cap becomes `maxParamsPerBatch / paramsPerRow`.
51
+ var DEFAULT_MAX_PARAMS = 30000;
52
+
53
+ async function upload(opts) {
54
+ opts = normalizeOptions(opts);
55
+ var driver = opts.driver;
56
+ var startedAt = Date.now();
57
+
58
+ var poolIsExternal = looksLikePool(opts.connection);
59
+ var pool = poolIsExternal ? opts.connection : await driver.connect(opts.connection);
60
+
61
+ // Shared state across the spool hooks (beforeAll → onBatch* → afterAll).
62
+ var columns = null;
63
+ var effectiveBatchSize = null;
64
+ var totalBatches = 0;
65
+
66
+ var spoolResult = null;
67
+ var runDir = null;
68
+
69
+ try {
70
+ var summary = await spool(opts.source, {
71
+ runId: opts.movementId,
72
+ batchDir: opts.batchDir,
73
+ batchSize: opts.batchSize,
74
+ maxInFlight: 1,
75
+ maxMemoryMB: opts.spoolMaxMemoryMB || 128,
76
+
77
+ target: {
78
+ // Bootstrap: first batch is on disk. Infer types from it, run DDL,
79
+ // reconcile against the target's existing schema, then compute the
80
+ // safe batch size for INSERT.
81
+ beforeAll: async function(ctx) {
82
+ runDir = ctx.batchDir;
83
+ var sample = await readBatchFile(ctx.firstBatchPath);
84
+ var scanLimit = Math.min(sample.length, opts.firstBatchScanRows);
85
+ var inferred = inferColumns(sample.slice(0, scanLimit));
86
+ columns = await runBootstrap(driver, pool, opts.targetTable, inferred, opts.primaryKeys);
87
+ effectiveBatchSize = Math.min(opts.batchSize, safeBatchSize(columns.length, opts.maxParamsPerBatch));
88
+ await opts.metaStore.recordStart(opts.movementId, {
89
+ targetTable: opts.targetTable,
90
+ connectionKey: opts.connectionName, // opaque label, not a real connection
91
+ dbType: opts.dbType, // recorded when the caller supplies it
92
+ columns: columns,
93
+ primaryKeys: opts.primaryKeys,
94
+ mtId1: opts.mtId1, mtId2: opts.mtId2, mtId3: opts.mtId3, mtId4: opts.mtId4,
95
+ details: opts.details
96
+ });
97
+ },
98
+
99
+ // Each rotated NDJSON file becomes onBatch(rows, meta). We subdivide
100
+ // into safe-INSERT-sized chunks and enqueue each to SqlQueue.
101
+ // addToQueue awaits when queue is over its 200MB backpressure, which
102
+ // in turn awaits spool's next batch — the whole pipeline throttles
103
+ // correctly, but disk (not memory) is the buffer.
104
+ onBatch: async function(rows, _meta) {
105
+ if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
106
+ for (var off = 0; off < rows.length; off += effectiveBatchSize) {
107
+ var chunk = rows.slice(off, off + effectiveBatchSize);
108
+ await enqueueBatch(opts, chunk, columns);
109
+ totalBatches++;
110
+ }
111
+ }
112
+ }
113
+ });
114
+
115
+ spoolResult = summary;
116
+
117
+ // Empty source — spool created no batches, so beforeAll never fired.
118
+ // Nothing to do downstream; return an empty-but-valid summary.
119
+ if (!columns) {
120
+ // beforeAll never fired, so recordStart never happened — this is the
121
+ // only chance to give the row its plan/tenant context (meta-store-knex
122
+ // handles this via its insert-fallback path).
123
+ await opts.metaStore.recordEnd(opts.movementId, {
124
+ status: 'completed', totalRows: 0, totalBatches: 0, completed: 0, dropped: 0,
125
+ targetTable: opts.targetTable, connectionKey: opts.connectionName, dbType: opts.dbType,
126
+ mtId1: opts.mtId1, mtId2: opts.mtId2, mtId3: opts.mtId3, mtId4: opts.mtId4,
127
+ details: opts.details
128
+ });
129
+ return {
130
+ movementId: opts.movementId,
131
+ tables: { main: opts.targetTable, errors: opts.errorTable },
132
+ columns: [],
133
+ totalRows: 0,
134
+ totalBatches: 0,
135
+ completed: 0,
136
+ dropped: 0,
137
+ aborted: false,
138
+ durationMs: Date.now() - startedAt
139
+ };
140
+ }
141
+
142
+ // Wait for the DB writes to finish (spool has finished feeding the queue).
143
+ await opts.queue.drain();
144
+
145
+ var stats = opts.queue.stats(opts.movementId) || {};
146
+ var status = stats.aborted ? 'aborted' : 'completed';
147
+ await opts.metaStore.recordEnd(opts.movementId, {
148
+ status: status,
149
+ totalRows: summary.totalRows,
150
+ totalBatches: totalBatches,
151
+ completed: stats.completed || 0,
152
+ dropped: stats.dropped || 0
153
+ });
154
+
155
+ return {
156
+ movementId: opts.movementId,
157
+ tables: { main: opts.targetTable, errors: opts.errorTable },
158
+ columns: columns,
159
+ totalRows: summary.totalRows,
160
+ totalBatches: totalBatches,
161
+ completed: stats.completed || 0,
162
+ dropped: stats.dropped || 0,
163
+ aborted: !!stats.aborted,
164
+ durationMs: Date.now() - startedAt
165
+ };
166
+ } finally {
167
+ // Best-effort cleanup: delete the NDJSON spool directory unless caller
168
+ // asked to keep it (e.g. for later resume / audit).
169
+ if (runDir && !opts.keepBatchFiles) {
170
+ try { await fsp.rm(runDir, { recursive: true, force: true }); } catch (_) {}
171
+ }
172
+ if (!poolIsExternal && pool) await driver.close(pool);
173
+ }
174
+ }
175
+
176
+ // Rollback a movement — drop from main + error tables, clear meta row.
177
+ // Graceful queue abort first (in-flight SQLs allowed to settle).
178
+ async function rollback(opts) {
179
+ if (!opts || !opts.movementId) throw new Error('rollback: movementId is required');
180
+ if (!opts.driver) throw new Error('rollback: driver is required');
181
+ if (!opts.connection) throw new Error('rollback: connection is required');
182
+ if (!opts.targetTable) throw new Error('rollback: targetTable is required');
183
+
184
+ if (opts.queue) {
185
+ opts.queue.abort(opts.movementId, 'rollback');
186
+ await opts.queue.drain();
187
+ }
188
+
189
+ var poolIsExternal = looksLikePool(opts.connection);
190
+ var pool = poolIsExternal ? opts.connection : await opts.driver.connect(opts.connection);
191
+
192
+ try {
193
+ var result = await opts.driver.rollbackMovement(pool, opts.targetTable, opts.movementId);
194
+ if (opts.metaStore) {
195
+ await opts.metaStore.recordEnd(opts.movementId, {
196
+ status: 'rolled-back',
197
+ mainDeleted: result.mainDeleted,
198
+ errorDeleted: result.errorDeleted
199
+ });
200
+ }
201
+ return result;
202
+ } finally {
203
+ if (!poolIsExternal) await opts.driver.close(pool);
204
+ }
205
+ }
206
+
207
+ // ─── helpers ────────────────────────────────────────────────────────────
208
+
209
+ function normalizeOptions(opts) {
210
+ opts = Object.assign({}, opts);
211
+ if (!opts.source) throw new Error('upload: source is required');
212
+ if (!opts.driver) throw new Error('upload: driver is required');
213
+ if (!opts.connection) throw new Error('upload: connection is required');
214
+ if (!opts.targetTable) throw new Error('upload: targetTable is required');
215
+ if (!opts.movementId) throw new Error('upload: movementId is required');
216
+ if (!opts.queue) throw new Error('upload: queue is required (a SqlQueue)');
217
+ opts.batchSize = opts.batchSize || 5000;
218
+ opts.firstBatchScanRows = opts.firstBatchScanRows || 1000;
219
+ // Per-dialect parameter ceiling: PG/MySQL ~65k, but SQL Server caps at 2100
220
+ // params + 1000 rows/statement. Drivers advertise `maxParams`; fall back to
221
+ // the PG-safe default when a driver doesn't.
222
+ opts.maxParamsPerBatch = opts.maxParamsPerBatch ||
223
+ (opts.driver && opts.driver.maxParams) || DEFAULT_MAX_PARAMS;
224
+ opts.metaStore = opts.metaStore || require('./meta-store-noop');
225
+ opts.errorTable = opts.errorTable || (opts.targetTable + '_import_errors');
226
+ opts.connectionName = opts.connectionName || opts.targetTable;
227
+ opts.batchDir = opts.batchDir ||
228
+ (process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions'));
229
+ opts.keepBatchFiles = !!opts.keepBatchFiles;
230
+ return opts;
231
+ }
232
+
233
+ function safeBatchSize(columnCount, maxParams) {
234
+ var paramsPerRow = columnCount + 1; // +1 for __xeplr_movement_id__
235
+ return Math.max(1, Math.floor(maxParams / paramsPerRow));
236
+ }
237
+
238
+ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys) {
239
+ await driver.query(pool, driver.buildCreateTableSql(targetTable, columns, primaryKeys));
240
+ await driver.query(pool, driver.buildCreateErrorTableSql(targetTable));
241
+
242
+ // Idempotent DDL via the driver's ensure-methods — the guard syntax differs
243
+ // per dialect (PG: IF NOT EXISTS; MySQL: introspect-then-DDL; MSSQL:
244
+ // IF OBJECT_ID/COL_LENGTH), so the driver owns it, not this file.
245
+ await driver.ensureMovementColumn(pool, targetTable);
246
+ await driver.ensureUpsertIndex(pool, targetTable, primaryKeys);
247
+
248
+ var targetSchema = await driver.getTableSchema(pool, targetTable);
249
+ var { reconcileColumns } = require('./reconcile');
250
+ var reconciled = reconcileColumns(columns, targetSchema, driver.dataTypeToLogical);
251
+
252
+ var missing = reconciled.filter(function(c) { return c.missing; });
253
+ if (missing.length > 0) {
254
+ throw new Error(
255
+ 'upload: source has columns not present in target table "' + targetTable + '": ' +
256
+ missing.map(function(c) { return c.name; }).join(', ') +
257
+ '. ALTER TABLE to add them, or omit them from the source.'
258
+ );
259
+ }
260
+ return reconciled;
261
+ }
262
+
263
+ async function enqueueBatch(opts, rows, columns) {
264
+ var driver = opts.driver;
265
+ var target = opts.targetTable;
266
+ var movementId = opts.movementId;
267
+ var pks = opts.primaryKeys;
268
+
269
+ function rowsToSql(rs) { return driver.buildInsertSql(target, rs, columns, movementId, pks); }
270
+
271
+ var built = rowsToSql(rows);
272
+ await opts.queue.addToQueue({
273
+ movementId: movementId,
274
+ connection: opts.connectionName,
275
+ sql: built.sql,
276
+ params: built.params,
277
+ meta: {
278
+ rows: rows,
279
+ rowCount: rows.length,
280
+ rowsToSql: rowsToSql,
281
+ errorTable: opts.errorTable
282
+ }
283
+ });
284
+ }
285
+
286
+ function looksLikePool(v) {
287
+ return v && typeof v.query === 'function' && typeof v.host !== 'string';
288
+ }
289
+
290
+ var { inferColumnType } = require('./infer');
291
+
292
+ module.exports = {
293
+ upload: upload,
294
+ rollback: rollback,
295
+ inferColumns: inferColumns,
296
+ inferColumnType: inferColumnType,
297
+ makeMetaStore: require('./meta-store-knex')
298
+ };
@@ -0,0 +1,76 @@
1
+ // Type inference from the first N rows.
2
+ //
3
+ // Precedence when a column has mixed values:
4
+ // 1. Any non-null value is an Array → 'array'
5
+ // 2. Any non-null value is a plain object → 'object'
6
+ // 3. All non-null values are Date instances OR ISO-parseable strings → 'datetime'
7
+ // 4. All non-null values are booleans → 'boolean'
8
+ // 5. All non-null values are numbers → 'number'
9
+ // 6. Anything else / all null → 'string'
10
+ //
11
+ // Text always wins ties (per user preference: "string always wins... no complexity").
12
+
13
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
14
+
15
+ function inferColumnType(values) {
16
+ var seenObject = false, seenArray = false;
17
+ var allBool = true, sawBool = false;
18
+ var allNum = true, sawNum = false;
19
+ var allDate = true, sawDate = false;
20
+ var allNull = true;
21
+
22
+ for (var i = 0; i < values.length; i++) {
23
+ var v = values[i];
24
+ if (v === undefined || v === null) continue;
25
+ allNull = false;
26
+
27
+ if (Array.isArray(v)) { seenArray = true; allBool = allNum = allDate = false; continue; }
28
+ if (v instanceof Date) { sawDate = true; allBool = allNum = false; continue; }
29
+ if (typeof v === 'boolean') { sawBool = true; allNum = allDate = false; continue; }
30
+ if (typeof v === 'number' && !isNaN(v)) { sawNum = true; allBool = allDate = false; continue; }
31
+ if (typeof v === 'object') { seenObject = true; allBool = allNum = allDate = false; continue; }
32
+ if (typeof v === 'string') {
33
+ if (ISO_DATE_RE.test(v)) { sawDate = true; allBool = allNum = false; continue; }
34
+ allBool = allNum = allDate = false;
35
+ continue;
36
+ }
37
+ // Anything else falls through to string.
38
+ allBool = allNum = allDate = false;
39
+ }
40
+
41
+ if (seenArray) return 'array';
42
+ if (seenObject) return 'object';
43
+ if (sawDate && allDate) return 'datetime';
44
+ if (sawBool && allBool) return 'boolean';
45
+ if (sawNum && allNum) return 'number';
46
+ return 'string';
47
+ }
48
+
49
+ // Given a buffer of rows, return an ordered list of column defs:
50
+ // [{ name, type }]
51
+ // Column order follows the FIRST row's keys, then any additional keys
52
+ // encountered in later rows appended in insertion order.
53
+ function inferColumns(rows) {
54
+ if (!rows || rows.length === 0) return [];
55
+
56
+ var order = [];
57
+ var seen = new Set();
58
+ var valuesByCol = {};
59
+
60
+ for (var i = 0; i < rows.length; i++) {
61
+ var row = rows[i];
62
+ if (!row || typeof row !== 'object') continue;
63
+ var keys = Object.keys(row);
64
+ for (var j = 0; j < keys.length; j++) {
65
+ var k = keys[j];
66
+ if (!seen.has(k)) { seen.add(k); order.push(k); valuesByCol[k] = []; }
67
+ valuesByCol[k].push(row[k]);
68
+ }
69
+ }
70
+
71
+ return order.map(function(name) {
72
+ return { name: name, type: inferColumnType(valuesByCol[name]) };
73
+ });
74
+ }
75
+
76
+ module.exports = { inferColumns: inferColumns, inferColumnType: inferColumnType };
@@ -0,0 +1,136 @@
1
+ // Knex-backed MetaStore — persists movement metadata to the `import_meta`
2
+ // table in the framework's xeplr_configs DB (control plane), shared across
3
+ // apps. Implements the MetaStore interface the uploader calls: getLast /
4
+ // recordStart / recordProgress / recordEnd.
5
+ //
6
+ // Peer deps: knex (+ the pg client — bootstrapConfigDb/import_meta are
7
+ // Postgres-only, see xeplr-db/lib/bootstrap-config.js).
8
+ //
9
+ // Factory shape — bind the knex handle AND this app's name at configuration
10
+ // time (service is deliberately constructor-supplied, not env-derived — the
11
+ // app that's calling knows its own name, no reason to make callers thread it
12
+ // through every recordStart/recordEnd call):
13
+ // const makeStore = require('@xeplr/actions/lib/uploader/meta-store-knex');
14
+ // const store = makeStore(knex, { table: 'import_meta', service: 'xeplr-bi' });
15
+ // await upload({ ..., metaStore: store });
16
+ //
17
+ // Records NO secrets: `connection_key` is an opaque label the consumer chose,
18
+ // never a real connection. `columns`/`primary_keys` describe the movement
19
+ // plan. `details` (app-specific context — filename, connectionId, who ran
20
+ // it) and `mtId1`-`mtId4` (tenant scoping, same column names as BaseModel)
21
+ // are set once at recordStart and never touched by recordEnd — this table is
22
+ // shared across apps, so every read/write is scoped by `service` too.
23
+
24
+ module.exports = function makeKnexMetaStore(knex, options) {
25
+ if (!knex) throw new Error('meta-store-knex: a knex instance is required');
26
+ if (!options || !options.service) throw new Error('meta-store-knex: options.service is required');
27
+ var TABLE = options.table || 'import_meta';
28
+ var SERVICE = options.service;
29
+
30
+ return {
31
+ // Most-recent movement for a target table (resume / dedupe / audit).
32
+ // Scoped to this store's own service — a movement recorded by another
33
+ // app is never "the last one" from this app's point of view.
34
+ async getLast(opts) {
35
+ var targetTable = opts && opts.targetTable;
36
+ if (!targetTable) return null;
37
+ var row = await knex(TABLE)
38
+ .where({ service: SERVICE, target_table: targetTable })
39
+ .orderBy('started_at', 'desc')
40
+ .first();
41
+ return row || null; // pg returns jsonb columns already parsed
42
+ },
43
+
44
+ // Open the movement row in 'running' state. Upsert on id so a re-run /
45
+ // resume of the same movementId overwrites its prior 'running' row.
46
+ async recordStart(movementId, plan) {
47
+ plan = plan || {};
48
+ await knex(TABLE)
49
+ .insert({
50
+ id: movementId,
51
+ service: SERVICE,
52
+ mtId1: plan.mtId1 || null,
53
+ mtId2: plan.mtId2 || null,
54
+ mtId3: plan.mtId3 || null,
55
+ mtId4: plan.mtId4 || null,
56
+ details: plan.details ? JSON.stringify(plan.details) : null,
57
+ target_table: plan.targetTable || null,
58
+ connection_key: plan.connectionKey || null,
59
+ db_type: plan.dbType || null,
60
+ status: 'running',
61
+ primary_keys: plan.primaryKeys ? JSON.stringify(plan.primaryKeys) : null,
62
+ columns: plan.columns ? JSON.stringify(plan.columns) : null,
63
+ error: null,
64
+ started_at: knex.fn.now(),
65
+ ended_at: null,
66
+ recordModifiedDate: knex.fn.now()
67
+ })
68
+ .onConflict('id')
69
+ .merge();
70
+ },
71
+
72
+ // Increment running counters. delta = { completed?, dropped?, totalRows?, totalBatches? }
73
+ async recordProgress(movementId, delta) {
74
+ delta = delta || {};
75
+ var patch = { recordModifiedDate: knex.fn.now() };
76
+ var COLS = { completed: 'completed', dropped: 'dropped', totalRows: 'total_rows', totalBatches: 'total_batches' };
77
+ var touched = false;
78
+ for (var key in COLS) {
79
+ if (typeof delta[key] === 'number') {
80
+ var col = COLS[key];
81
+ patch[col] = knex.raw('?? + ?', [col, delta[key]]);
82
+ touched = true;
83
+ }
84
+ }
85
+ if (!touched) return;
86
+ await knex(TABLE).where({ id: movementId }).update(patch);
87
+ },
88
+
89
+ // Close the movement. Upserts: the empty-source path calls recordEnd
90
+ // WITHOUT a prior recordStart (beforeAll never fires), so a missing row
91
+ // must be created rather than silently no-op'd.
92
+ async recordEnd(movementId, result) {
93
+ result = result || {};
94
+ var patch = {
95
+ status: result.status || 'completed',
96
+ ended_at: knex.fn.now(),
97
+ recordModifiedDate: knex.fn.now()
98
+ };
99
+ if (typeof result.totalRows === 'number') patch.total_rows = result.totalRows;
100
+ if (typeof result.totalBatches === 'number') patch.total_batches = result.totalBatches;
101
+ if (typeof result.completed === 'number') patch.completed = result.completed;
102
+ if (typeof result.dropped === 'number') patch.dropped = result.dropped;
103
+ if (result.error) patch.error = String(result.error.message || result.error);
104
+ // rollback reports mainDeleted/errorDeleted — keep them in meta jsonb.
105
+ if (result.mainDeleted != null || result.errorDeleted != null) {
106
+ patch.meta = JSON.stringify({ mainDeleted: result.mainDeleted, errorDeleted: result.errorDeleted });
107
+ }
108
+
109
+ var updated = await knex(TABLE).where({ id: movementId }).update(patch);
110
+ if (!updated) {
111
+ // No prior recordStart (empty-source path — beforeAll never fires) —
112
+ // this is the only chance to stamp service/tenant/plan context, or
113
+ // the row is left sparse forever. Caller may pass these through
114
+ // result for exactly this case (see uploader/index.js's empty-source
115
+ // recordEnd call).
116
+ var fallback = {
117
+ id: movementId,
118
+ service: SERVICE,
119
+ mtId1: result.mtId1 || null,
120
+ mtId2: result.mtId2 || null,
121
+ mtId3: result.mtId3 || null,
122
+ mtId4: result.mtId4 || null,
123
+ details: result.details ? JSON.stringify(result.details) : null,
124
+ target_table: result.targetTable || null,
125
+ connection_key: result.connectionKey || null,
126
+ db_type: result.dbType || null,
127
+ started_at: knex.fn.now()
128
+ };
129
+ await knex(TABLE)
130
+ .insert(Object.assign(fallback, patch))
131
+ .onConflict('id')
132
+ .merge();
133
+ }
134
+ }
135
+ };
136
+ };
@@ -0,0 +1,11 @@
1
+ // Default MetaStore — records nothing, returns nothing.
2
+ // Used when the uploader is called without a real metaStore instance.
3
+ // Consumers wire in meta-store-knex.js (optional peer: knex + pg) or
4
+ // their own implementation.
5
+
6
+ module.exports = {
7
+ async getLast(_opts) { return null; },
8
+ async recordStart(_uploadId, _plan) { /* no-op */ },
9
+ async recordProgress(_uploadId, _delta) { /* no-op */ },
10
+ async recordEnd(_uploadId, _result) { /* no-op */ }
11
+ };
@@ -0,0 +1,88 @@
1
+ // Reconcile source-inferred column types with an existing target table's
2
+ // actual schema. The target table is authoritative — its column types WIN.
3
+ // Source values will be coerced to match the target's type at INSERT.
4
+ //
5
+ // Special case: TEXT / VARCHAR target columns accept anything, so any
6
+ // source type widens safely to 'string' (numbers/dates/bools/objects all
7
+ // get serialized on INSERT).
8
+ //
9
+ // Columns that exist in the source but NOT in the target are surfaced as
10
+ // `missing: true` — the caller (uploader) decides whether to ALTER TABLE
11
+ // ADD or reject the upload.
12
+ //
13
+ // Framework-managed columns (__xeplr_id__, __xeplr_movement_id__) are
14
+ // ignored during reconciliation.
15
+
16
+ var INTERNAL_COLS = new Set(['__xeplr_id__', '__xeplr_movement_id__']);
17
+
18
+ // Postgres data_type strings → our logical types.
19
+ var PG_TO_LOGICAL = {
20
+ 'text': 'string',
21
+ 'character varying': 'string',
22
+ 'character': 'string',
23
+ 'uuid': 'string',
24
+ 'integer': 'number',
25
+ 'bigint': 'number',
26
+ 'smallint': 'number',
27
+ 'numeric': 'number',
28
+ 'real': 'number',
29
+ 'double precision': 'number',
30
+ 'boolean': 'boolean',
31
+ 'timestamp with time zone': 'datetime',
32
+ 'timestamp without time zone': 'datetime',
33
+ 'date': 'datetime',
34
+ 'time with time zone': 'datetime',
35
+ 'time without time zone': 'datetime',
36
+ 'json': 'object',
37
+ 'jsonb': 'object',
38
+ 'ARRAY': 'array'
39
+ };
40
+
41
+ function pgDataTypeToLogical(dataType) {
42
+ return PG_TO_LOGICAL[dataType] || 'string'; // unknown → assume TEXT-like
43
+ }
44
+
45
+ /**
46
+ * @param {Array<{name,type}>} sourceColumns
47
+ * @param {Array<{name,dataType,udtName?}> | null | undefined} targetSchema
48
+ * @param {(dataType:string)=>string} [toLogical] Dialect-specific native
49
+ * data_type → logical type mapper. Defaults to the Postgres mapping so
50
+ * existing callers are unaffected; the uploader passes driver.dataTypeToLogical
51
+ * so MySQL/MSSQL target schemas reconcile against their own type strings.
52
+ * @returns {Array<{ name, type, sourceType?, targetType?, missing? }>}
53
+ * `type` is the EFFECTIVE type — what values will be coerced to before
54
+ * INSERT. `sourceType` is preserved for auditing; `missing` flags columns
55
+ * present in source but absent from target.
56
+ */
57
+ function reconcileColumns(sourceColumns, targetSchema, toLogical) {
58
+ sourceColumns = sourceColumns || [];
59
+ toLogical = toLogical || pgDataTypeToLogical;
60
+
61
+ if (!targetSchema || targetSchema.length === 0) {
62
+ // No existing table → source types stand as effective.
63
+ return sourceColumns.map(function(s) { return { name: s.name, type: s.type }; });
64
+ }
65
+
66
+ var targetByName = {};
67
+ for (var i = 0; i < targetSchema.length; i++) {
68
+ var t = targetSchema[i];
69
+ if (INTERNAL_COLS.has(t.name)) continue;
70
+ targetByName[t.name] = toLogical(t.dataType);
71
+ }
72
+
73
+ return sourceColumns.map(function(src) {
74
+ if (INTERNAL_COLS.has(src.name)) return { name: src.name, type: src.type };
75
+ var targetType = targetByName[src.name];
76
+ if (targetType === undefined) {
77
+ return { name: src.name, type: src.type, sourceType: src.type, missing: true };
78
+ }
79
+ // Target's type wins.
80
+ if (targetType === src.type) return { name: src.name, type: src.type };
81
+ return { name: src.name, type: targetType, sourceType: src.type, targetType: targetType };
82
+ });
83
+ }
84
+
85
+ module.exports = {
86
+ reconcileColumns: reconcileColumns,
87
+ pgDataTypeToLogical: pgDataTypeToLogical
88
+ };