@uipath/data-fabric-tool 1.199.0 → 1.201.0-preview.115

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.
@@ -42,37 +42,222 @@ const UI_BROKEN_SUBSTITUTIONS: Record<string, string> = {
42
42
  };
43
43
 
44
44
  // System-generated columns present on every entity — reusing these names errors.
45
- const RESERVED_FIELD_NAMES = new Set([
46
- "Id",
47
- "CreatedBy",
48
- "CreateTime",
49
- "UpdatedBy",
50
- "UpdateTime",
45
+ // Mirrors backend `Constants.AllSystemFields` (StorageManager Common/Constants.cs).
46
+ const RESERVED_FIELD_NAMES_LOWER = new Set([
47
+ "id",
48
+ "createdby",
49
+ "createtime",
50
+ "updatedby",
51
+ "updatetime",
52
+ "recordowner",
53
+ "version",
51
54
  ]);
52
55
 
53
- // The commonly-hit C# / VB reserved words the server rejects with
54
- // `RESERVED_LANGUAGE_KEYWORDS`. Match is case-insensitive: the server treats
55
- // `Class`, `class`, and `CLASS` all as the same keyword.
56
- // Not exhaustive — the authoritative list lives server-side. Any keyword that
57
- // slips through still hits the server error path, which the CLI surfaces
58
- // verbatim.
56
+ // C# and VB reserved words the server rejects with `RESERVED_LANGUAGE_KEYWORDS`.
57
+ // The backend calls `CodeDomProvider.IsValidIdentifier` for both C# and VB
58
+ // (EntityValidationHelper.cs), so the CLI union is case-insensitive.
59
59
  const RESERVED_KEYWORDS_LOWER = new Set([
60
+ // C# reserved keywords
61
+ "abstract",
62
+ "as",
63
+ "base",
64
+ "bool",
65
+ "break",
66
+ "byte",
60
67
  "case",
68
+ "catch",
69
+ "char",
70
+ "checked",
61
71
  "class",
62
- "if",
63
- "then",
72
+ "const",
73
+ "continue",
74
+ "decimal",
75
+ "default",
76
+ "delegate",
77
+ "do",
78
+ "double",
64
79
  "else",
80
+ "enum",
81
+ "event",
82
+ "explicit",
83
+ "extern",
84
+ "false",
85
+ "finally",
86
+ "fixed",
87
+ "float",
88
+ "for",
89
+ "foreach",
90
+ "goto",
91
+ "if",
92
+ "implicit",
93
+ "in",
94
+ "int",
95
+ "interface",
96
+ "internal",
97
+ "is",
98
+ "lock",
99
+ "long",
100
+ "namespace",
65
101
  "new",
102
+ "null",
66
103
  "object",
104
+ "operator",
105
+ "out",
106
+ "override",
107
+ "params",
108
+ "private",
109
+ "protected",
67
110
  "public",
111
+ "readonly",
112
+ "ref",
68
113
  "return",
69
- "select",
70
- "internal",
71
- "private",
114
+ "sbyte",
115
+ "sealed",
116
+ "short",
117
+ "sizeof",
118
+ "stackalloc",
72
119
  "static",
120
+ "string",
121
+ "struct",
122
+ "switch",
123
+ "this",
124
+ "throw",
125
+ "true",
126
+ "try",
127
+ "typeof",
128
+ "uint",
129
+ "ulong",
130
+ "unchecked",
131
+ "unsafe",
132
+ "ushort",
133
+ "using",
134
+ "virtual",
135
+ "void",
136
+ "volatile",
137
+ "while",
138
+ // VB reserved keywords (unique to VB — C# overlaps deduped by Set)
139
+ "addhandler",
140
+ "addressof",
141
+ "alias",
142
+ "and",
143
+ "andalso",
144
+ "boolean",
145
+ "byref",
146
+ "byval",
147
+ "call",
148
+ "cbool",
149
+ "cbyte",
150
+ "cchar",
151
+ "cdate",
152
+ "cdbl",
153
+ "cdec",
154
+ "cint",
155
+ "clng",
156
+ "cobj",
157
+ "csbyte",
158
+ "cshort",
159
+ "csng",
160
+ "cstr",
161
+ "ctype",
162
+ "cuint",
163
+ "culng",
164
+ "cushort",
165
+ "date",
166
+ "declare",
167
+ "dim",
168
+ "directcast",
169
+ "each",
170
+ "elseif",
171
+ "end",
172
+ "endif",
173
+ "erase",
174
+ "error",
175
+ "exit",
176
+ "friend",
177
+ "function",
178
+ "get",
179
+ "gettype",
180
+ "getxmlnamespace",
181
+ "global",
182
+ "gosub",
183
+ "goto",
184
+ "handles",
185
+ "implements",
186
+ "imports",
187
+ "inherits",
188
+ "integer",
189
+ "isnot",
190
+ "let",
191
+ "lib",
192
+ "like",
193
+ "loop",
194
+ "me",
195
+ "mod",
196
+ "module",
197
+ "mustinherit",
198
+ "mustoverride",
199
+ "mybase",
200
+ "myclass",
201
+ "narrowing",
202
+ "next",
203
+ "not",
204
+ "nothing",
205
+ "notinheritable",
206
+ "notoverridable",
207
+ "of",
208
+ "on",
209
+ "option",
210
+ "optional",
211
+ "or",
212
+ "orelse",
213
+ "overloads",
214
+ "overridable",
215
+ "overrides",
216
+ "paramarray",
217
+ "partial",
218
+ "property",
219
+ "raiseevent",
220
+ "redim",
221
+ "rem",
222
+ "removehandler",
223
+ "resume",
224
+ "select",
225
+ "set",
226
+ "shadows",
227
+ "shared",
228
+ "single",
229
+ "step",
230
+ "stop",
231
+ "structure",
232
+ "sub",
233
+ "synclock",
234
+ "then",
235
+ "to",
236
+ "trycast",
237
+ "uinteger",
238
+ "ushort",
239
+ "variant",
240
+ "wend",
241
+ "when",
242
+ "widening",
243
+ "with",
244
+ "withevents",
245
+ "writeonly",
246
+ "xor",
73
247
  ]);
74
248
 
75
- const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{2,99}$/;
249
+ // SQL words confirmed to be rejected by Data Fabric for entity and field
250
+ // names. Similar-looking words such as Status, Key, and Type are accepted, so
251
+ // keep this list limited to names verified against the service.
252
+ const RESERVED_SQL_KEYWORDS_LOWER = new Set(["group", "order"]);
253
+
254
+ const ENTITY_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{2,99}$/;
255
+ const FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{2,99}$/;
256
+
257
+ const MAX_DISPLAY_NAME_LENGTH = 128;
258
+ const MAX_DESCRIPTION_LENGTH = 512;
259
+ const MAX_MULTILINE_MAX_FIELDS = 10;
260
+ const MAX_SAFE_NUMERIC_BOUND = 9_007_199_254_740_991;
76
261
 
77
262
  export type NameKind = "entity" | "field" | "choice set";
78
263
 
@@ -87,10 +272,14 @@ export function validateName(
87
272
  // Reserved / keyword checks fire before the format check so short reserved
88
273
  // names like `Id`, `If`, `New` get the actionable message instead of the
89
274
  // generic "too short" one.
90
- if (kind === "field" && RESERVED_FIELD_NAMES.has(name)) {
275
+ if (
276
+ kind === "field" &&
277
+ RESERVED_FIELD_NAMES_LOWER.has(name.toLowerCase())
278
+ ) {
91
279
  return {
92
280
  message: `Field name '${name}' is reserved`,
93
- instructions: `The following field names are reserved by the platform: ${[...RESERVED_FIELD_NAMES].join(", ")}. Pick a different name.`,
281
+ instructions:
282
+ "The following field names are reserved by the platform (case-insensitive): Id, CreatedBy, CreateTime, UpdatedBy, UpdateTime, RecordOwner, Version. Pick a different name.",
94
283
  };
95
284
  }
96
285
 
@@ -102,11 +291,25 @@ export function validateName(
102
291
  };
103
292
  }
104
293
 
105
- if (!NAME_PATTERN.test(name)) {
294
+ if (
295
+ kind === "entity" &&
296
+ RESERVED_SQL_KEYWORDS_LOWER.has(name.toLowerCase())
297
+ ) {
298
+ return {
299
+ message: `Entity name '${name}' is a reserved SQL keyword`,
300
+ instructions:
301
+ "The platform rejects 'Order' and 'Group' as entity names. Pick a domain-specific name such as 'PurchaseOrder' or 'TeamGroup'.",
302
+ };
303
+ }
304
+
305
+ const pattern = kind === "field" ? FIELD_NAME_PATTERN : ENTITY_NAME_PATTERN;
306
+ if (!pattern.test(name)) {
106
307
  return {
107
308
  message: `Invalid ${kind} name '${name}'`,
108
309
  instructions:
109
- "Names must start with a letter, contain only letters, digits, and underscores, and be 3-100 characters long.",
310
+ kind === "field"
311
+ ? "Field names must start with a letter, contain only letters and digits (no underscores), and be 3-100 characters long."
312
+ : "Names must start with a letter, contain only letters, digits, and underscores, and be 3-100 characters long.",
110
313
  };
111
314
  }
112
315
 
@@ -131,6 +334,128 @@ export function findInvalidFieldName(
131
334
  return null;
132
335
  }
133
336
 
337
+ function validateMetadataLengths(
338
+ rec: Record<string, unknown>,
339
+ subject: string,
340
+ ): ValidationError | null {
341
+ if (rec.displayName !== undefined) {
342
+ if (typeof rec.displayName !== "string") {
343
+ return {
344
+ message: `${subject} 'displayName' must be a string`,
345
+ instructions: `Pass a text value no longer than ${MAX_DISPLAY_NAME_LENGTH} characters.`,
346
+ };
347
+ }
348
+ if (rec.displayName.length > MAX_DISPLAY_NAME_LENGTH) {
349
+ return {
350
+ message: `${subject} 'displayName' exceeds ${MAX_DISPLAY_NAME_LENGTH} characters`,
351
+ instructions: `Shorten 'displayName' to ${MAX_DISPLAY_NAME_LENGTH} characters or fewer.`,
352
+ };
353
+ }
354
+ }
355
+
356
+ if (rec.description !== undefined) {
357
+ if (typeof rec.description !== "string") {
358
+ return {
359
+ message: `${subject} 'description' must be a string`,
360
+ instructions: `Pass a text value no longer than ${MAX_DESCRIPTION_LENGTH} characters.`,
361
+ };
362
+ }
363
+ if (rec.description.length > MAX_DESCRIPTION_LENGTH) {
364
+ return {
365
+ message: `${subject} 'description' exceeds ${MAX_DESCRIPTION_LENGTH} characters`,
366
+ instructions: `Shorten 'description' to ${MAX_DESCRIPTION_LENGTH} characters or fewer.`,
367
+ };
368
+ }
369
+ }
370
+
371
+ return null;
372
+ }
373
+
374
+ /** Validate entity or choice-set metadata against fixed backend limits. */
375
+ export function validateSchemaMetadata(
376
+ value: Record<string, unknown>,
377
+ subject: "Entity" | "Choice set",
378
+ ): ValidationError | null {
379
+ return validateMetadataLengths(value, subject);
380
+ }
381
+
382
+ /** Validate field metadata and case-insensitive batch collisions. */
383
+ export function findInvalidFieldMetadata(
384
+ fields: unknown[],
385
+ entityName?: string,
386
+ ): ValidationError | null {
387
+ const names = new Set<string>();
388
+ const displayNames = new Set<string>();
389
+
390
+ for (const f of fields) {
391
+ if (typeof f !== "object" || f === null) continue;
392
+ const rec = f as Record<string, unknown>;
393
+ const fieldName =
394
+ typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
395
+ const metadataErr = validateMetadataLengths(
396
+ rec,
397
+ `Field '${fieldName}'`,
398
+ );
399
+ if (metadataErr) return metadataErr;
400
+
401
+ if (typeof rec.fieldName !== "string") continue;
402
+ const normalizedName = rec.fieldName.toLowerCase();
403
+ if (names.has(normalizedName)) {
404
+ return {
405
+ message: `Duplicate field name '${rec.fieldName}' (case-insensitive)`,
406
+ instructions:
407
+ "Every field in one create/addFields batch must have a unique name, ignoring case.",
408
+ };
409
+ }
410
+ names.add(normalizedName);
411
+
412
+ if (
413
+ entityName !== undefined &&
414
+ normalizedName === entityName.toLowerCase()
415
+ ) {
416
+ return {
417
+ message: `Field name '${rec.fieldName}' cannot match entity name '${entityName}'`,
418
+ instructions:
419
+ "Entity and field names are compared case-insensitively. Pick a different field name.",
420
+ };
421
+ }
422
+
423
+ const effectiveDisplayName =
424
+ typeof rec.displayName === "string" && rec.displayName.length > 0
425
+ ? rec.displayName
426
+ : rec.fieldName;
427
+ const normalizedDisplayName = effectiveDisplayName.toLowerCase();
428
+ if (displayNames.has(normalizedDisplayName)) {
429
+ return {
430
+ message: `Duplicate field display name '${effectiveDisplayName}' (case-insensitive)`,
431
+ instructions:
432
+ "Every field in one create/addFields batch must have a unique display name, ignoring case.",
433
+ };
434
+ }
435
+ displayNames.add(normalizedDisplayName);
436
+ }
437
+
438
+ return null;
439
+ }
440
+
441
+ /** A new entity cannot contain more than ten MULTILINE_MAX fields. */
442
+ export function findTooManyMultilineMaxFields(
443
+ fields: unknown[],
444
+ ): ValidationError | null {
445
+ const count = fields.filter(
446
+ (field) =>
447
+ typeof field === "object" &&
448
+ field !== null &&
449
+ (field as Record<string, unknown>).type === "MULTILINE_MAX",
450
+ ).length;
451
+ if (count <= MAX_MULTILINE_MAX_FIELDS) return null;
452
+
453
+ return {
454
+ message: `Entity defines ${count} MULTILINE_MAX fields; the maximum is ${MAX_MULTILINE_MAX_FIELDS}`,
455
+ instructions: `Keep at most ${MAX_MULTILINE_MAX_FIELDS} MULTILINE_MAX fields on one entity.`,
456
+ };
457
+ }
458
+
134
459
  /**
135
460
  * Scan a list of field definitions for any type value that lives in
136
461
  * `UI_BROKEN_FIELD_TYPES`. Returns the first offender's suggested
@@ -157,8 +482,8 @@ export function findUiBrokenFieldType(
157
482
  return null;
158
483
  }
159
484
 
160
- // `referenceFolderKey` is a per-field folder hint used by RELATIONSHIP / FILE
161
- // fields to point at a target that lives in a specific folder. CHOICE_SET_*
485
+ // `referenceFolderKey` is a per-field folder hint used by RELATIONSHIP fields
486
+ // to point at a target that lives in a specific folder. CHOICE_SET_*
162
487
  // fields resolve their folder server-side from `choiceSetId`, so passing
163
488
  // `referenceFolderKey` on a CHOICE_SET_* field is always wrong. Reject it up
164
489
  // front instead of letting the server return a misleading cross-scope error.
@@ -185,6 +510,40 @@ export function findReferenceFolderKeyOnChoiceSet(
185
510
  return null;
186
511
  }
187
512
 
513
+ const FILE_REFERENCE_KEYS = [
514
+ "referenceFolderKey",
515
+ "referenceEntityId",
516
+ "referenceFieldId",
517
+ "choiceSetId",
518
+ ] as const;
519
+
520
+ /**
521
+ * FILE fields are auto-wired to the platform attachment entity. Supplying
522
+ * reference metadata conflicts with that server-managed relationship.
523
+ */
524
+ export function findReferenceFieldsOnFile(
525
+ fields: unknown[],
526
+ ): ValidationError | null {
527
+ for (const f of fields) {
528
+ if (typeof f !== "object" || f === null) continue;
529
+ const rec = f as Record<string, unknown>;
530
+ if (rec.type !== "FILE") continue;
531
+
532
+ const invalidKeys = FILE_REFERENCE_KEYS.filter(
533
+ (key) => rec[key] !== undefined,
534
+ );
535
+ if (invalidKeys.length === 0) continue;
536
+
537
+ const fieldName =
538
+ typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
539
+ return {
540
+ message: `FILE field '${fieldName}' must not pass reference fields`,
541
+ instructions: `FILE fields are auto-wired by the platform. Drop ${invalidKeys.map((key) => `'${key}'`).join(", ")} from the field definition.`,
542
+ };
543
+ }
544
+ return null;
545
+ }
546
+
188
547
  // Complex field types need extra config the SDK enum alone can't teach:
189
548
  // CHOICE_SET_* takes a choiceSetId; RELATIONSHIP takes referenceEntityId +
190
549
  // referenceFieldId. Missing extras trip a less-informative server error;
@@ -263,9 +622,12 @@ export function findInvalidFieldConstraints(
263
622
  typeof rec.fieldName === "string" ? rec.fieldName : "<field>";
264
623
 
265
624
  if (rec.lengthLimit !== undefined) {
266
- if (!isNumber(rec.lengthLimit)) {
625
+ if (
626
+ !isNumber(rec.lengthLimit) ||
627
+ !Number.isInteger(rec.lengthLimit)
628
+ ) {
267
629
  return {
268
- message: `Field '${fieldName}' 'lengthLimit' must be a number`,
630
+ message: `Field '${fieldName}' 'lengthLimit' must be an integer`,
269
631
  instructions:
270
632
  "Pass a positive integer within the type's range (STRING 1-4000, MULTILINE_TEXT 1-10000, MULTILINE_MAX 1-131072 UTF-16 bytes).",
271
633
  };
@@ -309,15 +671,37 @@ export function findInvalidFieldConstraints(
309
671
  instructions: "Pass a numeric value.",
310
672
  };
311
673
  }
674
+ const maxValue = rec.maxValue as number | undefined;
675
+ if (
676
+ maxValue !== undefined &&
677
+ (maxValue > MAX_SAFE_NUMERIC_BOUND ||
678
+ maxValue < -MAX_SAFE_NUMERIC_BOUND)
679
+ ) {
680
+ return {
681
+ message: `Field '${fieldName}' 'maxValue' ${rec.maxValue} is outside the supported numeric range`,
682
+ instructions: `Use a value between ${-MAX_SAFE_NUMERIC_BOUND} and ${MAX_SAFE_NUMERIC_BOUND}.`,
683
+ };
684
+ }
685
+ const minValue = rec.minValue as number | undefined;
686
+ if (
687
+ minValue !== undefined &&
688
+ (minValue > MAX_SAFE_NUMERIC_BOUND ||
689
+ minValue < -MAX_SAFE_NUMERIC_BOUND)
690
+ ) {
691
+ return {
692
+ message: `Field '${fieldName}' 'minValue' ${rec.minValue} is outside the supported numeric range`,
693
+ instructions: `Use a value between ${-MAX_SAFE_NUMERIC_BOUND} and ${MAX_SAFE_NUMERIC_BOUND}.`,
694
+ };
695
+ }
312
696
  if (
313
697
  rec.maxValue !== undefined &&
314
698
  rec.minValue !== undefined &&
315
- (rec.minValue as number) >= (rec.maxValue as number)
699
+ (rec.minValue as number) > (rec.maxValue as number)
316
700
  ) {
317
701
  return {
318
- message: `Field '${fieldName}' 'minValue' (${rec.minValue}) must be strictly less than 'maxValue' (${rec.maxValue})`,
702
+ message: `Field '${fieldName}' 'minValue' (${rec.minValue}) must not exceed 'maxValue' (${rec.maxValue})`,
319
703
  instructions:
320
- "Adjust the bounds so 'minValue < maxValue', or drop one of the two.",
704
+ "Adjust the bounds so 'minValue <= maxValue', or drop one of the two.",
321
705
  };
322
706
  }
323
707
  }
@@ -356,24 +740,93 @@ export function findInvalidFieldConstraints(
356
740
  // let the server catch anything else. The known bug where a rejected
357
741
  // value-create shifts subsequent NumberIds is documented for the caller —
358
742
  // this validator's job is to prevent the failed create in the first place.
743
+ // Choice-value validator: backend calls C#-only `IsValidIdentifier`
744
+ // (StorageManagementService.cs, choice-value path), so we mirror the full C#
745
+ // reserved-keyword set. Match is case-sensitive: C# keywords are all
746
+ // lowercase, so `Class` passes while `class` is rejected.
359
747
  const CHOICE_VALUE_KEYWORDS = new Set([
360
- "internal",
361
- "public",
362
- "private",
363
- "class",
748
+ "abstract",
749
+ "as",
750
+ "base",
751
+ "bool",
752
+ "break",
753
+ "byte",
364
754
  "case",
365
- "new",
755
+ "catch",
756
+ "char",
757
+ "checked",
758
+ "class",
759
+ "const",
760
+ "continue",
761
+ "decimal",
366
762
  "default",
367
- "static",
368
- "void",
763
+ "delegate",
764
+ "do",
765
+ "double",
766
+ "else",
767
+ "enum",
369
768
  "event",
769
+ "explicit",
770
+ "extern",
771
+ "false",
772
+ "finally",
773
+ "fixed",
774
+ "float",
775
+ "for",
776
+ "foreach",
777
+ "goto",
778
+ "if",
779
+ "implicit",
780
+ "in",
781
+ "int",
782
+ "interface",
783
+ "internal",
784
+ "is",
370
785
  "lock",
786
+ "long",
787
+ "namespace",
788
+ "new",
789
+ "null",
371
790
  "object",
791
+ "operator",
792
+ "out",
793
+ "override",
794
+ "params",
795
+ "private",
796
+ "protected",
797
+ "public",
798
+ "readonly",
799
+ "ref",
800
+ "return",
801
+ "sbyte",
802
+ "sealed",
803
+ "short",
804
+ "sizeof",
805
+ "stackalloc",
806
+ "static",
372
807
  "string",
373
- "int",
808
+ "struct",
809
+ "switch",
810
+ "this",
811
+ "throw",
812
+ "true",
813
+ "try",
814
+ "typeof",
815
+ "uint",
816
+ "ulong",
817
+ "unchecked",
818
+ "unsafe",
819
+ "ushort",
820
+ "using",
821
+ "virtual",
822
+ "void",
823
+ "volatile",
824
+ "while",
374
825
  ]);
375
826
 
376
- const CHOICE_VALUE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{2,99}$/;
827
+ // Server's effective cap is CodeDom's internal ~512-char identifier limit;
828
+ // use 511 to leave one char of headroom.
829
+ const CHOICE_VALUE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,510}$/;
377
830
 
378
831
  /**
379
832
  * Validate the `Name` argument of `choice-set-values create`. See
@@ -385,7 +838,7 @@ export function validateChoiceValueName(name: string): ValidationError | null {
385
838
  return {
386
839
  message: `Invalid choice-set value name '${name}'`,
387
840
  instructions:
388
- "Choice-set value names must start with a letter, contain only letters, digits, and underscores, and be 3-100 characters long.",
841
+ "Choice-set value names must start with a letter, contain only letters and digits (no underscores), and be at most 511 characters long.",
389
842
  };
390
843
  }
391
844
 
@@ -393,13 +846,33 @@ export function validateChoiceValueName(name: string): ValidationError | null {
393
846
  return {
394
847
  message: `Choice-set value name '${name}' is a reserved C# keyword`,
395
848
  instructions:
396
- "The server's choice-set-value validator is case-sensitive and rejects known reserved keywords. Use lowercase snake_case with a suffix so the token no longer matches, and move the label to --display-name. Example: --display-name \"Internal\" with a name of 'internal_audit'.",
849
+ "The server's choice-set-value validator is case-sensitive and rejects known reserved keywords. Add a letter or digit suffix so the token no longer matches, and move the label to --display-name. Example: --display-name \"Internal\" with a name of 'internalValue'.",
397
850
  };
398
851
  }
399
852
 
400
853
  return null;
401
854
  }
402
855
 
856
+ /** Choice-set value labels are stored in a 500-character backend column. */
857
+ export function validateChoiceValueDisplayName(
858
+ displayName: unknown,
859
+ ): ValidationError | null {
860
+ if (typeof displayName !== "string") {
861
+ return {
862
+ message: "Choice-set value display name must be a string",
863
+ instructions: "Pass a text value no longer than 500 characters.",
864
+ };
865
+ }
866
+ if (displayName.length > 500) {
867
+ return {
868
+ message: "Choice-set value display name exceeds 500 characters",
869
+ instructions:
870
+ "Shorten the display name to 500 characters or fewer.",
871
+ };
872
+ }
873
+ return null;
874
+ }
875
+
403
876
  // Operator tokens the server accepts on a `queryFilters[]` leaf. Anything
404
877
  // else (`==`, `equals`, `Equals`, `like`, `BETWEEN`, `regex`, …) trips a
405
878
  // server 400 with a message that doesn't teach the tokens; catch it here.
@@ -419,6 +892,7 @@ const FILTER_OPERATORS = new Set([
419
892
  ]);
420
893
 
421
894
  const VALUELIST_OPERATORS = new Set(["in", "not in"]);
895
+ const FILTER_FIELD_PATH_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.]*$/;
422
896
 
423
897
  const FILTER_OPERATORS_LIST = [...FILTER_OPERATORS]
424
898
  .map((o) => `'${o}'`)
@@ -444,6 +918,13 @@ function validateFilterLeaf(
444
918
  'Set fieldName to the column you want to filter, for example {"fieldName":"Status","operator":"=","value":"Active"}.',
445
919
  };
446
920
  }
921
+ if (!FILTER_FIELD_PATH_PATTERN.test(rec.fieldName)) {
922
+ return {
923
+ message: `${path}.fieldName '${rec.fieldName}' is invalid`,
924
+ instructions:
925
+ "Filter field names must start with a letter and contain only letters, digits, underscores, and dots.",
926
+ };
927
+ }
447
928
 
448
929
  if (typeof rec.operator !== "string") {
449
930
  return {
@@ -467,6 +948,13 @@ function validateFilterLeaf(
467
948
  '\'in\' and \'not in\' take a JSON array of values under \'valueList\', for example {"fieldName":"Status","operator":"in","valueList":["A","B"]}.',
468
949
  };
469
950
  }
951
+ if (rec.valueList.length === 0) {
952
+ return {
953
+ message: `${path} operator '${rec.operator}' requires a non-empty 'valueList'`,
954
+ instructions:
955
+ "Add at least one value to 'valueList', or remove this filter.",
956
+ };
957
+ }
470
958
  } else {
471
959
  // Every other operator uses 'value'. `null` is legal — it means
472
960
  // is-empty (with `=`) or is-not-empty (with `!=`). Missing key is
@@ -479,6 +967,17 @@ function validateFilterLeaf(
479
967
  "Set 'value' to the JSON-string form of the compared value. Use null for is-empty ('=' with null) / is-not-empty ('!=' with null).",
480
968
  };
481
969
  }
970
+ if (
971
+ rec.value === null &&
972
+ rec.operator !== "=" &&
973
+ rec.operator !== "!="
974
+ ) {
975
+ return {
976
+ message: `${path} operator '${rec.operator}' cannot use a null value`,
977
+ instructions:
978
+ "A null filter value is supported only with '=' (is empty) or '!=' (is not empty).",
979
+ };
980
+ }
482
981
  }
483
982
 
484
983
  return null;
@@ -561,6 +1060,161 @@ export function validateQueryFilter(
561
1060
  return validateFilterGroup(filterGroup, "filterGroup");
562
1061
  }
563
1062
 
1063
+ const AGGREGATE_FUNCTIONS = new Set(["COUNT", "SUM", "AVG", "MIN", "MAX"]);
1064
+ const AGGREGATE_ALIAS_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
1065
+ const MAX_AGGREGATES = 5;
1066
+ const MAX_GROUP_BY_FIELDS = 5;
1067
+
1068
+ /** Validate aggregate query shape using the backend's fixed limits. */
1069
+ export function validateQueryAggregates(
1070
+ body: Record<string, unknown>,
1071
+ ): ValidationError | null {
1072
+ if (body.aggregates === undefined) return null;
1073
+ if (!Array.isArray(body.aggregates)) {
1074
+ return {
1075
+ message: "'aggregates' must be an array",
1076
+ instructions:
1077
+ "Pass up to five aggregate objects with function, field, and optional alias.",
1078
+ };
1079
+ }
1080
+ if (body.aggregates.length === 0) return null;
1081
+ if (body.aggregates.length > MAX_AGGREGATES) {
1082
+ return {
1083
+ message: `A query supports at most ${MAX_AGGREGATES} aggregates`,
1084
+ instructions: `Reduce 'aggregates' from ${body.aggregates.length} entries to ${MAX_AGGREGATES} or fewer.`,
1085
+ };
1086
+ }
1087
+
1088
+ if (body.groupBy !== undefined && !Array.isArray(body.groupBy)) {
1089
+ return {
1090
+ message: "'groupBy' must be an array",
1091
+ instructions: "Pass field names as a JSON array.",
1092
+ };
1093
+ }
1094
+ const groupBy = (body.groupBy as unknown[] | undefined) ?? [];
1095
+ if (groupBy.length > MAX_GROUP_BY_FIELDS) {
1096
+ return {
1097
+ message: `A query supports at most ${MAX_GROUP_BY_FIELDS} groupBy fields`,
1098
+ instructions: `Reduce 'groupBy' from ${groupBy.length} entries to ${MAX_GROUP_BY_FIELDS} or fewer.`,
1099
+ };
1100
+ }
1101
+ if (
1102
+ groupBy.some(
1103
+ (field) => typeof field !== "string" || field.trim() === "",
1104
+ )
1105
+ ) {
1106
+ return {
1107
+ message: "'groupBy' field names must be non-empty strings",
1108
+ instructions:
1109
+ "Remove empty entries and pass valid root field names.",
1110
+ };
1111
+ }
1112
+
1113
+ const aliases = new Set<string>();
1114
+ for (let i = 0; i < body.aggregates.length; i++) {
1115
+ const aggregate = body.aggregates[i];
1116
+ if (
1117
+ typeof aggregate !== "object" ||
1118
+ aggregate === null ||
1119
+ Array.isArray(aggregate)
1120
+ ) {
1121
+ return {
1122
+ message: `aggregates[${i}] must be an object`,
1123
+ instructions:
1124
+ "Each aggregate needs function, field, and optional alias.",
1125
+ };
1126
+ }
1127
+ const rec = aggregate as Record<string, unknown>;
1128
+ if (
1129
+ typeof rec.function !== "string" ||
1130
+ !AGGREGATE_FUNCTIONS.has(rec.function.toUpperCase())
1131
+ ) {
1132
+ return {
1133
+ message: `aggregates[${i}] has invalid function '${String(rec.function)}'`,
1134
+ instructions:
1135
+ "Supported aggregate functions: COUNT, SUM, AVG, MIN, MAX.",
1136
+ };
1137
+ }
1138
+ if (typeof rec.field !== "string" || rec.field.trim() === "") {
1139
+ return {
1140
+ message: `aggregates[${i}].field must be a non-empty string`,
1141
+ instructions: "Set the field to aggregate.",
1142
+ };
1143
+ }
1144
+
1145
+ const alias =
1146
+ rec.alias === undefined
1147
+ ? `${rec.function.toUpperCase()}_${rec.field}`
1148
+ : rec.alias;
1149
+ if (typeof alias !== "string" || !AGGREGATE_ALIAS_PATTERN.test(alias)) {
1150
+ return {
1151
+ message: `Invalid aggregate alias '${String(alias)}'`,
1152
+ instructions:
1153
+ "Aliases must start with a letter, contain only letters, digits, and underscores, and be at most 128 characters.",
1154
+ };
1155
+ }
1156
+ const normalizedAlias = alias.toLowerCase();
1157
+ if (aliases.has(normalizedAlias)) {
1158
+ return {
1159
+ message: `Duplicate aggregate alias '${alias}'`,
1160
+ instructions:
1161
+ "Every aggregate alias must be unique, ignoring case. Set explicit aliases when repeated functions target the same field.",
1162
+ };
1163
+ }
1164
+ aliases.add(normalizedAlias);
1165
+ }
1166
+
1167
+ if (
1168
+ body.selectedFields !== undefined &&
1169
+ !Array.isArray(body.selectedFields)
1170
+ ) {
1171
+ return {
1172
+ message: "'selectedFields' must be an array",
1173
+ instructions: "Pass field names as a JSON array.",
1174
+ };
1175
+ }
1176
+ const selectedFields = (body.selectedFields as unknown[] | undefined) ?? [];
1177
+ if (
1178
+ selectedFields.some(
1179
+ (field) => typeof field !== "string" || field.trim() === "",
1180
+ )
1181
+ ) {
1182
+ return {
1183
+ message: "'selectedFields' must contain non-empty strings",
1184
+ instructions: "Remove null, empty, or non-string entries.",
1185
+ };
1186
+ }
1187
+
1188
+ const hasBinnings =
1189
+ Array.isArray(body.binnings) && body.binnings.length > 0;
1190
+ if (selectedFields.length > 0 && groupBy.length === 0 && !hasBinnings) {
1191
+ return {
1192
+ message:
1193
+ "'groupBy' is required when aggregates and selectedFields are used together",
1194
+ instructions:
1195
+ "Add every non-aggregated selected field to 'groupBy', or remove 'selectedFields'.",
1196
+ };
1197
+ }
1198
+ if (!hasBinnings && selectedFields.length > 0) {
1199
+ const grouped = new Set(
1200
+ (groupBy as string[]).map((field) => field.toLowerCase()),
1201
+ );
1202
+ const missing = (selectedFields as string[]).filter(
1203
+ (field) =>
1204
+ !field.includes(".") && !grouped.has(field.toLowerCase()),
1205
+ );
1206
+ if (missing.length > 0) {
1207
+ return {
1208
+ message: `Selected fields missing from groupBy: ${missing.join(", ")}`,
1209
+ instructions:
1210
+ "Every non-aggregated selected field must also appear in 'groupBy'.",
1211
+ };
1212
+ }
1213
+ }
1214
+
1215
+ return null;
1216
+ }
1217
+
564
1218
  /**
565
1219
  * `records insert` / `records update` silently strip any key whose target
566
1220
  * field is FILE-typed — the server returns Success and the file column is