@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
package/lib/runner.js
CHANGED
|
@@ -26,7 +26,7 @@ async function runAction(first, second, third) {
|
|
|
26
26
|
|
|
27
27
|
var input;
|
|
28
28
|
try {
|
|
29
|
-
input = applySchema(def.inputSchema, opts.input || {}, 'input');
|
|
29
|
+
input = applySchema(def.inputSchema, coerceScalars(def.inputSchema, opts.input || {}), 'input');
|
|
30
30
|
} catch (err) {
|
|
31
31
|
return finish(start, 'failed', null, err);
|
|
32
32
|
}
|
|
@@ -43,6 +43,51 @@ async function runAction(first, second, third) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* ONE VALUE WHERE A LIST WAS DECLARED MEANS A LIST OF ONE.
|
|
48
|
+
*
|
|
49
|
+
* Without this, an `array` field can only ever be given a literal array — and
|
|
50
|
+
* that quietly breaks the thing every caller of this package is built to do,
|
|
51
|
+
* which is BIND A REFERENCE. A workflow step writes `to = {params.email}`; the
|
|
52
|
+
* engine interpolates that to a string; applySchema then rejects it for not
|
|
53
|
+
* being an array, and the run fails on a step that reads as obviously correct.
|
|
54
|
+
* Every array-typed input was unbindable, in every action, for every consumer.
|
|
55
|
+
*
|
|
56
|
+
* It also makes email-send's own promise true. Its schema has said "a bare
|
|
57
|
+
* string is accepted and wrapped" since it was written, and nothing wrapped
|
|
58
|
+
* anything — applySchema refused it before execute() ever ran.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately NARROW:
|
|
61
|
+
*
|
|
62
|
+
* - only for fields the schema declares `array`, so nothing else can be
|
|
63
|
+
* reshaped by accident;
|
|
64
|
+
* - only when the value is a scalar. An object stays an object and fails
|
|
65
|
+
* the type check as it should, because `{a:1}` is a mistake, not a
|
|
66
|
+
* one-element list;
|
|
67
|
+
* - null and undefined are left alone, so `required` still means required
|
|
68
|
+
* and a default still fills in;
|
|
69
|
+
* - '' is left alone, so an empty box does not become `['']` — a list
|
|
70
|
+
* containing one empty string is a value, and "unset" is what was meant.
|
|
71
|
+
*
|
|
72
|
+
* No JSON parsing here. A string that looks like JSON is the CALLER's to
|
|
73
|
+
* parse: this layer cannot tell `"[1,2]"` the literal text from `[1,2]` the
|
|
74
|
+
* list, and guessing would make a value's meaning depend on its punctuation.
|
|
75
|
+
*/
|
|
76
|
+
function coerceScalars(schema, input) {
|
|
77
|
+
if (!Array.isArray(schema)) return input;
|
|
78
|
+
var out = null;
|
|
79
|
+
for (var i = 0; i < schema.length; i++) {
|
|
80
|
+
var field = schema[i];
|
|
81
|
+
if (!field || field.type !== 'array') continue;
|
|
82
|
+
var val = input[field.name];
|
|
83
|
+
if (val === undefined || val === null || val === '') continue;
|
|
84
|
+
if (Array.isArray(val) || typeof val === 'object') continue;
|
|
85
|
+
if (!out) out = Object.assign({}, input);
|
|
86
|
+
out[field.name] = [val];
|
|
87
|
+
}
|
|
88
|
+
return out || input;
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
// Accept either the options-object form or the shorthand (action, input, opts).
|
|
47
92
|
function normalizeArgs(first, second, third) {
|
|
48
93
|
// Shorthand form: first arg is a string name or an action module.
|
package/lib/streaming/spool.js
CHANGED
|
@@ -17,7 +17,15 @@ var DEFAULTS = {
|
|
|
17
17
|
batchSize: 5000,
|
|
18
18
|
maxInFlight: 1,
|
|
19
19
|
ratePerSecond: null,
|
|
20
|
-
maxMemoryMB: 256
|
|
20
|
+
maxMemoryMB: 256,
|
|
21
|
+
// How many batches may be buffered BEYOND the ones being processed. The
|
|
22
|
+
// reader blocks past this, which pauses the source when the source can be
|
|
23
|
+
// paused. 1 keeps one batch filling while another is written — the overlap
|
|
24
|
+
// this design is for — without letting a fast source outrun a slow target.
|
|
25
|
+
//
|
|
26
|
+
// Raising it trades memory for tolerance of a bursty target: peak footprint
|
|
27
|
+
// is roughly (maxInFlight + maxReadAhead) x batchSize rows.
|
|
28
|
+
maxReadAhead: 1
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -55,6 +63,30 @@ async function spool(source, opts) {
|
|
|
55
63
|
var rl = rateLimiter(opts.ratePerSecond);
|
|
56
64
|
var mem = memoryMonitor(opts.maxMemoryMB);
|
|
57
65
|
|
|
66
|
+
// The reader's own governor. Counts batches scheduled but not yet finished;
|
|
67
|
+
// wait() resolves once that falls back within maxReadAhead. Separate from
|
|
68
|
+
// `sem`, which bounds how many run AT ONCE — this bounds how far the reader
|
|
69
|
+
// may get ahead of them.
|
|
70
|
+
var readAhead = (function(limit) {
|
|
71
|
+
var outstanding = 0;
|
|
72
|
+
var waiters = [];
|
|
73
|
+
return {
|
|
74
|
+
enter: function() { outstanding++; },
|
|
75
|
+
leave: function() {
|
|
76
|
+
outstanding = Math.max(0, outstanding - 1);
|
|
77
|
+
// Wake everyone and let them re-test: there is only ever one reader,
|
|
78
|
+
// so this is at most one resumption.
|
|
79
|
+
var w = waiters; waiters = [];
|
|
80
|
+
w.forEach(function(resolve) { resolve(); });
|
|
81
|
+
},
|
|
82
|
+
wait: async function() {
|
|
83
|
+
while (outstanding > limit) {
|
|
84
|
+
await new Promise(function(resolve) { waiters.push(resolve); });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
})(Math.max(0, opts.maxReadAhead));
|
|
89
|
+
|
|
58
90
|
// Ordered checkpoint tracking: batches may complete out of order when
|
|
59
91
|
// maxInFlight > 1. `lastContiguous` = highest N where every batch 0..N has
|
|
60
92
|
// reported done. Only checkpoint that highwater — safer for resume.
|
|
@@ -108,8 +140,9 @@ async function spool(source, opts) {
|
|
|
108
140
|
// Adds to the pendingChain to preserve arrival order into the semaphore,
|
|
109
141
|
// then the semaphore itself gates concurrency (maxInFlight).
|
|
110
142
|
function schedule(cf, batchIndex, isLast) {
|
|
143
|
+
readAhead.enter();
|
|
111
144
|
pendingChain = pendingChain.then(async function() {
|
|
112
|
-
if (firstError) return;
|
|
145
|
+
if (firstError) { readAhead.leave(); return; } // short-circuit on failure
|
|
113
146
|
|
|
114
147
|
var release = await sem.acquire();
|
|
115
148
|
|
|
@@ -157,6 +190,10 @@ async function spool(source, opts) {
|
|
|
157
190
|
} catch (err) {
|
|
158
191
|
if (!firstError) firstError = err;
|
|
159
192
|
} finally {
|
|
193
|
+
// Before release(), and unconditionally — a batch that threw still
|
|
194
|
+
// stops being outstanding, or a reader waiting on room would block on
|
|
195
|
+
// a batch that is never coming back.
|
|
196
|
+
readAhead.leave();
|
|
160
197
|
release();
|
|
161
198
|
}
|
|
162
199
|
});
|
|
@@ -186,6 +223,29 @@ async function spool(source, opts) {
|
|
|
186
223
|
schedule(cf, idx, false);
|
|
187
224
|
fileIndex++;
|
|
188
225
|
await openNextFile();
|
|
226
|
+
|
|
227
|
+
// WAIT FOR ROOM before filling the next batch.
|
|
228
|
+
//
|
|
229
|
+
// schedule() is deliberately not awaited — batches must be able to
|
|
230
|
+
// process while the next one fills, which is the overlap this whole
|
|
231
|
+
// design exists for. But nothing bounded how far ahead the reader could
|
|
232
|
+
// get, so a source faster than the target (a DB cursor feeding a slower
|
|
233
|
+
// database, which is the normal case for a cross-engine move) piled
|
|
234
|
+
// batches up behind the semaphore until the memory monitor threw:
|
|
235
|
+
//
|
|
236
|
+
// Streaming memory ceiling exceeded: 129MB > 128MB
|
|
237
|
+
//
|
|
238
|
+
// Read as a limit somebody should raise. It was not: it was a stream
|
|
239
|
+
// that had stopped being a stream. Rows were being read as fast as the
|
|
240
|
+
// source could produce them and held in `buffered` until their batch
|
|
241
|
+
// was written, so peak memory was governed by the SPEED DIFFERENCE
|
|
242
|
+
// between the two databases rather than by batchSize.
|
|
243
|
+
//
|
|
244
|
+
// `maxReadAhead` batches may be buffered beyond the ones in flight;
|
|
245
|
+
// past that the reader blocks here, and blocking here pauses the source
|
|
246
|
+
// itself when the source is a cursor. That is backpressure arriving
|
|
247
|
+
// where it was always supposed to.
|
|
248
|
+
await readAhead.wait();
|
|
189
249
|
}
|
|
190
250
|
}
|
|
191
251
|
|
package/lib/uploader/attach.js
CHANGED
|
@@ -8,26 +8,40 @@
|
|
|
8
8
|
// const { attachConfig } = require('@xeplr/actions');
|
|
9
9
|
// const xcfg = attachConfig({ service: 'xeplr-bi' }); // service = this app's name
|
|
10
10
|
// await xcfg.ready();
|
|
11
|
-
// xcfg.metaStore
|
|
12
|
-
// xcfg.ImportMeta
|
|
13
|
-
// xcfg.SERVICE_NAME
|
|
11
|
+
// xcfg.metaStore // pass into uploads/loadMapping, uploader.rollback
|
|
12
|
+
// xcfg.ImportMeta // BaseModel bound to xeplr_configs — reads/soft-deletes
|
|
13
|
+
// xcfg.SERVICE_NAME // same string, for `.where('service', ...)` filters
|
|
14
|
+
// xcfg.APPLICATION_ID // the owning APP — see options.applicationId below
|
|
15
|
+
//
|
|
16
|
+
// An EMBEDDED package passes both, because they differ: the code is
|
|
17
|
+
// @xeplr-workflow/api, the product is xeplr-bi.
|
|
18
|
+
//
|
|
19
|
+
// attachConfig({ service: 'xeplr-workflow', applicationId: 'xeplr-bi' })
|
|
14
20
|
//
|
|
15
21
|
// import_meta's SCHEMA is identical for every app (see xeplr-db/config/
|
|
16
22
|
// migrations/0001_import_meta.js + 0002_...) — there is nothing app-specific
|
|
17
23
|
// about the ImportMeta model, so it lives here once instead of being
|
|
18
24
|
// copy-pasted into every app's models/ directory.
|
|
19
|
-
var { BaseModel, bootstrapConfigDb } = require('@xeplr/db');
|
|
25
|
+
var { BaseModel, bootstrapConfigDb, resolveDbConnection, getApplicationId, assertApplicationIdColumns } = require('@xeplr/db');
|
|
20
26
|
var { decrypt } = require('@xeplr/utils/isomorphic/crypto');
|
|
21
27
|
var makeMetaStore = require('./meta-store-knex');
|
|
22
28
|
|
|
23
29
|
var requiredEnv = [
|
|
24
30
|
'XCFG_DB_NAME', // xeplr_configs db name
|
|
25
|
-
|
|
31
|
+
// XCFG_DB_CONNECTION_INFO_ENCRYPTED is deliberately NOT here any more: the
|
|
32
|
+
// server login normally comes from the shared XEPLR_DB_CONNECTION, so
|
|
33
|
+
// demanding the xcfg-specific name would fail a correctly configured
|
|
34
|
+
// install. It still wins when set. Missing-ness is caught at the point of
|
|
35
|
+
// use by resolveDbConnection, which names both variables.
|
|
26
36
|
];
|
|
27
37
|
|
|
28
38
|
class ImportMeta extends BaseModel {
|
|
29
39
|
static get tableName() { return 'import_meta'; }
|
|
30
40
|
static get idColumn() { return 'id'; }
|
|
41
|
+
// xeplr_configs is opened by EVERY app, so this is one of the few tables
|
|
42
|
+
// where the database is not the boundary and the column has to be — see
|
|
43
|
+
// BaseModel.applicationScoped.
|
|
44
|
+
static get applicationScoped() { return true; }
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
function attachConfig(options) {
|
|
@@ -35,6 +49,21 @@ function attachConfig(options) {
|
|
|
35
49
|
var service = options.service;
|
|
36
50
|
if (!service) throw new Error('attachConfig: options.service is required');
|
|
37
51
|
|
|
52
|
+
// The APPLICATION that owns rows written through this handle, as distinct
|
|
53
|
+
// from `service` (the package writing them). They differ precisely when a
|
|
54
|
+
// package is embedded: @xeplr-workflow/api running inside xeplr-bi is
|
|
55
|
+
// service='xeplr-workflow', applicationId='xeplr-bi'.
|
|
56
|
+
//
|
|
57
|
+
// Resolved LAZILY, at ready(), not here. Every app holds its instance in a
|
|
58
|
+
// module that is require()d at load time (db/xcfgSetup.js), which is before
|
|
59
|
+
// registerApplication() has run — reading it now would capture null in every
|
|
60
|
+
// app and quietly fall back to the package name, which is the precise bug
|
|
61
|
+
// this column exists to fix. An explicit options.applicationId still wins,
|
|
62
|
+
// for a caller that knows better than the process-global.
|
|
63
|
+
function resolveApplicationId() {
|
|
64
|
+
return options.applicationId || getApplicationId() || service;
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
var _ready = null;
|
|
39
68
|
var _xcfg = null;
|
|
40
69
|
var _boundImportMeta = null;
|
|
@@ -43,10 +72,24 @@ function attachConfig(options) {
|
|
|
43
72
|
function ready() {
|
|
44
73
|
if (!_ready) {
|
|
45
74
|
_ready = (async function() {
|
|
46
|
-
|
|
75
|
+
// XCFG_DB_CONNECTION_INFO_ENCRYPTED is an OVERRIDE — the connection
|
|
76
|
+
// normally comes from the shared XEPLR_DB_CONNECTION every xeplr
|
|
77
|
+
// service reads. XCFG_DB_NAME (which database) is untouched.
|
|
78
|
+
var login = JSON.parse(await decrypt(resolveDbConnection('XCFG_DB_CONNECTION_INFO_ENCRYPTED'), process.env.ENCRYPTION_KEY));
|
|
47
79
|
_xcfg = await bootstrapConfigDb({ connection: login, database: process.env.XCFG_DB_NAME });
|
|
80
|
+
|
|
81
|
+
// xeplr_configs is THE shared store, so every table in it must be
|
|
82
|
+
// attributable to an application. Checked on each boot rather than
|
|
83
|
+
// trusted to the migration, because this database is written to by
|
|
84
|
+
// several codebases and a table added by any one of them without the
|
|
85
|
+
// column would go unnoticed until another app read it.
|
|
86
|
+
await assertApplicationIdColumns(_xcfg.db, { database: process.env.XCFG_DB_NAME });
|
|
48
87
|
_boundImportMeta = ImportMeta.bindKnex(_xcfg.db);
|
|
49
|
-
_metaStore = makeMetaStore(_xcfg.db, {
|
|
88
|
+
_metaStore = makeMetaStore(_xcfg.db, {
|
|
89
|
+
table: 'import_meta',
|
|
90
|
+
service: service,
|
|
91
|
+
applicationId: resolveApplicationId()
|
|
92
|
+
});
|
|
50
93
|
return _xcfg;
|
|
51
94
|
})();
|
|
52
95
|
}
|
|
@@ -60,6 +103,9 @@ function attachConfig(options) {
|
|
|
60
103
|
return {
|
|
61
104
|
ready: ready,
|
|
62
105
|
SERVICE_NAME: service,
|
|
106
|
+
// A getter, for the same reason resolveApplicationId is lazy: read at
|
|
107
|
+
// module load it would be the fallback, not the registered value.
|
|
108
|
+
get APPLICATION_ID() { return resolveApplicationId(); },
|
|
63
109
|
get metaStore() { requireReady('metaStore'); return _metaStore; },
|
|
64
110
|
get ImportMeta() { requireReady('ImportMeta'); return _boundImportMeta; },
|
|
65
111
|
get db() { requireReady('db'); return _xcfg.db; }
|
package/lib/uploader/index.js
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// connection, // decrypted config object OR pool
|
|
9
9
|
// targetTable,
|
|
10
10
|
// primaryKeys, // optional — enables UPSERT
|
|
11
|
+
// columns, // optional — [{ name, type }] in the logical vocabulary
|
|
12
|
+
// // (string|number|boolean|date|datetime|object|array).
|
|
13
|
+
// // Supplied, types are DECLARED and the first batch is
|
|
14
|
+
// // not sampled at all. Omitted, they are inferred from
|
|
15
|
+
// // the first batch's values, which is right for a CSV
|
|
16
|
+
// // and wrong wherever the source has a schema to ask.
|
|
17
|
+
// // See beforeAll.
|
|
11
18
|
// movementId, // required — the correlation key
|
|
12
19
|
// queue, // required — a SqlQueue instance
|
|
13
20
|
// connectionName, // key used in queue.connections; defaults to targetTable
|
|
@@ -21,6 +28,9 @@
|
|
|
21
28
|
// errorTable, // defaults to `<targetTable>_import_errors`
|
|
22
29
|
// batchDir, // where NDJSON files spool to. Defaults to os.tmpdir()/xeplr-actions
|
|
23
30
|
// keepBatchFiles, // default false — clean up NDJSON files after success
|
|
31
|
+
// onProgress, // optional ({rowsRead, batches}) => void, called per batch.
|
|
32
|
+
// // Rows READ, not rows confirmed written — see onBatch.
|
|
33
|
+
// // Throwing in it cannot fail the movement.
|
|
24
34
|
// })
|
|
25
35
|
//
|
|
26
36
|
// EXECUTION MODEL (production-critical — read this):
|
|
@@ -55,6 +65,7 @@ async function upload(opts) {
|
|
|
55
65
|
var driver = opts.driver;
|
|
56
66
|
var startedAt = Date.now();
|
|
57
67
|
|
|
68
|
+
var rowsSeen = 0;
|
|
58
69
|
var poolIsExternal = looksLikePool(opts.connection);
|
|
59
70
|
var pool = poolIsExternal ? opts.connection : await driver.connect(opts.connection);
|
|
60
71
|
|
|
@@ -75,15 +86,33 @@ async function upload(opts) {
|
|
|
75
86
|
maxMemoryMB: opts.spoolMaxMemoryMB || 128,
|
|
76
87
|
|
|
77
88
|
target: {
|
|
78
|
-
// Bootstrap: first batch is on disk.
|
|
79
|
-
// reconcile against the target's existing schema, then compute
|
|
80
|
-
// safe batch size for INSERT.
|
|
89
|
+
// Bootstrap: first batch is on disk. Work out the column types, run
|
|
90
|
+
// DDL, reconcile against the target's existing schema, then compute
|
|
91
|
+
// the safe batch size for INSERT.
|
|
81
92
|
beforeAll: async function(ctx) {
|
|
82
93
|
runDir = ctx.batchDir;
|
|
83
|
-
var
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
var declared;
|
|
95
|
+
if (opts.columns && opts.columns.length) {
|
|
96
|
+
// DECLARED, so nothing is guessed.
|
|
97
|
+
//
|
|
98
|
+
// Inference reads VALUES, which is the only option for a CSV and
|
|
99
|
+
// the wrong one when the source has a schema you can just ask for.
|
|
100
|
+
// The failures are quiet and permanent: Postgres hands `numeric`
|
|
101
|
+
// to node as a STRING, so a money column arrives as strings, is
|
|
102
|
+
// inferred as text, and the target column is created TEXT — you
|
|
103
|
+
// find out when somebody tries to SUM it. An all-null sample types
|
|
104
|
+
// as string for the same reason. A zero-padded code becomes a
|
|
105
|
+
// number and loses its leading zeros.
|
|
106
|
+
//
|
|
107
|
+
// The guess is fine when it is all you have. It should not be what
|
|
108
|
+
// happens when it is not.
|
|
109
|
+
declared = opts.columns;
|
|
110
|
+
} else {
|
|
111
|
+
var sample = await readBatchFile(ctx.firstBatchPath);
|
|
112
|
+
var scanLimit = Math.min(sample.length, opts.firstBatchScanRows);
|
|
113
|
+
declared = inferColumns(sample.slice(0, scanLimit));
|
|
114
|
+
}
|
|
115
|
+
columns = await runBootstrap(driver, pool, opts.targetTable, declared, opts.primaryKeys);
|
|
87
116
|
effectiveBatchSize = Math.min(opts.batchSize, safeBatchSize(columns.length, opts.maxParamsPerBatch));
|
|
88
117
|
await opts.metaStore.recordStart(opts.movementId, {
|
|
89
118
|
targetTable: opts.targetTable,
|
|
@@ -101,13 +130,35 @@ async function upload(opts) {
|
|
|
101
130
|
// addToQueue awaits when queue is over its 200MB backpressure, which
|
|
102
131
|
// in turn awaits spool's next batch — the whole pipeline throttles
|
|
103
132
|
// correctly, but disk (not memory) is the buffer.
|
|
104
|
-
onBatch: async function(rows,
|
|
133
|
+
onBatch: async function(rows, meta) {
|
|
105
134
|
if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
|
|
106
135
|
for (var off = 0; off < rows.length; off += effectiveBatchSize) {
|
|
107
136
|
var chunk = rows.slice(off, off + effectiveBatchSize);
|
|
108
|
-
|
|
137
|
+
// A NAME FOR THIS BATCH, carried all the way to the failure.
|
|
138
|
+
//
|
|
139
|
+
// Without one, "20 rows dropped" is unactionable: there is nothing
|
|
140
|
+
// to look up, nothing to re-run, nothing to tell somebody over the
|
|
141
|
+
// phone. b<spool batch>.<chunk> is stable for a given run and
|
|
142
|
+
// enough for a human to find the rows again in the source.
|
|
143
|
+
var batchRef = 'b' + (meta ? meta.batchIndex : totalBatches) +
|
|
144
|
+
'.' + Math.floor(off / effectiveBatchSize);
|
|
145
|
+
await enqueueBatch(opts, chunk, columns, batchRef);
|
|
109
146
|
totalBatches++;
|
|
110
147
|
}
|
|
148
|
+
rowsSeen += rows.length;
|
|
149
|
+
// WHAT HAS BEEN READ, not what has landed — the queue is still
|
|
150
|
+
// draining behind this. Deliberately so: for a long movement the only
|
|
151
|
+
// honest live number is "rows so far", and waiting for the write to
|
|
152
|
+
// confirm would report nothing for minutes.
|
|
153
|
+
//
|
|
154
|
+
// No total, because for a query source there isn't one to know
|
|
155
|
+
// without running it twice. A caller that wants a percentage has to
|
|
156
|
+
// supply its own denominator.
|
|
157
|
+
if (opts.onProgress) {
|
|
158
|
+
try {
|
|
159
|
+
opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches });
|
|
160
|
+
} catch (e) { /* a reporting failure must not fail the movement */ }
|
|
161
|
+
}
|
|
111
162
|
}
|
|
112
163
|
}
|
|
113
164
|
});
|
|
@@ -175,17 +226,38 @@ async function upload(opts) {
|
|
|
175
226
|
|
|
176
227
|
// Rollback a movement — drop from main + error tables, clear meta row.
|
|
177
228
|
// Graceful queue abort first (in-flight SQLs allowed to settle).
|
|
229
|
+
//
|
|
230
|
+
// Refuses outright for any movement recorded with primaryKeys (i.e. it ran
|
|
231
|
+
// as an upsert). UPSERT's ON CONFLICT/ON DUPLICATE KEY/MERGE stamps
|
|
232
|
+
// __xeplr_movement_id__ onto every row it MATCHES, not just the ones it
|
|
233
|
+
// creates (see buildInsertSql in each driver) — so a pre-existing row that
|
|
234
|
+
// was merely updated ends up tagged with this run's movementId too. Deleting
|
|
235
|
+
// by that column can't tell "created by this run" from "already existed and
|
|
236
|
+
// got touched by it" apart after the fact, so there is no safe partial
|
|
237
|
+
// rollback to fall back to — the whole operation is refused.
|
|
178
238
|
async function rollback(opts) {
|
|
179
239
|
if (!opts || !opts.movementId) throw new Error('rollback: movementId is required');
|
|
180
240
|
if (!opts.driver) throw new Error('rollback: driver is required');
|
|
181
241
|
if (!opts.connection) throw new Error('rollback: connection is required');
|
|
182
242
|
if (!opts.targetTable) throw new Error('rollback: targetTable is required');
|
|
183
243
|
|
|
244
|
+
if (opts.metaStore && opts.metaStore.getById) {
|
|
245
|
+
var meta = await opts.metaStore.getById(opts.movementId);
|
|
246
|
+
if (meta && meta.primary_keys && meta.primary_keys.length) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
'rollback: this movement used upsert (primary keys: ' + meta.primary_keys.join(', ') + ') — ' +
|
|
249
|
+
'rolling back could delete rows that already existed and were only updated, not created. ' +
|
|
250
|
+
'Rollback is not supported for upsert movements.'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
184
255
|
if (opts.queue) {
|
|
185
256
|
opts.queue.abort(opts.movementId, 'rollback');
|
|
186
257
|
await opts.queue.drain();
|
|
187
258
|
}
|
|
188
259
|
|
|
260
|
+
var rowsSeen = 0;
|
|
189
261
|
var poolIsExternal = looksLikePool(opts.connection);
|
|
190
262
|
var pool = poolIsExternal ? opts.connection : await opts.driver.connect(opts.connection);
|
|
191
263
|
|
|
@@ -260,7 +332,7 @@ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys) {
|
|
|
260
332
|
return reconciled;
|
|
261
333
|
}
|
|
262
334
|
|
|
263
|
-
async function enqueueBatch(opts, rows, columns) {
|
|
335
|
+
async function enqueueBatch(opts, rows, columns, batchRef) {
|
|
264
336
|
var driver = opts.driver;
|
|
265
337
|
var target = opts.targetTable;
|
|
266
338
|
var movementId = opts.movementId;
|
|
@@ -277,6 +349,8 @@ async function enqueueBatch(opts, rows, columns) {
|
|
|
277
349
|
meta: {
|
|
278
350
|
rows: rows,
|
|
279
351
|
rowCount: rows.length,
|
|
352
|
+
// Travels with the statement so a drop can name which batch it was.
|
|
353
|
+
batchRef: batchRef || null,
|
|
280
354
|
rowsToSql: rowsToSql,
|
|
281
355
|
errorTable: opts.errorTable
|
|
282
356
|
}
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// app that's calling knows its own name, no reason to make callers thread it
|
|
12
12
|
// through every recordStart/recordEnd call):
|
|
13
13
|
// const makeStore = require('@xeplr/actions/lib/uploader/meta-store-knex');
|
|
14
|
-
// const store = makeStore(knex, {
|
|
14
|
+
// const store = makeStore(knex, {
|
|
15
|
+
// table: 'import_meta',
|
|
16
|
+
// service: 'xeplr-workflow', // the PACKAGE writing the row
|
|
17
|
+
// applicationId: 'xeplr-bi' // the PRODUCT that owns it
|
|
18
|
+
// });
|
|
15
19
|
// await upload({ ..., metaStore: store });
|
|
16
20
|
//
|
|
17
21
|
// Records NO secrets: `connection_key` is an opaque label the consumer chose,
|
|
@@ -19,13 +23,24 @@
|
|
|
19
23
|
// plan. `details` (app-specific context — filename, connectionId, who ran
|
|
20
24
|
// it) and `mtId1`-`mtId4` (tenant scoping, same column names as BaseModel)
|
|
21
25
|
// are set once at recordStart and never touched by recordEnd — this table is
|
|
22
|
-
// shared across apps, so every read/write is scoped by `
|
|
26
|
+
// shared across apps, so every read/write is scoped by `applicationId` (which
|
|
27
|
+
// PRODUCT owns the row) and `service` (which PACKAGE wrote it). The two are
|
|
28
|
+
// the same string for a standalone deployment and differ for an embedded one,
|
|
29
|
+
// which is the whole reason both exist.
|
|
23
30
|
|
|
24
31
|
module.exports = function makeKnexMetaStore(knex, options) {
|
|
25
32
|
if (!knex) throw new Error('meta-store-knex: a knex instance is required');
|
|
26
33
|
if (!options || !options.service) throw new Error('meta-store-knex: options.service is required');
|
|
27
34
|
var TABLE = options.table || 'import_meta';
|
|
28
35
|
var SERVICE = options.service;
|
|
36
|
+
// This store talks RAW KNEX, so it gets none of BaseModel's automatic
|
|
37
|
+
// applicationId stamping or filtering — every statement below has to carry
|
|
38
|
+
// it by hand. That is the price of not going through the model, and the
|
|
39
|
+
// reason it is required here rather than defaulted: a store that silently
|
|
40
|
+
// wrote unattributed rows into the shared control plane would be worse than
|
|
41
|
+
// one that refuses to be constructed.
|
|
42
|
+
if (!options.applicationId) throw new Error('meta-store-knex: options.applicationId is required — import_meta is shared across apps');
|
|
43
|
+
var APPLICATION_ID = options.applicationId;
|
|
29
44
|
|
|
30
45
|
return {
|
|
31
46
|
// Most-recent movement for a target table (resume / dedupe / audit).
|
|
@@ -35,12 +50,24 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
35
50
|
var targetTable = opts && opts.targetTable;
|
|
36
51
|
if (!targetTable) return null;
|
|
37
52
|
var row = await knex(TABLE)
|
|
38
|
-
.where({ service: SERVICE, target_table: targetTable })
|
|
53
|
+
.where({ applicationId: APPLICATION_ID, service: SERVICE, target_table: targetTable })
|
|
39
54
|
.orderBy('started_at', 'desc')
|
|
40
55
|
.first();
|
|
41
56
|
return row || null; // pg returns jsonb columns already parsed
|
|
42
57
|
},
|
|
43
58
|
|
|
59
|
+
// Fetch one movement's own row by id — used by rollback() to check what
|
|
60
|
+
// the movement actually was (e.g. did it use primaryKeys/upsert) before
|
|
61
|
+
// deciding whether deleting by movementId is safe.
|
|
62
|
+
async getById(movementId) {
|
|
63
|
+
// Scoped even though `id` is the primary key: rollback() decides from
|
|
64
|
+
// this row whether deleting by movementId is safe, and answering that
|
|
65
|
+
// question from ANOTHER application's row is the worst possible outcome
|
|
66
|
+
// here. A miss returns null and rollback declines, which is correct.
|
|
67
|
+
var row = await knex(TABLE).where({ id: movementId, applicationId: APPLICATION_ID }).first();
|
|
68
|
+
return row || null; // pg returns jsonb columns already parsed
|
|
69
|
+
},
|
|
70
|
+
|
|
44
71
|
// Open the movement row in 'running' state. Upsert on id so a re-run /
|
|
45
72
|
// resume of the same movementId overwrites its prior 'running' row.
|
|
46
73
|
async recordStart(movementId, plan) {
|
|
@@ -48,6 +75,7 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
48
75
|
await knex(TABLE)
|
|
49
76
|
.insert({
|
|
50
77
|
id: movementId,
|
|
78
|
+
applicationId: APPLICATION_ID,
|
|
51
79
|
service: SERVICE,
|
|
52
80
|
mtId1: plan.mtId1 || null,
|
|
53
81
|
mtId2: plan.mtId2 || null,
|
|
@@ -83,7 +111,7 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
83
111
|
}
|
|
84
112
|
}
|
|
85
113
|
if (!touched) return;
|
|
86
|
-
await knex(TABLE).where({ id: movementId }).update(patch);
|
|
114
|
+
await knex(TABLE).where({ id: movementId, applicationId: APPLICATION_ID }).update(patch);
|
|
87
115
|
},
|
|
88
116
|
|
|
89
117
|
// Close the movement. Upserts: the empty-source path calls recordEnd
|
|
@@ -106,7 +134,7 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
106
134
|
patch.meta = JSON.stringify({ mainDeleted: result.mainDeleted, errorDeleted: result.errorDeleted });
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
var updated = await knex(TABLE).where({ id: movementId }).update(patch);
|
|
137
|
+
var updated = await knex(TABLE).where({ id: movementId, applicationId: APPLICATION_ID }).update(patch);
|
|
110
138
|
if (!updated) {
|
|
111
139
|
// No prior recordStart (empty-source path — beforeAll never fires) —
|
|
112
140
|
// this is the only chance to stamp service/tenant/plan context, or
|
|
@@ -115,6 +143,7 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
115
143
|
// recordEnd call).
|
|
116
144
|
var fallback = {
|
|
117
145
|
id: movementId,
|
|
146
|
+
applicationId: APPLICATION_ID,
|
|
118
147
|
service: SERVICE,
|
|
119
148
|
mtId1: result.mtId1 || null,
|
|
120
149
|
mtId2: result.mtId2 || null,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
module.exports = {
|
|
7
7
|
async getLast(_opts) { return null; },
|
|
8
|
+
async getById(_movementId) { return null; },
|
|
8
9
|
async recordStart(_uploadId, _plan) { /* no-op */ },
|
|
9
10
|
async recordProgress(_uploadId, _delta) { /* no-op */ },
|
|
10
11
|
async recordEnd(_uploadId, _result) { /* no-op */ }
|
|
@@ -30,7 +30,18 @@ var PG_TO_LOGICAL = {
|
|
|
30
30
|
'boolean': 'boolean',
|
|
31
31
|
'timestamp with time zone': 'datetime',
|
|
32
32
|
'timestamp without time zone': 'datetime',
|
|
33
|
-
|
|
33
|
+
// A DATE IS NOT A TIMESTAMP, and mapping it to one moves it.
|
|
34
|
+
//
|
|
35
|
+
// This said 'datetime', so a source `date` was created as TIMESTAMPTZ in the
|
|
36
|
+
// target and the value acquired midnight in the server's zone. From +05:30,
|
|
37
|
+
// 2026-01-05 stored as 2026-01-04T18:30:00Z — a business date a day earlier
|
|
38
|
+
// than the one that was moved, and every report grouped by it off by one for
|
|
39
|
+
// half the day.
|
|
40
|
+
//
|
|
41
|
+
// Reachable only via a declared schema (inference sees an ISO string and
|
|
42
|
+
// still says 'datetime', correctly — it cannot know), so nothing that infers
|
|
43
|
+
// changes behaviour. See upload()'s `columns` option.
|
|
44
|
+
'date': 'date',
|
|
34
45
|
'time with time zone': 'datetime',
|
|
35
46
|
'time without time zone': 'datetime',
|
|
36
47
|
'json': 'object',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/actions",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -31,13 +31,18 @@
|
|
|
31
31
|
"@xeplr/schema-handler": "^1.0.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
+
"@duckdb/node-api": "1.5.5-r.3",
|
|
35
|
+
"@xeplr/email": "^1.0.0",
|
|
34
36
|
"@xeplr/utils": "^1.0.0",
|
|
35
37
|
"csv-parse": "^5.0.0",
|
|
36
38
|
"exceljs": "^4.0.0",
|
|
39
|
+
"imapflow": "^1.0.0",
|
|
37
40
|
"knex": "^3.0.0",
|
|
41
|
+
"mailparser": "^3.0.0",
|
|
38
42
|
"mongodb": "^6.0.0",
|
|
39
43
|
"mssql": "^11.0.0",
|
|
40
44
|
"mysql2": "^3.0.0",
|
|
45
|
+
"nodemailer": "^8.0.2",
|
|
41
46
|
"pg": "^8.0.0",
|
|
42
47
|
"saxes": "^6.0.0",
|
|
43
48
|
"ssh2-sftp-client": "^11.0.0",
|
|
@@ -47,6 +52,9 @@
|
|
|
47
52
|
"@xeplr/utils": {
|
|
48
53
|
"optional": true
|
|
49
54
|
},
|
|
55
|
+
"@duckdb/node-api": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
50
58
|
"knex": {
|
|
51
59
|
"optional": true
|
|
52
60
|
},
|
|
@@ -74,11 +82,24 @@
|
|
|
74
82
|
"csv-parse": {
|
|
75
83
|
"optional": true
|
|
76
84
|
},
|
|
85
|
+
"imapflow": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"mailparser": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"nodemailer": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
77
94
|
"ssh2-sftp-client": {
|
|
78
95
|
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@xeplr/email": {
|
|
98
|
+
"optional": true
|
|
79
99
|
}
|
|
80
100
|
},
|
|
81
101
|
"devDependencies": {
|
|
102
|
+
"@duckdb/node-api": "^1.5.5-r.3",
|
|
82
103
|
"csv-parse": "^5.0.0",
|
|
83
104
|
"express": "^4.0.0",
|
|
84
105
|
"mssql": "^12.7.0",
|