@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.
- package/dist/browser-strategy-yccf8mtd.js +99 -0
- package/dist/getMachineId-bsd-wfkxpxjv.js +38 -0
- package/dist/getMachineId-darwin-9asn4zy6.js +38 -0
- package/dist/getMachineId-linux-62t6m027.js +31 -0
- package/dist/getMachineId-unsupported-dfrzeqc3.js +21 -0
- package/dist/getMachineId-win-zgvrp5q3.js +40 -0
- package/dist/index-2fj08e7n.js +2058 -0
- package/dist/index-w03qc9m8.js +634 -0
- package/dist/index.js +14 -2148
- package/dist/js-yaml-4ypbq2tt.js +3145 -0
- package/dist/node-strategy-12qfy0nv.js +348 -0
- package/dist/tool-0v6na3yp.js +37 -0
- package/dist/tool-5arsyj36.js +11 -0
- package/dist/tool-7eva0peq.js +265 -0
- package/dist/tool-9qecd4wb.js +14 -0
- package/dist/tool-cyfkvxhw.js +23350 -0
- package/dist/tool-h1k9mjs9.js +17 -0
- package/dist/tool-r6rhp5ep.js +1487 -0
- package/dist/tool.js +10 -48431
- package/package.json +2 -2
- package/src/commands/choice-sets.spec.ts +44 -0
- package/src/commands/choice-sets.ts +39 -2
- package/src/commands/entities.spec.ts +70 -8
- package/src/commands/entities.ts +40 -17
- package/src/commands/records.spec.ts +29 -0
- package/src/commands/records.ts +12 -1
- package/src/utils/validate.spec.ts +279 -12
- package/src/utils/validate.ts +694 -40
- package/tests/entities-lifecycle.e2e.test.ts +9 -8
- package/tests/performance.e2e.test.ts +32 -0
package/src/utils/validate.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
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
|
-
//
|
|
54
|
-
// `
|
|
55
|
-
//
|
|
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
|
-
"
|
|
63
|
-
"
|
|
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
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
|
161
|
-
//
|
|
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 (
|
|
625
|
+
if (
|
|
626
|
+
!isNumber(rec.lengthLimit) ||
|
|
627
|
+
!Number.isInteger(rec.lengthLimit)
|
|
628
|
+
) {
|
|
267
629
|
return {
|
|
268
|
-
message: `Field '${fieldName}' 'lengthLimit' must be
|
|
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)
|
|
699
|
+
(rec.minValue as number) > (rec.maxValue as number)
|
|
316
700
|
) {
|
|
317
701
|
return {
|
|
318
|
-
message: `Field '${fieldName}' 'minValue' (${rec.minValue}) must
|
|
702
|
+
message: `Field '${fieldName}' 'minValue' (${rec.minValue}) must not exceed 'maxValue' (${rec.maxValue})`,
|
|
319
703
|
instructions:
|
|
320
|
-
"Adjust the bounds so 'minValue
|
|
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
|
-
"
|
|
361
|
-
"
|
|
362
|
-
"
|
|
363
|
-
"
|
|
748
|
+
"abstract",
|
|
749
|
+
"as",
|
|
750
|
+
"base",
|
|
751
|
+
"bool",
|
|
752
|
+
"break",
|
|
753
|
+
"byte",
|
|
364
754
|
"case",
|
|
365
|
-
"
|
|
755
|
+
"catch",
|
|
756
|
+
"char",
|
|
757
|
+
"checked",
|
|
758
|
+
"class",
|
|
759
|
+
"const",
|
|
760
|
+
"continue",
|
|
761
|
+
"decimal",
|
|
366
762
|
"default",
|
|
367
|
-
"
|
|
368
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|