@xeplr/actions 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/index.js +51 -0
- package/lib/builtins/db/fetch.js +142 -0
- package/lib/builtins/db/list-columns.js +39 -0
- package/lib/builtins/db/list-procedures.js +36 -0
- package/lib/builtins/db/list-tables.js +38 -0
- package/lib/builtins/db/list-views.js +35 -0
- package/lib/builtins/db/push.js +107 -0
- package/lib/builtins/db/query.js +5 -0
- package/lib/builtins/file/move.js +8 -0
- package/lib/builtins/file/upload.js +123 -0
- package/lib/builtins/http-request.js +3 -0
- package/lib/builtins/index.js +40 -0
- package/lib/builtins/send-email.js +8 -0
- package/lib/builtins/spawn-program.js +4 -0
- package/lib/drivers/db/index.js +41 -0
- package/lib/drivers/db/mongo.js +8 -0
- package/lib/drivers/db/mssql.js +464 -0
- package/lib/drivers/db/mysql.js +425 -0
- package/lib/drivers/db/postgres.js +372 -0
- package/lib/drivers/file/google.js +8 -0
- package/lib/drivers/file/index.js +42 -0
- package/lib/drivers/file/local.js +68 -0
- package/lib/drivers/file/sftp.js +9 -0
- package/lib/drivers/file/sharepoint.js +9 -0
- package/lib/errors.js +37 -0
- package/lib/formats/_xlsx-stream.js +355 -0
- package/lib/formats/csv.js +54 -0
- package/lib/formats/excel.js +87 -0
- package/lib/formats/index.js +38 -0
- package/lib/formats/json.js +42 -0
- package/lib/formats/txt.js +6 -0
- package/lib/registry.js +75 -0
- package/lib/runner.js +81 -0
- package/lib/streaming/checkpoint.js +46 -0
- package/lib/streaming/index.js +10 -0
- package/lib/streaming/resume.js +35 -0
- package/lib/streaming/spool.js +253 -0
- package/lib/streaming/utils.js +82 -0
- package/lib/uploader/attach.js +69 -0
- package/lib/uploader/index.js +298 -0
- package/lib/uploader/infer.js +76 -0
- package/lib/uploader/meta-store-knex.js +136 -0
- package/lib/uploader/meta-store-noop.js +11 -0
- package/lib/uploader/reconcile.js +88 -0
- package/package.json +90 -0
package/lib/runner.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
var { applySchema } = require('@xeplr/schema-handler');
|
|
2
|
+
var { get } = require('./registry');
|
|
3
|
+
var { ActionNotRegisteredError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Safely run an action. Two call forms supported:
|
|
7
|
+
*
|
|
8
|
+
* runAction({ name, input, system?, timeoutMs? })
|
|
9
|
+
* — look up a registered action by name (the Jobs / config path).
|
|
10
|
+
*
|
|
11
|
+
* runAction(nameOrAction, input, opts?)
|
|
12
|
+
* — shorthand for one-off programmatic use. First arg can be a name
|
|
13
|
+
* (string) OR an action module `{ name, inputSchema, execute, ... }`.
|
|
14
|
+
* `opts` = { system, timeoutMs, metaStore }.
|
|
15
|
+
*
|
|
16
|
+
* Returns { status: 'success' | 'failed', output, error, durationMs }.
|
|
17
|
+
* Never throws — every failure (unregistered action, validation, executor
|
|
18
|
+
* exception, timeout) is captured in `error`.
|
|
19
|
+
*/
|
|
20
|
+
async function runAction(first, second, third) {
|
|
21
|
+
var start = Date.now();
|
|
22
|
+
var opts = normalizeArgs(first, second, third);
|
|
23
|
+
|
|
24
|
+
var def = opts.action || get(opts.name);
|
|
25
|
+
if (!def) return finish(start, 'failed', null, new ActionNotRegisteredError(opts.name));
|
|
26
|
+
|
|
27
|
+
var input;
|
|
28
|
+
try {
|
|
29
|
+
input = applySchema(def.inputSchema, opts.input || {}, 'input');
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return finish(start, 'failed', null, err);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
var call = def.execute({ input: input, system: opts.system || {}, metaStore: opts.metaStore });
|
|
36
|
+
var settled = (opts.timeoutMs && call && typeof call.then === 'function')
|
|
37
|
+
? withTimeout(call, opts.timeoutMs)
|
|
38
|
+
: call;
|
|
39
|
+
var output = await settled;
|
|
40
|
+
return finish(start, 'success', output, null);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return finish(start, 'failed', null, err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Accept either the options-object form or the shorthand (action, input, opts).
|
|
47
|
+
function normalizeArgs(first, second, third) {
|
|
48
|
+
// Shorthand form: first arg is a string name or an action module.
|
|
49
|
+
if (typeof first === 'string' || (first && typeof first.execute === 'function')) {
|
|
50
|
+
var extra = third || {};
|
|
51
|
+
var result = { input: second || {}, system: extra.system, timeoutMs: extra.timeoutMs, metaStore: extra.metaStore };
|
|
52
|
+
if (typeof first === 'string') result.name = first;
|
|
53
|
+
else result.action = first;
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
// Options-object form.
|
|
57
|
+
return first || {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finish(start, status, output, err) {
|
|
61
|
+
var e = null;
|
|
62
|
+
if (err) {
|
|
63
|
+
e = { name: err.name || 'Error', message: err.message };
|
|
64
|
+
if (err.stack) e.stack = err.stack;
|
|
65
|
+
if (err.details) e.details = err.details;
|
|
66
|
+
if (err.actionName) e.actionName = err.actionName;
|
|
67
|
+
}
|
|
68
|
+
return { status: status, output: output, error: e, durationMs: Date.now() - start };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withTimeout(promise, ms) {
|
|
72
|
+
return new Promise(function(resolve, reject) {
|
|
73
|
+
var t = setTimeout(function() { reject(new Error('Action timed out after ' + ms + 'ms')); }, ms);
|
|
74
|
+
promise.then(
|
|
75
|
+
function(v) { clearTimeout(t); resolve(v); },
|
|
76
|
+
function(e) { clearTimeout(t); reject(e); }
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { runAction: runAction };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Per-run checkpoint file. Written atomically (write .tmp, rename) after every
|
|
2
|
+
// batch completes its onBatch hook. Enables resume() to skip already-processed
|
|
3
|
+
// batches on retry.
|
|
4
|
+
//
|
|
5
|
+
// Shape:
|
|
6
|
+
// { runId, batchDir, batchSize, lastCompletedBatch, updatedAt }
|
|
7
|
+
|
|
8
|
+
var fs = require('fs');
|
|
9
|
+
var fsp = require('fs/promises');
|
|
10
|
+
var path = require('path');
|
|
11
|
+
|
|
12
|
+
function checkpointPath(dir, runId) {
|
|
13
|
+
return path.join(dir, runId + '.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function ensureDir(dir) {
|
|
17
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function read(dir, runId) {
|
|
21
|
+
if (!dir) return null;
|
|
22
|
+
try {
|
|
23
|
+
var raw = await fsp.readFile(checkpointPath(dir, runId), 'utf8');
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.code === 'ENOENT') return null;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function write(dir, checkpoint) {
|
|
32
|
+
if (!dir) return;
|
|
33
|
+
await ensureDir(dir);
|
|
34
|
+
var target = checkpointPath(dir, checkpoint.runId);
|
|
35
|
+
var tmp = target + '.tmp';
|
|
36
|
+
var payload = JSON.stringify(Object.assign({}, checkpoint, { updatedAt: new Date().toISOString() }));
|
|
37
|
+
await fsp.writeFile(tmp, payload, { encoding: 'utf8' });
|
|
38
|
+
await fsp.rename(tmp, target);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function clear(dir, runId) {
|
|
42
|
+
if (!dir) return;
|
|
43
|
+
await fsp.unlink(checkpointPath(dir, runId)).catch(function() { /* ignore */ });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { read: read, write: write, clear: clear, checkpointPath: checkpointPath };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// resume(runId, source, opts)
|
|
2
|
+
//
|
|
3
|
+
// Look up the checkpoint for runId (in opts.checkpointDir), figure out how
|
|
4
|
+
// many batches were already completed, then re-invoke spool() telling it to
|
|
5
|
+
// skip forward by that much. The caller's `source` must be replayable / at
|
|
6
|
+
// least positioned so the first row it yields corresponds to the first row
|
|
7
|
+
// of batch `lastCompletedBatch + 1`.
|
|
8
|
+
//
|
|
9
|
+
// Concretely: if `source` is `for-await SELECT * FROM foo ORDER BY id LIMIT ...
|
|
10
|
+
// OFFSET (lastCompletedBatch * batchSize)`, resume() will "just work."
|
|
11
|
+
|
|
12
|
+
var checkpoint = require('./checkpoint');
|
|
13
|
+
var { spool } = require('./spool');
|
|
14
|
+
|
|
15
|
+
async function resume(runId, source, opts) {
|
|
16
|
+
opts = opts || {};
|
|
17
|
+
if (!runId) throw new Error('resume: runId is required');
|
|
18
|
+
if (!opts.checkpointDir) throw new Error('resume: opts.checkpointDir is required');
|
|
19
|
+
|
|
20
|
+
var cp = await checkpoint.read(opts.checkpointDir, runId);
|
|
21
|
+
if (!cp) {
|
|
22
|
+
// No checkpoint → cold start with this runId.
|
|
23
|
+
return spool(source, Object.assign({}, opts, { runId: runId }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var startFromBatch = (cp.lastCompletedBatch != null ? cp.lastCompletedBatch : -1) + 1;
|
|
27
|
+
|
|
28
|
+
return spool(source, Object.assign({}, opts, {
|
|
29
|
+
runId: runId,
|
|
30
|
+
startFromBatch: startFromBatch,
|
|
31
|
+
batchSize: cp.batchSize || opts.batchSize
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { resume: resume };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// spool(source, opts)
|
|
2
|
+
//
|
|
3
|
+
// Consume an async iterable of rows, write them as rotating NDJSON batch
|
|
4
|
+
// files, and drive `target` hooks in order. Never throws mid-stream —
|
|
5
|
+
// hook errors reject the whole spool.
|
|
6
|
+
//
|
|
7
|
+
// See project_xeplr_actions_streaming.md for the locked design.
|
|
8
|
+
|
|
9
|
+
var fs = require('fs');
|
|
10
|
+
var fsp = require('fs/promises');
|
|
11
|
+
var path = require('path');
|
|
12
|
+
|
|
13
|
+
var { semaphore, rateLimiter, memoryMonitor, toNDJSON } = require('./utils');
|
|
14
|
+
var checkpoint = require('./checkpoint');
|
|
15
|
+
|
|
16
|
+
var DEFAULTS = {
|
|
17
|
+
batchSize: 5000,
|
|
18
|
+
maxInFlight: 1,
|
|
19
|
+
ratePerSecond: null,
|
|
20
|
+
maxMemoryMB: 256
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {AsyncIterable<object>} source
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {string} opts.runId stable id — used for the sub-dir + checkpoint file
|
|
27
|
+
* @param {string} opts.batchDir where the run dir goes (final dir = batchDir/<runId>)
|
|
28
|
+
* @param {number} [opts.batchSize=5000]
|
|
29
|
+
* @param {number} [opts.startFromBatch=0] skip N batches (drives resume)
|
|
30
|
+
* @param {number} [opts.maxInFlight=1]
|
|
31
|
+
* @param {number} [opts.ratePerSecond]
|
|
32
|
+
* @param {number} [opts.maxMemoryMB=256]
|
|
33
|
+
* @param {string} [opts.checkpointDir] enables checkpoint writes
|
|
34
|
+
* @param {object} [opts.target]
|
|
35
|
+
* @param {function} [opts.target.beforeAll] (ctx) => void fires ONCE, after 1st batch is buffered to disk
|
|
36
|
+
* @param {function} [opts.target.onBatch] (rows, meta) => void fires per completed batch, in order
|
|
37
|
+
* @param {function} [opts.target.afterAll] (summary) => void fires ONCE, after all batches settle
|
|
38
|
+
* @returns {Promise<{runId, batchDir, totalRows, totalBatches, files, durationMs}>}
|
|
39
|
+
*/
|
|
40
|
+
async function spool(source, opts) {
|
|
41
|
+
opts = Object.assign({}, DEFAULTS, opts || {});
|
|
42
|
+
|
|
43
|
+
if (!opts.runId) throw new Error('spool: opts.runId is required');
|
|
44
|
+
if (!opts.batchDir) throw new Error('spool: opts.batchDir is required');
|
|
45
|
+
if (!source || typeof source[Symbol.asyncIterator] !== 'function') {
|
|
46
|
+
throw new Error('spool: source must be an async iterable');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var target = opts.target || {};
|
|
50
|
+
var runDir = path.join(opts.batchDir, opts.runId);
|
|
51
|
+
await fsp.mkdir(runDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
var started = Date.now();
|
|
54
|
+
var sem = semaphore(opts.maxInFlight);
|
|
55
|
+
var rl = rateLimiter(opts.ratePerSecond);
|
|
56
|
+
var mem = memoryMonitor(opts.maxMemoryMB);
|
|
57
|
+
|
|
58
|
+
// Ordered checkpoint tracking: batches may complete out of order when
|
|
59
|
+
// maxInFlight > 1. `lastContiguous` = highest N where every batch 0..N has
|
|
60
|
+
// reported done. Only checkpoint that highwater — safer for resume.
|
|
61
|
+
var completedSet = new Set();
|
|
62
|
+
var lastContiguous = (opts.startFromBatch || 0) - 1;
|
|
63
|
+
|
|
64
|
+
// First-error latch. Since spool guarantees "all hooks awaited in order,
|
|
65
|
+
// any hook error rejects the whole thing," we surface the first error
|
|
66
|
+
// deterministically even if newer batches also fail.
|
|
67
|
+
var firstError = null;
|
|
68
|
+
|
|
69
|
+
var fileIndex = opts.startFromBatch || 0;
|
|
70
|
+
var currentFile = null; // { stream, path, byteCount, rowCount, buffered }
|
|
71
|
+
var beforeAllFired = false;
|
|
72
|
+
var pendingChain = Promise.resolve(); // sequential-arrival ordering into the semaphore
|
|
73
|
+
var filesCreated = [];
|
|
74
|
+
var totalRows = 0;
|
|
75
|
+
|
|
76
|
+
async function openNextFile() {
|
|
77
|
+
var name = String(fileIndex).padStart(6, '0') + '.ndjson';
|
|
78
|
+
var filePath = path.join(runDir, name);
|
|
79
|
+
var stream = fs.createWriteStream(filePath);
|
|
80
|
+
currentFile = {
|
|
81
|
+
stream: stream,
|
|
82
|
+
path: filePath,
|
|
83
|
+
byteCount: 0,
|
|
84
|
+
rowCount: 0,
|
|
85
|
+
// Buffer rows in memory too — we need them to invoke onBatch(rows, meta).
|
|
86
|
+
// Same footprint as reading the file back, but avoids the round trip.
|
|
87
|
+
buffered: []
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function closeCurrentFile() {
|
|
92
|
+
if (!currentFile) return null;
|
|
93
|
+
var cf = currentFile;
|
|
94
|
+
currentFile = null;
|
|
95
|
+
await new Promise(function(resolve) { cf.stream.end(resolve); });
|
|
96
|
+
return cf;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drop the current file if it's empty (avoids leaving 000042.ndjson behind
|
|
100
|
+
// when the source ended right on a rotation boundary).
|
|
101
|
+
async function dropEmptyFile(cf) {
|
|
102
|
+
if (!cf || cf.rowCount > 0) return false;
|
|
103
|
+
await fsp.unlink(cf.path).catch(function() { /* ignore */ });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Schedule a completed file for hook processing.
|
|
108
|
+
// Adds to the pendingChain to preserve arrival order into the semaphore,
|
|
109
|
+
// then the semaphore itself gates concurrency (maxInFlight).
|
|
110
|
+
function schedule(cf, batchIndex, isLast) {
|
|
111
|
+
pendingChain = pendingChain.then(async function() {
|
|
112
|
+
if (firstError) return; // short-circuit on failure
|
|
113
|
+
|
|
114
|
+
var release = await sem.acquire();
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// First-batch bootstrap — fire beforeAll AFTER the first batch is on
|
|
118
|
+
// disk (so consumers can peek at it), BEFORE its onBatch runs.
|
|
119
|
+
if (!beforeAllFired && batchIndex === 0 && target.beforeAll) {
|
|
120
|
+
beforeAllFired = true;
|
|
121
|
+
await target.beforeAll({
|
|
122
|
+
runId: opts.runId,
|
|
123
|
+
batchDir: runDir,
|
|
124
|
+
firstBatchPath: cf.path
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (target.onBatch) {
|
|
129
|
+
await target.onBatch(cf.buffered, {
|
|
130
|
+
batchIndex: batchIndex,
|
|
131
|
+
filePath: cf.path,
|
|
132
|
+
rowCount: cf.rowCount,
|
|
133
|
+
byteCount: cf.byteCount,
|
|
134
|
+
isLast: isLast
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
completedSet.add(batchIndex);
|
|
139
|
+
// Advance the contiguous highwater.
|
|
140
|
+
while (completedSet.has(lastContiguous + 1)) {
|
|
141
|
+
lastContiguous++;
|
|
142
|
+
completedSet.delete(lastContiguous);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Release the row bytes for GC + memory accounting.
|
|
146
|
+
mem.sub(cf.byteCount);
|
|
147
|
+
cf.buffered = null;
|
|
148
|
+
|
|
149
|
+
if (opts.checkpointDir) {
|
|
150
|
+
await checkpoint.write(opts.checkpointDir, {
|
|
151
|
+
runId: opts.runId,
|
|
152
|
+
batchDir: runDir,
|
|
153
|
+
batchSize: opts.batchSize,
|
|
154
|
+
lastCompletedBatch: lastContiguous
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!firstError) firstError = err;
|
|
159
|
+
} finally {
|
|
160
|
+
release();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── main pump ──────────────────────────────────────────────────────────
|
|
166
|
+
try {
|
|
167
|
+
await openNextFile();
|
|
168
|
+
|
|
169
|
+
for await (var row of source) {
|
|
170
|
+
if (firstError) break;
|
|
171
|
+
|
|
172
|
+
await rl.take(1);
|
|
173
|
+
var line = toNDJSON(row);
|
|
174
|
+
mem.add(line.length);
|
|
175
|
+
|
|
176
|
+
currentFile.stream.write(line);
|
|
177
|
+
currentFile.byteCount += line.length;
|
|
178
|
+
currentFile.rowCount++;
|
|
179
|
+
currentFile.buffered.push(row);
|
|
180
|
+
totalRows++;
|
|
181
|
+
|
|
182
|
+
if (currentFile.rowCount >= opts.batchSize) {
|
|
183
|
+
var idx = fileIndex;
|
|
184
|
+
var cf = await closeCurrentFile();
|
|
185
|
+
filesCreated.push(cf.path);
|
|
186
|
+
schedule(cf, idx, false);
|
|
187
|
+
fileIndex++;
|
|
188
|
+
await openNextFile();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Close the trailing file. If it's empty, drop it. If it had rows,
|
|
193
|
+
// it counts as the last batch.
|
|
194
|
+
var tail = await closeCurrentFile();
|
|
195
|
+
if (tail) {
|
|
196
|
+
if (await dropEmptyFile(tail)) {
|
|
197
|
+
// Nothing to schedule.
|
|
198
|
+
} else {
|
|
199
|
+
var tailIdx = fileIndex;
|
|
200
|
+
filesCreated.push(tail.path);
|
|
201
|
+
schedule(tail, tailIdx, true);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Wait for every scheduled hook to settle.
|
|
206
|
+
await pendingChain;
|
|
207
|
+
if (firstError) throw firstError;
|
|
208
|
+
|
|
209
|
+
if (target.afterAll) {
|
|
210
|
+
await target.afterAll({
|
|
211
|
+
runId: opts.runId,
|
|
212
|
+
batchDir: runDir,
|
|
213
|
+
totalRows: totalRows,
|
|
214
|
+
totalBatches: filesCreated.length,
|
|
215
|
+
files: filesCreated,
|
|
216
|
+
durationMs: Date.now() - started
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Success — clear the checkpoint so a future resume() with the same runId
|
|
221
|
+
// treats the run as fresh (rather than "already done at batch N").
|
|
222
|
+
if (opts.checkpointDir) {
|
|
223
|
+
await checkpoint.clear(opts.checkpointDir, opts.runId).catch(function() {});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
runId: opts.runId,
|
|
228
|
+
batchDir: runDir,
|
|
229
|
+
totalRows: totalRows,
|
|
230
|
+
totalBatches: filesCreated.length,
|
|
231
|
+
files: filesCreated,
|
|
232
|
+
durationMs: Date.now() - started
|
|
233
|
+
};
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// Best-effort teardown of the currently open file (nothing to schedule).
|
|
236
|
+
if (currentFile) {
|
|
237
|
+
try { await new Promise(function(r) { currentFile.stream.end(r); }); } catch (_) {}
|
|
238
|
+
currentFile = null;
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read one NDJSON batch file back into an array of rows. Consumer counterpart
|
|
246
|
+
* to spool's writer — one JSON object per non-blank line.
|
|
247
|
+
*/
|
|
248
|
+
async function readBatchFile(filePath) {
|
|
249
|
+
var raw = await fsp.readFile(filePath, 'utf8');
|
|
250
|
+
return raw.split(/\r?\n/).filter(function(l) { return l.trim().length > 0; }).map(function(l) { return JSON.parse(l); });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { spool: spool, readBatchFile: readBatchFile };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Small building blocks used by spool.js. No external deps.
|
|
2
|
+
|
|
3
|
+
// Semaphore that gates concurrency to `n` (default 1).
|
|
4
|
+
// acquire() returns a release() function that MUST be called (finally block).
|
|
5
|
+
function semaphore(n) {
|
|
6
|
+
var slots = n;
|
|
7
|
+
var queue = [];
|
|
8
|
+
return {
|
|
9
|
+
async acquire() {
|
|
10
|
+
if (slots > 0) { slots--; return release; }
|
|
11
|
+
await new Promise(function(resolve) { queue.push(resolve); });
|
|
12
|
+
slots--;
|
|
13
|
+
return release;
|
|
14
|
+
},
|
|
15
|
+
inFlight() { return n - slots; }
|
|
16
|
+
};
|
|
17
|
+
function release() {
|
|
18
|
+
slots++;
|
|
19
|
+
var next = queue.shift();
|
|
20
|
+
if (next) next();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Simple leaky-bucket rate limiter. `perSecond=null` disables (no-op).
|
|
25
|
+
function rateLimiter(perSecond) {
|
|
26
|
+
if (!perSecond || perSecond <= 0) {
|
|
27
|
+
return { async take(_n) { /* no-op */ } };
|
|
28
|
+
}
|
|
29
|
+
var tokens = perSecond;
|
|
30
|
+
var lastRefill = Date.now();
|
|
31
|
+
return {
|
|
32
|
+
async take(n) {
|
|
33
|
+
n = n || 1;
|
|
34
|
+
while (true) {
|
|
35
|
+
// Refill based on elapsed time.
|
|
36
|
+
var now = Date.now();
|
|
37
|
+
var elapsed = (now - lastRefill) / 1000;
|
|
38
|
+
tokens = Math.min(perSecond, tokens + elapsed * perSecond);
|
|
39
|
+
lastRefill = now;
|
|
40
|
+
if (tokens >= n) { tokens -= n; return; }
|
|
41
|
+
// Wait for enough tokens.
|
|
42
|
+
var need = (n - tokens) / perSecond * 1000;
|
|
43
|
+
await new Promise(function(r) { setTimeout(r, Math.max(1, Math.ceil(need))); });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Coarse memory tracker. Bytes added via .add(n); throws with a clear message
|
|
50
|
+
// when the running total crosses the ceiling. Consumer decrements via .sub()
|
|
51
|
+
// when a batch is released (file flushed + onBatch settled).
|
|
52
|
+
function memoryMonitor(maxMB) {
|
|
53
|
+
if (!maxMB || maxMB <= 0) {
|
|
54
|
+
return { add() {}, sub() {}, current() { return 0; } };
|
|
55
|
+
}
|
|
56
|
+
var ceiling = maxMB * 1024 * 1024;
|
|
57
|
+
var used = 0;
|
|
58
|
+
return {
|
|
59
|
+
add(n) {
|
|
60
|
+
used += n;
|
|
61
|
+
if (used > ceiling) {
|
|
62
|
+
var mb = Math.round(used / 1024 / 1024);
|
|
63
|
+
throw new Error('Streaming memory ceiling exceeded: ' + mb + 'MB > ' + maxMB + 'MB (raise maxMemoryMB or lower batchSize)');
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
sub(n) { used = Math.max(0, used - n); },
|
|
67
|
+
current() { return used; }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Serialize a row to a NDJSON line. Replaces embedded newlines so each row is
|
|
72
|
+
// exactly one line — the invariant `readNDJSONLines` relies on.
|
|
73
|
+
function toNDJSON(row) {
|
|
74
|
+
return JSON.stringify(row).replace(/\r?\n/g, ' ') + '\n';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
semaphore: semaphore,
|
|
79
|
+
rateLimiter: rateLimiter,
|
|
80
|
+
memoryMonitor: memoryMonitor,
|
|
81
|
+
toNDJSON: toNDJSON
|
|
82
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// attachConfig — connect an app to the shared xeplr_configs control-plane DB
|
|
2
|
+
// and get back a ready-to-use metaStore + ImportMeta model, in one call.
|
|
3
|
+
// Mirrors @xeplr/auth's attach() shape exactly (ready()/model-style getters,
|
|
4
|
+
// required env owned by the library, lazy + memoized) so every consuming app
|
|
5
|
+
// writes the SAME few lines instead of each hand-rolling its own
|
|
6
|
+
// bootstrapConfigDb()/bindKnex()/meta-store-knex() dance:
|
|
7
|
+
//
|
|
8
|
+
// const { attachConfig } = require('@xeplr/actions');
|
|
9
|
+
// const xcfg = attachConfig({ service: 'xeplr-bi' }); // service = this app's name
|
|
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
|
|
14
|
+
//
|
|
15
|
+
// import_meta's SCHEMA is identical for every app (see xeplr-db/config/
|
|
16
|
+
// migrations/0001_import_meta.js + 0002_...) — there is nothing app-specific
|
|
17
|
+
// about the ImportMeta model, so it lives here once instead of being
|
|
18
|
+
// copy-pasted into every app's models/ directory.
|
|
19
|
+
var { BaseModel, bootstrapConfigDb } = require('@xeplr/db');
|
|
20
|
+
var { decrypt } = require('@xeplr/utils/isomorphic/crypto');
|
|
21
|
+
var makeMetaStore = require('./meta-store-knex');
|
|
22
|
+
|
|
23
|
+
var requiredEnv = [
|
|
24
|
+
'XCFG_DB_NAME', // xeplr_configs db name
|
|
25
|
+
'XCFG_DB_CONNECTION_INFO_ENCRYPTED', // xeplr_configs server login
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
class ImportMeta extends BaseModel {
|
|
29
|
+
static get tableName() { return 'import_meta'; }
|
|
30
|
+
static get idColumn() { return 'id'; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function attachConfig(options) {
|
|
34
|
+
options = options || {};
|
|
35
|
+
var service = options.service;
|
|
36
|
+
if (!service) throw new Error('attachConfig: options.service is required');
|
|
37
|
+
|
|
38
|
+
var _ready = null;
|
|
39
|
+
var _xcfg = null;
|
|
40
|
+
var _boundImportMeta = null;
|
|
41
|
+
var _metaStore = null;
|
|
42
|
+
|
|
43
|
+
function ready() {
|
|
44
|
+
if (!_ready) {
|
|
45
|
+
_ready = (async function() {
|
|
46
|
+
var login = JSON.parse(await decrypt(process.env.XCFG_DB_CONNECTION_INFO_ENCRYPTED, process.env.ENCRYPTION_KEY));
|
|
47
|
+
_xcfg = await bootstrapConfigDb({ connection: login, database: process.env.XCFG_DB_NAME });
|
|
48
|
+
_boundImportMeta = ImportMeta.bindKnex(_xcfg.db);
|
|
49
|
+
_metaStore = makeMetaStore(_xcfg.db, { table: 'import_meta', service: service });
|
|
50
|
+
return _xcfg;
|
|
51
|
+
})();
|
|
52
|
+
}
|
|
53
|
+
return _ready;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireReady(name) {
|
|
57
|
+
if (!_xcfg) throw new Error('attachConfig("' + service + '"): ' + name + ' accessed before ready() resolved');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ready: ready,
|
|
62
|
+
SERVICE_NAME: service,
|
|
63
|
+
get metaStore() { requireReady('metaStore'); return _metaStore; },
|
|
64
|
+
get ImportMeta() { requireReady('ImportMeta'); return _boundImportMeta; },
|
|
65
|
+
get db() { requireReady('db'); return _xcfg.db; }
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { attachConfig: attachConfig, requiredEnv: requiredEnv, ImportMeta: ImportMeta };
|