@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 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((f) => formatCsvCell(row?.[f])).join(","));
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 dryRun = body.dryRun === true;
2511
- const mapping = body.mapping ?? {};
2512
- let rows = [];
2513
- if (body.format === "json" && Array.isArray(body.rows)) {
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 max = 5e3;
2527
- if (rows.length > max) {
2528
- res.status(413).json({
2529
- code: "PAYLOAD_TOO_LARGE",
2530
- error: `Import limit is ${max} rows per request (got ${rows.length})`
2531
- });
2532
- return;
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: okCount,
2565
- errors: errCount,
2566
- results
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 format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
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
- if (!fields || fields.length === 0) {
2634
- try {
2635
- const schema = await p.getObjectSchema?.(objectName, environmentId);
2636
- const schemaFields = schema?.fields;
2637
- if (Array.isArray(schemaFields)) {
2638
- fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
2639
- }
2640
- } catch {
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
- if ((!fields || fields.length === 0) && firstChunk) {
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") res.write("]");
2693
- res.end();
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 JSON)",
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 (_ctx) => {
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";