@uipath/data-fabric-tool 0.9.1 → 1.0.4

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.
@@ -37,6 +37,14 @@ interface UpdateEntityOptions {
37
37
  tenant?: string;
38
38
  file?: string;
39
39
  body?: string;
40
+ confirm?: boolean;
41
+ reason?: string;
42
+ }
43
+
44
+ interface DeleteOptions {
45
+ tenant?: string;
46
+ confirm?: boolean;
47
+ reason?: string;
40
48
  }
41
49
 
42
50
  const ENTITIES_LIST_EXAMPLES: CommandExample[] = [
@@ -106,6 +114,22 @@ const ENTITIES_GET_EXAMPLES: CommandExample[] = [
106
114
  },
107
115
  ];
108
116
 
117
+ const ENTITIES_DELETE_EXAMPLES: CommandExample[] = [
118
+ {
119
+ Description:
120
+ "Delete an entity (irreversible — requires --confirm and --reason)",
121
+ Command:
122
+ 'uip df entities delete a1b2c3d4-0000-0000-0000-000000000001 --confirm --reason "test entity cleanup"',
123
+ Output: {
124
+ Code: "EntityDeleted",
125
+ Data: {
126
+ ID: "a1b2c3d4-0000-0000-0000-000000000001",
127
+ Reason: "test entity cleanup",
128
+ },
129
+ },
130
+ },
131
+ ];
132
+
109
133
  const VALID_FIELD_TYPES = new Set(Object.values(EntityFieldDataType));
110
134
  const VALID_FIELD_TYPES_LIST = [...VALID_FIELD_TYPES].join(", ");
111
135
 
@@ -119,6 +143,25 @@ function hasInvalidFieldType(fields: unknown[]): boolean {
119
143
  });
120
144
  }
121
145
 
