@xeplr/actions 1.1.1 → 1.1.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/lib/builtins/db/fetch.js +34 -5
- package/lib/builtins/db/move.js +691 -0
- package/lib/builtins/db/procedure.js +210 -0
- package/lib/builtins/db/push.js +16 -6
- package/lib/builtins/email/delete.js +42 -0
- package/lib/builtins/email/download-attachments.js +52 -0
- package/lib/builtins/email/download-email.js +45 -0
- package/lib/builtins/email/move.js +45 -0
- package/lib/builtins/email/read.js +128 -0
- package/lib/builtins/file/upload.js +6 -6
- package/lib/builtins/index.js +24 -1
- package/lib/builtins/send-email.js +114 -5
- package/lib/drivers/db/duckdb.js +646 -0
- package/lib/drivers/db/index.js +4 -1
- package/lib/drivers/db/mssql.js +32 -3
- package/lib/drivers/db/mysql.js +64 -4
- package/lib/drivers/db/postgres.js +35 -2
- package/lib/drivers/db/procedure.js +222 -0
- package/lib/drivers/email/config.js +122 -0
- package/lib/drivers/email/files.js +76 -0
- package/lib/drivers/email/imap.js +395 -0
- package/lib/drivers/email/index.js +45 -0
- package/lib/drivers/email/smtp.js +214 -0
- package/lib/runner.js +46 -1
- package/lib/streaming/spool.js +62 -2
- package/lib/uploader/attach.js +53 -7
- package/lib/uploader/index.js +84 -10
- package/lib/uploader/meta-store-knex.js +34 -5
- package/lib/uploader/meta-store-noop.js +1 -0
- package/lib/uploader/reconcile.js +12 -1
- package/package.json +22 -1
|
@@ -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
|
+
}
|