@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,691 @@
1
+ // db-move action — ONE data movement, source database to target database.
2
+ //
3
+ // Composes: drivers.db.<source> + drivers.db.<target> + uploader.upload + SqlQueue
4
+ //
5
+ // ── why this is not db-fetch chained into db-push ────────────────────────
6
+ //
7
+ // It could be, and it would be worse. db-fetch spools its whole result to an
8
+ // NDJSON file and db-push reads it back, so a 40M-row move lands on disk in
9
+ // full before a single row reaches the target. That round trip buys nothing
10
+ // here: driver.fetchStream is an ASYNC GENERATOR of rows and upload() takes an
11
+ // async iterable of rows, so the two plug straight together and the movement is
12
+ // one streamed pass. upload()'s own spool still breaks backpressure between
13
+ // source and target — that is what it is for — it just does it in batches
14
+ // rather than staging the entire result first.
15
+ //
16
+ // The chained version stays useful for what it is good at: two runs at
17
+ // different times, or a file that outlives the movement. This is for when you
18
+ // want the rows in the target.
19
+ //
20
+ // ── one window, and nothing about schedules ──────────────────────────────
21
+ //
22
+ // The action moves ONE window and has no idea whether it is a backfill, an
23
+ // hourly top-up or somebody pressing a button. That is deliberate and it is
24
+ // the whole seam:
25
+ //
26
+ // db-move(window: Jan 1 → Feb 1) ┐
27
+ // db-move(window: Feb 1 → Mar 1) ├ a monthly backfill
28
+ // db-move(window: Mar 1 → Apr 1) ┘
29
+ // db-move(window: 14:00 → 15:00) an hourly job
30
+ // db-move() everything, once
31
+ //
32
+ // Same action every time. "Incremental", "historical" and "one-off" are not
33
+ // features of a movement; they are a caller deciding which window to ask for.
34
+ // Whatever schedules it — a job, a workflow, a for-loop — lives outside.
35
+ //
36
+ // ── the column mapping is SQL, not a transform ───────────────────────────
37
+ //
38
+ // `columns: [{ from: 'date', to: 'dt_created' }]` becomes
39
+ // `SELECT "date" AS "dt_created"`, so rows arrive already carrying TARGET
40
+ // names. inferColumns and buildCreateTableSql then need no notion of mapping
41
+ // at all: the CREATE is built from the target names, the SELECT from the source
42
+ // names, and nothing in the uploader has to know the two ever differed.
43
+ //
44
+ // A stored procedure is the exception — you cannot alias `EXEC proc` — so for
45
+ // mode 'procedure' the mapping is applied as a row transform on the way past.
46
+ // Same for the window: a table or query gets a WHERE, a procedure has to be
47
+ // handed the range as its own parameters, and if it does not take them then
48
+ // this refuses rather than filtering a full result set in memory and calling it
49
+ // incremental.
50
+
51
+ var { upload } = require('../../uploader');
52
+ var dbDrivers = require('../../drivers/db');
53
+ var { SqlQueue } = require('@xeplr/utils/lib/queue');
54
+ // HOW A PROCEDURE IS CALLED lives in one place, shared with the db-procedure
55
+ // action — this file used to own that knowledge, which meant a procedure could
56
+ // only be called by moving its output somewhere. See drivers/db/procedure.js.
57
+ var procedureCall = require('../../drivers/db/procedure');
58
+ var { generateId } = require('@xeplr/utils/lib/helpers');
59
+
60
+ var WRITE_MODES = ['replace', 'upsert', 'append'];
61
+
62
+ module.exports = {
63
+ name: 'db-move',
64
+ description: 'Move one window of rows from a source DB (table, query or procedure) into a ' +
65
+ 'target DB table, in a single streamed pass. Column mapping becomes SELECT ' +
66
+ 'aliases; writeMode is replace | upsert | append.',
67
+ requires: [], // per-driver requires checked at runtime once the dbTypes are known
68
+
69
+ inputSchema: [
70
+ { name: 'sourceDbType', type: 'string', required: true, default: 'postgres', order: 1,
71
+ description: 'postgres | mysql | mssql' },
72
+ { name: 'sourceConnection', type: 'object', required: true, order: 2,
73
+ description: 'Source connection config: { host, port, user, password, database }' },
74
+ { name: 'mode', type: 'string', default: 'table', order: 3,
75
+ description: '"table" | "query" | "procedure"' },
76
+ { name: 'table', type: 'string', order: 4,
77
+ description: 'Source table (mode="table").' },
78
+ { name: 'sql', type: 'string', order: 5,
79
+ description: 'Source SQL (mode="query") or the procedure name (mode="procedure").' },
80
+ { name: 'params', type: 'array', order: 6,
81
+ description: 'Bind params for the source SQL or procedure.' },
82
+
83
+ { name: 'columns', type: 'array', order: 7,
84
+ description: 'Mapping [{ from, to }]. Omitted = every source column, unrenamed.' },
85
+
86
+ { name: 'targetDbType', type: 'string', required: true, default: 'postgres', order: 8,
87
+ description: 'postgres | mysql | mssql' },
88
+ { name: 'targetConnection', type: 'object', required: true, order: 9,
89
+ description: 'Target connection config.' },
90
+ { name: 'targetTable', type: 'string', required: true, order: 10 },
91
+
92
+ { name: 'writeMode', type: 'string', default: 'append', order: 11, group: 'Write options',
93
+ description: 'replace (truncate first) | upsert (needs primaryKeys) | append' },
94
+ { name: 'primaryKeys', type: 'array', order: 12, group: 'Write options',
95
+ description: 'Target column names for UPSERT. Required when writeMode="upsert".' },
96
+
97
+ { name: 'window', type: 'object', order: 13, group: 'Advanced',
98
+ description: 'One window: { column, from, to }. Half-open — from <= x < to. ' +
99
+ 'Omitted = no filter. For mode="procedure", also needs { fromParam, toParam }.' },
100
+
101
+ { name: 'where', type: 'string', order: 13.5, group: 'Advanced',
102
+ description: 'A raw SQL condition ANDed into the source query — "department_id = 42", ' +
103
+ '"EXTRACT(HOUR FROM created) = 2". Runs on the SOURCE database, so its own ' +
104
+ 'functions and dialect apply. Omitted = no condition. Not supported for ' +
105
+ 'mode="procedure", which has no WHERE to add it to.' },
106
+ { name: 'movementId', type: 'string', order: 14, group: 'Advanced',
107
+ description: 'Correlation key for rollback. Defaults to system.occurrenceId or a fresh id.' },
108
+ { name: 'batchSize', type: 'number', default: 5000, order: 15, group: 'Performance' },
109
+ { name: 'concurrency', type: 'number', default: 4, order: 16, group: 'Performance' }
110
+ ],
111
+
112
+ execute: async function(ctx) {
113
+ var input = ctx.input || {};
114
+ var system = ctx.system || {};
115
+
116
+ var writeMode = input.writeMode || 'append';
117
+ if (WRITE_MODES.indexOf(writeMode) === -1) {
118
+ throw new Error('db-move: writeMode must be one of ' + WRITE_MODES.join(', ') + ' (got "' + writeMode + '")');
119
+ }
120
+ var primaryKeys = (input.primaryKeys && input.primaryKeys.length) ? input.primaryKeys : null;
121
+ // Refused rather than quietly downgraded to an append. An upsert that
122
+ // silently inserts is a duplicate every run, discovered a week later.
123
+ if (writeMode === 'upsert' && !primaryKeys) {
124
+ throw new Error('db-move: writeMode "upsert" requires primaryKeys');
125
+ }
126
+
127
+ var sourceDriver = dbDrivers.getDriver(input.sourceDbType);
128
+ dbDrivers.checkDriverRequires('db-move', input.sourceDbType, sourceDriver);
129
+ var targetDriver = dbDrivers.getDriver(input.targetDbType);
130
+ dbDrivers.checkDriverRequires('db-move', input.targetDbType, targetDriver);
131
+ if (typeof sourceDriver.fetchStream !== 'function') {
132
+ throw new Error('db-move: source driver "' + input.sourceDbType + '" does not implement fetchStream');
133
+ }
134
+
135
+ var plan = buildSourcePlan(sourceDriver, input);
136
+ var movementId = input.movementId || system.occurrenceId || ('mv_' + generateId());
137
+
138
+ // ONE LINE PER STEP, in the order they actually happen — the point is a
139
+ // log somebody can read top to bottom and know exactly what this run did
140
+ // and how far it got, without guessing from a single packed summary
141
+ // sentence. system.log is optional: a caller that passes nothing (a plain
142
+ // runAction call with no logging wired up) costs this action nothing.
143
+ if (system.log) {
144
+ // The correlation key — what a MoveRun row stores, what rollback would
145
+ // undo, and what ties this run to import_meta in xeplr_configs. Logged
146
+ // first and by itself so it's the one thing findable by eye even in a
147
+ // long log, if that's what someone is cross-referencing against.
148
+ // THE RUN'S OWN SHAPE, as facts as well as sentences.
149
+ //
150
+ // This is the block that answers "what was this movement even supposed
151
+ // to do" — the first question when the numbers look wrong — so an
152
+ // analyzer needs it structured rather than reconstructed from prose.
153
+ // Identifiers, schema and configuration only; no customer values.
154
+ system.log('Movement id: ' + movementId,
155
+ { phase: 'start', event: 'movement_id', movementId: movementId });
156
+ system.log('Source: ' + input.sourceDbType + ' "' + connDbName(input.sourceConnection) + '" — ' + describeSource(input),
157
+ { phase: 'start', event: 'source', dbType: input.sourceDbType,
158
+ database: connDbName(input.sourceConnection), mode: input.mode || 'table',
159
+ table: input.table || null });
160
+ system.log('Target: ' + input.targetDbType + ' "' + connDbName(input.targetConnection) + '" — table "' + input.targetTable + '"',
161
+ { phase: 'start', event: 'target', dbType: input.targetDbType,
162
+ database: connDbName(input.targetConnection), table: input.targetTable });
163
+ system.log('Columns: ' + describeColumns(input.columns),
164
+ { phase: 'start', event: 'columns', count: (input.columns || []).length });
165
+ system.log('Write mode: ' + describeWriteMode(writeMode, primaryKeys),
166
+ { phase: 'start', event: 'write_mode', writeMode: writeMode, primaryKeys: primaryKeys || [] });
167
+ system.log('Window: ' + describeWindow(input.window),
168
+ { phase: 'start', event: 'window',
169
+ // Bounds are CONFIGURATION — a date range somebody chose — not rows
170
+ // out of the customer's tables. The `where` text is deliberately not
171
+ // here for the opposite reason; see the SQL line.
172
+ window: input.window || null });
173
+ }
174
+
175
+ var sourcePool = await sourceDriver.connect(input.sourceConnection);
176
+ var targetPool = await targetDriver.connect(input.targetConnection);
177
+
178
+ var connectionName = input.targetTable;
179
+ var connections = {};
180
+ connections[connectionName] = targetPool;
181
+ // Counted here rather than derived later: the queue reports rows, and
182
+ // "20 rows across 3 statements" is a different diagnosis from "20 rows in
183
+ // 20 statements" — the first is a bad batch, the second is bad data.
184
+ var droppedGroups = 0;
185
+ var queue = new SqlQueue({
186
+ connections: connections,
187
+ // Through driver.query, not conn.query: each driver normalizes the
188
+ // result shape AND binds params dialect-correctly.
189
+ executor: async function(item, conn) { await targetDriver.query(conn, item.sql, item.params || []); },
190
+ concurrency: input.concurrency || 4,
191
+ maxAttempts: 3,
192
+
193
+ // WHY A ROW DID NOT ARRIVE.
194
+ //
195
+ // The queue hands the failing statement, its rows and the database's own
196
+ // error to this hook — and its default is a no-op, so until now every one
197
+ // of those was discarded the moment it happened. The run then reported
198
+ // "1000 read, 980 loaded, 20 dropped" and nothing, anywhere, could say
199
+ // why those 20 went missing. That is the single question a client asks.
200
+ //
201
+ // A BOUNDED SAMPLE, not every row: a broken column drops the whole batch
202
+ // and writing 5000 rejected rows into the log makes it unreadable and
203
+ // pushes out the lines around it. Three is enough to see the shape of
204
+ // what failed; the count says how much of it there was.
205
+ onErrorTable: function(info) {
206
+ droppedGroups++;
207
+ var meta = (info.item && info.item.meta) || {};
208
+ var rows = meta.rows || [];
209
+ var reason = (info.error && info.error.message) || 'unknown error';
210
+ if (!system.log) return;
211
+ // WHICH BATCH, WHY, AND ONE ROW TO LOOK AT.
212
+ //
213
+ // Not every failing row: a batch fails for one reason, and printing
214
+ // five hundred variations of it buries the line that says which batch
215
+ // it was. What a person needs to act is the reference (to find and
216
+ // re-run it), the reason (to know what to fix) and one example (to see
217
+ // the shape). They can read the rest from the source.
218
+ // THE FACTS IN meta, THE SENTENCE IN THE MESSAGE.
219
+ //
220
+ // A log analyzer reads meta and never parses the wording — so a line
221
+ // can be rephrased without breaking anything that depends on it, and
222
+ // the analyzer can group, count and filter by batch or reason without
223
+ // regexes. Nothing here is customer data: identifiers, counts and the
224
+ // database's own error text.
225
+ system.log('BATCH ' + (meta.batchRef || '?') + ' FAILED — ' + rows.length +
226
+ ' row(s) dropped: ' + reason, {
227
+ phase: 'write',
228
+ event: 'batch_failed',
229
+ batchRef: meta.batchRef || null,
230
+ rows: rows.length,
231
+ reason: reason,
232
+ // The DATABASE's own name for the failure, and whether it was ever
233
+ // worth retrying — '23502'/deterministic groups cleanly across
234
+ // thousands of lines where the message text does not, and it says
235
+ // in one field whether a re-run could plausibly behave differently.
236
+ errorCode: info.errorCode || null,
237
+ errorKind: info.errorKind || null,
238
+ attempts: info.attempts || null,
239
+ rowIdentity: rows.length ? rowIdentity(rows[0], primaryKeys) : null,
240
+ movementId: movementId,
241
+ targetTable: input.targetTable
242
+ });
243
+ }
244
+ });
245
+
246
+ try {
247
+ // TRUNCATE BEFORE READING A SINGLE ROW, and only if the table is already
248
+ // there — on a first run upload() creates it, and truncating a table that
249
+ // does not exist is an error rather than a no-op.
250
+ //
251
+ // If the load then fails the target is empty until somebody runs it
252
+ // again. That is the accepted trade: the alternative (stage and swap, or
253
+ // delete-by-previous-movement) costs a second copy of the data or a
254
+ // schema change, and re-running is what people do anyway.
255
+ var truncated = false;
256
+ if (writeMode === 'replace') truncated = await truncateIfExists(targetDriver, targetPool, input.targetTable);
257
+ if (truncated && system.log) system.log('Target table emptied first (replace)',
258
+ { phase: 'write', event: 'target_truncated', table: input.targetTable });
259
+
260
+ // THE SOURCE'S DECLARED TYPES, where the source has any to declare.
261
+ //
262
+ // Without this upload() infers from the first batch's VALUES, which is
263
+ // correct for a CSV and quietly lossy here — see declaredColumns.
264
+ var declared = await declaredColumns(sourceDriver, sourcePool, input, plan);
265
+
266
+ // The actual SQL, so a discrepancy is checkable against the source
267
+ // directly rather than trusted on faith — this is what actually ran,
268
+ // not a paraphrase of it. Truncated: a query source's own SQL can be
269
+ // arbitrarily long, and the point is a debugging trail, not a full copy.
270
+ // The SQL is the answer to every "why did these rows come through"
271
+ // question, so it is a fact, not only a sentence. Parameters are NOT
272
+ // included: a window's bounds are fine, but a `where` can carry values
273
+ // from the customer's own data.
274
+ if (system.log) system.log('SQL: ' + truncateForLog(plan.sql),
275
+ { phase: 'read', event: 'source_sql', sql: plan.sql, mode: input.mode || 'table' });
276
+ if (system.log) system.log('Starting the read…', { phase: 'read', event: 'read_started' });
277
+ var rows = sourceDriver.fetchStream(sourcePool, {
278
+ sql: plan.sql,
279
+ params: plan.params,
280
+ batchSize: input.batchSize || 5000
281
+ });
282
+ // Only a procedure needs this — see the note at the top. For a table or
283
+ // query the aliases are already in the SELECT and the rows arrive named
284
+ // the way the target wants them.
285
+ if (plan.renameInJs) rows = renamed(rows, plan.renameInJs);
286
+
287
+ var result = await upload({
288
+ source: rows,
289
+ driver: targetDriver,
290
+ connection: targetPool,
291
+ targetTable: input.targetTable,
292
+ // A replace has just emptied the table, so there is nothing to conflict
293
+ // with — passing keys would build an upsert that can never fire and
294
+ // would demand a unique index the target may not have.
295
+ primaryKeys: writeMode === 'upsert' ? primaryKeys : null,
296
+ // Null for a query or a procedure — upload() falls back to inference.
297
+ columns: declared,
298
+ movementId: movementId,
299
+ queue: queue,
300
+ connectionName: connectionName,
301
+ dbType: input.targetDbType, // recorded in import_meta
302
+ metaStore: ctx.metaStore,
303
+ batchSize: input.batchSize,
304
+ // Passed through from the caller's `system`, not from `input` — a
305
+ // progress reporter is a function, and an action's input is data that
306
+ // has to survive being stored in a job row.
307
+ onProgress: system.onProgress || null
308
+ });
309
+
310
+ // Every count upload() has, not just the headline one — "read" and
311
+ // "loaded" legitimately differ (a dropped row, an upsert that matched
312
+ // and updated rather than inserted), and the discrepancy itself is
313
+ // exactly what this line exists to make checkable without a query.
314
+ if (system.log) {
315
+ var bits = [(result.completed || 0) + ' loaded'];
316
+ if ((result.totalRows || 0) !== (result.completed || 0)) bits.push((result.totalRows || 0) + ' read');
317
+ if (result.dropped) {
318
+ bits.push(result.dropped + ' dropped' +
319
+ (droppedGroups ? ' in ' + droppedGroups + ' failed batch(es) — see the BATCH … FAILED lines for which' : ''));
320
+ }
321
+ bits.push((result.totalBatches || 0) + ' batch' + (result.totalBatches === 1 ? '' : 'es'));
322
+ bits.push(((result.durationMs || 0) / 1000).toFixed(1) + 's');
323
+ // THE LINE THE WHOLE LOG EXISTS FOR. Every number a discrepancy
324
+ // question starts from, in one record — so "1000 read, 980 loaded" is
325
+ // a fact an analyzer can compare against the source, not a sentence it
326
+ // has to pick apart.
327
+ system.log('Done — ' + bits.join(', ') + ' — into "' + input.targetTable + '"' +
328
+ (result.aborted ? ' (ABORTED)' : ''), {
329
+ phase: 'summary',
330
+ event: result.aborted ? 'movement_aborted' : 'movement_done',
331
+ movementId: movementId,
332
+ targetTable: input.targetTable,
333
+ read: result.totalRows || 0,
334
+ loaded: result.completed || 0,
335
+ dropped: result.dropped || 0,
336
+ failedBatches: droppedGroups,
337
+ batches: result.totalBatches || 0,
338
+ durationMs: result.durationMs || 0,
339
+ aborted: Boolean(result.aborted),
340
+ writeMode: writeMode
341
+ });
342
+ }
343
+
344
+ return Object.assign({}, result, {
345
+ movementId: movementId,
346
+ writeMode: writeMode,
347
+ truncated: truncated,
348
+ // Echoed back so a caller driving a backfill can log which window this
349
+ // was without holding on to what it sent.
350
+ window: input.window || null,
351
+ sourceSql: plan.sql
352
+ });
353
+ } finally {
354
+ queue.stop();
355
+ await sourceDriver.close(sourcePool);
356
+ await targetDriver.close(targetPool);
357
+ }
358
+ }
359
+ };
360
+
361
+ /** A short, human phrase for what's being read — the log line, not the SQL. */
362
+ function describeSource(input) {
363
+ var mode = input.mode || (input.table ? 'table' : 'query');
364
+ if (mode === 'table') return 'table "' + input.table + '"';
365
+ if (mode === 'procedure') return 'procedure "' + input.sql + '"';
366
+ return 'a query';
367
+ }
368
+
369
+ /** The database name off a connection config — never the password. */
370
+ function connDbName(connection) {
371
+ return (connection && connection.database) || '?';
372
+ }
373
+
374
+ /** What range, if any, this run actually asked for. */
375
+ function describeWindow(win) {
376
+ if (!win) return 'none — moving everything';
377
+ if (win.fromParam || win.toParam) {
378
+ return '@' + (win.fromParam || '?') + ' / @' + (win.toParam || '?') + ': ' +
379
+ (win.from || 'anything') + ' → ' + (win.to || 'now');
380
+ }
381
+ if (win.column) return win.column + ': ' + (win.from || 'anything') + ' → ' + (win.to || 'now');
382
+ return 'none — moving everything';
383
+ }
384
+
385
+ /** A log line has room for the SQL, not a second copy of a 50KB query. */
386
+ /**
387
+ * HOW TO FIND A ROW, NOT WHAT IS IN IT.
388
+ *
389
+ * Logs stay on the customer's own machine, but they are still read by support
390
+ * engineers, copied into tickets and pasted into chats — so a log line is not
391
+ * a safe place for their data, and printing one costs nothing to avoid.
392
+ *
393
+ * The primary key is the reference somebody needs to go and look at the row in
394
+ * the source; the column list says what shape the statement had. Neither is
395
+ * the customer's content. With no primary key configured there is nothing safe
396
+ * to identify it by, and saying so is better than printing the row instead.
397
+ */
398
+ function rowIdentity(row, primaryKeys) {
399
+ if (!row || typeof row !== 'object') return '<no row>';
400
+ var keys = Object.keys(row);
401
+ if (primaryKeys && primaryKeys.length) {
402
+ var parts = primaryKeys.map(function(k) {
403
+ // The key VALUE is the one thing that has to travel — it is the address
404
+ // of the row, and without it the reference is not a reference.
405
+ return k + '=' + String(row[k] === undefined ? '<missing>' : row[k]).slice(0, 60);
406
+ });
407
+ return parts.join(', ') + ' (' + keys.length + ' columns)';
408
+ }
409
+ return 'no primary key configured — cannot identify the row; columns were: ' +
410
+ truncateForLog(keys.join(', '), 300);
411
+ }
412
+
413
+ function truncateForLog(sql, limit) {
414
+ var max = limit || 800;
415
+ var s = String(sql || '').replace(/\s+/g, ' ').trim();
416
+ return s.length > max ? s.slice(0, max) + '…' : s;
417
+ }
418
+
419
+ /** What the column mapping actually does, for the log line. */
420
+ function describeColumns(columns) {
421
+ var mapping = normalizeMapping(columns);
422
+ if (!mapping.length) return 'all source columns, unrenamed';
423
+ return mapping.map(function(c) { return c.from === c.to ? c.from : c.from + ' → ' + c.to; }).join(', ');
424
+ }
425
+
426
+ /**
427
+ * The write mode, with the detail that actually matters for each one.
428
+ *
429
+ * NOT "target emptied first" for replace here — whether that actually
430
+ * happens depends on whether the table exists yet, which isn't known until
431
+ * truncateIfExists runs; that outcome gets its own log line when it does.
432
+ */
433
+ function describeWriteMode(writeMode, primaryKeys) {
434
+ if (writeMode === 'upsert') return 'upsert (key: ' + (primaryKeys || []).join(', ') + ')';
435
+ return writeMode;
436
+ }
437
+
438
+ // ── what the columns actually are ────────────────────────────────────────
439
+
440
+ /**
441
+ * The source's DECLARED column types, in the target's names — or null when the
442
+ * source has none to declare.
443
+ *
444
+ * Inference reads values, and for a database source that is throwing away an
445
+ * answer the server already holds. Three ways it goes wrong, all of them quiet
446
+ * and all of them baked into a CREATE TABLE that outlives the sample:
447
+ *
448
+ * Postgres returns `numeric` to node as a STRING. A money column arrives as
449
+ * strings, infers as text, and the target column is created TEXT. You find
450
+ * out when somebody tries to SUM it.
451
+ *
452
+ * A column that is all-null in the first batch has no type, so it becomes
453
+ * text — and stays text once the table exists.
454
+ *
455
+ * A zero-padded code ('007') looks like a number and loses its zeros.
456
+ *
457
+ * Only for mode 'table': a query or a procedure has no single table to
458
+ * describe. Both dialects CAN describe a result set — Postgres by preparing,
459
+ * SQL Server via sp_describe_first_result_set — but that is a per-driver
460
+ * capability that does not exist yet, so those fall back to inference and the
461
+ * caveat above still applies to them.
462
+ */
463
+ async function declaredColumns(driver, pool, input, plan) {
464
+ var mode = input.mode || (input.table ? 'table' : 'query');
465
+ if (mode !== 'table' || !input.table) return null;
466
+ if (typeof driver.getTableSchema !== 'function' || typeof driver.dataTypeToLogical !== 'function') return null;
467
+
468
+ var schema = await driver.getTableSchema(pool, input.table);
469
+ if (!schema || !schema.length) return null;
470
+
471
+ var byName = {};
472
+ schema.forEach(function(c) { byName[c.name] = c; });
473
+
474
+ var mapping = normalizeMapping(input.columns);
475
+ // No mapping means every source column, unrenamed — so the declaration is
476
+ // the source's own schema in its own order.
477
+ var wanted = mapping.length
478
+ ? mapping
479
+ : schema.map(function(c) { return { from: c.name, to: c.name }; });
480
+
481
+ var out = [];
482
+ for (var i = 0; i < wanted.length; i++) {
483
+ var src = byName[wanted[i].from];
484
+ // A mapped column the source does not have is left out rather than
485
+ // guessed at — the SELECT will fail on it anyway, with a better message
486
+ // than anything invented here.
487
+ if (!src) return null;
488
+ out.push({ name: wanted[i].to, type: driver.dataTypeToLogical(src.dataType) });
489
+ }
490
+ return out.length ? out : null;
491
+ }
492
+
493
+ // ── the source query ─────────────────────────────────────────────────────
494
+
495
+ /**
496
+ * Turn the source inputs into { sql, params, renameInJs }.
497
+ *
498
+ * `renameInJs` is set only where the mapping cannot be expressed as a SELECT
499
+ * alias — which is procedures, and only procedures.
500
+ */
501
+ function buildSourcePlan(driver, input) {
502
+ var mode = input.mode || (input.table ? 'table' : 'query');
503
+ var ph = placeholders(input.sourceDbType);
504
+ var mapping = normalizeMapping(input.columns);
505
+ // A window is "on" when it has something to address BY — a column for
506
+ // table/query, or fromParam/toParam for a procedure (see procedurePlan).
507
+ // Checking `column` alone here meant a procedure's window was silently
508
+ // dropped before procedurePlan ever saw it, regardless of fromParam/toParam.
509
+ var win = input.window && (input.window.column || (input.window.columns && input.window.columns.length) ||
510
+ input.window.fromParam || input.window.toParam)
511
+ ? input.window : null;
512
+
513
+ if (mode === 'procedure') return procedurePlan(driver, input, mapping, win);
514
+
515
+ var selectList = mapping.length
516
+ ? mapping.map(function(c) {
517
+ return driver.quoteIdent(c.from) + ' AS ' + driver.quoteIdent(c.to);
518
+ }).join(', ')
519
+ : '*';
520
+
521
+ var from;
522
+ if (mode === 'table') {
523
+ if (!input.table) throw new Error('db-move: mode "table" requires input.table');
524
+ from = driver.quoteIdent(input.table);
525
+ } else if (mode === 'query') {
526
+ if (!input.sql) throw new Error('db-move: mode "query" requires input.sql');
527
+ // WRAPPED, so the mapping and the window apply to whatever the caller
528
+ // wrote without this having to parse it. The alias is not decoration —
529
+ // Postgres and MySQL both reject a derived table without one.
530
+ from = '(' + input.sql + ') AS __src';
531
+ } else {
532
+ throw new Error('db-move: unsupported mode "' + mode + '" (use "table", "query" or "procedure")');
533
+ }
534
+
535
+ var params = (input.params || []).slice();
536
+ var sql = 'SELECT ' + selectList + ' FROM ' + from;
537
+
538
+ // Declared out here, not inside the window branch: a `where` with no window
539
+ // is an ordinary thing to ask for, and `var` would have hoisted the name
540
+ // while leaving it undefined — so the push below threw instead of filtering.
541
+ var clauses = [];
542
+
543
+ if (win) {
544
+ // HALF-OPEN: from <= x < to. Closed at both ends would move the boundary
545
+ // row twice — harmless under upsert, a duplicate under append — and
546
+ // consecutive windows are the normal case here, so the boundary is hit
547
+ // every single run rather than occasionally.
548
+ if (win.columns && win.columns.length) {
549
+ // Independent watermarks, OR'd — a row counts as "new" the moment ANY
550
+ // tracked column exceeds its own last-seen max (not a composite tuple
551
+ // tie-break). A column with no watermark yet (null `from` — nothing in
552
+ // the target for it) is left out of the OR entirely rather than forced
553
+ // true/false; if every column is null (a fresh target) the whole group
554
+ // is skipped below, same as the single-column "load everything the
555
+ // first time" case.
556
+ var orParts = [];
557
+ win.columns.forEach(function(c) {
558
+ if (c.from == null) return;
559
+ orParts.push(driver.quoteIdent(c.column) + ' >= ' + ph(params.length));
560
+ params.push(c.from);
561
+ });
562
+ if (orParts.length) clauses.push('(' + orParts.join(' OR ') + ')');
563
+ } else {
564
+ var col = driver.quoteIdent(win.column);
565
+ if (win.from != null) { clauses.push(col + ' >= ' + ph(params.length)); params.push(win.from); }
566
+ if (win.to != null) { clauses.push(col + ' < ' + ph(params.length)); params.push(win.to); }
567
+ }
568
+ }
569
+
570
+ // THE CALLER'S OWN CONDITION, ANDed with the window rather than replacing it.
571
+ //
572
+ // Both are filters on the same rows and both must hold: an incremental run
573
+ // of "department 42 only" means new rows AND that department, never one or
574
+ // the other. Parenthesised because a condition containing OR would otherwise
575
+ // bind loosely and quietly widen the window it was supposed to narrow.
576
+ //
577
+ // NOT parameterised, and this is the same trust boundary mode="query"
578
+ // already sits on: a caller who can write a source SELECT can write a WHERE.
579
+ // What it must never become is a field an END USER types into without that
580
+ // being a deliberate decision — see the note in moveService.
581
+ if (input.where && String(input.where).trim()) {
582
+ clauses.push('(' + String(input.where).trim() + ')');
583
+ }
584
+
585
+ if (clauses.length) sql += ' WHERE ' + clauses.join(' AND ');
586
+
587
+ return { sql: sql, params: params, renameInJs: null };
588
+ }
589
+
590
+ // normalizeParamValue moved to drivers/db/procedure.js with the rest of the
591
+ // call-building — the only caller left was the procedure path, and a second
592
+ // copy of "what may be bound" is how two callers start disagreeing about it.
593
+ // Re-exported here for anything reaching for it by name.
594
+ var normalizeParamValue = procedureCall.normalizeParamValue;
595
+
596
+ /**
597
+ * A procedure call.
598
+ *
599
+ * Neither the alias nor the WHERE is available: `SELECT … FROM (EXEC p) x` is
600
+ * not a thing in any of the three dialects. So the window has to be handed to
601
+ * the procedure as its own parameters, and the caller has to say which ones
602
+ * those are. If it cannot, this REFUSES — the alternative is pulling the whole
603
+ * result set and filtering it in memory, which costs exactly what incremental
604
+ * was meant to save while looking like it worked.
605
+ */
606
+ function procedurePlan(driver, input, mapping, win) {
607
+ if (!input.sql) throw new Error('db-move: mode "procedure" requires input.sql (the procedure name)');
608
+
609
+ // ONE implementation of how a procedure is called — including what a window
610
+ // means to one — shared with the db-procedure action. A window here is not a
611
+ // WHERE but two more parameters; the builder owns that rule, and its refusal
612
+ // when they are not named is the same refusal this file used to make itself.
613
+ //
614
+ // returnsRows is always true here and cannot be anything else: a movement
615
+ // exists to move the rows, so on Postgres this is always the set-returning
616
+ // FUNCTION form rather than CALL, which returns nothing a client can read.
617
+ var call;
618
+ try {
619
+ call = procedureCall.buildProcedureCall({
620
+ driver: driver,
621
+ dbType: input.sourceDbType,
622
+ name: input.sql,
623
+ params: input.params,
624
+ window: win,
625
+ returnsRows: true
626
+ });
627
+ } catch (err) {
628
+ // The shared builder does not know whose call this is; the message a user
629
+ // reads should still name the action they ran.
630
+ throw new Error('db-move: ' + err.message);
631
+ }
632
+
633
+ return {
634
+ sql: call.sql,
635
+ params: call.params,
636
+ renameInJs: mapping.length ? mapping : null
637
+ };
638
+ }
639
+
640
+ // Schema-qualified quoting moved to drivers/db/procedure.js with the rest of
641
+ // the call-building. Still used here for the table/query paths, so it is taken
642
+ // from there rather than kept as a second copy.
643
+ var quoteQualifiedIdent = procedureCall.quoteQualifiedIdent;
644
+
645
+ function normalizeMapping(columns) {
646
+ return (columns || [])
647
+ .filter(function(c) { return c && c.from; })
648
+ .map(function(c) { return { from: c.from, to: c.to || c.from }; });
649
+ }
650
+
651
+ /** Dialect placeholder for bind param n (0-based). */
652
+ function placeholders(dbType) {
653
+ if (dbType === 'mysql') return function() { return '?'; };
654
+ if (dbType === 'mssql') return function(n) { return '@p' + n; };
655
+ return function(n) { return '$' + (n + 1); }; // postgres
656
+ }
657
+
658
+ /**
659
+ * Rename keys on the way past — the procedure-only path.
660
+ *
661
+ * Columns NOT in the mapping are dropped rather than passed through: the
662
+ * mapping is the statement of what the target has, and a stray column would
663
+ * either fail the insert or silently widen the table on creation.
664
+ */
665
+ async function* renamed(rows, mapping) {
666
+ for await (var row of rows) {
667
+ var out = {};
668
+ for (var i = 0; i < mapping.length; i++) {
669
+ out[mapping[i].to] = row[mapping[i].from];
670
+ }
671
+ yield out;
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Empty the target, if it is there at all.
677
+ *
678
+ * TRUNCATE first, DELETE as the fallback: truncate is far cheaper but a
679
+ * foreign key pointing at this table makes it illegal in every one of the
680
+ * three dialects, and that is not a reason to fail the movement.
681
+ */
682
+ async function truncateIfExists(driver, pool, table) {
683
+ var existing = await driver.getTableSchema(pool, table);
684
+ if (!existing || !existing.length) return false;
685
+ try {
686
+ await driver.query(pool, 'TRUNCATE TABLE ' + driver.quoteIdent(table), []);
687
+ } catch (e) {
688
+ await driver.query(pool, 'DELETE FROM ' + driver.quoteIdent(table), []);
689
+ }
690
+ return true;
691
+ }