146
+ const KNOWN_UPDATE_KEYS = [
147
+ "addFields",
148
+ "removeFields",
149
+ "updateFields",
150
+ "displayName",
151
+ "description",
152
+ "isRbacEnabled",
153
+ ] as const;
154
+
155
+ function pickKnownUpdateKeys(
156
+ input: Record<string, unknown>,
157
+ ): EntityUpdateByIdOptions {
158
+ const out: Record<string, unknown> = {};
159
+ for (const key of KNOWN_UPDATE_KEYS) {
160
+ if (input[key] !== undefined) out[key] = input[key];
161
+ }
162
+ return out as EntityUpdateByIdOptions;
163
+ }
164
+
122
165
  export const registerEntitiesCommand = (program: Command) => {
123
166
  const entities = program
124
167
  .command("entities")
@@ -417,9 +460,17 @@ export const registerEntitiesCommand = (program: Command) => {
417
460
  .option("-t, --tenant <tenant-name>", "Tenant name")
418
461
  .option(
419
462
  "-f, --file <path>",
420
- "Path to JSON file with update options (addFields, updateFields, displayName, description, isRbacEnabled)",
463
+ "Path to JSON file with update options (addFields, updateFields, removeFields, displayName, description, isRbacEnabled)",
421
464
  )
422
465
  .option("--body <json>", "Inline JSON update options")
466
+ .option(
467
+ "--confirm",
468
+ "Required when 'removeFields' is non-empty — acknowledges the field deletion is irreversible",
469
+ )
470
+ .option(
471
+ "--reason <reason>",
472
+ "Required when 'removeFields' is non-empty — echoed back in the response so the caller can log it",
473
+ )
423
474
  .trackedAction(
424
475
  processContext,
425
476
  async (id: string, options: UpdateEntityOptions) => {
@@ -450,37 +501,125 @@ export const registerEntitiesCommand = (program: Command) => {
450
501
  Result: RESULTS.Failure,
451
502
  Message: "Update options must be a JSON object",
452
503
  Instructions:
453
- "Provide a JSON object with addFields, updateFields, displayName, description, or isRbacEnabled.",
504
+ "Provide a JSON object with addFields, updateFields, removeFields, displayName, description, or isRbacEnabled.",
454
505
  });
455
506
  processContext.exit(1);
456
507
  return;
457
508
  }
458
509
 
459
510
  const input = parsed as Record<string, unknown>;
460
- if (input.removeFields !== undefined) {
511
+
512
+ if (
513
+ input.addFields !== undefined &&
514
+ !Array.isArray(input.addFields)
515
+ ) {
516
+ OutputFormatter.error({
517
+ Result: RESULTS.Failure,
518
+ Message: "'addFields' must be an array",
519
+ Instructions:
520
+ 'Example: {"addFields":[{"fieldName":"title","type":"STRING"}]}',
521
+ });
522
+ processContext.exit(1);
523
+ return;
524
+ }
525
+
526
+ if (
527
+ input.updateFields !== undefined &&
528
+ !Array.isArray(input.updateFields)
529
+ ) {
461
530
  OutputFormatter.error({
462
531
  Result: RESULTS.Failure,
463
- Message: "removeFields is not supported",
532
+ Message: "'updateFields' must be an array",
464
533
  Instructions:
465
- "Removing fields is not supported at this time. Use addFields or updateFields instead.",
534
+ 'Example: {"updateFields":[{"id":"<fieldId>","displayName":"New Name"}]}',
466
535
  });
467
536
  processContext.exit(1);
468
537
  return;
469
538
  }
470
539
 
540
+ if (
541
+ input.removeFields !== undefined &&
542
+ !Array.isArray(input.removeFields)
543
+ ) {
544
+ OutputFormatter.error({
545
+ Result: RESULTS.Failure,
546
+ Message: "'removeFields' must be an array",
547
+ Instructions:
548
+ 'Example: {"removeFields":[{"fieldName":"old_field"}]}',
549
+ });
550
+ processContext.exit(1);
551
+ return;
552
+ }
553
+
554
+ let trimmedReason: string | undefined;
555
+ let removedFieldNames: string[] = [];
556
+ const hasRemove =
557
+ Array.isArray(input.removeFields) &&
558
+ input.removeFields.length > 0;
559
+
560
+ if (hasRemove) {
561
+ const removeFields = input.removeFields as unknown[];
562
+ const hasInvalidRemove = removeFields.some((f) => {
563
+ if (typeof f !== "object" || f === null) return true;
564
+ const fn = (f as Record<string, unknown>).fieldName;
565
+ return typeof fn !== "string" || fn.trim() === "";
566
+ });
567
+ if (hasInvalidRemove) {
568
+ OutputFormatter.error({
569
+ Result: RESULTS.Failure,
570
+ Message:
571
+ "Each field in removeFields must include a non-empty 'fieldName' string",
572
+ Instructions:
573
+ 'Example: {"removeFields":[{"fieldName":"old_field"}]}',
574
+ });
575
+ processContext.exit(1);
576
+ return;
577
+ }
578
+
579
+ if (options.confirm !== true) {
580
+ OutputFormatter.error({
581
+ Result: RESULTS.Failure,
582
+ Message:
583
+ "Confirmation required for destructive operation",
584
+ Instructions:
585
+ "Pass --confirm to acknowledge field deletion is irreversible.",
586
+ });
587
+ processContext.exit(1);
588
+ return;
589
+ }
590
+
591
+ trimmedReason = options.reason?.trim();
592
+ if (trimmedReason === undefined || trimmedReason === "") {
593
+ OutputFormatter.error({
594
+ Result: RESULTS.Failure,
595
+ Message:
596
+ "Reason required for destructive operation",
597
+ Instructions:
598
+ 'Pass --reason "<text>" to record why fields are being removed.',
599
+ });
600
+ processContext.exit(1);
601
+ return;
602
+ }
603
+
604
+ removedFieldNames = (
605
+ removeFields as { fieldName: string }[]
606
+ ).map((f) => f.fieldName);
607
+ }
608
+
471
609
  if (Array.isArray(input.addFields)) {
472
610
  const hasInvalidField = (input.addFields as unknown[]).some(
473
- (f) =>
474
- typeof f !== "object" ||
475
- f === null ||
476
- typeof (f as Record<string, unknown>).fieldName !==
477
- "string",
611
+ (f) => {
612
+ if (typeof f !== "object" || f === null)
613
+ return true;
614
+ const fn = (f as Record<string, unknown>).fieldName;
615
+ return typeof fn !== "string" || fn.trim() === "";
616
+ },
478
617
  );
479
618
  if (hasInvalidField) {
480
619
  OutputFormatter.error({
481
620
  Result: RESULTS.Failure,
482
621
  Message:
483
- "Each field in addFields must include a 'fieldName' string",
622
+ "Each field in addFields must include a non-empty 'fieldName' string",
484
623
  Instructions:
485
624
  'Example: {"fieldName":"title","type":"STRING"}',
486
625
  });
@@ -497,23 +636,54 @@ export const registerEntitiesCommand = (program: Command) => {
497
636
  processContext.exit(1);
498
637
  return;
499
638
  }
639
+
640
+ const addNames = (
641
+ input.addFields as { fieldName: string }[]
642
+ ).map((f) => f.fieldName);
643
+ const duplicateAdd = addNames.find(
644
+ (name, i) => addNames.indexOf(name) !== i,
645
+ );
646
+ if (duplicateAdd !== undefined) {
647
+ OutputFormatter.error({
648
+ Result: RESULTS.Failure,
649
+ Message: `Duplicate fieldName '${duplicateAdd}' in addFields`,
650
+ Instructions:
651
+ "Each entry in addFields must have a unique fieldName.",
652
+ });
653
+ processContext.exit(1);
654
+ return;
655
+ }
656
+
657
+ if (removedFieldNames.length > 0) {
658
+ const conflict = addNames.find((name) =>
659
+ removedFieldNames.includes(name),
660
+ );
661
+ if (conflict !== undefined) {
662
+ OutputFormatter.error({
663
+ Result: RESULTS.Failure,
664
+ Message: `Field '${conflict}' appears in both addFields and removeFields`,
665
+ Instructions:
666
+ "A single update cannot add and remove the same field. Split into two calls if you need to recreate it.",
667
+ });
668
+ processContext.exit(1);
669
+ return;
670
+ }
671
+ }
500
672
  }
501
673
 
502
674
  if (Array.isArray(input.updateFields)) {
503
675
  const hasInvalidField = (
504
676
  input.updateFields as unknown[]
505
- ).some(
506
- (f) =>
507
- typeof f !== "object" ||
508
- f === null ||
509
- typeof (f as Record<string, unknown>).id !==
510
- "string",
511
- );
677
+ ).some((f) => {
678
+ if (typeof f !== "object" || f === null) return true;
679
+ const fid = (f as Record<string, unknown>).id;
680
+ return typeof fid !== "string" || fid.trim() === "";
681
+ });
512
682
  if (hasInvalidField) {
513
683
  OutputFormatter.error({
514
684
  Result: RESULTS.Failure,
515
685
  Message:
516
- "Each field in updateFields must include an 'id' string",
686
+ "Each field in updateFields must include a non-empty 'id' string",
517
687
  Instructions:
518
688
  'Use \'df entities get <entityId>\' to find field IDs. Example: {"id":"<fieldId>","displayName":"Total Amount","isRequired":true}',
519
689
  });
@@ -538,10 +708,7 @@ export const registerEntitiesCommand = (program: Command) => {
538
708
 
539
709
  const entityService: EntityServiceModel = sdk.entities;
540
710
  const [updateError] = await catchError(
541
- entityService.updateById(
542
- id,
543
- input as EntityUpdateByIdOptions,
544
- ),
711
+ entityService.updateById(id, pickKnownUpdateKeys(input)),
545
712
  );
546
713
 
547
714
  if (updateError) {
@@ -557,7 +724,88 @@ export const registerEntitiesCommand = (program: Command) => {
557
724
  OutputFormatter.success({
558
725
  Result: RESULTS.Success,
559
726
  Code: "EntityUpdated",
560
- Data: { ID: id },
727
+ Data: {
728
+ ID: id,
729
+ ...(removedFieldNames.length > 0 && {
730
+ RemovedFields: removedFieldNames,
731
+ Reason: trimmedReason,
732
+ }),
733
+ },
734
+ });
735
+ },
736
+ );
737
+
738
+ entities
739
+ .command("delete")
740
+ .description("Delete a Data Fabric entity (irreversible)")
741
+ .argument("<id>", "Entity ID")
742
+ .option("-t, --tenant <tenant-name>", "Tenant name")
743
+ .option("--confirm", "Acknowledge this is an irreversible operation")
744
+ .option(
745
+ "--reason <reason>",
746
+ "Reason for the deletion — echoed back in the response so the caller can log it",
747
+ )
748
+ .examples(ENTITIES_DELETE_EXAMPLES)
749
+ .trackedAction(
750
+ processContext,
751
+ async (id: string, options: DeleteOptions) => {
752
+ if (options.confirm !== true) {
753
+ OutputFormatter.error({
754
+ Result: RESULTS.Failure,
755
+ Message:
756
+ "Confirmation required for destructive operation",
757
+ Instructions:
758
+ "Pass --confirm to acknowledge this is irreversible.",
759
+ });
760
+ processContext.exit(1);
761
+ return;
762
+ }
763
+
764
+ const reason = options.reason?.trim();
765
+ if (reason === undefined || reason === "") {
766
+ OutputFormatter.error({
767
+ Result: RESULTS.Failure,
768
+ Message: "Reason required for destructive operation",
769
+ Instructions:
770
+ 'Pass --reason "<text>" to record why the entity is being deleted.',
771
+ });
772
+ processContext.exit(1);
773
+ return;
774
+ }
775
+
776
+ const [clientError, sdk] = await catchError(
777
+ createDataFabricClient(options.tenant),
778
+ );
779
+
780
+ if (clientError) {
781
+ OutputFormatter.error({
782
+ Result: RESULTS.Failure,
783
+ Message: "Error connecting to Data Fabric",
784
+ Instructions: await extractErrorMessage(clientError),
785
+ });
786
+ processContext.exit(1);
787
+ return;
788
+ }
789
+
790
+ const entityService: EntityServiceModel = sdk.entities;
791
+ const [deleteError] = await catchError(
792
+ entityService.deleteById(id),
793
+ );
794
+
795
+ if (deleteError) {
796
+ OutputFormatter.error({
797
+ Result: RESULTS.Failure,
798
+ Message: `Error deleting entity '${id}'`,
799
+ Instructions: await extractErrorMessage(deleteError),
800
+ });
801
+ processContext.exit(1);
802
+ return;
803
+ }
804
+
805
+ OutputFormatter.success({
806
+ Result: RESULTS.Success,
807
+ Code: "EntityDeleted",
808
+ Data: { ID: id, Reason: reason },
561
809
  });
562
810
  },
563
811
  );
@@ -233,6 +233,126 @@ describe("records list", () => {
233
233
  );
234
234
  expect(process.exitCode).toBe(1);
235
235
  });
236
+
237
+ it("should pass jumpToPage to SDK when --offset is given", async () => {
238
+ const sdk = mockSdk();
239
+ vi.mocked(sdk.entities.getAllRecords).mockResolvedValue({
240
+ items: [{ Id: "rec-51" }],
241
+ totalCount: 100,
242
+ hasNextPage: true,
243
+ });
244
+
245
+ const program = buildProgram();
246
+ await program.parseAsync([
247
+ "node",
248
+ "test",
249
+ "records",
250
+ "list",
251
+ "entity-id",
252
+ "--offset",
253
+ "50",
254
+ "--limit",
255
+ "50",
256
+ ]);
257
+
258
+ expect(sdk.entities.getAllRecords).toHaveBeenCalledWith("entity-id", {
259
+ pageSize: 50,
260
+ jumpToPage: 2,
261
+ });
262
+ expect(OutputFormatter.success).toHaveBeenCalledWith(
263
+ expect.objectContaining({ Result: "Success", Code: "RecordList" }),
264
+ );
265
+ });
266
+
267
+ it("should use jumpToPage 1 when --offset is 0", async () => {
268
+ const sdk = mockSdk();
269
+ vi.mocked(sdk.entities.getAllRecords).mockResolvedValue({
270
+ items: [{ Id: "rec-1" }],
271
+ totalCount: 10,
272
+ hasNextPage: false,
273
+ });
274
+
275
+ const program = buildProgram();
276
+ await program.parseAsync([
277
+ "node",
278
+ "test",
279
+ "records",
280
+ "list",
281
+ "entity-id",
282
+ "--offset",
283
+ "0",
284
+ ]);
285
+
286
+ expect(sdk.entities.getAllRecords).toHaveBeenCalledWith("entity-id", {
287
+ pageSize: 50,
288
+ jumpToPage: 1,
289
+ });
290
+ });
291
+
292
+ it("should error when --offset and --cursor are both given on list", async () => {
293
+ mockSdk();
294
+ const program = buildProgram();
295
+ await program.parseAsync([
296
+ "node",
297
+ "test",
298
+ "records",
299
+ "list",
300
+ "entity-id",
301
+ "--offset",
302
+ "50",
303
+ "--cursor",
304
+ "some-cursor",
305
+ ]);
306
+ expect(OutputFormatter.error).toHaveBeenCalledWith(
307
+ expect.objectContaining({
308
+ Result: "Failure",
309
+ Message: "--offset and --cursor are mutually exclusive",
310
+ }),
311
+ );
312
+ expect(process.exitCode).toBe(1);
313
+ });
314
+
315
+ it("should error when --offset is not a number on list", async () => {
316
+ mockSdk();
317
+ const program = buildProgram();
318
+ await program.parseAsync([
319
+ "node",
320
+ "test",
321
+ "records",
322
+ "list",
323
+ "entity-id",
324
+ "--offset",
325
+ "abc",
326
+ ]);
327
+ expect(OutputFormatter.error).toHaveBeenCalledWith(
328
+ expect.objectContaining({
329
+ Result: "Failure",
330
+ Message: "Invalid --offset value",
331
+ }),
332
+ );
333
+ expect(process.exitCode).toBe(1);
334
+ });
335
+
336
+ it("should error when --offset is negative on list", async () => {
337
+ mockSdk();
338
+ const program = buildProgram();
339
+ await program.parseAsync([
340
+ "node",
341
+ "test",
342
+ "records",
343
+ "list",
344
+ "entity-id",
345
+ "--offset",
346
+ "-1",
347
+ ]);
348
+ expect(OutputFormatter.error).toHaveBeenCalledWith(
349
+ expect.objectContaining({
350
+ Result: "Failure",
351
+ Message: "Invalid --offset value",
352
+ }),
353
+ );
354
+ expect(process.exitCode).toBe(1);
355
+ });
236
356
  });
237
357
 
238
358
  describe("records list client error", () => {
@@ -1237,6 +1357,60 @@ describe("records query", () => {
1237
1357
  );
1238
1358
  });
1239
1359
 
1360
+ it("should query records with aggregates and groupBy via --body", async () => {
1361
+ const sdk = mockSdk();
1362
+ vi.mocked(sdk.entities.queryRecordsById).mockResolvedValue({
1363
+ items: [
1364
+ { status: "Open", total: 12 },
1365
+ { status: "Closed", total: 5 },
1366
+ ],
1367
+ totalCount: 2,
1368
+ hasNextPage: false,
1369
+ });
1370
+
1371
+ const aggregateBody = JSON.stringify({
1372
+ selectedFields: ["status"],
1373
+ groupBy: ["status"],
1374
+ aggregates: [{ function: "COUNT", field: "Id", alias: "total" }],
1375
+ });
1376
+
1377
+ const program = buildProgram();
1378
+ await program.parseAsync([
1379
+ "node",
1380
+ "test",
1381
+ "records",
1382
+ "query",
1383
+ "entity-id",
1384
+ "--body",
1385
+ aggregateBody,
1386
+ ]);
1387
+
1388
+ expect(sdk.entities.queryRecordsById).toHaveBeenCalledWith(
1389
+ "entity-id",
1390
+ expect.objectContaining({
1391
+ selectedFields: ["status"],
1392
+ groupBy: ["status"],
1393
+ aggregates: [
1394
+ { function: "COUNT", field: "Id", alias: "total" },
1395
+ ],
1396
+ pageSize: 50,
1397
+ }),
1398
+ );
1399
+ expect(OutputFormatter.success).toHaveBeenCalledWith(
1400
+ expect.objectContaining({
1401
+ Result: "Success",
1402
+ Code: "RecordQuery",
1403
+ Data: expect.objectContaining({
1404
+ TotalCount: 2,
1405
+ Records: [
1406
+ { status: "Open", total: 12 },
1407
+ { status: "Closed", total: 5 },
1408
+ ],
1409
+ }),
1410
+ }),
1411
+ );
1412
+ });
1413
+
1240
1414
  it("should query records with filterGroup and sortOptions combined", async () => {
1241
1415
  const sdk = mockSdk();
1242
1416
  vi.mocked(sdk.entities.queryRecordsById).mockResolvedValue({
@@ -1920,6 +2094,83 @@ describe("records query — pagination", () => {
1920
2094
  );
1921
2095
  });
1922
2096
 
2097
+ it("should pass jumpToPage to SDK when --offset is given on query", async () => {
2098
+ const sdk = mockSdk();
2099
+ vi.mocked(sdk.entities.queryRecordsById).mockResolvedValue({
2100
+ items: [{ Id: "r101" }],
2101
+ totalCount: 200,
2102
+ hasNextPage: true,
2103
+ });
2104
+
2105
+ const program = buildProgram();
2106
+ await program.parseAsync([
2107
+ "node",
2108
+ "test",
2109
+ "records",
2110
+ "query",
2111
+ "entity-id",
2112
+ "--offset",
2113
+ "100",
2114
+ "--limit",
2115
+ "50",
2116
+ ]);
2117
+
2118
+ expect(sdk.entities.queryRecordsById).toHaveBeenCalledWith(
2119
+ "entity-id",
2120
+ expect.objectContaining({
2121
+ pageSize: 50,
2122
+ jumpToPage: 3,
2123
+ }),
2124
+ );
2125
+ expect(OutputFormatter.success).toHaveBeenCalledWith(
2126
+ expect.objectContaining({ Result: "Success", Code: "RecordQuery" }),
2127
+ );
2128
+ });
2129
+
2130
+ it("should error when --offset and --cursor are both given on query", async () => {
2131
+ mockSdk();
2132
+ const program = buildProgram();
2133
+ await program.parseAsync([
2134
+ "node",
2135
+ "test",
2136
+ "records",
2137
+ "query",
2138
+ "entity-id",
2139
+ "--offset",
2140
+ "50",
2141
+ "--cursor",
2142
+ "some-cursor",
2143
+ ]);
2144
+ expect(OutputFormatter.error).toHaveBeenCalledWith(
2145
+ expect.objectContaining({
2146
+ Result: "Failure",
2147
+ Message: "--offset and --cursor are mutually exclusive",
2148
+ }),
2149
+ );
2150
+ expect(process.exitCode).toBe(1);
2151
+ });
2152
+
2153
+ it("should error when --offset is invalid on query", async () => {
2154
+ mockSdk();
2155
+ const program = buildProgram();
2156
+ await program.parseAsync([
2157
+ "node",
2158
+ "test",
2159
+ "records",
2160
+ "query",
2161
+ "entity-id",
2162
+ "--offset",
2163
+ "bad",
2164
+ ]);
2165
+ expect(OutputFormatter.error).toHaveBeenCalledWith(
2166
+ expect.objectContaining({
2167
+ Result: "Failure",
2168
+ Message: "Invalid --offset value",
2169
+ }),
2170
+ );
2171
+ expect(process.exitCode).toBe(1);
2172
+ });
2173
+
1923
2174
  it("should handle last page correctly — no NextCursor even if SDK emits undefined", async () => {
1924
2175
  const sdk = mockSdk();
1925
2176
  vi.mocked(sdk.entities.queryRecordsById).mockResolvedValue({
@@ -1963,16 +2214,6 @@ describe("records — negative scenarios", () => {
1963
2214
  process.exitCode = undefined;
1964
2215
  });
1965
2216
 
1966
- it("entities delete is not a registered command", async () => {
1967
- const { registerEntitiesCommand } = await import("./entities");
1968
- const { Command } = await import("commander");
1969
- const prog = new Command().exitOverride();
1970
- registerEntitiesCommand(prog);
1971
- const cmd = prog.commands.find((c) => c.name() === "entities");
1972
- const subNames = cmd?.commands.map((c) => c.name());
1973
- expect(subNames).not.toContain("delete");
1974
- });
1975
-
1976
2217
  it("records query — entity not found returns error", async () => {
1977
2218
  const sdk = mockSdk();
1978
2219
  vi.mocked(sdk.entities.queryRecordsById).mockRejectedValue(