@objectstack/rest 11.4.0 → 11.5.0
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/dist/index.cjs +1108 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1108 -73
- package/dist/index.js.map +1 -1
- package/package.json +10 -5
package/dist/index.js
CHANGED
|
@@ -211,6 +211,503 @@ var RouteGroupBuilder = class {
|
|
|
211
211
|
}
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
+
// src/export-format.ts
|
|
215
|
+
var REFERENCE_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail", "user", "reference", "tree"]);
|
|
216
|
+
var OPTION_TYPES = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
217
|
+
var MULTI_OPTION_TYPES = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
218
|
+
var NAME_KEY_FALLBACKS = [
|
|
219
|
+
"name",
|
|
220
|
+
"title",
|
|
221
|
+
"label",
|
|
222
|
+
"full_name",
|
|
223
|
+
"fullName",
|
|
224
|
+
"display_name",
|
|
225
|
+
"username",
|
|
226
|
+
"email"
|
|
227
|
+
];
|
|
228
|
+
function buildFieldMetaMap(schema) {
|
|
229
|
+
const map = /* @__PURE__ */ new Map();
|
|
230
|
+
const fields = schema?.fields;
|
|
231
|
+
let entries;
|
|
232
|
+
if (Array.isArray(fields)) {
|
|
233
|
+
entries = fields.filter((f) => f && typeof f === "object").map((f) => [typeof f.name === "string" ? f.name : "", f]);
|
|
234
|
+
} else if (fields && typeof fields === "object") {
|
|
235
|
+
entries = Object.entries(fields).map(
|
|
236
|
+
([key, def]) => [
|
|
237
|
+
def && typeof def === "object" && typeof def.name === "string" ? def.name : key,
|
|
238
|
+
def
|
|
239
|
+
]
|
|
240
|
+
);
|
|
241
|
+
} else {
|
|
242
|
+
return map;
|
|
243
|
+
}
|
|
244
|
+
for (const [name, f] of entries) {
|
|
245
|
+
if (!name || !f || typeof f !== "object") continue;
|
|
246
|
+
map.set(name, {
|
|
247
|
+
name,
|
|
248
|
+
type: typeof f.type === "string" ? f.type : void 0,
|
|
249
|
+
label: typeof f.label === "string" ? f.label : void 0,
|
|
250
|
+
options: Array.isArray(f.options) ? f.options : void 0,
|
|
251
|
+
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
252
|
+
displayField: typeof f.displayField === "string" ? f.displayField : void 0
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return map;
|
|
256
|
+
}
|
|
257
|
+
function referenceFieldNames(metaMap) {
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const meta of metaMap.values()) {
|
|
260
|
+
if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) out.push(meta.name);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
function headerLabel(field, metaMap) {
|
|
265
|
+
return metaMap.get(field)?.label || field;
|
|
266
|
+
}
|
|
267
|
+
function pad2(n) {
|
|
268
|
+
return n < 10 ? `0${n}` : String(n);
|
|
269
|
+
}
|
|
270
|
+
function toDate(value) {
|
|
271
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
272
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
273
|
+
const d = new Date(value);
|
|
274
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
function formatDate(value, withTime) {
|
|
279
|
+
const d = toDate(value);
|
|
280
|
+
if (!d) return value;
|
|
281
|
+
const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
282
|
+
if (!withTime) return ymd;
|
|
283
|
+
return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
|
|
284
|
+
}
|
|
285
|
+
function optionLabel(value, options) {
|
|
286
|
+
if (!options) return value;
|
|
287
|
+
const hit = options.find((o) => o && o.value === value);
|
|
288
|
+
return hit?.label ?? value;
|
|
289
|
+
}
|
|
290
|
+
function displayFromRecord(rec, displayField) {
|
|
291
|
+
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
292
|
+
for (const k of NAME_KEY_FALLBACKS) {
|
|
293
|
+
const v = rec[k];
|
|
294
|
+
if (v != null && typeof v !== "object") return String(v);
|
|
295
|
+
}
|
|
296
|
+
if (rec.id != null) return String(rec.id);
|
|
297
|
+
try {
|
|
298
|
+
return JSON.stringify(rec);
|
|
299
|
+
} catch {
|
|
300
|
+
return String(rec);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function formatReference(value, displayField) {
|
|
304
|
+
const one = (v) => v && typeof v === "object" ? displayFromRecord(v, displayField) : v;
|
|
305
|
+
if (Array.isArray(value)) return value.map(one).join(", ");
|
|
306
|
+
return one(value);
|
|
307
|
+
}
|
|
308
|
+
function formatCellValue(value, meta) {
|
|
309
|
+
if (value === null || value === void 0) return value;
|
|
310
|
+
if (!meta || !meta.type) return value;
|
|
311
|
+
const t = meta.type;
|
|
312
|
+
if (t === "boolean" || t === "toggle") {
|
|
313
|
+
if (value === true || value === "true" || value === 1) return "\u662F";
|
|
314
|
+
if (value === false || value === "false" || value === 0) return "\u5426";
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options);
|
|
318
|
+
if (MULTI_OPTION_TYPES.has(t)) {
|
|
319
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
320
|
+
return arr.map((v) => optionLabel(v, meta.options)).join(", ");
|
|
321
|
+
}
|
|
322
|
+
if (t === "date") return formatDate(value, false);
|
|
323
|
+
if (t === "datetime") return formatDate(value, true);
|
|
324
|
+
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField);
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
function formatRowCells(row, fields, metaMap) {
|
|
328
|
+
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f)));
|
|
329
|
+
}
|
|
330
|
+
function formatRowForJson(row, metaMap) {
|
|
331
|
+
if (metaMap.size === 0 || !row || typeof row !== "object") return row;
|
|
332
|
+
let copy = null;
|
|
333
|
+
for (const key of Object.keys(row)) {
|
|
334
|
+
const meta = metaMap.get(key);
|
|
335
|
+
if (!meta) continue;
|
|
336
|
+
const formatted = formatCellValue(row[key], meta);
|
|
337
|
+
if (formatted !== row[key]) {
|
|
338
|
+
if (!copy) copy = { ...row };
|
|
339
|
+
copy[key] = formatted;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return copy ?? row;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/import-coerce.ts
|
|
346
|
+
var REFERENCE_TYPES2 = /* @__PURE__ */ new Set(["lookup", "master_detail", "user", "reference", "tree"]);
|
|
347
|
+
var OPTION_TYPES2 = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
348
|
+
var MULTI_OPTION_TYPES2 = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
349
|
+
var NUMBER_TYPES = /* @__PURE__ */ new Set(["number", "currency", "percent", "rating", "slider"]);
|
|
350
|
+
var BOOL_TYPES = /* @__PURE__ */ new Set(["boolean", "toggle"]);
|
|
351
|
+
function normalizeRefMatch(result) {
|
|
352
|
+
if (result == null) return {};
|
|
353
|
+
if (typeof result === "string") return result ? { id: result } : {};
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
function isBlank(value, nullValues) {
|
|
357
|
+
if (value === null || value === void 0) return true;
|
|
358
|
+
if (typeof value === "string") {
|
|
359
|
+
const s = value.trim();
|
|
360
|
+
if (s === "") return true;
|
|
361
|
+
if (nullValues && nullValues.some((nv) => nv === value || nv === s)) return true;
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
var BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "yes", "y", "1", "on", "\u662F", "\u5BF9", "\u2713", "\u221A"]);
|
|
366
|
+
var BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "no", "n", "0", "off", "\u5426", "\u9519", "\u2717", "\xD7"]);
|
|
367
|
+
function parseBooleanCell(raw) {
|
|
368
|
+
if (typeof raw === "boolean") return raw;
|
|
369
|
+
if (typeof raw === "number") {
|
|
370
|
+
if (raw === 1) return true;
|
|
371
|
+
if (raw === 0) return false;
|
|
372
|
+
return void 0;
|
|
373
|
+
}
|
|
374
|
+
const s = String(raw).trim().toLowerCase();
|
|
375
|
+
if (BOOL_TRUE.has(s)) return true;
|
|
376
|
+
if (BOOL_FALSE.has(s)) return false;
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
function parseNumberCell(raw) {
|
|
380
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
381
|
+
let s = String(raw).trim();
|
|
382
|
+
if (s === "") return void 0;
|
|
383
|
+
let negative = false;
|
|
384
|
+
if (/^\(.*\)$/.test(s)) {
|
|
385
|
+
negative = true;
|
|
386
|
+
s = s.slice(1, -1).trim();
|
|
387
|
+
}
|
|
388
|
+
s = s.replace(/^[$¥€£¥]\s*/, "");
|
|
389
|
+
s = s.replace(/%$/, "").trim();
|
|
390
|
+
s = s.replace(/,/g, "");
|
|
391
|
+
if (s === "" || !/^[+-]?\d*\.?\d+(e[+-]?\d+)?$/i.test(s)) return void 0;
|
|
392
|
+
const n = Number(s);
|
|
393
|
+
if (!Number.isFinite(n)) return void 0;
|
|
394
|
+
return negative ? -n : n;
|
|
395
|
+
}
|
|
396
|
+
function pad22(n) {
|
|
397
|
+
return n < 10 ? `0${n}` : String(n);
|
|
398
|
+
}
|
|
399
|
+
var TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
|
|
400
|
+
function parseDateCell(raw, kind) {
|
|
401
|
+
if (raw instanceof Date) {
|
|
402
|
+
if (Number.isNaN(raw.getTime())) return void 0;
|
|
403
|
+
if (kind === "datetime") return raw.toISOString();
|
|
404
|
+
if (kind === "date") return `${raw.getUTCFullYear()}-${pad22(raw.getUTCMonth() + 1)}-${pad22(raw.getUTCDate())}`;
|
|
405
|
+
return `${pad22(raw.getUTCHours())}:${pad22(raw.getUTCMinutes())}:${pad22(raw.getUTCSeconds())}`;
|
|
406
|
+
}
|
|
407
|
+
const s = String(raw).trim();
|
|
408
|
+
if (s === "") return void 0;
|
|
409
|
+
if (kind === "time") {
|
|
410
|
+
if (TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
|
|
411
|
+
const t = new Date(s);
|
|
412
|
+
if (!Number.isNaN(t.getTime())) return `${pad22(t.getUTCHours())}:${pad22(t.getUTCMinutes())}:${pad22(t.getUTCSeconds())}`;
|
|
413
|
+
return void 0;
|
|
414
|
+
}
|
|
415
|
+
const ymd = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
|
416
|
+
if (ymd) {
|
|
417
|
+
const y = Number(ymd[1]);
|
|
418
|
+
const mo = Number(ymd[2]);
|
|
419
|
+
const d = Number(ymd[3]);
|
|
420
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return void 0;
|
|
421
|
+
if (kind === "date") return `${y}-${pad22(mo)}-${pad22(d)}`;
|
|
422
|
+
return new Date(Date.UTC(y, mo - 1, d)).toISOString();
|
|
423
|
+
}
|
|
424
|
+
const parsed = new Date(s);
|
|
425
|
+
if (Number.isNaN(parsed.getTime())) return void 0;
|
|
426
|
+
if (kind === "date") {
|
|
427
|
+
return `${parsed.getUTCFullYear()}-${pad22(parsed.getUTCMonth() + 1)}-${pad22(parsed.getUTCDate())}`;
|
|
428
|
+
}
|
|
429
|
+
return parsed.toISOString();
|
|
430
|
+
}
|
|
431
|
+
function matchOption(raw, options) {
|
|
432
|
+
const s = String(raw).trim();
|
|
433
|
+
if (!options || options.length === 0) return s;
|
|
434
|
+
for (const o of options) {
|
|
435
|
+
if (o && o.value !== void 0 && String(o.value) === s) return o.value;
|
|
436
|
+
}
|
|
437
|
+
const lower = s.toLowerCase();
|
|
438
|
+
for (const o of options) {
|
|
439
|
+
if (o && typeof o.label === "string" && o.label.trim().toLowerCase() === lower) return o.value;
|
|
440
|
+
}
|
|
441
|
+
return void 0;
|
|
442
|
+
}
|
|
443
|
+
function splitMulti(raw) {
|
|
444
|
+
if (Array.isArray(raw)) return raw.map((v) => String(v).trim()).filter((v) => v !== "");
|
|
445
|
+
return String(raw).split(/[,;、\n]/).map((v) => v.trim()).filter((v) => v !== "");
|
|
446
|
+
}
|
|
447
|
+
async function coerceFieldValue(raw, meta, ctx) {
|
|
448
|
+
const trim = ctx.trimWhitespace !== false;
|
|
449
|
+
const field = meta?.name ?? "";
|
|
450
|
+
if (isBlank(raw, ctx.nullValues)) return { value: void 0 };
|
|
451
|
+
const t = meta?.type;
|
|
452
|
+
if (!t) return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
453
|
+
if (BOOL_TYPES.has(t)) {
|
|
454
|
+
const b = parseBooleanCell(raw);
|
|
455
|
+
if (b === void 0) return { error: { field, code: "invalid_boolean", message: `${field}: "${String(raw)}" is not a boolean` } };
|
|
456
|
+
return { value: b };
|
|
457
|
+
}
|
|
458
|
+
if (NUMBER_TYPES.has(t)) {
|
|
459
|
+
const n = parseNumberCell(raw);
|
|
460
|
+
if (n === void 0) return { error: { field, code: "invalid_number", message: `${field}: "${String(raw)}" is not a number` } };
|
|
461
|
+
return { value: n };
|
|
462
|
+
}
|
|
463
|
+
if (t === "date" || t === "datetime" || t === "time") {
|
|
464
|
+
const d = parseDateCell(raw, t);
|
|
465
|
+
if (d === void 0) return { error: { field, code: "invalid_date", message: `${field}: "${String(raw)}" is not a valid ${t}` } };
|
|
466
|
+
return { value: d };
|
|
467
|
+
}
|
|
468
|
+
if (OPTION_TYPES2.has(t)) {
|
|
469
|
+
const v = matchOption(raw, meta?.options);
|
|
470
|
+
if (v === void 0) {
|
|
471
|
+
if (ctx.createMissingOptions) return { value: String(raw).trim() };
|
|
472
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${String(raw)}" is not a known option` } };
|
|
473
|
+
}
|
|
474
|
+
return { value: v };
|
|
475
|
+
}
|
|
476
|
+
if (MULTI_OPTION_TYPES2.has(t)) {
|
|
477
|
+
const parts = splitMulti(raw);
|
|
478
|
+
const out = [];
|
|
479
|
+
for (const part of parts) {
|
|
480
|
+
const v = matchOption(part, meta?.options);
|
|
481
|
+
if (v === void 0) {
|
|
482
|
+
if (ctx.createMissingOptions) {
|
|
483
|
+
out.push(part);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${part}" is not a known option` } };
|
|
487
|
+
}
|
|
488
|
+
out.push(v);
|
|
489
|
+
}
|
|
490
|
+
return { value: out };
|
|
491
|
+
}
|
|
492
|
+
if (REFERENCE_TYPES2.has(t)) {
|
|
493
|
+
const display = String(raw).trim();
|
|
494
|
+
if (!ctx.resolveRef || !meta?.reference) return { value: display };
|
|
495
|
+
const match = normalizeRefMatch(await ctx.resolveRef(meta.reference, display, meta));
|
|
496
|
+
if (match.ambiguous) {
|
|
497
|
+
return { error: { field, code: "reference_ambiguous", message: `${field}: "${display}" matches more than one ${meta.reference} \u2014 use a unique value or the record id` } };
|
|
498
|
+
}
|
|
499
|
+
if (match.id === void 0) {
|
|
500
|
+
return { error: { field, code: "reference_not_found", message: `${field}: no ${meta.reference} matches "${display}"` } };
|
|
501
|
+
}
|
|
502
|
+
return { value: match.id };
|
|
503
|
+
}
|
|
504
|
+
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
505
|
+
}
|
|
506
|
+
async function coerceRow(rawRow, metaMap, ctx) {
|
|
507
|
+
const data = {};
|
|
508
|
+
const errors = [];
|
|
509
|
+
for (const [key, raw] of Object.entries(rawRow)) {
|
|
510
|
+
const meta = metaMap.get(key);
|
|
511
|
+
const res = await coerceFieldValue(raw, meta ? meta : void 0, ctx);
|
|
512
|
+
if ("error" in res) {
|
|
513
|
+
errors.push({ ...res.error, field: res.error.field || key });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (res.value !== void 0) data[key] = res.value;
|
|
517
|
+
}
|
|
518
|
+
return { data, errors };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/import-runner.ts
|
|
522
|
+
function runImport(opts) {
|
|
523
|
+
const {
|
|
524
|
+
p,
|
|
525
|
+
objectName,
|
|
526
|
+
environmentId,
|
|
527
|
+
context,
|
|
528
|
+
rows,
|
|
529
|
+
metaMap,
|
|
530
|
+
writeMode,
|
|
531
|
+
matchFields,
|
|
532
|
+
dryRun,
|
|
533
|
+
runAutomations,
|
|
534
|
+
trimWhitespace,
|
|
535
|
+
nullValues,
|
|
536
|
+
createMissingOptions,
|
|
537
|
+
skipBlankMatchKey,
|
|
538
|
+
onProgress,
|
|
539
|
+
shouldCancel,
|
|
540
|
+
captureUndo
|
|
541
|
+
} = opts;
|
|
542
|
+
const collectUndo = !!captureUndo && !dryRun;
|
|
543
|
+
const undoLog = { created: [], updated: [] };
|
|
544
|
+
const captureBefore = (before, written) => {
|
|
545
|
+
const snap = {};
|
|
546
|
+
for (const k of Object.keys(written)) snap[k] = before[k] ?? null;
|
|
547
|
+
return snap;
|
|
548
|
+
};
|
|
549
|
+
const progressEvery = Math.max(1, opts.progressEvery ?? 200);
|
|
550
|
+
const findRows = (r) => Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
551
|
+
const findArgsBase = (query) => ({
|
|
552
|
+
object: "",
|
|
553
|
+
query,
|
|
554
|
+
...environmentId ? { environmentId } : {},
|
|
555
|
+
...context ? { context } : {}
|
|
556
|
+
});
|
|
557
|
+
const refCache = /* @__PURE__ */ new Map();
|
|
558
|
+
const resolveRef = async (referenceObject, display, meta) => {
|
|
559
|
+
const cacheKey = `${referenceObject}::${display}`;
|
|
560
|
+
const cached = refCache.get(cacheKey);
|
|
561
|
+
if (cached) return cached;
|
|
562
|
+
const candidates = [.../* @__PURE__ */ new Set([
|
|
563
|
+
"id",
|
|
564
|
+
...meta.displayField ? [meta.displayField] : [],
|
|
565
|
+
"name",
|
|
566
|
+
"title",
|
|
567
|
+
"label",
|
|
568
|
+
"full_name",
|
|
569
|
+
"email",
|
|
570
|
+
"username"
|
|
571
|
+
])];
|
|
572
|
+
let match = {};
|
|
573
|
+
for (const f of candidates) {
|
|
574
|
+
try {
|
|
575
|
+
const r = await p.findData({
|
|
576
|
+
...findArgsBase({ $filter: { [f]: display }, $top: 2 }),
|
|
577
|
+
object: referenceObject
|
|
578
|
+
});
|
|
579
|
+
const recs = findRows(r);
|
|
580
|
+
if (recs.length === 0) continue;
|
|
581
|
+
if (recs.length > 1) {
|
|
582
|
+
match = { ambiguous: true, matchedField: f };
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
if (recs[0]?.id != null) {
|
|
586
|
+
match = { id: String(recs[0].id), matchedField: f };
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
refCache.set(cacheKey, match);
|
|
593
|
+
return match;
|
|
594
|
+
};
|
|
595
|
+
const findExisting = async (data) => {
|
|
596
|
+
const filter = {};
|
|
597
|
+
for (const f of matchFields) {
|
|
598
|
+
const v = data[f];
|
|
599
|
+
if (v === void 0 || v === null || v === "") return "blank";
|
|
600
|
+
filter[f] = v;
|
|
601
|
+
}
|
|
602
|
+
const r = await p.findData({ ...findArgsBase({ $filter: filter, $top: 2 }), object: objectName });
|
|
603
|
+
const recs = findRows(r);
|
|
604
|
+
if (recs.length === 0) return "none";
|
|
605
|
+
if (recs.length > 1) return "ambiguous";
|
|
606
|
+
return recs[0];
|
|
607
|
+
};
|
|
608
|
+
const writeCtx = { ...context ?? {}, skipAutomations: !runAutomations };
|
|
609
|
+
const results = [];
|
|
610
|
+
let okCount = 0, errCount = 0, created = 0, updated = 0, skipped = 0;
|
|
611
|
+
let cancelled = false;
|
|
612
|
+
const snapshot = (processed) => ({
|
|
613
|
+
processed,
|
|
614
|
+
total: rows.length,
|
|
615
|
+
created,
|
|
616
|
+
updated,
|
|
617
|
+
skipped,
|
|
618
|
+
errors: errCount
|
|
619
|
+
});
|
|
620
|
+
return (async () => {
|
|
621
|
+
for (let i = 0; i < rows.length; i++) {
|
|
622
|
+
const rowNo = i + 1;
|
|
623
|
+
try {
|
|
624
|
+
const { data, errors } = await coerceRow(rows[i], metaMap, {
|
|
625
|
+
trimWhitespace,
|
|
626
|
+
nullValues,
|
|
627
|
+
createMissingOptions,
|
|
628
|
+
resolveRef
|
|
629
|
+
});
|
|
630
|
+
if (errors.length > 0) {
|
|
631
|
+
const first = errors[0];
|
|
632
|
+
errCount++;
|
|
633
|
+
results.push({ row: rowNo, ok: false, action: "failed", field: first.field, code: first.code, error: first.message });
|
|
634
|
+
} else {
|
|
635
|
+
let existing = "none";
|
|
636
|
+
let handled = false;
|
|
637
|
+
if (writeMode !== "insert") {
|
|
638
|
+
existing = await findExisting(data);
|
|
639
|
+
if (existing === "ambiguous") {
|
|
640
|
+
errCount++;
|
|
641
|
+
results.push({ row: rowNo, ok: false, action: "failed", code: "AMBIGUOUS_MATCH", error: `matchFields matched more than one ${objectName} record` });
|
|
642
|
+
handled = true;
|
|
643
|
+
} else if (existing === "blank" && (skipBlankMatchKey || writeMode === "update")) {
|
|
644
|
+
skipped++;
|
|
645
|
+
results.push({ row: rowNo, ok: true, action: "skipped", code: "BLANK_MATCH_KEY" });
|
|
646
|
+
handled = true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!handled) {
|
|
650
|
+
const willUpdate = existing && typeof existing === "object";
|
|
651
|
+
const willCreate = !willUpdate && (writeMode === "insert" || writeMode === "upsert");
|
|
652
|
+
if (!willUpdate && !willCreate) {
|
|
653
|
+
skipped++;
|
|
654
|
+
results.push({ row: rowNo, ok: true, action: "skipped", code: "NO_MATCH" });
|
|
655
|
+
} else if (dryRun) {
|
|
656
|
+
okCount++;
|
|
657
|
+
if (willUpdate) {
|
|
658
|
+
updated++;
|
|
659
|
+
results.push({ row: rowNo, ok: true, action: "updated", id: String(existing.id ?? "") || void 0 });
|
|
660
|
+
} else {
|
|
661
|
+
created++;
|
|
662
|
+
results.push({ row: rowNo, ok: true, action: "created" });
|
|
663
|
+
}
|
|
664
|
+
} else if (willUpdate) {
|
|
665
|
+
const target = existing;
|
|
666
|
+
const res2 = await p.updateData({ object: objectName, id: target.id, data, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
667
|
+
const id = res2?.id ?? res2?.record?.id ?? target.id;
|
|
668
|
+
okCount++;
|
|
669
|
+
updated++;
|
|
670
|
+
if (collectUndo && target.id != null) {
|
|
671
|
+
undoLog.updated.push({ id: String(target.id), before: captureBefore(target, data) });
|
|
672
|
+
}
|
|
673
|
+
results.push({ row: rowNo, ok: true, action: "updated", id: id != null ? String(id) : void 0 });
|
|
674
|
+
} else {
|
|
675
|
+
const res2 = await p.createData({ object: objectName, data, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
676
|
+
const id = res2?.id ?? res2?.record?.id;
|
|
677
|
+
okCount++;
|
|
678
|
+
created++;
|
|
679
|
+
if (collectUndo && id != null) undoLog.created.push(String(id));
|
|
680
|
+
results.push({ row: rowNo, ok: true, action: "created", id: id != null ? String(id) : void 0 });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} catch (err) {
|
|
685
|
+
errCount++;
|
|
686
|
+
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
687
|
+
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
688
|
+
results.push({ row: rowNo, ok: false, action: "failed", error: message, code });
|
|
689
|
+
}
|
|
690
|
+
const processed = i + 1;
|
|
691
|
+
if (onProgress && (processed % progressEvery === 0 || processed === rows.length)) {
|
|
692
|
+
await onProgress(snapshot(processed));
|
|
693
|
+
}
|
|
694
|
+
if (shouldCancel && processed < rows.length && processed % progressEvery === 0) {
|
|
695
|
+
if (await shouldCancel()) {
|
|
696
|
+
cancelled = true;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
...snapshot(results.length),
|
|
703
|
+
ok: okCount,
|
|
704
|
+
results,
|
|
705
|
+
cancelled,
|
|
706
|
+
...collectUndo ? { undoLog } : {}
|
|
707
|
+
};
|
|
708
|
+
})();
|
|
709
|
+
}
|
|
710
|
+
|
|
214
711
|
// src/rest-server.ts
|
|
215
712
|
var logError = (...args) => globalThis.console?.error(...args);
|
|
216
713
|
var TRANSLATABLE_META_TYPES = /* @__PURE__ */ new Set(["view", "action", "object", "app", "dashboard"]);
|
|
@@ -423,6 +920,212 @@ function parseCsvToRows(csv, mapping = {}) {
|
|
|
423
920
|
}
|
|
424
921
|
return out;
|
|
425
922
|
}
|
|
923
|
+
function xlsxCellToString(value) {
|
|
924
|
+
if (value === null || value === void 0) return "";
|
|
925
|
+
if (typeof value === "string") return value;
|
|
926
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
927
|
+
if (value instanceof Date) return value.toISOString();
|
|
928
|
+
if (typeof value === "object") {
|
|
929
|
+
if ("result" in value && value.result !== void 0 && value.result !== null) return xlsxCellToString(value.result);
|
|
930
|
+
if ("text" in value && typeof value.text === "string") return value.text;
|
|
931
|
+
if ("hyperlink" in value && typeof value.hyperlink === "string") return value.hyperlink;
|
|
932
|
+
if (Array.isArray(value.richText)) return value.richText.map((r) => r?.text ?? "").join("");
|
|
933
|
+
if ("error" in value && value.error) return String(value.error);
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
return String(value);
|
|
937
|
+
} catch {
|
|
938
|
+
return "";
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function parseXlsxToRows(buffer, mapping = {}, sheet) {
|
|
942
|
+
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
943
|
+
const wb = new ExcelJS.Workbook();
|
|
944
|
+
await wb.xlsx.load(buffer);
|
|
945
|
+
const ws = sheet !== void 0 ? wb.getWorksheet(sheet) : wb.worksheets[0];
|
|
946
|
+
if (!ws) return [];
|
|
947
|
+
const cells = [];
|
|
948
|
+
ws.eachRow({ includeEmpty: false }, (row) => {
|
|
949
|
+
const values = row.values;
|
|
950
|
+
const line = [];
|
|
951
|
+
for (let c = 1; c < values.length; c++) line.push(xlsxCellToString(values[c]));
|
|
952
|
+
cells.push(line);
|
|
953
|
+
});
|
|
954
|
+
while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
|
|
955
|
+
if (cells.length < 2) return [];
|
|
956
|
+
const header = cells[0].map((h) => h.trim());
|
|
957
|
+
const fields = header.map((h) => mapping[h] ?? h);
|
|
958
|
+
const out = [];
|
|
959
|
+
for (let r = 1; r < cells.length; r++) {
|
|
960
|
+
const line = cells[r];
|
|
961
|
+
const obj = {};
|
|
962
|
+
for (let c = 0; c < fields.length; c++) {
|
|
963
|
+
const key = fields[c];
|
|
964
|
+
if (!key) continue;
|
|
965
|
+
obj[key] = line[c] ?? "";
|
|
966
|
+
}
|
|
967
|
+
out.push(obj);
|
|
968
|
+
}
|
|
969
|
+
return out;
|
|
970
|
+
}
|
|
971
|
+
async function prepareImportRequest(body, opts) {
|
|
972
|
+
const { p, objectName, environmentId, maxRows } = opts;
|
|
973
|
+
const dryRun = body?.dryRun === true;
|
|
974
|
+
const writeMode = body?.writeMode === "update" || body?.writeMode === "upsert" ? body.writeMode : "insert";
|
|
975
|
+
const matchFields = Array.isArray(body?.matchFields) ? body.matchFields.filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
976
|
+
const runAutomations = body?.runAutomations === true;
|
|
977
|
+
const trimWhitespace = body?.trimWhitespace !== false;
|
|
978
|
+
const nullValues = Array.isArray(body?.nullValues) ? body.nullValues.filter((v) => typeof v === "string") : void 0;
|
|
979
|
+
const createMissingOptions = body?.createMissingOptions === true;
|
|
980
|
+
const skipBlankMatchKey = body?.skipBlankMatchKey === true;
|
|
981
|
+
if (writeMode !== "insert" && matchFields.length === 0) {
|
|
982
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: `writeMode "${writeMode}" requires a non-empty matchFields[]` };
|
|
983
|
+
}
|
|
984
|
+
const mapping = {};
|
|
985
|
+
if (Array.isArray(body?.mapping)) {
|
|
986
|
+
for (const e of body.mapping) {
|
|
987
|
+
if (e && typeof e.sourceField === "string" && typeof e.targetField === "string") {
|
|
988
|
+
mapping[e.sourceField] = e.targetField;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
} else if (body?.mapping && typeof body.mapping === "object") {
|
|
992
|
+
for (const [k, v] of Object.entries(body.mapping)) {
|
|
993
|
+
if (typeof v === "string") mapping[k] = v;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const applyMapping = (row) => {
|
|
997
|
+
if (Object.keys(mapping).length === 0) return row;
|
|
998
|
+
const out = {};
|
|
999
|
+
for (const [k, val] of Object.entries(row)) out[mapping[k] ?? k] = val;
|
|
1000
|
+
return out;
|
|
1001
|
+
};
|
|
1002
|
+
let rows = [];
|
|
1003
|
+
if (body?.format === "json" && Array.isArray(body.rows)) {
|
|
1004
|
+
rows = body.rows.map(applyMapping);
|
|
1005
|
+
} else if ((body?.format === "csv" || typeof body?.csv === "string") && typeof body?.csv === "string") {
|
|
1006
|
+
rows = parseCsvToRows(body.csv, mapping);
|
|
1007
|
+
} else if ((body?.format === "xlsx" || typeof body?.xlsxBase64 === "string") && typeof body?.xlsxBase64 === "string") {
|
|
1008
|
+
try {
|
|
1009
|
+
const buf = Buffer.from(body.xlsxBase64, "base64");
|
|
1010
|
+
rows = await parseXlsxToRows(buf, mapping, body.sheet);
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: `Failed to parse xlsx: ${e?.message ?? String(e)}` };
|
|
1013
|
+
}
|
|
1014
|
+
} else if (Array.isArray(body)) {
|
|
1015
|
+
rows = body.map(applyMapping);
|
|
1016
|
+
} else {
|
|
1017
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' };
|
|
1018
|
+
}
|
|
1019
|
+
if (rows.length > maxRows) {
|
|
1020
|
+
return { ok: false, status: 413, code: "PAYLOAD_TOO_LARGE", error: `Import limit is ${maxRows} rows per request (got ${rows.length}).` };
|
|
1021
|
+
}
|
|
1022
|
+
let metaMap = /* @__PURE__ */ new Map();
|
|
1023
|
+
try {
|
|
1024
|
+
let schema = void 0;
|
|
1025
|
+
if (typeof p.getMetaItem === "function") {
|
|
1026
|
+
const r = await p.getMetaItem({ type: "object", name: objectName });
|
|
1027
|
+
schema = isMetaEnvelope(r) ? r.item : r;
|
|
1028
|
+
}
|
|
1029
|
+
if (!schema && typeof p.getObjectSchema === "function") {
|
|
1030
|
+
schema = await p.getObjectSchema(objectName, environmentId);
|
|
1031
|
+
}
|
|
1032
|
+
metaMap = buildFieldMetaMap(schema);
|
|
1033
|
+
} catch {
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
prepared: {
|
|
1038
|
+
rows,
|
|
1039
|
+
metaMap,
|
|
1040
|
+
writeMode,
|
|
1041
|
+
matchFields,
|
|
1042
|
+
dryRun,
|
|
1043
|
+
runAutomations,
|
|
1044
|
+
trimWhitespace,
|
|
1045
|
+
nullValues,
|
|
1046
|
+
createMissingOptions,
|
|
1047
|
+
skipBlankMatchKey
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1052
|
+
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1053
|
+
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
1054
|
+
var IMPORT_JOB_UNDO_MAX_ROWS = 5e3;
|
|
1055
|
+
function newImportJobId() {
|
|
1056
|
+
return `imp_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
1057
|
+
}
|
|
1058
|
+
function capImportResults(results) {
|
|
1059
|
+
if (results.length <= IMPORT_JOB_RESULTS_CAP) return { items: results, truncated: false };
|
|
1060
|
+
const failures = results.filter((r) => !r.ok);
|
|
1061
|
+
const successes = results.filter((r) => r.ok);
|
|
1062
|
+
const items = [...failures, ...successes].slice(0, IMPORT_JOB_RESULTS_CAP);
|
|
1063
|
+
return { items, truncated: true };
|
|
1064
|
+
}
|
|
1065
|
+
function parseUndoLog(raw) {
|
|
1066
|
+
if (!raw) return void 0;
|
|
1067
|
+
let v = raw;
|
|
1068
|
+
if (typeof v === "string") {
|
|
1069
|
+
try {
|
|
1070
|
+
v = JSON.parse(v);
|
|
1071
|
+
} catch {
|
|
1072
|
+
return void 0;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (!v || typeof v !== "object") return void 0;
|
|
1076
|
+
const created = Array.isArray(v.created) ? v.created.map(String) : [];
|
|
1077
|
+
const updated = Array.isArray(v.updated) ? v.updated.filter((u) => u && u.id != null).map((u) => ({ id: String(u.id), before: u.before ?? {} })) : [];
|
|
1078
|
+
return { created, updated };
|
|
1079
|
+
}
|
|
1080
|
+
function importJobUndoable(row) {
|
|
1081
|
+
if (row?.reverted_at) return false;
|
|
1082
|
+
const status = String(row?.status ?? "");
|
|
1083
|
+
if (status !== "succeeded" && status !== "cancelled") return false;
|
|
1084
|
+
const log = parseUndoLog(row?.undo_log);
|
|
1085
|
+
return !!log && (log.created.length > 0 || log.updated.length > 0);
|
|
1086
|
+
}
|
|
1087
|
+
function importJobToProgress(row) {
|
|
1088
|
+
const total = Number(row?.total_rows ?? 0);
|
|
1089
|
+
const processed = Number(row?.processed_rows ?? 0);
|
|
1090
|
+
return {
|
|
1091
|
+
undoable: importJobUndoable(row),
|
|
1092
|
+
...row?.reverted_at ? { revertedAt: String(row.reverted_at) } : {},
|
|
1093
|
+
jobId: String(row?.id ?? ""),
|
|
1094
|
+
object: String(row?.object_name ?? ""),
|
|
1095
|
+
status: String(row?.status ?? "pending"),
|
|
1096
|
+
dryRun: !!row?.dry_run,
|
|
1097
|
+
writeMode: String(row?.write_mode ?? "insert"),
|
|
1098
|
+
total,
|
|
1099
|
+
processed,
|
|
1100
|
+
created: Number(row?.created_count ?? 0),
|
|
1101
|
+
updated: Number(row?.updated_count ?? 0),
|
|
1102
|
+
skipped: Number(row?.skipped_count ?? 0),
|
|
1103
|
+
errors: Number(row?.error_count ?? 0),
|
|
1104
|
+
percentComplete: total > 0 ? Math.min(100, Math.round(processed / total * 100)) : processed > 0 ? 100 : 0,
|
|
1105
|
+
...row?.error ? { error: String(row.error) } : {},
|
|
1106
|
+
...row?.started_at ? { startedAt: String(row.started_at) } : {},
|
|
1107
|
+
...row?.completed_at ? { completedAt: String(row.completed_at) } : {},
|
|
1108
|
+
createdAt: String(row?.created_at ?? "")
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function importJobToSummary(row) {
|
|
1112
|
+
const p = importJobToProgress(row);
|
|
1113
|
+
return {
|
|
1114
|
+
jobId: p.jobId,
|
|
1115
|
+
object: p.object,
|
|
1116
|
+
status: p.status,
|
|
1117
|
+
total: p.total,
|
|
1118
|
+
processed: p.processed,
|
|
1119
|
+
created: p.created,
|
|
1120
|
+
updated: p.updated,
|
|
1121
|
+
skipped: p.skipped,
|
|
1122
|
+
errors: p.errors,
|
|
1123
|
+
createdAt: p.createdAt,
|
|
1124
|
+
undoable: p.undoable,
|
|
1125
|
+
...p.completedAt ? { completedAt: p.completedAt } : {},
|
|
1126
|
+
...p.revertedAt ? { revertedAt: p.revertedAt } : {}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
426
1129
|
function formatCsvCell(value) {
|
|
427
1130
|
if (value === null || value === void 0) return "";
|
|
428
1131
|
let s;
|
|
@@ -441,14 +1144,42 @@ function formatCsvCell(value) {
|
|
|
441
1144
|
}
|
|
442
1145
|
return s;
|
|
443
1146
|
}
|
|
444
|
-
function rowsToCsv(fields, rows, includeHeader) {
|
|
1147
|
+
function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
445
1148
|
const lines = [];
|
|
446
|
-
if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
|
|
1149
|
+
if (includeHeader) lines.push(fields.map((f) => formatCsvCell(headerLabel(f, metaMap))).join(","));
|
|
447
1150
|
for (const row of rows) {
|
|
448
|
-
lines.push(fields.map(
|
|
1151
|
+
lines.push(formatRowCells(row, fields, metaMap).map(formatCsvCell).join(","));
|
|
449
1152
|
}
|
|
450
1153
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
451
1154
|
}
|
|
1155
|
+
async function createXlsxStream(res) {
|
|
1156
|
+
const { PassThrough } = await import("stream");
|
|
1157
|
+
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1158
|
+
const passthrough = new PassThrough();
|
|
1159
|
+
const done = new Promise((resolve, reject) => {
|
|
1160
|
+
passthrough.on("data", (chunk) => {
|
|
1161
|
+
res.write(chunk);
|
|
1162
|
+
});
|
|
1163
|
+
passthrough.on("end", () => {
|
|
1164
|
+
try {
|
|
1165
|
+
res.end();
|
|
1166
|
+
} catch {
|
|
1167
|
+
}
|
|
1168
|
+
resolve();
|
|
1169
|
+
});
|
|
1170
|
+
passthrough.on("error", reject);
|
|
1171
|
+
});
|
|
1172
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles: false });
|
|
1173
|
+
const ws = wb.addWorksheet("Export");
|
|
1174
|
+
return {
|
|
1175
|
+
ws,
|
|
1176
|
+
finalize: async () => {
|
|
1177
|
+
await ws.commit();
|
|
1178
|
+
await wb.commit();
|
|
1179
|
+
await done;
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
452
1183
|
var RestServer = class {
|
|
453
1184
|
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
454
1185
|
/**
|
|
@@ -473,6 +1204,13 @@ var RestServer = class {
|
|
|
473
1204
|
* in-flight Promise so concurrent callers share one resolution.
|
|
474
1205
|
*/
|
|
475
1206
|
this.execCtxMemo = /* @__PURE__ */ new WeakMap();
|
|
1207
|
+
/**
|
|
1208
|
+
* In-flight async import jobs the caller has asked to cancel. The worker
|
|
1209
|
+
* checks membership at each progress boundary and stops cooperatively. This
|
|
1210
|
+
* is process-local (single-node); the persisted `sys_import_job.status` is
|
|
1211
|
+
* the durable source of truth a restarted/other node reads.
|
|
1212
|
+
*/
|
|
1213
|
+
this.cancelledImportJobs = /* @__PURE__ */ new Set();
|
|
476
1214
|
/**
|
|
477
1215
|
* Lazily load the OpenAPI spec JSON shipped by @objectstack/spec.
|
|
478
1216
|
* Cached after first read. Resilient to missing files / parse errors
|
|
@@ -2467,63 +3205,31 @@ var RestServer = class {
|
|
|
2467
3205
|
}
|
|
2468
3206
|
if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
|
|
2469
3207
|
const body = req.body ?? {};
|
|
2470
|
-
const
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
rows = body.rows;
|
|
2475
|
-
} else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
|
|
2476
|
-
rows = parseCsvToRows(body.csv, mapping);
|
|
2477
|
-
} else if (Array.isArray(body)) {
|
|
2478
|
-
rows = body;
|
|
2479
|
-
} else {
|
|
2480
|
-
res.status(400).json({
|
|
2481
|
-
code: "INVALID_REQUEST",
|
|
2482
|
-
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
|
|
2483
|
-
});
|
|
3208
|
+
const prep = await prepareImportRequest(body, { p, objectName, environmentId, maxRows: 5e3 });
|
|
3209
|
+
if (!prep.ok) {
|
|
3210
|
+
if (prep.status === 413) prep.error += " Use an async import job for larger files.";
|
|
3211
|
+
res.status(prep.status).json({ code: prep.code, error: prep.error });
|
|
2484
3212
|
return;
|
|
2485
3213
|
}
|
|
2486
|
-
const
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
}
|
|
2494
|
-
const results = [];
|
|
2495
|
-
let okCount = 0;
|
|
2496
|
-
let errCount = 0;
|
|
2497
|
-
for (let i = 0; i < rows.length; i++) {
|
|
2498
|
-
const data = rows[i];
|
|
2499
|
-
try {
|
|
2500
|
-
if (dryRun) {
|
|
2501
|
-
const validate = p.validate;
|
|
2502
|
-
if (typeof validate === "function") {
|
|
2503
|
-
await validate.call(p, { object: objectName, data, context });
|
|
2504
|
-
}
|
|
2505
|
-
results.push({ row: i + 1, ok: true });
|
|
2506
|
-
okCount++;
|
|
2507
|
-
} else {
|
|
2508
|
-
const created = await p.createData({ object: objectName, data, context });
|
|
2509
|
-
const id = created?.id ?? created?.record?.id;
|
|
2510
|
-
results.push({ row: i + 1, ok: true, id });
|
|
2511
|
-
okCount++;
|
|
2512
|
-
}
|
|
2513
|
-
} catch (err) {
|
|
2514
|
-
errCount++;
|
|
2515
|
-
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
2516
|
-
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
2517
|
-
results.push({ row: i + 1, ok: false, error: message, code });
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
3214
|
+
const { rows, writeMode, dryRun } = prep.prepared;
|
|
3215
|
+
const summary = await runImport({
|
|
3216
|
+
p,
|
|
3217
|
+
objectName,
|
|
3218
|
+
environmentId,
|
|
3219
|
+
context,
|
|
3220
|
+
...prep.prepared
|
|
3221
|
+
});
|
|
2520
3222
|
res.json({
|
|
2521
3223
|
object: objectName,
|
|
2522
3224
|
dryRun,
|
|
3225
|
+
writeMode,
|
|
2523
3226
|
total: rows.length,
|
|
2524
|
-
ok:
|
|
2525
|
-
errors:
|
|
2526
|
-
|
|
3227
|
+
ok: summary.ok,
|
|
3228
|
+
errors: summary.errors,
|
|
3229
|
+
created: summary.created,
|
|
3230
|
+
updated: summary.updated,
|
|
3231
|
+
skipped: summary.skipped,
|
|
3232
|
+
results: summary.results
|
|
2527
3233
|
});
|
|
2528
3234
|
} catch (error) {
|
|
2529
3235
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2535,6 +3241,291 @@ var RestServer = class {
|
|
|
2535
3241
|
tags: ["data", "import"]
|
|
2536
3242
|
}
|
|
2537
3243
|
});
|
|
3244
|
+
this.routeManager.register({
|
|
3245
|
+
method: "POST",
|
|
3246
|
+
path: `${dataPath}/:object/import/jobs`,
|
|
3247
|
+
handler: async (req, res) => {
|
|
3248
|
+
try {
|
|
3249
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3250
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3251
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3252
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3253
|
+
const objectName = String(req.params.object || "");
|
|
3254
|
+
if (!objectName) {
|
|
3255
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
|
|
3259
|
+
const prep = await prepareImportRequest(req.body ?? {}, { p, objectName, environmentId, maxRows: IMPORT_JOB_MAX_ROWS });
|
|
3260
|
+
if (!prep.ok) {
|
|
3261
|
+
if (prep.status === 413) prep.error += ` This is the async import ceiling; split the file into batches of ${IMPORT_JOB_MAX_ROWS}.`;
|
|
3262
|
+
res.status(prep.status).json({ code: prep.code, error: prep.error });
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3265
|
+
const prepared = prep.prepared;
|
|
3266
|
+
const jobId = newImportJobId();
|
|
3267
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3268
|
+
const createdBy = String(context?.userId ?? context?.user?.id ?? "") || void 0;
|
|
3269
|
+
const jobRow = {
|
|
3270
|
+
id: jobId,
|
|
3271
|
+
object_name: objectName,
|
|
3272
|
+
status: "pending",
|
|
3273
|
+
total_rows: prepared.rows.length,
|
|
3274
|
+
processed_rows: 0,
|
|
3275
|
+
created_count: 0,
|
|
3276
|
+
updated_count: 0,
|
|
3277
|
+
skipped_count: 0,
|
|
3278
|
+
error_count: 0,
|
|
3279
|
+
write_mode: prepared.writeMode,
|
|
3280
|
+
dry_run: prepared.dryRun,
|
|
3281
|
+
run_automations: prepared.runAutomations,
|
|
3282
|
+
created_at: createdAt,
|
|
3283
|
+
...createdBy ? { created_by: createdBy } : {}
|
|
3284
|
+
};
|
|
3285
|
+
try {
|
|
3286
|
+
await p.createData({ object: IMPORT_JOB_OBJECT, data: jobRow, context, ...environmentId ? { environmentId } : {} });
|
|
3287
|
+
} catch (err) {
|
|
3288
|
+
logError("[REST] Failed to persist import job:", err);
|
|
3289
|
+
res.status(500).json({ code: "IMPORT_JOB_CREATE_FAILED", error: "Could not create import job" });
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
res.status(201).json({ jobId, object: objectName, status: "pending", total: prepared.rows.length, createdAt });
|
|
3293
|
+
const patch = async (data) => {
|
|
3294
|
+
try {
|
|
3295
|
+
await p.updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data, context, ...environmentId ? { environmentId } : {} });
|
|
3296
|
+
} catch (err) {
|
|
3297
|
+
logError("[REST] import job progress write failed:", err);
|
|
3298
|
+
}
|
|
3299
|
+
};
|
|
3300
|
+
const captureUndo = !prepared.dryRun && prepared.rows.length <= IMPORT_JOB_UNDO_MAX_ROWS;
|
|
3301
|
+
void (async () => {
|
|
3302
|
+
await patch({ status: "running", started_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3303
|
+
try {
|
|
3304
|
+
const summary = await runImport({
|
|
3305
|
+
p,
|
|
3306
|
+
objectName,
|
|
3307
|
+
environmentId,
|
|
3308
|
+
context,
|
|
3309
|
+
...prepared,
|
|
3310
|
+
captureUndo,
|
|
3311
|
+
progressEvery: 200,
|
|
3312
|
+
onProgress: (pr) => patch({
|
|
3313
|
+
processed_rows: pr.processed,
|
|
3314
|
+
created_count: pr.created,
|
|
3315
|
+
updated_count: pr.updated,
|
|
3316
|
+
skipped_count: pr.skipped,
|
|
3317
|
+
error_count: pr.errors
|
|
3318
|
+
}),
|
|
3319
|
+
shouldCancel: () => this.cancelledImportJobs.has(jobId)
|
|
3320
|
+
});
|
|
3321
|
+
await patch({
|
|
3322
|
+
status: summary.cancelled ? "cancelled" : "succeeded",
|
|
3323
|
+
processed_rows: summary.processed,
|
|
3324
|
+
created_count: summary.created,
|
|
3325
|
+
updated_count: summary.updated,
|
|
3326
|
+
skipped_count: summary.skipped,
|
|
3327
|
+
error_count: summary.errors,
|
|
3328
|
+
results: capImportResults(summary.results),
|
|
3329
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3330
|
+
...summary.undoLog ? { undo_log: summary.undoLog } : {}
|
|
3331
|
+
});
|
|
3332
|
+
} catch (err) {
|
|
3333
|
+
await patch({
|
|
3334
|
+
status: "failed",
|
|
3335
|
+
error: String(err?.message ?? err).slice(0, 1e3),
|
|
3336
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3337
|
+
});
|
|
3338
|
+
} finally {
|
|
3339
|
+
this.cancelledImportJobs.delete(jobId);
|
|
3340
|
+
}
|
|
3341
|
+
})();
|
|
3342
|
+
} catch (error) {
|
|
3343
|
+
logError("[REST] Unhandled error:", error);
|
|
3344
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
3345
|
+
}
|
|
3346
|
+
},
|
|
3347
|
+
metadata: {
|
|
3348
|
+
summary: "Create an asynchronous import job (large files, up to 50k rows)",
|
|
3349
|
+
tags: ["data", "import"]
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3353
|
+
const r = await p.findData({
|
|
3354
|
+
object: IMPORT_JOB_OBJECT,
|
|
3355
|
+
query: { $filter: { id: jobId }, $top: 1 },
|
|
3356
|
+
...environmentId ? { environmentId } : {},
|
|
3357
|
+
...context ? { context } : {}
|
|
3358
|
+
});
|
|
3359
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3360
|
+
return rows[0];
|
|
3361
|
+
};
|
|
3362
|
+
this.routeManager.register({
|
|
3363
|
+
method: "POST",
|
|
3364
|
+
path: `${dataPath}/import/jobs/:jobId/cancel`,
|
|
3365
|
+
handler: async (req, res) => {
|
|
3366
|
+
try {
|
|
3367
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3368
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3369
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3370
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3371
|
+
const jobId = String(req.params.jobId || "");
|
|
3372
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3373
|
+
if (!row) {
|
|
3374
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
const status = String(row.status ?? "");
|
|
3378
|
+
if (status === "pending" || status === "running") {
|
|
3379
|
+
this.cancelledImportJobs.add(jobId);
|
|
3380
|
+
try {
|
|
3381
|
+
await p.updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { status: "cancelled", completed_at: (/* @__PURE__ */ new Date()).toISOString() }, context, ...environmentId ? { environmentId } : {} });
|
|
3382
|
+
} catch {
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
res.json({ success: true });
|
|
3386
|
+
} catch (error) {
|
|
3387
|
+
logError("[REST] Unhandled error:", error);
|
|
3388
|
+
sendError(res, error, "");
|
|
3389
|
+
}
|
|
3390
|
+
},
|
|
3391
|
+
metadata: { summary: "Cancel an in-flight import job", tags: ["data", "import"] }
|
|
3392
|
+
});
|
|
3393
|
+
this.routeManager.register({
|
|
3394
|
+
method: "POST",
|
|
3395
|
+
path: `${dataPath}/import/jobs/:jobId/undo`,
|
|
3396
|
+
handler: async (req, res) => {
|
|
3397
|
+
try {
|
|
3398
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3399
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3400
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3401
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3402
|
+
const jobId = String(req.params.jobId || "");
|
|
3403
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3404
|
+
if (!row) {
|
|
3405
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
if (row.reverted_at) {
|
|
3409
|
+
res.status(409).json({ code: "ALREADY_REVERTED", error: "This import has already been undone" });
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
if (!importJobUndoable(row)) {
|
|
3413
|
+
res.status(422).json({ code: "NOT_UNDOABLE", error: "This import cannot be undone (too large, still running, or nothing was written)" });
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
const objectName = String(row.object_name ?? "");
|
|
3417
|
+
const log = parseUndoLog(row.undo_log);
|
|
3418
|
+
const writeCtx = { ...context ?? {}, skipAutomations: true };
|
|
3419
|
+
let deleted = 0, restored = 0, failed = 0;
|
|
3420
|
+
for (const id of log.created) {
|
|
3421
|
+
try {
|
|
3422
|
+
await p.deleteData({ object: objectName, id, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
3423
|
+
deleted++;
|
|
3424
|
+
} catch {
|
|
3425
|
+
failed++;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
for (const u of log.updated) {
|
|
3429
|
+
try {
|
|
3430
|
+
await p.updateData({ object: objectName, id: u.id, data: u.before, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
3431
|
+
restored++;
|
|
3432
|
+
} catch {
|
|
3433
|
+
failed++;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
await p.updateData({
|
|
3437
|
+
object: IMPORT_JOB_OBJECT,
|
|
3438
|
+
id: jobId,
|
|
3439
|
+
data: { reverted_at: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3440
|
+
context,
|
|
3441
|
+
...environmentId ? { environmentId } : {}
|
|
3442
|
+
});
|
|
3443
|
+
res.json({ success: true, jobId, object: objectName, deleted, restored, failed });
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
logError("[REST] Unhandled error:", error);
|
|
3446
|
+
sendError(res, error, "");
|
|
3447
|
+
}
|
|
3448
|
+
},
|
|
3449
|
+
metadata: { summary: "Undo (logically roll back) a finished import job", tags: ["data", "import"] }
|
|
3450
|
+
});
|
|
3451
|
+
this.routeManager.register({
|
|
3452
|
+
method: "GET",
|
|
3453
|
+
path: `${dataPath}/import/jobs/:jobId/results`,
|
|
3454
|
+
handler: async (req, res) => {
|
|
3455
|
+
try {
|
|
3456
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3457
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3458
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3459
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3460
|
+
const jobId = String(req.params.jobId || "");
|
|
3461
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3462
|
+
if (!row) {
|
|
3463
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const stored = row.results;
|
|
3467
|
+
const items = Array.isArray(stored?.items) ? stored.items : Array.isArray(stored) ? stored : [];
|
|
3468
|
+
res.json({ ...importJobToProgress(row), results: items, resultsTruncated: !!stored?.truncated });
|
|
3469
|
+
} catch (error) {
|
|
3470
|
+
logError("[REST] Unhandled error:", error);
|
|
3471
|
+
sendError(res, error, "");
|
|
3472
|
+
}
|
|
3473
|
+
},
|
|
3474
|
+
metadata: { summary: "Import job results (capped per-row report)", tags: ["data", "import"] }
|
|
3475
|
+
});
|
|
3476
|
+
this.routeManager.register({
|
|
3477
|
+
method: "GET",
|
|
3478
|
+
path: `${dataPath}/import/jobs/:jobId`,
|
|
3479
|
+
handler: async (req, res) => {
|
|
3480
|
+
try {
|
|
3481
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3482
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3483
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3484
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3485
|
+
const jobId = String(req.params.jobId || "");
|
|
3486
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3487
|
+
if (!row) {
|
|
3488
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
res.json(importJobToProgress(row));
|
|
3492
|
+
} catch (error) {
|
|
3493
|
+
logError("[REST] Unhandled error:", error);
|
|
3494
|
+
sendError(res, error, "");
|
|
3495
|
+
}
|
|
3496
|
+
},
|
|
3497
|
+
metadata: { summary: "Import job progress", tags: ["data", "import"] }
|
|
3498
|
+
});
|
|
3499
|
+
this.routeManager.register({
|
|
3500
|
+
method: "GET",
|
|
3501
|
+
path: `${dataPath}/import/jobs`,
|
|
3502
|
+
handler: async (req, res) => {
|
|
3503
|
+
try {
|
|
3504
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3505
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3506
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3507
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3508
|
+
const q = req.query ?? {};
|
|
3509
|
+
const filter = {};
|
|
3510
|
+
if (typeof q.object === "string" && q.object) filter.object_name = q.object;
|
|
3511
|
+
if (typeof q.status === "string" && q.status) filter.status = q.status;
|
|
3512
|
+
const limit = Math.min(200, Math.max(1, Number(q.limit) || 50));
|
|
3513
|
+
const offset = Math.max(0, Number(q.offset) || 0);
|
|
3514
|
+
const r = await p.findData({
|
|
3515
|
+
object: IMPORT_JOB_OBJECT,
|
|
3516
|
+
query: { $filter: filter, $orderby: { created_at: "desc" }, $top: limit, $skip: offset },
|
|
3517
|
+
...environmentId ? { environmentId } : {},
|
|
3518
|
+
...context ? { context } : {}
|
|
3519
|
+
});
|
|
3520
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3521
|
+
res.json({ jobs: rows.map(importJobToSummary) });
|
|
3522
|
+
} catch (error) {
|
|
3523
|
+
logError("[REST] Unhandled error:", error);
|
|
3524
|
+
sendError(res, error, "");
|
|
3525
|
+
}
|
|
3526
|
+
},
|
|
3527
|
+
metadata: { summary: "List import jobs (history)", tags: ["data", "import"] }
|
|
3528
|
+
});
|
|
2538
3529
|
this.routeManager.register({
|
|
2539
3530
|
method: "GET",
|
|
2540
3531
|
path: `${dataPath}/:object/export`,
|
|
@@ -2551,7 +3542,9 @@ var RestServer = class {
|
|
|
2551
3542
|
}
|
|
2552
3543
|
if (await this.enforceApiAccess(req, res, p, environmentId, "export")) return;
|
|
2553
3544
|
const q = req.query ?? {};
|
|
2554
|
-
const
|
|
3545
|
+
const fmtRaw = String(q.format ?? "csv").toLowerCase();
|
|
3546
|
+
const format = fmtRaw === "json" ? "json" : fmtRaw === "xlsx" ? "xlsx" : "csv";
|
|
3547
|
+
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
2555
3548
|
const HARD_CAP = 5e4;
|
|
2556
3549
|
const MAX_CHUNK = 5e3;
|
|
2557
3550
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
@@ -2590,21 +3583,33 @@ var RestServer = class {
|
|
|
2590
3583
|
} else if (Array.isArray(q.fields)) {
|
|
2591
3584
|
fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
|
|
2592
3585
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
3586
|
+
let metaMap = /* @__PURE__ */ new Map();
|
|
3587
|
+
try {
|
|
3588
|
+
let schema = void 0;
|
|
3589
|
+
if (typeof p.getMetaItem === "function") {
|
|
3590
|
+
const res2 = await p.getMetaItem({ type: "object", name: objectName });
|
|
3591
|
+
schema = isMetaEnvelope(res2) ? res2.item : res2;
|
|
3592
|
+
}
|
|
3593
|
+
if (!schema && typeof p.getObjectSchema === "function") {
|
|
3594
|
+
schema = await p.getObjectSchema(objectName, environmentId);
|
|
2601
3595
|
}
|
|
3596
|
+
schema = await this.translateMetaItem(req, "object", environmentId, schema);
|
|
3597
|
+
metaMap = buildFieldMetaMap(schema);
|
|
3598
|
+
if (!fields || fields.length === 0) {
|
|
3599
|
+
const names = [...metaMap.keys()];
|
|
3600
|
+
if (names.length > 0) fields = names;
|
|
3601
|
+
}
|
|
3602
|
+
} catch {
|
|
2602
3603
|
}
|
|
3604
|
+
const expandFields = referenceFieldNames(metaMap);
|
|
2603
3605
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2604
3606
|
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
2605
3607
|
if (format === "csv") {
|
|
2606
3608
|
res.header("Content-Type", "text/csv; charset=utf-8");
|
|
2607
3609
|
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
|
|
3610
|
+
} else if (format === "xlsx") {
|
|
3611
|
+
res.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
3612
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.xlsx"`);
|
|
2608
3613
|
} else {
|
|
2609
3614
|
res.header("Content-Type", "application/json; charset=utf-8");
|
|
2610
3615
|
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
|
|
@@ -2616,6 +3621,7 @@ var RestServer = class {
|
|
|
2616
3621
|
let firstChunk = true;
|
|
2617
3622
|
let skip = 0;
|
|
2618
3623
|
if (format === "json") res.write("[");
|
|
3624
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
2619
3625
|
while (exported < limit) {
|
|
2620
3626
|
const take = Math.min(chunkSize, limit - exported);
|
|
2621
3627
|
const findArgs = {
|
|
@@ -2623,6 +3629,7 @@ var RestServer = class {
|
|
|
2623
3629
|
query: {
|
|
2624
3630
|
...filter ? { $filter: filter } : {},
|
|
2625
3631
|
...orderby ? { $orderby: orderby } : {},
|
|
3632
|
+
...expandFields.length > 0 ? { $expand: expandFields.join(",") } : {},
|
|
2626
3633
|
$top: take,
|
|
2627
3634
|
$skip: skip
|
|
2628
3635
|
},
|
|
@@ -2630,18 +3637,25 @@ var RestServer = class {
|
|
|
2630
3637
|
...context ? { context } : {}
|
|
2631
3638
|
};
|
|
2632
3639
|
const result = await p.findData(findArgs);
|
|
2633
|
-
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
3640
|
+
const rows = Array.isArray(result?.records) ? result.records : Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
2634
3641
|
if (rows.length === 0) break;
|
|
3642
|
+
if ((!fields || fields.length === 0) && firstChunk) {
|
|
3643
|
+
fields = Object.keys(rows[0] ?? {});
|
|
3644
|
+
}
|
|
2635
3645
|
if (format === "csv") {
|
|
2636
|
-
|
|
2637
|
-
fields = Object.keys(rows[0] ?? {});
|
|
2638
|
-
}
|
|
2639
|
-
const text = rowsToCsv(fields ?? [], rows, firstChunk);
|
|
3646
|
+
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap);
|
|
2640
3647
|
res.write(text);
|
|
3648
|
+
} else if (format === "xlsx") {
|
|
3649
|
+
if (firstChunk && includeHeader) {
|
|
3650
|
+
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3651
|
+
}
|
|
3652
|
+
for (const row of rows) {
|
|
3653
|
+
xlsx.ws.addRow(formatRowCells(row, fields ?? [], metaMap)).commit();
|
|
3654
|
+
}
|
|
2641
3655
|
} else {
|
|
2642
3656
|
for (let i = 0; i < rows.length; i++) {
|
|
2643
3657
|
const prefix = firstChunk && i === 0 ? "" : ",";
|
|
2644
|
-
res.write(prefix + JSON.stringify(rows[i]));
|
|
3658
|
+
res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap)));
|
|
2645
3659
|
}
|
|
2646
3660
|
}
|
|
2647
3661
|
firstChunk = false;
|
|
@@ -2649,8 +3663,14 @@ var RestServer = class {
|
|
|
2649
3663
|
skip += rows.length;
|
|
2650
3664
|
if (rows.length < take) break;
|
|
2651
3665
|
}
|
|
2652
|
-
if (format === "json")
|
|
2653
|
-
|
|
3666
|
+
if (format === "json") {
|
|
3667
|
+
res.write("]");
|
|
3668
|
+
res.end();
|
|
3669
|
+
} else if (format === "xlsx") {
|
|
3670
|
+
await xlsx.finalize();
|
|
3671
|
+
} else {
|
|
3672
|
+
res.end();
|
|
3673
|
+
}
|
|
2654
3674
|
} catch (error) {
|
|
2655
3675
|
logError("[REST] Unhandled error:", error);
|
|
2656
3676
|
try {
|
|
@@ -2664,7 +3684,7 @@ var RestServer = class {
|
|
|
2664
3684
|
}
|
|
2665
3685
|
},
|
|
2666
3686
|
metadata: {
|
|
2667
|
-
summary: "Streaming export of object rows (CSV or
|
|
3687
|
+
summary: "Streaming export of object rows (CSV, JSON, or XLSX)",
|
|
2668
3688
|
tags: ["data", "export"]
|
|
2669
3689
|
}
|
|
2670
3690
|
});
|
|
@@ -4374,11 +5394,26 @@ function registerExternalDatasourceRoutes(server, ctx, basePath = "/api/v1") {
|
|
|
4374
5394
|
}
|
|
4375
5395
|
|
|
4376
5396
|
// src/rest-api-plugin.ts
|
|
5397
|
+
import { SysImportJob } from "@objectstack/platform-objects/audit";
|
|
4377
5398
|
function createRestApiPlugin(config = {}) {
|
|
4378
5399
|
return {
|
|
4379
5400
|
name: "com.objectstack.rest.api",
|
|
4380
5401
|
version: "1.0.0",
|
|
4381
|
-
init: async (
|
|
5402
|
+
init: async (ctx) => {
|
|
5403
|
+
try {
|
|
5404
|
+
ctx.getService("manifest").register({
|
|
5405
|
+
id: "com.objectstack.rest.api",
|
|
5406
|
+
name: "REST API",
|
|
5407
|
+
version: "1.0.0",
|
|
5408
|
+
type: "plugin",
|
|
5409
|
+
scope: "system",
|
|
5410
|
+
defaultDatasource: "cloud",
|
|
5411
|
+
namespace: "sys",
|
|
5412
|
+
objects: [SysImportJob]
|
|
5413
|
+
});
|
|
5414
|
+
} catch (err) {
|
|
5415
|
+
ctx.logger.warn("RestApiPlugin: manifest service unavailable; sys_import_job not registered", err);
|
|
5416
|
+
}
|
|
4382
5417
|
},
|
|
4383
5418
|
start: async (ctx) => {
|
|
4384
5419
|
const serverService = config.serverServiceName || "http.server";
|