@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/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.
@@ -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; // short-circuit on failure
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
 
@@ -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 // 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
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
- 'XCFG_DB_CONNECTION_INFO_ENCRYPTED', // xeplr_configs server login
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
- var login = JSON.parse(await decrypt(process.env.XCFG_DB_CONNECTION_INFO_ENCRYPTED, process.env.ENCRYPTION_KEY));
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, { table: 'import_meta', service: service });
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; }
@@ -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. Infer types from it, run DDL,
79
- // reconcile against the target's existing schema, then compute the
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 sample = await readBatchFile(ctx.firstBatchPath);
84
- var scanLimit = Math.min(sample.length, opts.firstBatchScanRows);
85
- var inferred = inferColumns(sample.slice(0, scanLimit));
86
- columns = await runBootstrap(driver, pool, opts.targetTable, inferred, opts.primaryKeys);
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, _meta) {
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
- await enqueueBatch(opts, chunk, columns);
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, { table: 'import_meta', service: 'xeplr-bi' });
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 `service` too.
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
- 'date': 'datetime',
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.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",