@objectstack/rest 11.4.0 → 11.6.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.cjs
CHANGED
|
@@ -250,6 +250,503 @@ var RouteGroupBuilder = class {
|
|
|
250
250
|
}
|
|
251
251
|
};
|
|
252
252
|
|
|
253
|
+
// src/export-format.ts
|
|
254
|
+
var REFERENCE_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail", "user", "reference", "tree"]);
|
|
255
|
+
var OPTION_TYPES = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
256
|
+
var MULTI_OPTION_TYPES = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
257
|
+
var NAME_KEY_FALLBACKS = [
|
|
258
|
+
"name",
|
|
259
|
+
"title",
|
|
260
|
+
"label",
|
|
261
|
+
"full_name",
|
|
262
|
+
"fullName",
|
|
263
|
+
"display_name",
|
|
264
|
+
"username",
|
|
265
|
+
"email"
|
|
266
|
+
];
|
|
267
|
+
function buildFieldMetaMap(schema) {
|
|
268
|
+
const map = /* @__PURE__ */ new Map();
|
|
269
|
+
const fields = schema?.fields;
|
|
270
|
+
let entries;
|
|
271
|
+
if (Array.isArray(fields)) {
|
|
272
|
+
entries = fields.filter((f) => f && typeof f === "object").map((f) => [typeof f.name === "string" ? f.name : "", f]);
|
|
273
|
+
} else if (fields && typeof fields === "object") {
|
|
274
|
+
entries = Object.entries(fields).map(
|
|
275
|
+
([key, def]) => [
|
|
276
|
+
def && typeof def === "object" && typeof def.name === "string" ? def.name : key,
|
|
277
|
+
def
|
|
278
|
+
]
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
return map;
|
|
282
|
+
}
|
|
283
|
+
for (const [name, f] of entries) {
|
|
284
|
+
if (!name || !f || typeof f !== "object") continue;
|
|
285
|
+
map.set(name, {
|
|
286
|
+
name,
|
|
287
|
+
type: typeof f.type === "string" ? f.type : void 0,
|
|
288
|
+
label: typeof f.label === "string" ? f.label : void 0,
|
|
289
|
+
options: Array.isArray(f.options) ? f.options : void 0,
|
|
290
|
+
reference: typeof f.reference === "string" ? f.reference : void 0,
|
|
291
|
+
displayField: typeof f.displayField === "string" ? f.displayField : void 0
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return map;
|
|
295
|
+
}
|
|
296
|
+
function referenceFieldNames(metaMap) {
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const meta of metaMap.values()) {
|
|
299
|
+
if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) out.push(meta.name);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function headerLabel(field, metaMap) {
|
|
304
|
+
return metaMap.get(field)?.label || field;
|
|
305
|
+
}
|
|
306
|
+
function pad2(n) {
|
|
307
|
+
return n < 10 ? `0${n}` : String(n);
|
|
308
|
+
}
|
|
309
|
+
function toDate(value) {
|
|
310
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
311
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
312
|
+
const d = new Date(value);
|
|
313
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
function formatDate(value, withTime) {
|
|
318
|
+
const d = toDate(value);
|
|
319
|
+
if (!d) return value;
|
|
320
|
+
const ymd = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
321
|
+
if (!withTime) return ymd;
|
|
322
|
+
return `${ymd} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
|
|
323
|
+
}
|
|
324
|
+
function optionLabel(value, options) {
|
|
325
|
+
if (!options) return value;
|
|
326
|
+
const hit = options.find((o) => o && o.value === value);
|
|
327
|
+
return hit?.label ?? value;
|
|
328
|
+
}
|
|
329
|
+
function displayFromRecord(rec, displayField) {
|
|
330
|
+
if (displayField && rec[displayField] != null) return String(rec[displayField]);
|
|
331
|
+
for (const k of NAME_KEY_FALLBACKS) {
|
|
332
|
+
const v = rec[k];
|
|
333
|
+
if (v != null && typeof v !== "object") return String(v);
|
|
334
|
+
}
|
|
335
|
+
if (rec.id != null) return String(rec.id);
|
|
336
|
+
try {
|
|
337
|
+
return JSON.stringify(rec);
|
|
338
|
+
} catch {
|
|
339
|
+
return String(rec);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function formatReference(value, displayField) {
|
|
343
|
+
const one = (v) => v && typeof v === "object" ? displayFromRecord(v, displayField) : v;
|
|
344
|
+
if (Array.isArray(value)) return value.map(one).join(", ");
|
|
345
|
+
return one(value);
|
|
346
|
+
}
|
|
347
|
+
function formatCellValue(value, meta) {
|
|
348
|
+
if (value === null || value === void 0) return value;
|
|
349
|
+
if (!meta || !meta.type) return value;
|
|
350
|
+
const t = meta.type;
|
|
351
|
+
if (t === "boolean" || t === "toggle") {
|
|
352
|
+
if (value === true || value === "true" || value === 1) return "\u662F";
|
|
353
|
+
if (value === false || value === "false" || value === 0) return "\u5426";
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
if (OPTION_TYPES.has(t)) return optionLabel(value, meta.options);
|
|
357
|
+
if (MULTI_OPTION_TYPES.has(t)) {
|
|
358
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
359
|
+
return arr.map((v) => optionLabel(v, meta.options)).join(", ");
|
|
360
|
+
}
|
|
361
|
+
if (t === "date") return formatDate(value, false);
|
|
362
|
+
if (t === "datetime") return formatDate(value, true);
|
|
363
|
+
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField);
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
function formatRowCells(row, fields, metaMap) {
|
|
367
|
+
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f)));
|
|
368
|
+
}
|
|
369
|
+
function formatRowForJson(row, metaMap) {
|
|
370
|
+
if (metaMap.size === 0 || !row || typeof row !== "object") return row;
|
|
371
|
+
let copy = null;
|
|
372
|
+
for (const key of Object.keys(row)) {
|
|
373
|
+
const meta = metaMap.get(key);
|
|
374
|
+
if (!meta) continue;
|
|
375
|
+
const formatted = formatCellValue(row[key], meta);
|
|
376
|
+
if (formatted !== row[key]) {
|
|
377
|
+
if (!copy) copy = { ...row };
|
|
378
|
+
copy[key] = formatted;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return copy ?? row;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/import-coerce.ts
|
|
385
|
+
var REFERENCE_TYPES2 = /* @__PURE__ */ new Set(["lookup", "master_detail", "user", "reference", "tree"]);
|
|
386
|
+
var OPTION_TYPES2 = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
387
|
+
var MULTI_OPTION_TYPES2 = /* @__PURE__ */ new Set(["multiselect", "checkboxes", "tags"]);
|
|
388
|
+
var NUMBER_TYPES = /* @__PURE__ */ new Set(["number", "currency", "percent", "rating", "slider"]);
|
|
389
|
+
var BOOL_TYPES = /* @__PURE__ */ new Set(["boolean", "toggle"]);
|
|
390
|
+
function normalizeRefMatch(result) {
|
|
391
|
+
if (result == null) return {};
|
|
392
|
+
if (typeof result === "string") return result ? { id: result } : {};
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
function isBlank(value, nullValues) {
|
|
396
|
+
if (value === null || value === void 0) return true;
|
|
397
|
+
if (typeof value === "string") {
|
|
398
|
+
const s = value.trim();
|
|
399
|
+
if (s === "") return true;
|
|
400
|
+
if (nullValues && nullValues.some((nv) => nv === value || nv === s)) return true;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
var BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "yes", "y", "1", "on", "\u662F", "\u5BF9", "\u2713", "\u221A"]);
|
|
405
|
+
var BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "no", "n", "0", "off", "\u5426", "\u9519", "\u2717", "\xD7"]);
|
|
406
|
+
function parseBooleanCell(raw) {
|
|
407
|
+
if (typeof raw === "boolean") return raw;
|
|
408
|
+
if (typeof raw === "number") {
|
|
409
|
+
if (raw === 1) return true;
|
|
410
|
+
if (raw === 0) return false;
|
|
411
|
+
return void 0;
|
|
412
|
+
}
|
|
413
|
+
const s = String(raw).trim().toLowerCase();
|
|
414
|
+
if (BOOL_TRUE.has(s)) return true;
|
|
415
|
+
if (BOOL_FALSE.has(s)) return false;
|
|
416
|
+
return void 0;
|
|
417
|
+
}
|
|
418
|
+
function parseNumberCell(raw) {
|
|
419
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
|
|
420
|
+
let s = String(raw).trim();
|
|
421
|
+
if (s === "") return void 0;
|
|
422
|
+
let negative = false;
|
|
423
|
+
if (/^\(.*\)$/.test(s)) {
|
|
424
|
+
negative = true;
|
|
425
|
+
s = s.slice(1, -1).trim();
|
|
426
|
+
}
|
|
427
|
+
s = s.replace(/^[$¥€£¥]\s*/, "");
|
|
428
|
+
s = s.replace(/%$/, "").trim();
|
|
429
|
+
s = s.replace(/,/g, "");
|
|
430
|
+
if (s === "" || !/^[+-]?\d*\.?\d+(e[+-]?\d+)?$/i.test(s)) return void 0;
|
|
431
|
+
const n = Number(s);
|
|
432
|
+
if (!Number.isFinite(n)) return void 0;
|
|
433
|
+
return negative ? -n : n;
|
|
434
|
+
}
|
|
435
|
+
function pad22(n) {
|
|
436
|
+
return n < 10 ? `0${n}` : String(n);
|
|
437
|
+
}
|
|
438
|
+
var TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
|
|
439
|
+
function parseDateCell(raw, kind) {
|
|
440
|
+
if (raw instanceof Date) {
|
|
441
|
+
if (Number.isNaN(raw.getTime())) return void 0;
|
|
442
|
+
if (kind === "datetime") return raw.toISOString();
|
|
443
|
+
if (kind === "date") return `${raw.getUTCFullYear()}-${pad22(raw.getUTCMonth() + 1)}-${pad22(raw.getUTCDate())}`;
|
|
444
|
+
return `${pad22(raw.getUTCHours())}:${pad22(raw.getUTCMinutes())}:${pad22(raw.getUTCSeconds())}`;
|
|
445
|
+
}
|
|
446
|
+
const s = String(raw).trim();
|
|
447
|
+
if (s === "") return void 0;
|
|
448
|
+
if (kind === "time") {
|
|
449
|
+
if (TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
|
|
450
|
+
const t = new Date(s);
|
|
451
|
+
if (!Number.isNaN(t.getTime())) return `${pad22(t.getUTCHours())}:${pad22(t.getUTCMinutes())}:${pad22(t.getUTCSeconds())}`;
|
|
452
|
+
return void 0;
|
|
453
|
+
}
|
|
454
|
+
const ymd = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
|
455
|
+
if (ymd) {
|
|
456
|
+
const y = Number(ymd[1]);
|
|
457
|
+
const mo = Number(ymd[2]);
|
|
458
|
+
const d = Number(ymd[3]);
|
|
459
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return void 0;
|
|
460
|
+
if (kind === "date") return `${y}-${pad22(mo)}-${pad22(d)}`;
|
|
461
|
+
return new Date(Date.UTC(y, mo - 1, d)).toISOString();
|
|
462
|
+
}
|
|
463
|
+
const parsed = new Date(s);
|
|
464
|
+
if (Number.isNaN(parsed.getTime())) return void 0;
|
|
465
|
+
if (kind === "date") {
|
|
466
|
+
return `${parsed.getUTCFullYear()}-${pad22(parsed.getUTCMonth() + 1)}-${pad22(parsed.getUTCDate())}`;
|
|
467
|
+
}
|
|
468
|
+
return parsed.toISOString();
|
|
469
|
+
}
|
|
470
|
+
function matchOption(raw, options) {
|
|
471
|
+
const s = String(raw).trim();
|
|
472
|
+
if (!options || options.length === 0) return s;
|
|
473
|
+
for (const o of options) {
|
|
474
|
+
if (o && o.value !== void 0 && String(o.value) === s) return o.value;
|
|
475
|
+
}
|
|
476
|
+
const lower = s.toLowerCase();
|
|
477
|
+
for (const o of options) {
|
|
478
|
+
if (o && typeof o.label === "string" && o.label.trim().toLowerCase() === lower) return o.value;
|
|
479
|
+
}
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
function splitMulti(raw) {
|
|
483
|
+
if (Array.isArray(raw)) return raw.map((v) => String(v).trim()).filter((v) => v !== "");
|
|
484
|
+
return String(raw).split(/[,;、\n]/).map((v) => v.trim()).filter((v) => v !== "");
|
|
485
|
+
}
|
|
486
|
+
async function coerceFieldValue(raw, meta, ctx) {
|
|
487
|
+
const trim = ctx.trimWhitespace !== false;
|
|
488
|
+
const field = meta?.name ?? "";
|
|
489
|
+
if (isBlank(raw, ctx.nullValues)) return { value: void 0 };
|
|
490
|
+
const t = meta?.type;
|
|
491
|
+
if (!t) return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
492
|
+
if (BOOL_TYPES.has(t)) {
|
|
493
|
+
const b = parseBooleanCell(raw);
|
|
494
|
+
if (b === void 0) return { error: { field, code: "invalid_boolean", message: `${field}: "${String(raw)}" is not a boolean` } };
|
|
495
|
+
return { value: b };
|
|
496
|
+
}
|
|
497
|
+
if (NUMBER_TYPES.has(t)) {
|
|
498
|
+
const n = parseNumberCell(raw);
|
|
499
|
+
if (n === void 0) return { error: { field, code: "invalid_number", message: `${field}: "${String(raw)}" is not a number` } };
|
|
500
|
+
return { value: n };
|
|
501
|
+
}
|
|
502
|
+
if (t === "date" || t === "datetime" || t === "time") {
|
|
503
|
+
const d = parseDateCell(raw, t);
|
|
504
|
+
if (d === void 0) return { error: { field, code: "invalid_date", message: `${field}: "${String(raw)}" is not a valid ${t}` } };
|
|
505
|
+
return { value: d };
|
|
506
|
+
}
|
|
507
|
+
if (OPTION_TYPES2.has(t)) {
|
|
508
|
+
const v = matchOption(raw, meta?.options);
|
|
509
|
+
if (v === void 0) {
|
|
510
|
+
if (ctx.createMissingOptions) return { value: String(raw).trim() };
|
|
511
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${String(raw)}" is not a known option` } };
|
|
512
|
+
}
|
|
513
|
+
return { value: v };
|
|
514
|
+
}
|
|
515
|
+
if (MULTI_OPTION_TYPES2.has(t)) {
|
|
516
|
+
const parts = splitMulti(raw);
|
|
517
|
+
const out = [];
|
|
518
|
+
for (const part of parts) {
|
|
519
|
+
const v = matchOption(part, meta?.options);
|
|
520
|
+
if (v === void 0) {
|
|
521
|
+
if (ctx.createMissingOptions) {
|
|
522
|
+
out.push(part);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
return { error: { field, code: "invalid_option", message: `${field}: "${part}" is not a known option` } };
|
|
526
|
+
}
|
|
527
|
+
out.push(v);
|
|
528
|
+
}
|
|
529
|
+
return { value: out };
|
|
530
|
+
}
|
|
531
|
+
if (REFERENCE_TYPES2.has(t)) {
|
|
532
|
+
const display = String(raw).trim();
|
|
533
|
+
if (!ctx.resolveRef || !meta?.reference) return { value: display };
|
|
534
|
+
const match = normalizeRefMatch(await ctx.resolveRef(meta.reference, display, meta));
|
|
535
|
+
if (match.ambiguous) {
|
|
536
|
+
return { error: { field, code: "reference_ambiguous", message: `${field}: "${display}" matches more than one ${meta.reference} \u2014 use a unique value or the record id` } };
|
|
537
|
+
}
|
|
538
|
+
if (match.id === void 0) {
|
|
539
|
+
return { error: { field, code: "reference_not_found", message: `${field}: no ${meta.reference} matches "${display}"` } };
|
|
540
|
+
}
|
|
541
|
+
return { value: match.id };
|
|
542
|
+
}
|
|
543
|
+
return { value: trim && typeof raw === "string" ? raw.trim() : raw };
|
|
544
|
+
}
|
|
545
|
+
async function coerceRow(rawRow, metaMap, ctx) {
|
|
546
|
+
const data = {};
|
|
547
|
+
const errors = [];
|
|
548
|
+
for (const [key, raw] of Object.entries(rawRow)) {
|
|
549
|
+
const meta = metaMap.get(key);
|
|
550
|
+
const res = await coerceFieldValue(raw, meta ? meta : void 0, ctx);
|
|
551
|
+
if ("error" in res) {
|
|
552
|
+
errors.push({ ...res.error, field: res.error.field || key });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (res.value !== void 0) data[key] = res.value;
|
|
556
|
+
}
|
|
557
|
+
return { data, errors };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/import-runner.ts
|
|
561
|
+
function runImport(opts) {
|
|
562
|
+
const {
|
|
563
|
+
p,
|
|
564
|
+
objectName,
|
|
565
|
+
environmentId,
|
|
566
|
+
context,
|
|
567
|
+
rows,
|
|
568
|
+
metaMap,
|
|
569
|
+
writeMode,
|
|
570
|
+
matchFields,
|
|
571
|
+
dryRun,
|
|
572
|
+
runAutomations,
|
|
573
|
+
trimWhitespace,
|
|
574
|
+
nullValues,
|
|
575
|
+
createMissingOptions,
|
|
576
|
+
skipBlankMatchKey,
|
|
577
|
+
onProgress,
|
|
578
|
+
shouldCancel,
|
|
579
|
+
captureUndo
|
|
580
|
+
} = opts;
|
|
581
|
+
const collectUndo = !!captureUndo && !dryRun;
|
|
582
|
+
const undoLog = { created: [], updated: [] };
|
|
583
|
+
const captureBefore = (before, written) => {
|
|
584
|
+
const snap = {};
|
|
585
|
+
for (const k of Object.keys(written)) snap[k] = before[k] ?? null;
|
|
586
|
+
return snap;
|
|
587
|
+
};
|
|
588
|
+
const progressEvery = Math.max(1, opts.progressEvery ?? 200);
|
|
589
|
+
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 : [];
|
|
590
|
+
const findArgsBase = (query) => ({
|
|
591
|
+
object: "",
|
|
592
|
+
query,
|
|
593
|
+
...environmentId ? { environmentId } : {},
|
|
594
|
+
...context ? { context } : {}
|
|
595
|
+
});
|
|
596
|
+
const refCache = /* @__PURE__ */ new Map();
|
|
597
|
+
const resolveRef = async (referenceObject, display, meta) => {
|
|
598
|
+
const cacheKey = `${referenceObject}::${display}`;
|
|
599
|
+
const cached = refCache.get(cacheKey);
|
|
600
|
+
if (cached) return cached;
|
|
601
|
+
const candidates = [.../* @__PURE__ */ new Set([
|
|
602
|
+
"id",
|
|
603
|
+
...meta.displayField ? [meta.displayField] : [],
|
|
604
|
+
"name",
|
|
605
|
+
"title",
|
|
606
|
+
"label",
|
|
607
|
+
"full_name",
|
|
608
|
+
"email",
|
|
609
|
+
"username"
|
|
610
|
+
])];
|
|
611
|
+
let match = {};
|
|
612
|
+
for (const f of candidates) {
|
|
613
|
+
try {
|
|
614
|
+
const r = await p.findData({
|
|
615
|
+
...findArgsBase({ $filter: { [f]: display }, $top: 2 }),
|
|
616
|
+
object: referenceObject
|
|
617
|
+
});
|
|
618
|
+
const recs = findRows(r);
|
|
619
|
+
if (recs.length === 0) continue;
|
|
620
|
+
if (recs.length > 1) {
|
|
621
|
+
match = { ambiguous: true, matchedField: f };
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
if (recs[0]?.id != null) {
|
|
625
|
+
match = { id: String(recs[0].id), matchedField: f };
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
refCache.set(cacheKey, match);
|
|
632
|
+
return match;
|
|
633
|
+
};
|
|
634
|
+
const findExisting = async (data) => {
|
|
635
|
+
const filter = {};
|
|
636
|
+
for (const f of matchFields) {
|
|
637
|
+
const v = data[f];
|
|
638
|
+
if (v === void 0 || v === null || v === "") return "blank";
|
|
639
|
+
filter[f] = v;
|
|
640
|
+
}
|
|
641
|
+
const r = await p.findData({ ...findArgsBase({ $filter: filter, $top: 2 }), object: objectName });
|
|
642
|
+
const recs = findRows(r);
|
|
643
|
+
if (recs.length === 0) return "none";
|
|
644
|
+
if (recs.length > 1) return "ambiguous";
|
|
645
|
+
return recs[0];
|
|
646
|
+
};
|
|
647
|
+
const writeCtx = { ...context ?? {}, skipAutomations: !runAutomations };
|
|
648
|
+
const results = [];
|
|
649
|
+
let okCount = 0, errCount = 0, created = 0, updated = 0, skipped = 0;
|
|
650
|
+
let cancelled = false;
|
|
651
|
+
const snapshot = (processed) => ({
|
|
652
|
+
processed,
|
|
653
|
+
total: rows.length,
|
|
654
|
+
created,
|
|
655
|
+
updated,
|
|
656
|
+
skipped,
|
|
657
|
+
errors: errCount
|
|
658
|
+
});
|
|
659
|
+
return (async () => {
|
|
660
|
+
for (let i = 0; i < rows.length; i++) {
|
|
661
|
+
const rowNo = i + 1;
|
|
662
|
+
try {
|
|
663
|
+
const { data, errors } = await coerceRow(rows[i], metaMap, {
|
|
664
|
+
trimWhitespace,
|
|
665
|
+
nullValues,
|
|
666
|
+
createMissingOptions,
|
|
667
|
+
resolveRef
|
|
668
|
+
});
|
|
669
|
+
if (errors.length > 0) {
|
|
670
|
+
const first = errors[0];
|
|
671
|
+
errCount++;
|
|
672
|
+
results.push({ row: rowNo, ok: false, action: "failed", field: first.field, code: first.code, error: first.message });
|
|
673
|
+
} else {
|
|
674
|
+
let existing = "none";
|
|
675
|
+
let handled = false;
|
|
676
|
+
if (writeMode !== "insert") {
|
|
677
|
+
existing = await findExisting(data);
|
|
678
|
+
if (existing === "ambiguous") {
|
|
679
|
+
errCount++;
|
|
680
|
+
results.push({ row: rowNo, ok: false, action: "failed", code: "AMBIGUOUS_MATCH", error: `matchFields matched more than one ${objectName} record` });
|
|
681
|
+
handled = true;
|
|
682
|
+
} else if (existing === "blank" && (skipBlankMatchKey || writeMode === "update")) {
|
|
683
|
+
skipped++;
|
|
684
|
+
results.push({ row: rowNo, ok: true, action: "skipped", code: "BLANK_MATCH_KEY" });
|
|
685
|
+
handled = true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (!handled) {
|
|
689
|
+
const willUpdate = existing && typeof existing === "object";
|
|
690
|
+
const willCreate = !willUpdate && (writeMode === "insert" || writeMode === "upsert");
|
|
691
|
+
if (!willUpdate && !willCreate) {
|
|
692
|
+
skipped++;
|
|
693
|
+
results.push({ row: rowNo, ok: true, action: "skipped", code: "NO_MATCH" });
|
|
694
|
+
} else if (dryRun) {
|
|
695
|
+
okCount++;
|
|
696
|
+
if (willUpdate) {
|
|
697
|
+
updated++;
|
|
698
|
+
results.push({ row: rowNo, ok: true, action: "updated", id: String(existing.id ?? "") || void 0 });
|
|
699
|
+
} else {
|
|
700
|
+
created++;
|
|
701
|
+
results.push({ row: rowNo, ok: true, action: "created" });
|
|
702
|
+
}
|
|
703
|
+
} else if (willUpdate) {
|
|
704
|
+
const target = existing;
|
|
705
|
+
const res2 = await p.updateData({ object: objectName, id: target.id, data, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
706
|
+
const id = res2?.id ?? res2?.record?.id ?? target.id;
|
|
707
|
+
okCount++;
|
|
708
|
+
updated++;
|
|
709
|
+
if (collectUndo && target.id != null) {
|
|
710
|
+
undoLog.updated.push({ id: String(target.id), before: captureBefore(target, data) });
|
|
711
|
+
}
|
|
712
|
+
results.push({ row: rowNo, ok: true, action: "updated", id: id != null ? String(id) : void 0 });
|
|
713
|
+
} else {
|
|
714
|
+
const res2 = await p.createData({ object: objectName, data, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
715
|
+
const id = res2?.id ?? res2?.record?.id;
|
|
716
|
+
okCount++;
|
|
717
|
+
created++;
|
|
718
|
+
if (collectUndo && id != null) undoLog.created.push(String(id));
|
|
719
|
+
results.push({ row: rowNo, ok: true, action: "created", id: id != null ? String(id) : void 0 });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} catch (err) {
|
|
724
|
+
errCount++;
|
|
725
|
+
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
726
|
+
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
727
|
+
results.push({ row: rowNo, ok: false, action: "failed", error: message, code });
|
|
728
|
+
}
|
|
729
|
+
const processed = i + 1;
|
|
730
|
+
if (onProgress && (processed % progressEvery === 0 || processed === rows.length)) {
|
|
731
|
+
await onProgress(snapshot(processed));
|
|
732
|
+
}
|
|
733
|
+
if (shouldCancel && processed < rows.length && processed % progressEvery === 0) {
|
|
734
|
+
if (await shouldCancel()) {
|
|
735
|
+
cancelled = true;
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
...snapshot(results.length),
|
|
742
|
+
ok: okCount,
|
|
743
|
+
results,
|
|
744
|
+
cancelled,
|
|
745
|
+
...collectUndo ? { undoLog } : {}
|
|
746
|
+
};
|
|
747
|
+
})();
|
|
748
|
+
}
|
|
749
|
+
|
|
253
750
|
// src/rest-server.ts
|
|
254
751
|
var import_meta = {};
|
|
255
752
|
var logError = (...args) => globalThis.console?.error(...args);
|
|
@@ -463,6 +960,212 @@ function parseCsvToRows(csv, mapping = {}) {
|
|
|
463
960
|
}
|
|
464
961
|
return out;
|
|
465
962
|
}
|
|
963
|
+
function xlsxCellToString(value) {
|
|
964
|
+
if (value === null || value === void 0) return "";
|
|
965
|
+
if (typeof value === "string") return value;
|
|
966
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
967
|
+
if (value instanceof Date) return value.toISOString();
|
|
968
|
+
if (typeof value === "object") {
|
|
969
|
+
if ("result" in value && value.result !== void 0 && value.result !== null) return xlsxCellToString(value.result);
|
|
970
|
+
if ("text" in value && typeof value.text === "string") return value.text;
|
|
971
|
+
if ("hyperlink" in value && typeof value.hyperlink === "string") return value.hyperlink;
|
|
972
|
+
if (Array.isArray(value.richText)) return value.richText.map((r) => r?.text ?? "").join("");
|
|
973
|
+
if ("error" in value && value.error) return String(value.error);
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
return String(value);
|
|
977
|
+
} catch {
|
|
978
|
+
return "";
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async function parseXlsxToRows(buffer, mapping = {}, sheet) {
|
|
982
|
+
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
983
|
+
const wb = new ExcelJS.Workbook();
|
|
984
|
+
await wb.xlsx.load(buffer);
|
|
985
|
+
const ws = sheet !== void 0 ? wb.getWorksheet(sheet) : wb.worksheets[0];
|
|
986
|
+
if (!ws) return [];
|
|
987
|
+
const cells = [];
|
|
988
|
+
ws.eachRow({ includeEmpty: false }, (row) => {
|
|
989
|
+
const values = row.values;
|
|
990
|
+
const line = [];
|
|
991
|
+
for (let c = 1; c < values.length; c++) line.push(xlsxCellToString(values[c]));
|
|
992
|
+
cells.push(line);
|
|
993
|
+
});
|
|
994
|
+
while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
|
|
995
|
+
if (cells.length < 2) return [];
|
|
996
|
+
const header = cells[0].map((h) => h.trim());
|
|
997
|
+
const fields = header.map((h) => mapping[h] ?? h);
|
|
998
|
+
const out = [];
|
|
999
|
+
for (let r = 1; r < cells.length; r++) {
|
|
1000
|
+
const line = cells[r];
|
|
1001
|
+
const obj = {};
|
|
1002
|
+
for (let c = 0; c < fields.length; c++) {
|
|
1003
|
+
const key = fields[c];
|
|
1004
|
+
if (!key) continue;
|
|
1005
|
+
obj[key] = line[c] ?? "";
|
|
1006
|
+
}
|
|
1007
|
+
out.push(obj);
|
|
1008
|
+
}
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
async function prepareImportRequest(body, opts) {
|
|
1012
|
+
const { p, objectName, environmentId, maxRows } = opts;
|
|
1013
|
+
const dryRun = body?.dryRun === true;
|
|
1014
|
+
const writeMode = body?.writeMode === "update" || body?.writeMode === "upsert" ? body.writeMode : "insert";
|
|
1015
|
+
const matchFields = Array.isArray(body?.matchFields) ? body.matchFields.filter((f) => typeof f === "string" && f.length > 0) : [];
|
|
1016
|
+
const runAutomations = body?.runAutomations === true;
|
|
1017
|
+
const trimWhitespace = body?.trimWhitespace !== false;
|
|
1018
|
+
const nullValues = Array.isArray(body?.nullValues) ? body.nullValues.filter((v) => typeof v === "string") : void 0;
|
|
1019
|
+
const createMissingOptions = body?.createMissingOptions === true;
|
|
1020
|
+
const skipBlankMatchKey = body?.skipBlankMatchKey === true;
|
|
1021
|
+
if (writeMode !== "insert" && matchFields.length === 0) {
|
|
1022
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: `writeMode "${writeMode}" requires a non-empty matchFields[]` };
|
|
1023
|
+
}
|
|
1024
|
+
const mapping = {};
|
|
1025
|
+
if (Array.isArray(body?.mapping)) {
|
|
1026
|
+
for (const e of body.mapping) {
|
|
1027
|
+
if (e && typeof e.sourceField === "string" && typeof e.targetField === "string") {
|
|
1028
|
+
mapping[e.sourceField] = e.targetField;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
} else if (body?.mapping && typeof body.mapping === "object") {
|
|
1032
|
+
for (const [k, v] of Object.entries(body.mapping)) {
|
|
1033
|
+
if (typeof v === "string") mapping[k] = v;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const applyMapping = (row) => {
|
|
1037
|
+
if (Object.keys(mapping).length === 0) return row;
|
|
1038
|
+
const out = {};
|
|
1039
|
+
for (const [k, val] of Object.entries(row)) out[mapping[k] ?? k] = val;
|
|
1040
|
+
return out;
|
|
1041
|
+
};
|
|
1042
|
+
let rows = [];
|
|
1043
|
+
if (body?.format === "json" && Array.isArray(body.rows)) {
|
|
1044
|
+
rows = body.rows.map(applyMapping);
|
|
1045
|
+
} else if ((body?.format === "csv" || typeof body?.csv === "string") && typeof body?.csv === "string") {
|
|
1046
|
+
rows = parseCsvToRows(body.csv, mapping);
|
|
1047
|
+
} else if ((body?.format === "xlsx" || typeof body?.xlsxBase64 === "string") && typeof body?.xlsxBase64 === "string") {
|
|
1048
|
+
try {
|
|
1049
|
+
const buf = Buffer.from(body.xlsxBase64, "base64");
|
|
1050
|
+
rows = await parseXlsxToRows(buf, mapping, body.sheet);
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: `Failed to parse xlsx: ${e?.message ?? String(e)}` };
|
|
1053
|
+
}
|
|
1054
|
+
} else if (Array.isArray(body)) {
|
|
1055
|
+
rows = body.map(applyMapping);
|
|
1056
|
+
} else {
|
|
1057
|
+
return { ok: false, status: 400, code: "INVALID_REQUEST", error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' };
|
|
1058
|
+
}
|
|
1059
|
+
if (rows.length > maxRows) {
|
|
1060
|
+
return { ok: false, status: 413, code: "PAYLOAD_TOO_LARGE", error: `Import limit is ${maxRows} rows per request (got ${rows.length}).` };
|
|
1061
|
+
}
|
|
1062
|
+
let metaMap = /* @__PURE__ */ new Map();
|
|
1063
|
+
try {
|
|
1064
|
+
let schema = void 0;
|
|
1065
|
+
if (typeof p.getMetaItem === "function") {
|
|
1066
|
+
const r = await p.getMetaItem({ type: "object", name: objectName });
|
|
1067
|
+
schema = isMetaEnvelope(r) ? r.item : r;
|
|
1068
|
+
}
|
|
1069
|
+
if (!schema && typeof p.getObjectSchema === "function") {
|
|
1070
|
+
schema = await p.getObjectSchema(objectName, environmentId);
|
|
1071
|
+
}
|
|
1072
|
+
metaMap = buildFieldMetaMap(schema);
|
|
1073
|
+
} catch {
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
ok: true,
|
|
1077
|
+
prepared: {
|
|
1078
|
+
rows,
|
|
1079
|
+
metaMap,
|
|
1080
|
+
writeMode,
|
|
1081
|
+
matchFields,
|
|
1082
|
+
dryRun,
|
|
1083
|
+
runAutomations,
|
|
1084
|
+
trimWhitespace,
|
|
1085
|
+
nullValues,
|
|
1086
|
+
createMissingOptions,
|
|
1087
|
+
skipBlankMatchKey
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
var IMPORT_JOB_OBJECT = "sys_import_job";
|
|
1092
|
+
var IMPORT_JOB_MAX_ROWS = 5e4;
|
|
1093
|
+
var IMPORT_JOB_RESULTS_CAP = 500;
|
|
1094
|
+
var IMPORT_JOB_UNDO_MAX_ROWS = 5e3;
|
|
1095
|
+
function newImportJobId() {
|
|
1096
|
+
return `imp_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
1097
|
+
}
|
|
1098
|
+
function capImportResults(results) {
|
|
1099
|
+
if (results.length <= IMPORT_JOB_RESULTS_CAP) return { items: results, truncated: false };
|
|
1100
|
+
const failures = results.filter((r) => !r.ok);
|
|
1101
|
+
const successes = results.filter((r) => r.ok);
|
|
1102
|
+
const items = [...failures, ...successes].slice(0, IMPORT_JOB_RESULTS_CAP);
|
|
1103
|
+
return { items, truncated: true };
|
|
1104
|
+
}
|
|
1105
|
+
function parseUndoLog(raw) {
|
|
1106
|
+
if (!raw) return void 0;
|
|
1107
|
+
let v = raw;
|
|
1108
|
+
if (typeof v === "string") {
|
|
1109
|
+
try {
|
|
1110
|
+
v = JSON.parse(v);
|
|
1111
|
+
} catch {
|
|
1112
|
+
return void 0;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (!v || typeof v !== "object") return void 0;
|
|
1116
|
+
const created = Array.isArray(v.created) ? v.created.map(String) : [];
|
|
1117
|
+
const updated = Array.isArray(v.updated) ? v.updated.filter((u) => u && u.id != null).map((u) => ({ id: String(u.id), before: u.before ?? {} })) : [];
|
|
1118
|
+
return { created, updated };
|
|
1119
|
+
}
|
|
1120
|
+
function importJobUndoable(row) {
|
|
1121
|
+
if (row?.reverted_at) return false;
|
|
1122
|
+
const status = String(row?.status ?? "");
|
|
1123
|
+
if (status !== "succeeded" && status !== "cancelled") return false;
|
|
1124
|
+
const log = parseUndoLog(row?.undo_log);
|
|
1125
|
+
return !!log && (log.created.length > 0 || log.updated.length > 0);
|
|
1126
|
+
}
|
|
1127
|
+
function importJobToProgress(row) {
|
|
1128
|
+
const total = Number(row?.total_rows ?? 0);
|
|
1129
|
+
const processed = Number(row?.processed_rows ?? 0);
|
|
1130
|
+
return {
|
|
1131
|
+
undoable: importJobUndoable(row),
|
|
1132
|
+
...row?.reverted_at ? { revertedAt: String(row.reverted_at) } : {},
|
|
1133
|
+
jobId: String(row?.id ?? ""),
|
|
1134
|
+
object: String(row?.object_name ?? ""),
|
|
1135
|
+
status: String(row?.status ?? "pending"),
|
|
1136
|
+
dryRun: !!row?.dry_run,
|
|
1137
|
+
writeMode: String(row?.write_mode ?? "insert"),
|
|
1138
|
+
total,
|
|
1139
|
+
processed,
|
|
1140
|
+
created: Number(row?.created_count ?? 0),
|
|
1141
|
+
updated: Number(row?.updated_count ?? 0),
|
|
1142
|
+
skipped: Number(row?.skipped_count ?? 0),
|
|
1143
|
+
errors: Number(row?.error_count ?? 0),
|
|
1144
|
+
percentComplete: total > 0 ? Math.min(100, Math.round(processed / total * 100)) : processed > 0 ? 100 : 0,
|
|
1145
|
+
...row?.error ? { error: String(row.error) } : {},
|
|
1146
|
+
...row?.started_at ? { startedAt: String(row.started_at) } : {},
|
|
1147
|
+
...row?.completed_at ? { completedAt: String(row.completed_at) } : {},
|
|
1148
|
+
createdAt: String(row?.created_at ?? "")
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
function importJobToSummary(row) {
|
|
1152
|
+
const p = importJobToProgress(row);
|
|
1153
|
+
return {
|
|
1154
|
+
jobId: p.jobId,
|
|
1155
|
+
object: p.object,
|
|
1156
|
+
status: p.status,
|
|
1157
|
+
total: p.total,
|
|
1158
|
+
processed: p.processed,
|
|
1159
|
+
created: p.created,
|
|
1160
|
+
updated: p.updated,
|
|
1161
|
+
skipped: p.skipped,
|
|
1162
|
+
errors: p.errors,
|
|
1163
|
+
createdAt: p.createdAt,
|
|
1164
|
+
undoable: p.undoable,
|
|
1165
|
+
...p.completedAt ? { completedAt: p.completedAt } : {},
|
|
1166
|
+
...p.revertedAt ? { revertedAt: p.revertedAt } : {}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
466
1169
|
function formatCsvCell(value) {
|
|
467
1170
|
if (value === null || value === void 0) return "";
|
|
468
1171
|
let s;
|
|
@@ -481,14 +1184,42 @@ function formatCsvCell(value) {
|
|
|
481
1184
|
}
|
|
482
1185
|
return s;
|
|
483
1186
|
}
|
|
484
|
-
function rowsToCsv(fields, rows, includeHeader) {
|
|
1187
|
+
function rowsToCsv(fields, rows, includeHeader, metaMap) {
|
|
485
1188
|
const lines = [];
|
|
486
|
-
if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
|
|
1189
|
+
if (includeHeader) lines.push(fields.map((f) => formatCsvCell(headerLabel(f, metaMap))).join(","));
|
|
487
1190
|
for (const row of rows) {
|
|
488
|
-
lines.push(fields.map(
|
|
1191
|
+
lines.push(formatRowCells(row, fields, metaMap).map(formatCsvCell).join(","));
|
|
489
1192
|
}
|
|
490
1193
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
491
1194
|
}
|
|
1195
|
+
async function createXlsxStream(res) {
|
|
1196
|
+
const { PassThrough } = await import("stream");
|
|
1197
|
+
const ExcelJS = (await import("exceljs")).default ?? await import("exceljs");
|
|
1198
|
+
const passthrough = new PassThrough();
|
|
1199
|
+
const done = new Promise((resolve, reject) => {
|
|
1200
|
+
passthrough.on("data", (chunk) => {
|
|
1201
|
+
res.write(chunk);
|
|
1202
|
+
});
|
|
1203
|
+
passthrough.on("end", () => {
|
|
1204
|
+
try {
|
|
1205
|
+
res.end();
|
|
1206
|
+
} catch {
|
|
1207
|
+
}
|
|
1208
|
+
resolve();
|
|
1209
|
+
});
|
|
1210
|
+
passthrough.on("error", reject);
|
|
1211
|
+
});
|
|
1212
|
+
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: passthrough, useStyles: false });
|
|
1213
|
+
const ws = wb.addWorksheet("Export");
|
|
1214
|
+
return {
|
|
1215
|
+
ws,
|
|
1216
|
+
finalize: async () => {
|
|
1217
|
+
await ws.commit();
|
|
1218
|
+
await wb.commit();
|
|
1219
|
+
await done;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
492
1223
|
var RestServer = class {
|
|
493
1224
|
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider) {
|
|
494
1225
|
/**
|
|
@@ -513,6 +1244,13 @@ var RestServer = class {
|
|
|
513
1244
|
* in-flight Promise so concurrent callers share one resolution.
|
|
514
1245
|
*/
|
|
515
1246
|
this.execCtxMemo = /* @__PURE__ */ new WeakMap();
|
|
1247
|
+
/**
|
|
1248
|
+
* In-flight async import jobs the caller has asked to cancel. The worker
|
|
1249
|
+
* checks membership at each progress boundary and stops cooperatively. This
|
|
1250
|
+
* is process-local (single-node); the persisted `sys_import_job.status` is
|
|
1251
|
+
* the durable source of truth a restarted/other node reads.
|
|
1252
|
+
*/
|
|
1253
|
+
this.cancelledImportJobs = /* @__PURE__ */ new Set();
|
|
516
1254
|
/**
|
|
517
1255
|
* Lazily load the OpenAPI spec JSON shipped by @objectstack/spec.
|
|
518
1256
|
* Cached after first read. Resilient to missing files / parse errors
|
|
@@ -2507,63 +3245,31 @@ var RestServer = class {
|
|
|
2507
3245
|
}
|
|
2508
3246
|
if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
|
|
2509
3247
|
const body = req.body ?? {};
|
|
2510
|
-
const
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
rows = body.rows;
|
|
2515
|
-
} else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
|
|
2516
|
-
rows = parseCsvToRows(body.csv, mapping);
|
|
2517
|
-
} else if (Array.isArray(body)) {
|
|
2518
|
-
rows = body;
|
|
2519
|
-
} else {
|
|
2520
|
-
res.status(400).json({
|
|
2521
|
-
code: "INVALID_REQUEST",
|
|
2522
|
-
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
|
|
2523
|
-
});
|
|
3248
|
+
const prep = await prepareImportRequest(body, { p, objectName, environmentId, maxRows: 5e3 });
|
|
3249
|
+
if (!prep.ok) {
|
|
3250
|
+
if (prep.status === 413) prep.error += " Use an async import job for larger files.";
|
|
3251
|
+
res.status(prep.status).json({ code: prep.code, error: prep.error });
|
|
2524
3252
|
return;
|
|
2525
3253
|
}
|
|
2526
|
-
const
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
}
|
|
2534
|
-
const results = [];
|
|
2535
|
-
let okCount = 0;
|
|
2536
|
-
let errCount = 0;
|
|
2537
|
-
for (let i = 0; i < rows.length; i++) {
|
|
2538
|
-
const data = rows[i];
|
|
2539
|
-
try {
|
|
2540
|
-
if (dryRun) {
|
|
2541
|
-
const validate = p.validate;
|
|
2542
|
-
if (typeof validate === "function") {
|
|
2543
|
-
await validate.call(p, { object: objectName, data, context });
|
|
2544
|
-
}
|
|
2545
|
-
results.push({ row: i + 1, ok: true });
|
|
2546
|
-
okCount++;
|
|
2547
|
-
} else {
|
|
2548
|
-
const created = await p.createData({ object: objectName, data, context });
|
|
2549
|
-
const id = created?.id ?? created?.record?.id;
|
|
2550
|
-
results.push({ row: i + 1, ok: true, id });
|
|
2551
|
-
okCount++;
|
|
2552
|
-
}
|
|
2553
|
-
} catch (err) {
|
|
2554
|
-
errCount++;
|
|
2555
|
-
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
2556
|
-
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
2557
|
-
results.push({ row: i + 1, ok: false, error: message, code });
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
3254
|
+
const { rows, writeMode, dryRun } = prep.prepared;
|
|
3255
|
+
const summary = await runImport({
|
|
3256
|
+
p,
|
|
3257
|
+
objectName,
|
|
3258
|
+
environmentId,
|
|
3259
|
+
context,
|
|
3260
|
+
...prep.prepared
|
|
3261
|
+
});
|
|
2560
3262
|
res.json({
|
|
2561
3263
|
object: objectName,
|
|
2562
3264
|
dryRun,
|
|
3265
|
+
writeMode,
|
|
2563
3266
|
total: rows.length,
|
|
2564
|
-
ok:
|
|
2565
|
-
errors:
|
|
2566
|
-
|
|
3267
|
+
ok: summary.ok,
|
|
3268
|
+
errors: summary.errors,
|
|
3269
|
+
created: summary.created,
|
|
3270
|
+
updated: summary.updated,
|
|
3271
|
+
skipped: summary.skipped,
|
|
3272
|
+
results: summary.results
|
|
2567
3273
|
});
|
|
2568
3274
|
} catch (error) {
|
|
2569
3275
|
logError("[REST] Unhandled error:", error);
|
|
@@ -2575,6 +3281,291 @@ var RestServer = class {
|
|
|
2575
3281
|
tags: ["data", "import"]
|
|
2576
3282
|
}
|
|
2577
3283
|
});
|
|
3284
|
+
this.routeManager.register({
|
|
3285
|
+
method: "POST",
|
|
3286
|
+
path: `${dataPath}/:object/import/jobs`,
|
|
3287
|
+
handler: async (req, res) => {
|
|
3288
|
+
try {
|
|
3289
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3290
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3291
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3292
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3293
|
+
const objectName = String(req.params.object || "");
|
|
3294
|
+
if (!objectName) {
|
|
3295
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
if (await this.enforceApiAccess(req, res, p, environmentId, "import")) return;
|
|
3299
|
+
const prep = await prepareImportRequest(req.body ?? {}, { p, objectName, environmentId, maxRows: IMPORT_JOB_MAX_ROWS });
|
|
3300
|
+
if (!prep.ok) {
|
|
3301
|
+
if (prep.status === 413) prep.error += ` This is the async import ceiling; split the file into batches of ${IMPORT_JOB_MAX_ROWS}.`;
|
|
3302
|
+
res.status(prep.status).json({ code: prep.code, error: prep.error });
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
const prepared = prep.prepared;
|
|
3306
|
+
const jobId = newImportJobId();
|
|
3307
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3308
|
+
const createdBy = String(context?.userId ?? context?.user?.id ?? "") || void 0;
|
|
3309
|
+
const jobRow = {
|
|
3310
|
+
id: jobId,
|
|
3311
|
+
object_name: objectName,
|
|
3312
|
+
status: "pending",
|
|
3313
|
+
total_rows: prepared.rows.length,
|
|
3314
|
+
processed_rows: 0,
|
|
3315
|
+
created_count: 0,
|
|
3316
|
+
updated_count: 0,
|
|
3317
|
+
skipped_count: 0,
|
|
3318
|
+
error_count: 0,
|
|
3319
|
+
write_mode: prepared.writeMode,
|
|
3320
|
+
dry_run: prepared.dryRun,
|
|
3321
|
+
run_automations: prepared.runAutomations,
|
|
3322
|
+
created_at: createdAt,
|
|
3323
|
+
...createdBy ? { created_by: createdBy } : {}
|
|
3324
|
+
};
|
|
3325
|
+
try {
|
|
3326
|
+
await p.createData({ object: IMPORT_JOB_OBJECT, data: jobRow, context, ...environmentId ? { environmentId } : {} });
|
|
3327
|
+
} catch (err) {
|
|
3328
|
+
logError("[REST] Failed to persist import job:", err);
|
|
3329
|
+
res.status(500).json({ code: "IMPORT_JOB_CREATE_FAILED", error: "Could not create import job" });
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
res.status(201).json({ jobId, object: objectName, status: "pending", total: prepared.rows.length, createdAt });
|
|
3333
|
+
const patch = async (data) => {
|
|
3334
|
+
try {
|
|
3335
|
+
await p.updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data, context, ...environmentId ? { environmentId } : {} });
|
|
3336
|
+
} catch (err) {
|
|
3337
|
+
logError("[REST] import job progress write failed:", err);
|
|
3338
|
+
}
|
|
3339
|
+
};
|
|
3340
|
+
const captureUndo = !prepared.dryRun && prepared.rows.length <= IMPORT_JOB_UNDO_MAX_ROWS;
|
|
3341
|
+
void (async () => {
|
|
3342
|
+
await patch({ status: "running", started_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3343
|
+
try {
|
|
3344
|
+
const summary = await runImport({
|
|
3345
|
+
p,
|
|
3346
|
+
objectName,
|
|
3347
|
+
environmentId,
|
|
3348
|
+
context,
|
|
3349
|
+
...prepared,
|
|
3350
|
+
captureUndo,
|
|
3351
|
+
progressEvery: 200,
|
|
3352
|
+
onProgress: (pr) => patch({
|
|
3353
|
+
processed_rows: pr.processed,
|
|
3354
|
+
created_count: pr.created,
|
|
3355
|
+
updated_count: pr.updated,
|
|
3356
|
+
skipped_count: pr.skipped,
|
|
3357
|
+
error_count: pr.errors
|
|
3358
|
+
}),
|
|
3359
|
+
shouldCancel: () => this.cancelledImportJobs.has(jobId)
|
|
3360
|
+
});
|
|
3361
|
+
await patch({
|
|
3362
|
+
status: summary.cancelled ? "cancelled" : "succeeded",
|
|
3363
|
+
processed_rows: summary.processed,
|
|
3364
|
+
created_count: summary.created,
|
|
3365
|
+
updated_count: summary.updated,
|
|
3366
|
+
skipped_count: summary.skipped,
|
|
3367
|
+
error_count: summary.errors,
|
|
3368
|
+
results: capImportResults(summary.results),
|
|
3369
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3370
|
+
...summary.undoLog ? { undo_log: summary.undoLog } : {}
|
|
3371
|
+
});
|
|
3372
|
+
} catch (err) {
|
|
3373
|
+
await patch({
|
|
3374
|
+
status: "failed",
|
|
3375
|
+
error: String(err?.message ?? err).slice(0, 1e3),
|
|
3376
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3377
|
+
});
|
|
3378
|
+
} finally {
|
|
3379
|
+
this.cancelledImportJobs.delete(jobId);
|
|
3380
|
+
}
|
|
3381
|
+
})();
|
|
3382
|
+
} catch (error) {
|
|
3383
|
+
logError("[REST] Unhandled error:", error);
|
|
3384
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
3385
|
+
}
|
|
3386
|
+
},
|
|
3387
|
+
metadata: {
|
|
3388
|
+
summary: "Create an asynchronous import job (large files, up to 50k rows)",
|
|
3389
|
+
tags: ["data", "import"]
|
|
3390
|
+
}
|
|
3391
|
+
});
|
|
3392
|
+
const loadImportJob = async (p, jobId, environmentId, context) => {
|
|
3393
|
+
const r = await p.findData({
|
|
3394
|
+
object: IMPORT_JOB_OBJECT,
|
|
3395
|
+
query: { $filter: { id: jobId }, $top: 1 },
|
|
3396
|
+
...environmentId ? { environmentId } : {},
|
|
3397
|
+
...context ? { context } : {}
|
|
3398
|
+
});
|
|
3399
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3400
|
+
return rows[0];
|
|
3401
|
+
};
|
|
3402
|
+
this.routeManager.register({
|
|
3403
|
+
method: "POST",
|
|
3404
|
+
path: `${dataPath}/import/jobs/:jobId/cancel`,
|
|
3405
|
+
handler: async (req, res) => {
|
|
3406
|
+
try {
|
|
3407
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3408
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3409
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3410
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3411
|
+
const jobId = String(req.params.jobId || "");
|
|
3412
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3413
|
+
if (!row) {
|
|
3414
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
const status = String(row.status ?? "");
|
|
3418
|
+
if (status === "pending" || status === "running") {
|
|
3419
|
+
this.cancelledImportJobs.add(jobId);
|
|
3420
|
+
try {
|
|
3421
|
+
await p.updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { status: "cancelled", completed_at: (/* @__PURE__ */ new Date()).toISOString() }, context, ...environmentId ? { environmentId } : {} });
|
|
3422
|
+
} catch {
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
res.json({ success: true });
|
|
3426
|
+
} catch (error) {
|
|
3427
|
+
logError("[REST] Unhandled error:", error);
|
|
3428
|
+
sendError(res, error, "");
|
|
3429
|
+
}
|
|
3430
|
+
},
|
|
3431
|
+
metadata: { summary: "Cancel an in-flight import job", tags: ["data", "import"] }
|
|
3432
|
+
});
|
|
3433
|
+
this.routeManager.register({
|
|
3434
|
+
method: "POST",
|
|
3435
|
+
path: `${dataPath}/import/jobs/:jobId/undo`,
|
|
3436
|
+
handler: async (req, res) => {
|
|
3437
|
+
try {
|
|
3438
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3439
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3440
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3441
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3442
|
+
const jobId = String(req.params.jobId || "");
|
|
3443
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3444
|
+
if (!row) {
|
|
3445
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
if (row.reverted_at) {
|
|
3449
|
+
res.status(409).json({ code: "ALREADY_REVERTED", error: "This import has already been undone" });
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
if (!importJobUndoable(row)) {
|
|
3453
|
+
res.status(422).json({ code: "NOT_UNDOABLE", error: "This import cannot be undone (too large, still running, or nothing was written)" });
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
const objectName = String(row.object_name ?? "");
|
|
3457
|
+
const log = parseUndoLog(row.undo_log);
|
|
3458
|
+
const writeCtx = { ...context ?? {}, skipAutomations: true };
|
|
3459
|
+
let deleted = 0, restored = 0, failed = 0;
|
|
3460
|
+
for (const id of log.created) {
|
|
3461
|
+
try {
|
|
3462
|
+
await p.deleteData({ object: objectName, id, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
3463
|
+
deleted++;
|
|
3464
|
+
} catch {
|
|
3465
|
+
failed++;
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
for (const u of log.updated) {
|
|
3469
|
+
try {
|
|
3470
|
+
await p.updateData({ object: objectName, id: u.id, data: u.before, context: writeCtx, ...environmentId ? { environmentId } : {} });
|
|
3471
|
+
restored++;
|
|
3472
|
+
} catch {
|
|
3473
|
+
failed++;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
await p.updateData({
|
|
3477
|
+
object: IMPORT_JOB_OBJECT,
|
|
3478
|
+
id: jobId,
|
|
3479
|
+
data: { reverted_at: (/* @__PURE__ */ new Date()).toISOString() },
|
|
3480
|
+
context,
|
|
3481
|
+
...environmentId ? { environmentId } : {}
|
|
3482
|
+
});
|
|
3483
|
+
res.json({ success: true, jobId, object: objectName, deleted, restored, failed });
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
logError("[REST] Unhandled error:", error);
|
|
3486
|
+
sendError(res, error, "");
|
|
3487
|
+
}
|
|
3488
|
+
},
|
|
3489
|
+
metadata: { summary: "Undo (logically roll back) a finished import job", tags: ["data", "import"] }
|
|
3490
|
+
});
|
|
3491
|
+
this.routeManager.register({
|
|
3492
|
+
method: "GET",
|
|
3493
|
+
path: `${dataPath}/import/jobs/:jobId/results`,
|
|
3494
|
+
handler: async (req, res) => {
|
|
3495
|
+
try {
|
|
3496
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3497
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3498
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3499
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3500
|
+
const jobId = String(req.params.jobId || "");
|
|
3501
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3502
|
+
if (!row) {
|
|
3503
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
const stored = row.results;
|
|
3507
|
+
const items = Array.isArray(stored?.items) ? stored.items : Array.isArray(stored) ? stored : [];
|
|
3508
|
+
res.json({ ...importJobToProgress(row), results: items, resultsTruncated: !!stored?.truncated });
|
|
3509
|
+
} catch (error) {
|
|
3510
|
+
logError("[REST] Unhandled error:", error);
|
|
3511
|
+
sendError(res, error, "");
|
|
3512
|
+
}
|
|
3513
|
+
},
|
|
3514
|
+
metadata: { summary: "Import job results (capped per-row report)", tags: ["data", "import"] }
|
|
3515
|
+
});
|
|
3516
|
+
this.routeManager.register({
|
|
3517
|
+
method: "GET",
|
|
3518
|
+
path: `${dataPath}/import/jobs/:jobId`,
|
|
3519
|
+
handler: async (req, res) => {
|
|
3520
|
+
try {
|
|
3521
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3522
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3523
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3524
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3525
|
+
const jobId = String(req.params.jobId || "");
|
|
3526
|
+
const row = await loadImportJob(p, jobId, environmentId, context);
|
|
3527
|
+
if (!row) {
|
|
3528
|
+
res.status(404).json({ code: "NOT_FOUND", error: `No import job ${jobId}` });
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
res.json(importJobToProgress(row));
|
|
3532
|
+
} catch (error) {
|
|
3533
|
+
logError("[REST] Unhandled error:", error);
|
|
3534
|
+
sendError(res, error, "");
|
|
3535
|
+
}
|
|
3536
|
+
},
|
|
3537
|
+
metadata: { summary: "Import job progress", tags: ["data", "import"] }
|
|
3538
|
+
});
|
|
3539
|
+
this.routeManager.register({
|
|
3540
|
+
method: "GET",
|
|
3541
|
+
path: `${dataPath}/import/jobs`,
|
|
3542
|
+
handler: async (req, res) => {
|
|
3543
|
+
try {
|
|
3544
|
+
const environmentId = isScoped ? req.params?.environmentId : void 0;
|
|
3545
|
+
const p = await this.resolveProtocol(environmentId, req);
|
|
3546
|
+
const context = await this.resolveExecCtx(environmentId, req);
|
|
3547
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
3548
|
+
const q = req.query ?? {};
|
|
3549
|
+
const filter = {};
|
|
3550
|
+
if (typeof q.object === "string" && q.object) filter.object_name = q.object;
|
|
3551
|
+
if (typeof q.status === "string" && q.status) filter.status = q.status;
|
|
3552
|
+
const limit = Math.min(200, Math.max(1, Number(q.limit) || 50));
|
|
3553
|
+
const offset = Math.max(0, Number(q.offset) || 0);
|
|
3554
|
+
const r = await p.findData({
|
|
3555
|
+
object: IMPORT_JOB_OBJECT,
|
|
3556
|
+
query: { $filter: filter, $orderby: { created_at: "desc" }, $top: limit, $skip: offset },
|
|
3557
|
+
...environmentId ? { environmentId } : {},
|
|
3558
|
+
...context ? { context } : {}
|
|
3559
|
+
});
|
|
3560
|
+
const rows = Array.isArray(r?.records) ? r.records : Array.isArray(r?.data) ? r.data : Array.isArray(r?.rows) ? r.rows : Array.isArray(r) ? r : [];
|
|
3561
|
+
res.json({ jobs: rows.map(importJobToSummary) });
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
logError("[REST] Unhandled error:", error);
|
|
3564
|
+
sendError(res, error, "");
|
|
3565
|
+
}
|
|
3566
|
+
},
|
|
3567
|
+
metadata: { summary: "List import jobs (history)", tags: ["data", "import"] }
|
|
3568
|
+
});
|
|
2578
3569
|
this.routeManager.register({
|
|
2579
3570
|
method: "GET",
|
|
2580
3571
|
path: `${dataPath}/:object/export`,
|
|
@@ -2591,7 +3582,9 @@ var RestServer = class {
|
|
|
2591
3582
|
}
|
|
2592
3583
|
if (await this.enforceApiAccess(req, res, p, environmentId, "export")) return;
|
|
2593
3584
|
const q = req.query ?? {};
|
|
2594
|
-
const
|
|
3585
|
+
const fmtRaw = String(q.format ?? "csv").toLowerCase();
|
|
3586
|
+
const format = fmtRaw === "json" ? "json" : fmtRaw === "xlsx" ? "xlsx" : "csv";
|
|
3587
|
+
const includeHeader = String(q.header ?? "true").toLowerCase() !== "false";
|
|
2595
3588
|
const HARD_CAP = 5e4;
|
|
2596
3589
|
const MAX_CHUNK = 5e3;
|
|
2597
3590
|
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
@@ -2630,21 +3623,33 @@ var RestServer = class {
|
|
|
2630
3623
|
} else if (Array.isArray(q.fields)) {
|
|
2631
3624
|
fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
|
|
2632
3625
|
}
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
3626
|
+
let metaMap = /* @__PURE__ */ new Map();
|
|
3627
|
+
try {
|
|
3628
|
+
let schema = void 0;
|
|
3629
|
+
if (typeof p.getMetaItem === "function") {
|
|
3630
|
+
const res2 = await p.getMetaItem({ type: "object", name: objectName });
|
|
3631
|
+
schema = isMetaEnvelope(res2) ? res2.item : res2;
|
|
3632
|
+
}
|
|
3633
|
+
if (!schema && typeof p.getObjectSchema === "function") {
|
|
3634
|
+
schema = await p.getObjectSchema(objectName, environmentId);
|
|
2641
3635
|
}
|
|
3636
|
+
schema = await this.translateMetaItem(req, "object", environmentId, schema);
|
|
3637
|
+
metaMap = buildFieldMetaMap(schema);
|
|
3638
|
+
if (!fields || fields.length === 0) {
|
|
3639
|
+
const names = [...metaMap.keys()];
|
|
3640
|
+
if (names.length > 0) fields = names;
|
|
3641
|
+
}
|
|
3642
|
+
} catch {
|
|
2642
3643
|
}
|
|
3644
|
+
const expandFields = referenceFieldNames(metaMap);
|
|
2643
3645
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2644
3646
|
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
2645
3647
|
if (format === "csv") {
|
|
2646
3648
|
res.header("Content-Type", "text/csv; charset=utf-8");
|
|
2647
3649
|
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
|
|
3650
|
+
} else if (format === "xlsx") {
|
|
3651
|
+
res.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
3652
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.xlsx"`);
|
|
2648
3653
|
} else {
|
|
2649
3654
|
res.header("Content-Type", "application/json; charset=utf-8");
|
|
2650
3655
|
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
|
|
@@ -2656,6 +3661,7 @@ var RestServer = class {
|
|
|
2656
3661
|
let firstChunk = true;
|
|
2657
3662
|
let skip = 0;
|
|
2658
3663
|
if (format === "json") res.write("[");
|
|
3664
|
+
const xlsx = format === "xlsx" ? await createXlsxStream(res) : null;
|
|
2659
3665
|
while (exported < limit) {
|
|
2660
3666
|
const take = Math.min(chunkSize, limit - exported);
|
|
2661
3667
|
const findArgs = {
|
|
@@ -2663,6 +3669,7 @@ var RestServer = class {
|
|
|
2663
3669
|
query: {
|
|
2664
3670
|
...filter ? { $filter: filter } : {},
|
|
2665
3671
|
...orderby ? { $orderby: orderby } : {},
|
|
3672
|
+
...expandFields.length > 0 ? { $expand: expandFields.join(",") } : {},
|
|
2666
3673
|
$top: take,
|
|
2667
3674
|
$skip: skip
|
|
2668
3675
|
},
|
|
@@ -2670,18 +3677,25 @@ var RestServer = class {
|
|
|
2670
3677
|
...context ? { context } : {}
|
|
2671
3678
|
};
|
|
2672
3679
|
const result = await p.findData(findArgs);
|
|
2673
|
-
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
3680
|
+
const rows = Array.isArray(result?.records) ? result.records : Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
2674
3681
|
if (rows.length === 0) break;
|
|
3682
|
+
if ((!fields || fields.length === 0) && firstChunk) {
|
|
3683
|
+
fields = Object.keys(rows[0] ?? {});
|
|
3684
|
+
}
|
|
2675
3685
|
if (format === "csv") {
|
|
2676
|
-
|
|
2677
|
-
fields = Object.keys(rows[0] ?? {});
|
|
2678
|
-
}
|
|
2679
|
-
const text = rowsToCsv(fields ?? [], rows, firstChunk);
|
|
3686
|
+
const text = rowsToCsv(fields ?? [], rows, firstChunk && includeHeader, metaMap);
|
|
2680
3687
|
res.write(text);
|
|
3688
|
+
} else if (format === "xlsx") {
|
|
3689
|
+
if (firstChunk && includeHeader) {
|
|
3690
|
+
xlsx.ws.addRow((fields ?? []).map((f) => headerLabel(f, metaMap))).commit();
|
|
3691
|
+
}
|
|
3692
|
+
for (const row of rows) {
|
|
3693
|
+
xlsx.ws.addRow(formatRowCells(row, fields ?? [], metaMap)).commit();
|
|
3694
|
+
}
|
|
2681
3695
|
} else {
|
|
2682
3696
|
for (let i = 0; i < rows.length; i++) {
|
|
2683
3697
|
const prefix = firstChunk && i === 0 ? "" : ",";
|
|
2684
|
-
res.write(prefix + JSON.stringify(rows[i]));
|
|
3698
|
+
res.write(prefix + JSON.stringify(formatRowForJson(rows[i], metaMap)));
|
|
2685
3699
|
}
|
|
2686
3700
|
}
|
|
2687
3701
|
firstChunk = false;
|
|
@@ -2689,8 +3703,14 @@ var RestServer = class {
|
|
|
2689
3703
|
skip += rows.length;
|
|
2690
3704
|
if (rows.length < take) break;
|
|
2691
3705
|
}
|
|
2692
|
-
if (format === "json")
|
|
2693
|
-
|
|
3706
|
+
if (format === "json") {
|
|
3707
|
+
res.write("]");
|
|
3708
|
+
res.end();
|
|
3709
|
+
} else if (format === "xlsx") {
|
|
3710
|
+
await xlsx.finalize();
|
|
3711
|
+
} else {
|
|
3712
|
+
res.end();
|
|
3713
|
+
}
|
|
2694
3714
|
} catch (error) {
|
|
2695
3715
|
logError("[REST] Unhandled error:", error);
|
|
2696
3716
|
try {
|
|
@@ -2704,7 +3724,7 @@ var RestServer = class {
|
|
|
2704
3724
|
}
|
|
2705
3725
|
},
|
|
2706
3726
|
metadata: {
|
|
2707
|
-
summary: "Streaming export of object rows (CSV or
|
|
3727
|
+
summary: "Streaming export of object rows (CSV, JSON, or XLSX)",
|
|
2708
3728
|
tags: ["data", "export"]
|
|
2709
3729
|
}
|
|
2710
3730
|
});
|
|
@@ -4414,11 +5434,26 @@ function registerExternalDatasourceRoutes(server, ctx, basePath = "/api/v1") {
|
|
|
4414
5434
|
}
|
|
4415
5435
|
|
|
4416
5436
|
// src/rest-api-plugin.ts
|
|
5437
|
+
var import_audit = require("@objectstack/platform-objects/audit");
|
|
4417
5438
|
function createRestApiPlugin(config = {}) {
|
|
4418
5439
|
return {
|
|
4419
5440
|
name: "com.objectstack.rest.api",
|
|
4420
5441
|
version: "1.0.0",
|
|
4421
|
-
init: async (
|
|
5442
|
+
init: async (ctx) => {
|
|
5443
|
+
try {
|
|
5444
|
+
ctx.getService("manifest").register({
|
|
5445
|
+
id: "com.objectstack.rest.api",
|
|
5446
|
+
name: "REST API",
|
|
5447
|
+
version: "1.0.0",
|
|
5448
|
+
type: "plugin",
|
|
5449
|
+
scope: "system",
|
|
5450
|
+
defaultDatasource: "cloud",
|
|
5451
|
+
namespace: "sys",
|
|
5452
|
+
objects: [import_audit.SysImportJob]
|
|
5453
|
+
});
|
|
5454
|
+
} catch (err) {
|
|
5455
|
+
ctx.logger.warn("RestApiPlugin: manifest service unavailable; sys_import_job not registered", err);
|
|
5456
|
+
}
|
|
4422
5457
|
},
|
|
4423
5458
|
start: async (ctx) => {
|
|
4424
5459
|
const serverService = config.serverServiceName || "http.server";
|