@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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Postgres driver — thin. SqlQueue owns retries + bisection + abort,
|
|
2
|
+
// so the driver just does: connect / query / close, and provides SQL
|
|
3
|
+
// builders the uploader composes into batch INSERT statements.
|
|
4
|
+
//
|
|
5
|
+
// Peer dep: pg (declared in @xeplr/actions/package.json as optional).
|
|
6
|
+
//
|
|
7
|
+
// Interface every db driver implements:
|
|
8
|
+
//
|
|
9
|
+
// requires: ['pg']
|
|
10
|
+
// async connect(config) → pool
|
|
11
|
+
// async query(pool, sql, params?) → { rows, rowCount, ... }
|
|
12
|
+
// async close(pool) → void
|
|
13
|
+
//
|
|
14
|
+
// buildCreateTableSql(tableName, columns, primaryKeys?) → string
|
|
15
|
+
// buildCreateErrorTableSql(tableName) → string
|
|
16
|
+
// buildAlterTableAddSql(tableName, columns) → string[]
|
|
17
|
+
// buildInsertSql(tableName, rows, columns, movementId, primaryKeys?)
|
|
18
|
+
// → { sql, params }
|
|
19
|
+
// async rollbackMovement(pool, tableName, movementId) → { mainDeleted, errorDeleted }
|
|
20
|
+
//
|
|
21
|
+
// Type mapping — deliberately narrow to avoid width/precision footguns:
|
|
22
|
+
// string → TEXT
|
|
23
|
+
// number → NUMERIC (integer + float both handled)
|
|
24
|
+
// boolean → BOOLEAN
|
|
25
|
+
// date → TIMESTAMPTZ (user rule: "always datetime, never date-only")
|
|
26
|
+
// datetime→ TIMESTAMPTZ
|
|
27
|
+
// object → JSONB
|
|
28
|
+
// array → JSONB
|
|
29
|
+
|
|
30
|
+
var TYPE_MAP = {
|
|
31
|
+
string: 'TEXT',
|
|
32
|
+
number: 'NUMERIC',
|
|
33
|
+
boolean: 'BOOLEAN',
|
|
34
|
+
date: 'TIMESTAMPTZ',
|
|
35
|
+
datetime: 'TIMESTAMPTZ',
|
|
36
|
+
object: 'JSONB',
|
|
37
|
+
array: 'JSONB'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Framework-managed columns on every uploader-created table.
|
|
41
|
+
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
42
|
+
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
43
|
+
|
|
44
|
+
async function connect(config) {
|
|
45
|
+
var pg = require('pg');
|
|
46
|
+
var pool = new pg.Pool({
|
|
47
|
+
host: config.host || 'localhost',
|
|
48
|
+
port: config.port || 5432,
|
|
49
|
+
user: config.user,
|
|
50
|
+
password: config.password,
|
|
51
|
+
database: config.database,
|
|
52
|
+
max: config.maxConnections || 10
|
|
53
|
+
});
|
|
54
|
+
return pool;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function close(pool) {
|
|
58
|
+
if (pool) await pool.end();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function query(pool, sql, params) {
|
|
62
|
+
return pool.query(sql, params || []);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toPgType(type) { return TYPE_MAP[type] || 'TEXT'; }
|
|
66
|
+
|
|
67
|
+
// Double-quote a Postgres identifier, escaping embedded quotes.
|
|
68
|
+
function quoteIdent(name) {
|
|
69
|
+
return '"' + String(name).replace(/"/g, '""') + '"';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build the CREATE TABLE IF NOT EXISTS statement.
|
|
73
|
+
// columns — [{ name, type }]
|
|
74
|
+
// primaryKeys — optional; realized as a UNIQUE INDEX (see buildUpsertIndexSql)
|
|
75
|
+
// so __xeplr_id__ stays the framework PK.
|
|
76
|
+
function buildCreateTableSql(tableName, columns, _primaryKeys) {
|
|
77
|
+
var defs = [];
|
|
78
|
+
defs.push(quoteIdent(INTERNAL_ID_COL) + ' BIGSERIAL PRIMARY KEY');
|
|
79
|
+
defs.push(quoteIdent(INTERNAL_MOVEMENT_COL) + ' TEXT NOT NULL');
|
|
80
|
+
for (var i = 0; i < columns.length; i++) {
|
|
81
|
+
var c = columns[i];
|
|
82
|
+
defs.push(quoteIdent(c.name) + ' ' + toPgType(c.type));
|
|
83
|
+
}
|
|
84
|
+
return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(tableName) +
|
|
85
|
+
' (\n ' + defs.join(',\n ') + '\n)';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildCreateErrorTableSql(tableName) {
|
|
89
|
+
var errTable = tableName + '_import_errors';
|
|
90
|
+
return 'CREATE TABLE IF NOT EXISTS ' + quoteIdent(errTable) + ' (\n' +
|
|
91
|
+
' ' + quoteIdent(INTERNAL_ID_COL) + ' BIGSERIAL PRIMARY KEY,\n' +
|
|
92
|
+
' movement_id TEXT NOT NULL,\n' +
|
|
93
|
+
' row_num INTEGER,\n' +
|
|
94
|
+
' error_description TEXT,\n' +
|
|
95
|
+
' underlying_sql TEXT,\n' +
|
|
96
|
+
' raw_row JSONB,\n' +
|
|
97
|
+
' recorded_at TIMESTAMPTZ DEFAULT NOW()\n' +
|
|
98
|
+
')';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Optional UNIQUE INDEX for UPSERT conflict-target when primaryKeys given.
|
|
102
|
+
function buildUpsertIndexSql(tableName, primaryKeys) {
|
|
103
|
+
if (!primaryKeys || primaryKeys.length === 0) return null;
|
|
104
|
+
var idxName = tableName + '_upsert_uniq';
|
|
105
|
+
var cols = primaryKeys.map(quoteIdent).join(', ');
|
|
106
|
+
return 'CREATE UNIQUE INDEX IF NOT EXISTS ' + quoteIdent(idxName) +
|
|
107
|
+
' ON ' + quoteIdent(tableName) + ' (' + cols + ')';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One ALTER TABLE ADD COLUMN IF NOT EXISTS per new column — idempotent.
|
|
111
|
+
function buildAlterTableAddSql(tableName, columns) {
|
|
112
|
+
return columns.map(function(c) {
|
|
113
|
+
return 'ALTER TABLE ' + quoteIdent(tableName) +
|
|
114
|
+
' ADD COLUMN IF NOT EXISTS ' + quoteIdent(c.name) + ' ' + toPgType(c.type);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Bulk INSERT for a batch of rows. Emits parameterized SQL — values are
|
|
119
|
+
// never concatenated into the SQL string. Auto-adds __xeplr_movement_id__
|
|
120
|
+
// to every row. UPSERT if primaryKeys supplied.
|
|
121
|
+
function buildInsertSql(tableName, rows, columns, movementId, primaryKeys) {
|
|
122
|
+
var colNames = columns.map(function(c) { return c.name; });
|
|
123
|
+
var allCols = [INTERNAL_MOVEMENT_COL].concat(colNames);
|
|
124
|
+
|
|
125
|
+
var params = [];
|
|
126
|
+
var groups = [];
|
|
127
|
+
var idx = 1;
|
|
128
|
+
|
|
129
|
+
for (var r = 0; r < rows.length; r++) {
|
|
130
|
+
var row = rows[r];
|
|
131
|
+
var placeholders = ['$' + idx];
|
|
132
|
+
params.push(movementId); idx++;
|
|
133
|
+
for (var c = 0; c < colNames.length; c++) {
|
|
134
|
+
placeholders.push('$' + idx);
|
|
135
|
+
params.push(coerceValue(row[colNames[c]], columns[c].type));
|
|
136
|
+
idx++;
|
|
137
|
+
}
|
|
138
|
+
groups.push('(' + placeholders.join(',') + ')');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
var sql = 'INSERT INTO ' + quoteIdent(tableName) +
|
|
142
|
+
' (' + allCols.map(quoteIdent).join(', ') + ') VALUES ' + groups.join(',');
|
|
143
|
+
|
|
144
|
+
if (primaryKeys && primaryKeys.length > 0) {
|
|
145
|
+
// Non-PK user columns get updated. __xeplr_movement_id__ ALWAYS updates
|
|
146
|
+
// on conflict — otherwise the latest movement can silently modify a row
|
|
147
|
+
// it doesn't own on paper, and rollback would miss it.
|
|
148
|
+
var updateCols = colNames.filter(function(c) { return primaryKeys.indexOf(c) === -1; });
|
|
149
|
+
var setColumns = updateCols.concat([INTERNAL_MOVEMENT_COL]);
|
|
150
|
+
var setClause = setColumns.map(function(c) {
|
|
151
|
+
return quoteIdent(c) + ' = EXCLUDED.' + quoteIdent(c);
|
|
152
|
+
}).join(', ');
|
|
153
|
+
sql += ' ON CONFLICT (' + primaryKeys.map(quoteIdent).join(', ') +
|
|
154
|
+
') DO UPDATE SET ' + setClause;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { sql: sql, params: params };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Coerce a JS value to the target column's effective type. The uploader
|
|
161
|
+
// passes the RECONCILED column type here (target's type wins), so this
|
|
162
|
+
// function's job is to widen/narrow the JS value to fit.
|
|
163
|
+
//
|
|
164
|
+
// Failures aren't thrown — mismatched values (e.g. "abc" going into a
|
|
165
|
+
// number column) pass through unchanged, PG rejects them, and SqlQueue's
|
|
166
|
+
// bisection isolates the offending row into the error table.
|
|
167
|
+
function coerceValue(v, type) {
|
|
168
|
+
if (v === undefined || v === null) return null;
|
|
169
|
+
|
|
170
|
+
switch (type) {
|
|
171
|
+
case 'string':
|
|
172
|
+
// "String wins" widening: TEXT columns accept everything.
|
|
173
|
+
if (v instanceof Date) return v.toISOString();
|
|
174
|
+
if (Array.isArray(v)) return JSON.stringify(v);
|
|
175
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
176
|
+
return String(v);
|
|
177
|
+
|
|
178
|
+
case 'number':
|
|
179
|
+
if (typeof v === 'number') return v;
|
|
180
|
+
if (typeof v === 'string') {
|
|
181
|
+
var n = Number(v);
|
|
182
|
+
return isNaN(n) ? v : n; // non-numeric string → let PG reject
|
|
183
|
+
}
|
|
184
|
+
return v;
|
|
185
|
+
|
|
186
|
+
case 'boolean':
|
|
187
|
+
if (typeof v === 'boolean') return v;
|
|
188
|
+
if (typeof v === 'string') {
|
|
189
|
+
var lower = v.toLowerCase().trim();
|
|
190
|
+
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'y') return true;
|
|
191
|
+
if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'n') return false;
|
|
192
|
+
}
|
|
193
|
+
if (typeof v === 'number') return v !== 0;
|
|
194
|
+
return v; // let PG reject
|
|
195
|
+
|
|
196
|
+
case 'datetime':
|
|
197
|
+
if (v instanceof Date) return v.toISOString();
|
|
198
|
+
return v; // strings pass through; PG parses
|
|
199
|
+
|
|
200
|
+
case 'object':
|
|
201
|
+
case 'array':
|
|
202
|
+
if (typeof v === 'string') return v; // assume valid JSON string
|
|
203
|
+
return JSON.stringify(v);
|
|
204
|
+
|
|
205
|
+
default:
|
|
206
|
+
return v;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Native data_type → logical type, for reconcile. Shared with the reconcile
|
|
211
|
+
// module so the mapping lives in one place; exposed here so the uploader can
|
|
212
|
+
// call driver.dataTypeToLogical uniformly across all dialects.
|
|
213
|
+
var pgDataTypeToLogical = require('../../uploader/reconcile').pgDataTypeToLogical;
|
|
214
|
+
function dataTypeToLogical(dataType) { return pgDataTypeToLogical(dataType); }
|
|
215
|
+
|
|
216
|
+
// Idempotently ensure __xeplr_movement_id__ exists on a (possibly pre-existing)
|
|
217
|
+
// target table. Same operation the uploader used to inline; kept here so every
|
|
218
|
+
// driver exposes the same async ensure interface.
|
|
219
|
+
async function ensureMovementColumn(pool, tableName) {
|
|
220
|
+
await pool.query(
|
|
221
|
+
'ALTER TABLE ' + quoteIdent(tableName) +
|
|
222
|
+
' ADD COLUMN IF NOT EXISTS ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' TEXT'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Idempotently ensure the UNIQUE index that backs ON CONFLICT.
|
|
227
|
+
async function ensureUpsertIndex(pool, tableName, primaryKeys) {
|
|
228
|
+
var sql = buildUpsertIndexSql(tableName, primaryKeys);
|
|
229
|
+
if (sql) await pool.query(sql);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Query the target table's actual column set. Returns [{name, dataType, udtName}]
|
|
233
|
+
// in ordinal-position order. Empty array if the table doesn't exist.
|
|
234
|
+
async function getTableSchema(pool, tableName, schema) {
|
|
235
|
+
var r = await pool.query(
|
|
236
|
+
"SELECT column_name, data_type, udt_name " +
|
|
237
|
+
"FROM information_schema.columns " +
|
|
238
|
+
"WHERE table_schema = $2 AND table_name = $1 " +
|
|
239
|
+
"ORDER BY ordinal_position",
|
|
240
|
+
[tableName, schema || 'public']
|
|
241
|
+
);
|
|
242
|
+
return r.rows.map(function(row) {
|
|
243
|
+
return { name: row.column_name, dataType: row.data_type, udtName: row.udt_name };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Table/view/procedure listing — used by the db-list-* actions. All three
|
|
248
|
+
// default to the 'public' schema (Postgres's default search_path entry),
|
|
249
|
+
// same fallback getTableSchema already uses.
|
|
250
|
+
async function listTables(pool, schema) {
|
|
251
|
+
var r = await pool.query(
|
|
252
|
+
"SELECT table_name FROM information_schema.tables " +
|
|
253
|
+
"WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name",
|
|
254
|
+
[schema || 'public']
|
|
255
|
+
);
|
|
256
|
+
return r.rows.map(function(row) { return row.table_name; });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function listViews(pool, schema) {
|
|
260
|
+
var r = await pool.query(
|
|
261
|
+
"SELECT table_name FROM information_schema.views " +
|
|
262
|
+
"WHERE table_schema = $1 ORDER BY table_name",
|
|
263
|
+
[schema || 'public']
|
|
264
|
+
);
|
|
265
|
+
return r.rows.map(function(row) { return row.table_name; });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// routine_type='PROCEDURE' only (true CREATE PROCEDURE, PG11+) — deliberately
|
|
269
|
+
// excludes FUNCTION so this lines up with what "procedures" means on the
|
|
270
|
+
// other two dialects, which have no separate function/procedure distinction
|
|
271
|
+
// in this listing.
|
|
272
|
+
async function listProcedures(pool, schema) {
|
|
273
|
+
var r = await pool.query(
|
|
274
|
+
"SELECT routine_name FROM information_schema.routines " +
|
|
275
|
+
"WHERE routine_schema = $1 AND routine_type = 'PROCEDURE' ORDER BY routine_name",
|
|
276
|
+
[schema || 'public']
|
|
277
|
+
);
|
|
278
|
+
return r.rows.map(function(row) { return row.routine_name; });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Stream a result set WITHOUT buffering it in memory — the read primitive
|
|
282
|
+
// db-fetch composes. Uses a server-side cursor (DECLARE/FETCH) on a dedicated
|
|
283
|
+
// checked-out client, so PG holds the result set and we pull it `batchSize`
|
|
284
|
+
// rows at a time. The async generator naturally applies backpressure: the next
|
|
285
|
+
// FETCH only fires when the consumer asks for more.
|
|
286
|
+
//
|
|
287
|
+
// opts: { sql, params?, batchSize? } → async iterable<row>
|
|
288
|
+
async function* fetchStream(pool, opts) {
|
|
289
|
+
var batchSize = Math.max(1, parseInt(opts.batchSize || 1000, 10));
|
|
290
|
+
var client = await pool.connect();
|
|
291
|
+
var declared = false;
|
|
292
|
+
try {
|
|
293
|
+
await client.query('BEGIN');
|
|
294
|
+
await client.query('DECLARE __xeplr_fetch_cur NO SCROLL CURSOR FOR ' + opts.sql, opts.params || []);
|
|
295
|
+
declared = true;
|
|
296
|
+
while (true) {
|
|
297
|
+
var res = await client.query('FETCH FORWARD ' + batchSize + ' FROM __xeplr_fetch_cur');
|
|
298
|
+
if (res.rows.length === 0) break;
|
|
299
|
+
for (var i = 0; i < res.rows.length; i++) yield res.rows[i];
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
try { if (declared) await client.query('CLOSE __xeplr_fetch_cur'); } catch (_) {}
|
|
303
|
+
try { await client.query('COMMIT'); } catch (_) {}
|
|
304
|
+
client.release();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Delete all rows for a movement across main + error tables.
|
|
309
|
+
// Error table might not exist yet (movement failed before first batch) —
|
|
310
|
+
// swallow those errors silently.
|
|
311
|
+
async function rollbackMovement(pool, tableName, movementId) {
|
|
312
|
+
var errTable = tableName + '_import_errors';
|
|
313
|
+
var result = { mainDeleted: 0, errorDeleted: 0 };
|
|
314
|
+
|
|
315
|
+
var r1 = await pool.query(
|
|
316
|
+
'DELETE FROM ' + quoteIdent(tableName) +
|
|
317
|
+
' WHERE ' + quoteIdent(INTERNAL_MOVEMENT_COL) + ' = $1',
|
|
318
|
+
[movementId]
|
|
319
|
+
);
|
|
320
|
+
result.mainDeleted = r1.rowCount || 0;
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
var r2 = await pool.query(
|
|
324
|
+
'DELETE FROM ' + quoteIdent(errTable) + ' WHERE movement_id = $1',
|
|
325
|
+
[movementId]
|
|
326
|
+
);
|
|
327
|
+
result.errorDeleted = r2.rowCount || 0;
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err.code !== '42P01') throw err; // 42P01 = relation does not exist
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = {
|
|
336
|
+
requires: ['pg'],
|
|
337
|
+
|
|
338
|
+
// Connection lifecycle
|
|
339
|
+
connect: connect,
|
|
340
|
+
query: query,
|
|
341
|
+
close: close,
|
|
342
|
+
|
|
343
|
+
// SQL builders (called by uploader — pure functions, no DB access)
|
|
344
|
+
toPgType: toPgType,
|
|
345
|
+
dataTypeToLogical: dataTypeToLogical,
|
|
346
|
+
quoteIdent: quoteIdent,
|
|
347
|
+
buildCreateTableSql: buildCreateTableSql,
|
|
348
|
+
buildCreateErrorTableSql:buildCreateErrorTableSql,
|
|
349
|
+
buildUpsertIndexSql: buildUpsertIndexSql,
|
|
350
|
+
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
351
|
+
buildInsertSql: buildInsertSql,
|
|
352
|
+
|
|
353
|
+
// Idempotent DDL helpers (async — uniform across all dialect drivers)
|
|
354
|
+
ensureMovementColumn: ensureMovementColumn,
|
|
355
|
+
ensureUpsertIndex: ensureUpsertIndex,
|
|
356
|
+
|
|
357
|
+
// Introspection (used by uploader.reconcile + the db-list-* actions)
|
|
358
|
+
getTableSchema: getTableSchema,
|
|
359
|
+
listTables: listTables,
|
|
360
|
+
listViews: listViews,
|
|
361
|
+
listProcedures: listProcedures,
|
|
362
|
+
|
|
363
|
+
// Streaming read primitive (used by db-fetch)
|
|
364
|
+
fetchStream: fetchStream,
|
|
365
|
+
|
|
366
|
+
// Rollback helper
|
|
367
|
+
rollbackMovement: rollbackMovement,
|
|
368
|
+
|
|
369
|
+
// Constants
|
|
370
|
+
INTERNAL_ID_COL: INTERNAL_ID_COL,
|
|
371
|
+
INTERNAL_MOVEMENT_COL: INTERNAL_MOVEMENT_COL
|
|
372
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Google Drive source — implementation pending user spec.
|
|
2
|
+
// Peer dep: TBD (candidate: googleapis)
|
|
3
|
+
// Implements the same interface as file/sources/local.js.
|
|
4
|
+
// Config supplies OAuth credentials + folder — treated as a "dbInfo" equivalent.
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
requires: []
|
|
8
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// File source factory. Each action calls getSource(type) to get the right
|
|
2
|
+
// source driver, then invokes the common source interface (see local.js
|
|
3
|
+
// for the contract).
|
|
4
|
+
//
|
|
5
|
+
// Adding a new source type = add a file here + register it below.
|
|
6
|
+
|
|
7
|
+
var SOURCES = {
|
|
8
|
+
local: function() { return require('./local'); },
|
|
9
|
+
sharepoint: function() { return require('./sharepoint'); },
|
|
10
|
+
google: function() { return require('./google'); },
|
|
11
|
+
sftp: function() { return require('./sftp'); }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getSource(type) {
|
|
15
|
+
var loader = SOURCES[type];
|
|
16
|
+
if (!loader) {
|
|
17
|
+
throw new Error('Unknown sourceType: "' + type + '". Supported: ' + Object.keys(SOURCES).join(', '));
|
|
18
|
+
}
|
|
19
|
+
return loader();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check a source's peer deps and throw a friendly error if any are missing.
|
|
23
|
+
// Called at action execution time (not registration) — the concrete
|
|
24
|
+
// sourceType is only known when the action fires.
|
|
25
|
+
function checkSourceRequires(actionName, sourceType, source) {
|
|
26
|
+
var requires = (source && source.requires) || [];
|
|
27
|
+
for (var i = 0; i < requires.length; i++) {
|
|
28
|
+
try { require.resolve(requires[i]); }
|
|
29
|
+
catch (_) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
actionName + ' requires "' + requires[i] + '" for sourceType="' + sourceType + '". ' +
|
|
32
|
+
'Install with: npm install ' + requires[i]
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
getSource: getSource,
|
|
40
|
+
checkSourceRequires: checkSourceRequires,
|
|
41
|
+
SUPPORTED: Object.keys(SOURCES)
|
|
42
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Local filesystem source — thin wrappers over fs. No peer deps.
|
|
2
|
+
//
|
|
3
|
+
// openStream(config) → node stream (Readable)
|
|
4
|
+
// writeFile(config, {content|filePath})
|
|
5
|
+
// listFiles(config) → [{ name, size, mtime }]
|
|
6
|
+
// moveFile(config, { from, to }) → void
|
|
7
|
+
// deleteFile(config) → void
|
|
8
|
+
//
|
|
9
|
+
// `config.path` is the file/dir path — local source config is intentionally
|
|
10
|
+
// minimal since fs handles most concerns natively.
|
|
11
|
+
|
|
12
|
+
var fs = require('fs');
|
|
13
|
+
var fsp = require('fs/promises');
|
|
14
|
+
var path = require('path');
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
requires: [],
|
|
18
|
+
|
|
19
|
+
async openStream(config) {
|
|
20
|
+
if (!config || !config.path) throw new Error('local source: config.path is required');
|
|
21
|
+
return fs.createReadStream(config.path, config.encoding ? { encoding: config.encoding } : {});
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// Formats that need random access (e.g. .xlsx via unzipper — the zip central
|
|
25
|
+
// directory lives at the file tail) request a path instead of a stream.
|
|
26
|
+
// Local: return the path directly. Remote sources (SFTP, Google, etc.)
|
|
27
|
+
// must implement this by downloading to a caller-provided tmp path.
|
|
28
|
+
async openPath(config) {
|
|
29
|
+
if (!config || !config.path) throw new Error('local source: config.path is required');
|
|
30
|
+
return config.path;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async writeFile(config, opts) {
|
|
34
|
+
if (!config.path) throw new Error('local source: config.path is required for writeFile');
|
|
35
|
+
if (opts.filePath) {
|
|
36
|
+
await fsp.copyFile(opts.filePath, config.path);
|
|
37
|
+
return { bytes: (await fsp.stat(config.path)).size };
|
|
38
|
+
}
|
|
39
|
+
if (opts.content != null) {
|
|
40
|
+
await fsp.writeFile(config.path, opts.content);
|
|
41
|
+
return { bytes: Buffer.byteLength(opts.content) };
|
|
42
|
+
}
|
|
43
|
+
throw new Error('local source: writeFile requires opts.content or opts.filePath');
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async listFiles(config) {
|
|
47
|
+
var entries = await fsp.readdir(config.path, { withFileTypes: true });
|
|
48
|
+
var out = [];
|
|
49
|
+
for (var i = 0; i < entries.length; i++) {
|
|
50
|
+
var e = entries[i];
|
|
51
|
+
if (!e.isFile()) continue;
|
|
52
|
+
var full = path.join(config.path, e.name);
|
|
53
|
+
var stat = await fsp.stat(full);
|
|
54
|
+
out.push({ name: e.name, path: full, size: stat.size, mtime: stat.mtime.toISOString() });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async moveFile(_config, opts) {
|
|
60
|
+
if (!opts || !opts.from || !opts.to) throw new Error('local source: moveFile requires from + to');
|
|
61
|
+
await fsp.rename(opts.from, opts.to);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async deleteFile(config) {
|
|
65
|
+
if (!config.path) throw new Error('local source: config.path is required for deleteFile');
|
|
66
|
+
await fsp.unlink(config.path);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SFTP source — implementation pending user spec.
|
|
2
|
+
// Peer dep candidate: ssh2-sftp-client (friendlier wrapper) or ssh2 (raw).
|
|
3
|
+
// Implements the same interface as file/sources/local.js — see that file
|
|
4
|
+
// for the full contract including streaming_mode semantics.
|
|
5
|
+
// Config supplies host, port, username, and one of password | privateKey.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
requires: ['ssh2-sftp-client']
|
|
9
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SharePoint source — implementation pending user spec.
|
|
2
|
+
// Peer dep: TBD (candidate: @microsoft/microsoft-graph-client)
|
|
3
|
+
// Implements the same interface as file/sources/local.js — see that file
|
|
4
|
+
// for the full contract including streaming_mode semantics.
|
|
5
|
+
// Config supplies auth + site/library — treated as a "dbInfo" equivalent.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
requires: []
|
|
9
|
+
};
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class ActionNotRegisteredError extends Error {
|
|
2
|
+
constructor(name) {
|
|
3
|
+
super('No action registered: ' + name);
|
|
4
|
+
this.name = 'ActionNotRegisteredError';
|
|
5
|
+
this.actionName = name;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Thrown at register() when a declared peer dep (`requires`) isn't installed.
|
|
10
|
+
class ActionMissingDependencyError extends Error {
|
|
11
|
+
constructor(actionName, dep) {
|
|
12
|
+
super(
|
|
13
|
+
'Action "' + actionName + '" requires peer dependency "' + dep +
|
|
14
|
+
'" which is not installed. Install with: npm install ' + dep
|
|
15
|
+
);
|
|
16
|
+
this.name = 'ActionMissingDependencyError';
|
|
17
|
+
this.actionName = actionName;
|
|
18
|
+
this.dep = dep;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Placeholder for future retry logic. Callers (jobs, workflow) can use this
|
|
23
|
+
// to signal "this failure is transient, please retry me." Today no framework
|
|
24
|
+
// acts on it; when retry rules land, they will branch on this error class.
|
|
25
|
+
class TransientError extends Error {
|
|
26
|
+
constructor(message, opts) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'TransientError';
|
|
29
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
ActionNotRegisteredError: ActionNotRegisteredError,
|
|
35
|
+
ActionMissingDependencyError: ActionMissingDependencyError,
|
|
36
|
+
TransientError: TransientError
|
|
37
|
+
};
|