@objectstack/rest 11.3.0 → 11.5.0

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