@genesislcap/mock-server 15.60.0 → 15.61.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.
Files changed (57) hide show
  1. package/README.md +287 -40
  2. package/dist/db/criteria.d.ts +28 -9
  3. package/dist/db/criteria.js +856 -258
  4. package/dist/db/criteriaDialect.d.ts +6 -0
  5. package/dist/db/criteriaDialect.js +441 -0
  6. package/dist/db/criteriaTokens.d.ts +12 -0
  7. package/dist/db/criteriaTokens.js +53 -0
  8. package/dist/db/groovySyntax.d.ts +26 -0
  9. package/dist/db/groovySyntax.js +481 -0
  10. package/dist/db/groovyValidator.d.ts +11 -0
  11. package/dist/db/groovyValidator.js +523 -0
  12. package/dist/db/gsfExpr.d.ts +21 -0
  13. package/dist/db/gsfExpr.js +333 -0
  14. package/dist/db/identity.d.ts +2 -1
  15. package/dist/db/identity.js +24 -3
  16. package/dist/db/legacyCriteria.d.ts +7 -0
  17. package/dist/db/legacyCriteria.js +280 -0
  18. package/dist/db/ordering.d.ts +2 -0
  19. package/dist/db/ordering.js +24 -3
  20. package/dist/db/schema.d.ts +2 -0
  21. package/dist/db/schema.js +34 -3
  22. package/dist/db/store.d.ts +7 -0
  23. package/dist/db/store.js +55 -7
  24. package/dist/db/values.d.ts +19 -0
  25. package/dist/db/values.js +243 -0
  26. package/dist/handlers/commitEvent.js +3 -2
  27. package/dist/handlers/dataLogon.d.ts +2 -1
  28. package/dist/handlers/dataLogon.js +108 -51
  29. package/dist/handlers/eventValidation.d.ts +2 -1
  30. package/dist/handlers/eventValidation.js +33 -6
  31. package/dist/handlers/requestReply.js +43 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/protocol/connection.d.ts +1 -1
  35. package/dist/server.js +15 -8
  36. package/dist/types.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/db/criteria.ts +979 -290
  39. package/src/db/criteriaDialect.ts +487 -0
  40. package/src/db/criteriaTokens.ts +72 -0
  41. package/src/db/groovySyntax.ts +500 -0
  42. package/src/db/groovyValidator.ts +546 -0
  43. package/src/db/gsfExpr.ts +392 -0
  44. package/src/db/identity.ts +22 -4
  45. package/src/db/legacyCriteria.ts +301 -0
  46. package/src/db/ordering.ts +27 -3
  47. package/src/db/schema.ts +43 -8
  48. package/src/db/store.ts +65 -7
  49. package/src/db/values.ts +247 -0
  50. package/src/handlers/commitEvent.ts +3 -2
  51. package/src/handlers/dataLogon.ts +134 -56
  52. package/src/handlers/eventValidation.ts +40 -13
  53. package/src/handlers/requestReply.ts +47 -14
  54. package/src/index.ts +6 -1
  55. package/src/protocol/connection.ts +3 -4
  56. package/src/server.ts +16 -15
  57. package/src/types.ts +7 -0
@@ -0,0 +1,487 @@
1
+ // What GSF answers a CRITERIA_MATCH written in this engine's own dialect: the
2
+ // forms its grammar (criteria.ts) accepts but GSF never does — the AND, OR
3
+ // and NOT keywords (any case), `<>`, a single `=`, a dotted field name, a
4
+ // double-quoted string with a `$` in it, and a call GSF's validator doesn't
5
+ // allow: an Expr function or field method off its whitelists
6
+ // (`Expr.contains`, `NAME.endsWith`, `Expr.foo`), a call on a class
7
+ // (`Math.abs`) or on an implicit `this` (`foo(X)`).
8
+ //
9
+ // GSF refuses every one of them, at one of three stages
10
+ // (CriteriaFilterBuilder.getCriteriaFilter, GSF v8.15.29):
11
+ // 1. Groovy's lexer and parser (AstBuilder().buildFromString, Groovy 3.0.25):
12
+ // `A == 1 AND B == 2`, `A <> 1`, `"US$"`;
13
+ // 2. CriteriaValidatorVisitor over the parsed AST: Groovy reads `NOT x` as a
14
+ // call on an implicit `this`, `A = 1` as an assignment, `A.B` as a
15
+ // property, `"a$b"` as a GString, `Expr.foo(x)` as a method its
16
+ // whitelist lacks — all refused, with every other error in source order
17
+ // (unknown fields too);
18
+ // 3. the @CompileStatic compilation of the filter (EvaluatorPool
19
+ // addExpression), for what Groovy reads as a method call on a Boolean:
20
+ // `Expr.f(x) AND y`, `(A) AND B`, `!A AND B` — its text the criteria
21
+ // with each field replaced by its getter (criteria.ts compileFailureText).
22
+ // The texts below are Groovy's and the validator's for the shapes clients
23
+ // write (checked by running Groovy 3.0.25 and genesis-db 8.15.29's
24
+ // CriteriaValidatorVisitor on them; test/criteriaDialect.test.ts pins them).
25
+ // Any other shape is still refused, with an approximate text: ANTLR's error
26
+ // recovery differs case by case.
27
+
28
+ import { compileFailureText, tokenizeCriteria } from './criteria.ts';
29
+ import { groovyCompileErrorText, groovySyntaxError } from './groovySyntax.ts';
30
+ import { EXPR_METHODS, FIELD_METHODS, groovyValidation, isClassName } from './groovyValidator.ts';
31
+
32
+ const VALIDATION_FAILED = 'GenericCriteriaValidationException : Criteria validation failed: ';
33
+ const THIS_NOT_ALLOWED = 'Variable this is not allowed in criteria expression';
34
+ const ASSIGNMENT_NOT_ALLOWED = 'Binary operator = not allowed in criteria expression';
35
+ const PROPERTY_NOT_ALLOWED = 'Property expressions are not supported in criteria';
36
+ const GSTRING_NOT_ALLOWED = 'GString expressions are not supported in criteria';
37
+ const LHS_NOT_ASSIGNABLE =
38
+ 'The LHS of an assignment should be a variable or a field accessing expression';
39
+ const CLASS_NOT_ALLOWED = 'Class expressions are not supported in criteria';
40
+
41
+ const COMPARISONS = new Set(['==', '!=', '<', '<=', '>', '>=']);
42
+ const LITERAL_WORDS = new Set(['true', 'false', 'null']);
43
+
44
+ type Role =
45
+ | 'field' // a name read as a field
46
+ | 'literal' // a string, number, true, false or null
47
+ | 'call' // `Expr.fn` or `FIELD.fn` before its '('
48
+ | 'thisCall' // `fn` before its '(': a call on an implicit `this`
49
+ | 'new' // the `new` of a constructor call
50
+ | 'ctor' // its class, before its '('
51
+ | 'property' // a dotted name that isn't a call
52
+ | 'comparison' // == != < <= > >=
53
+ | 'logic' // && ||
54
+ | 'and' // the AND / OR keyword (dialect)
55
+ | 'not' // the NOT keyword (dialect)
56
+ | 'bang' // !
57
+ | 'diamond' // <> (dialect)
58
+ | 'assign' // = (dialect)
59
+ | 'open' // a grouping '('
60
+ | 'callOpen' // a call's '('
61
+ | 'close'
62
+ | 'comma';
63
+
64
+ interface Token {
65
+ text: string;
66
+ offset: number;
67
+ end: number;
68
+ role: Role;
69
+ // The index of the grouping '(' this token is inside (a call's parentheses
70
+ // don't count), or -1 at the top.
71
+ group: number;
72
+ // A grouping '(': the index of its ')'.
73
+ closeAt?: number;
74
+ // A double-quoted string: whether a `$` starts an interpolation (a
75
+ // GString), and the offset of the character a lone `$` can't be followed by.
76
+ gstring?: boolean;
77
+ badDollarAt?: number;
78
+ }
79
+
80
+ const endsOperand = (token: Token | undefined) =>
81
+ token !== undefined &&
82
+ (token.role === 'field' ||
83
+ token.role === 'literal' ||
84
+ token.role === 'property' ||
85
+ token.role === 'close');
86
+
87
+ const startsNegation = (token: Token | undefined) =>
88
+ token === undefined ||
89
+ token.role === 'open' ||
90
+ token.role === 'logic' ||
91
+ token.role === 'and' ||
92
+ token.role === 'not' ||
93
+ token.role === 'bang';
94
+
95
+ // In a double-quoted Groovy string, `$` starts an interpolation when a name or
96
+ // `{` follows; anything else after it is a lexer error at that character.
97
+ function inspectDollars(token: Token): void {
98
+ if (!token.text.startsWith('"')) return;
99
+ const body = token.text.slice(1, -1);
100
+ for (let at = 0; at < body.length; at += 1) {
101
+ if (body[at] === '\\') {
102
+ at += 1;
103
+ continue;
104
+ }
105
+ if (body[at] !== '$') continue;
106
+ if (/[A-Za-z_{]/.test(body[at + 1] ?? '')) {
107
+ token.gstring = true;
108
+ continue;
109
+ }
110
+ // The character after the `$`: at worst the closing quote.
111
+ token.badDollarAt = token.offset + 1 + at + 1;
112
+ return;
113
+ }
114
+ }
115
+
116
+ // The criteria's tokens with the role this grammar gives each one.
117
+ function classify(criteria: string): Token[] {
118
+ const tokens: Token[] = [];
119
+ // Open parentheses: a grouping one by its index, a call's as -1.
120
+ const opens: number[] = [];
121
+ const currentGroup = () => opens.findLast((open) => open >= 0) ?? -1;
122
+ for (const { text, offset } of tokenizeCriteria(criteria.trimEnd())) {
123
+ const previous = tokens.at(-1);
124
+ const token: Token = {
125
+ text,
126
+ offset,
127
+ end: offset + text.length,
128
+ role: 'field',
129
+ group: currentGroup(),
130
+ };
131
+ const upper = text.toUpperCase();
132
+ if (/^['"]/.test(text) || /^-?\d/.test(text) || LITERAL_WORDS.has(text)) {
133
+ token.role = 'literal';
134
+ inspectDollars(token);
135
+ } else if (text === '(') {
136
+ const called = ['property', 'field', 'ctor'].includes(previous?.role ?? '');
137
+ token.role = called ? 'callOpen' : 'open';
138
+ } else if (text === ')') {
139
+ token.role = 'close';
140
+ } else if (text === ',') {
141
+ token.role = 'comma';
142
+ } else if (COMPARISONS.has(text)) {
143
+ token.role = 'comparison';
144
+ } else if (text === '&&' || text === '||') {
145
+ token.role = 'logic';
146
+ } else if (text === '!') {
147
+ token.role = 'bang';
148
+ } else if (text === '<>') {
149
+ token.role = 'diamond';
150
+ } else if (text === '=') {
151
+ token.role = 'assign';
152
+ } else if ((upper === 'AND' || upper === 'OR') && endsOperand(previous)) {
153
+ token.role = 'and';
154
+ } else if (upper === 'NOT' && startsNegation(previous)) {
155
+ token.role = 'not';
156
+ } else if (text.includes('.')) {
157
+ token.role = 'property'; // a call is told apart at its '('
158
+ }
159
+ // `new X(...)`: a constructor call (groovyValidator.ts gives GSF's verdict
160
+ // on it), not a variable `new` and a call on `this`.
161
+ if (previous?.text === 'new' && previous.role === 'field' && /^[A-Za-z_]/.test(text)) {
162
+ if (token.role === 'field' || token.role === 'property') {
163
+ previous.role = 'new';
164
+ token.role = 'ctor';
165
+ }
166
+ }
167
+ if (token.role === 'callOpen' && previous!.role !== 'ctor') {
168
+ previous!.role = previous!.role === 'field' ? 'thisCall' : 'call';
169
+ }
170
+ if (token.role === 'open') opens.push(tokens.length);
171
+ if (token.role === 'callOpen') opens.push(-1);
172
+ if (token.role === 'close') {
173
+ const open = opens.pop();
174
+ if (open !== undefined && open >= 0) tokens[open].closeAt = tokens.length;
175
+ token.group = currentGroup();
176
+ }
177
+ tokens.push(token);
178
+ }
179
+ return tokens;
180
+ }
181
+
182
+ const DIALECT_ROLES = new Set<Role>(['and', 'not', 'diamond', 'assign', 'property', 'thisCall']);
183
+
184
+ const splitCall = (token: Token) => {
185
+ const dot = token.text.lastIndexOf('.');
186
+ return [token.text.slice(0, dot), token.text.slice(dot + 1)];
187
+ };
188
+
189
+ // A call the validator refuses whatever else the criteria holds: an Expr
190
+ // function or method off its whitelists — on a field, or on any other name
191
+ // (`UNKNOWN.bar(x)`, `this.foo(x)`: the validator names the variable) — or a
192
+ // call on a class or a property.
193
+ function isRefusedCall(token: Token): boolean {
194
+ if (token.role !== 'call') return false;
195
+ const [receiver, method] = splitCall(token);
196
+ if (receiver === 'Expr') return !EXPR_METHODS.has(method);
197
+ if (isClassName(receiver) || receiver.includes('.')) return true;
198
+ return !FIELD_METHODS.has(method);
199
+ }
200
+
201
+ const isDialect = (token: Token) =>
202
+ DIALECT_ROLES.has(token.role) ||
203
+ token.gstring === true ||
204
+ token.badDollarAt !== undefined ||
205
+ isRefusedCall(token);
206
+
207
+ // operand, comparison, operand
208
+ const COMPARISON_LENGTH = 3;
209
+
210
+ const isOperand = (token: Token | undefined) =>
211
+ token?.role === 'field' || token?.role === 'literal' || token?.role === 'property';
212
+
213
+ // A complete expression Groovy can't continue with an AND or OR: comparisons
214
+ // (`A == 1`) and parenthesised groups joined by && and ||. A lone group is
215
+ // not one: Groovy reads `(A) AND (B)` as a call instead.
216
+ function isCompleteExpression(tokens: Token[]): boolean {
217
+ let at = 0;
218
+ let items = 0;
219
+ let comparisons = 0;
220
+ while (at < tokens.length) {
221
+ const token = tokens[at];
222
+ if (token.role === 'open' && token.closeAt !== undefined) {
223
+ at = token.closeAt + 1;
224
+ } else if (
225
+ isOperand(token) &&
226
+ tokens[at + 1]?.role === 'comparison' &&
227
+ isOperand(tokens[at + 2])
228
+ ) {
229
+ at += COMPARISON_LENGTH;
230
+ comparisons += 1;
231
+ } else {
232
+ return false;
233
+ }
234
+ items += 1;
235
+ if (at === tokens.length) break;
236
+ if (tokens[at].role !== 'logic') return false;
237
+ at += 1;
238
+ if (at === tokens.length) return false;
239
+ }
240
+ return items > 0 && (comparisons > 0 || items > 1);
241
+ }
242
+
243
+ // Every token from the start of the group `index` is in up to `index`.
244
+ function groupPrefix(tokens: Token[], index: number): Token[] {
245
+ return tokens.slice(tokens[index].group + 1, index);
246
+ }
247
+
248
+ // The index of the ')' closing the group a token is in, or the end.
249
+ function groupEnd(tokens: Token[], index: number): number {
250
+ const group = tokens[index].group;
251
+ return group < 0 ? tokens.length : (tokens[group].closeAt ?? tokens.length);
252
+ }
253
+
254
+ const syntaxError = (criteria: string, message: string, offset: number) =>
255
+ groovyCompileErrorText(criteria, { message, line: 1, column: offset + 1 });
256
+
257
+ const span = (criteria: string, from: Token, to: Token) =>
258
+ `Unexpected input: '${criteria.slice(from.offset, to.end)}'`;
259
+
260
+ // Stage 1, the parser's errors. `<>` is reported at its '>': a name before
261
+ // it that starts the criteria or a group reads as the start of a declaration
262
+ // or cast, so the text runs from there; anything else names the operator,
263
+ // and after an AND or OR, only its '<'. Without `<>`, the first AND or OR
264
+ // after a complete expression, or NOT after `X &&` before a name, is
265
+ // reported spanning what came before it in its group.
266
+ function parseError(criteria: string, tokens: Token[]): string | undefined {
267
+ const diamondAt = tokens.findIndex((token) => token.role === 'diamond');
268
+ if (diamondAt >= 0) {
269
+ const diamond = tokens[diamondAt];
270
+ if (tokens.slice(0, diamondAt).some((token) => token.role === 'and')) {
271
+ return syntaxError(criteria, "Unexpected input: '<'", diamond.offset);
272
+ }
273
+ const [left, beforeLeft] = [tokens[diamondAt - 1], tokens[diamondAt - 2]];
274
+ let from = diamond.offset;
275
+ const named = left?.role === 'field' || left?.role === 'property';
276
+ if (named && beforeLeft === undefined) from = left.offset;
277
+ if (named && beforeLeft?.role === 'open') from = beforeLeft.offset;
278
+ const gt = diamond.offset + 1;
279
+ return syntaxError(criteria, `Unexpected input: '${criteria.slice(from, gt + 1)}'`, gt);
280
+ }
281
+ for (const [index, token] of tokens.entries()) {
282
+ if (token.role === 'and') {
283
+ const prefix = groupPrefix(tokens, index);
284
+ if (isCompleteExpression(prefix)) {
285
+ return syntaxError(criteria, span(criteria, prefix[0], prefix.at(-1)!), prefix[0].offset);
286
+ }
287
+ }
288
+ if (
289
+ token.role === 'not' &&
290
+ token.group === -1 &&
291
+ tokens[index - 1]?.role === 'logic' &&
292
+ ['field', 'property', 'call'].includes(tokens[index + 1]?.role ?? '')
293
+ ) {
294
+ const prefix = groupPrefix(tokens, index - 1);
295
+ if (isCompleteExpression(prefix)) {
296
+ return syntaxError(criteria, span(criteria, prefix[0], token), prefix[0].offset);
297
+ }
298
+ }
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ // Stage 1 still, after parsing: a capitalised NOT before a lone name declares
304
+ // a variable of class NOT; an assignment needs a name on its left (each `=`
305
+ // of a group checked from the right, as assignment binds rightwards).
306
+ function semanticError(criteria: string, tokens: Token[]): string | undefined {
307
+ const [not, name] = tokens;
308
+ if (
309
+ tokens.length === 2 &&
310
+ not.role === 'not' &&
311
+ name.role === 'field' &&
312
+ /^[A-Z]/.test(not.text)
313
+ ) {
314
+ return groovyCompileErrorText(
315
+ criteria,
316
+ { message: `unable to resolve class ${not.text}`, line: 1, column: name.offset + 1 },
317
+ { ownLine: true },
318
+ );
319
+ }
320
+ const assigns = tokens.flatMap((token, index) => (token.role === 'assign' ? [index] : []));
321
+ for (const index of [...assigns].reverse()) {
322
+ // The left side: back to the group's start, or to the `=` before it in
323
+ // the same group.
324
+ const group = tokens[index].group;
325
+ let from = group + 1;
326
+ for (let at = index - 1; at > group; at -= 1) {
327
+ if (tokens[at].role === 'assign' && tokens[at].group === group) {
328
+ from = at + 1;
329
+ break;
330
+ }
331
+ }
332
+ const left = tokens.slice(from, index);
333
+ // A name, or "a field accessing expression" (A.B).
334
+ if (left.length !== 1 || (left[0].role !== 'field' && left[0].role !== 'property')) {
335
+ return syntaxError(criteria, LHS_NOT_ASSIGNABLE, (left[0] ?? tokens[index]).offset);
336
+ }
337
+ }
338
+ return undefined;
339
+ }
340
+
341
+ // Stage 2, the validator's errors in the order it visits the AST — source
342
+ // order for the shapes modelled here — or undefined for a shape it isn't
343
+ // modelled for. `allowed` unset: every field is (a query whose fields aren't
344
+ // known). Groovy reads `NOT x` as `this.NOT(x)`, `(A) AND (B)` as a call of
345
+ // AND on `this` (its first such keyword only), `F AND x` as `F.AND(x)`.
346
+ function validationErrors(tokens: Token[], allowed?: ReadonlySet<string>): string[] | undefined {
347
+ const errors: string[] = [];
348
+ const known = (name: string) => !allowed || allowed.has(name);
349
+ let calledOnThis = false;
350
+ for (let index = 0; index < tokens.length; index += 1) {
351
+ const token = tokens[index];
352
+ const next = tokens[index + 1];
353
+ if ((token.role === 'field' || token.role === 'property') && next?.role === 'assign') {
354
+ // Refused whole: nothing to its right in the group is visited.
355
+ errors.push(ASSIGNMENT_NOT_ALLOWED);
356
+ index = groupEnd(tokens, index) - 1;
357
+ } else if (index === 0 && token.role === 'field' && next?.role === 'and') {
358
+ errors.push(
359
+ known(token.text)
360
+ ? `Field method ${token.text}.${next.text} is not supported`
361
+ : `Variable ${token.text} is not allowed in criteria expression`,
362
+ );
363
+ index += 1;
364
+ } else if (token.role === 'and') {
365
+ const prefix = groupPrefix(tokens, index);
366
+ const loneGroup = prefix[0]?.role === 'open' && prefix[0].closeAt === index - 1;
367
+ if (!calledOnThis && loneGroup && next?.role === 'open') {
368
+ errors.push(THIS_NOT_ALLOWED);
369
+ calledOnThis = true;
370
+ } else if (
371
+ !calledOnThis &&
372
+ loneGroup &&
373
+ (next?.role === 'call' || next?.role === 'property')
374
+ ) {
375
+ // `(A) AND X.y(...)`: Groovy's AST has a property expression here,
376
+ // which the validator refuses before visiting the rest.
377
+ errors.push(PROPERTY_NOT_ALLOWED);
378
+ return errors;
379
+ }
380
+ } else if (token.role === 'not') {
381
+ if (next?.role === 'not') return undefined;
382
+ errors.push(THIS_NOT_ALLOWED);
383
+ } else if (token.role === 'field' && !known(token.text)) {
384
+ errors.push(`Variable ${token.text} is not allowed in criteria expression`);
385
+ } else if (token.role === 'property') {
386
+ errors.push(PROPERTY_NOT_ALLOWED);
387
+ } else if (token.role === 'literal' && token.gstring) {
388
+ errors.push(GSTRING_NOT_ALLOWED);
389
+ } else if (token.role === 'thisCall') {
390
+ errors.push(THIS_NOT_ALLOWED);
391
+ } else if (token.role === 'call') {
392
+ const [receiver, method] = splitCall(token);
393
+ if (receiver === 'Expr') {
394
+ if (!EXPR_METHODS.has(method)) errors.push(`Method call Expr.${method} is not supported`);
395
+ } else if (isClassName(receiver)) {
396
+ errors.push(CLASS_NOT_ALLOWED);
397
+ } else if (receiver.includes('.')) {
398
+ errors.push(PROPERTY_NOT_ALLOWED);
399
+ } else if (!known(receiver)) {
400
+ errors.push(`Variable ${receiver} is not allowed in criteria expression`);
401
+ } else if (!FIELD_METHODS.has(method)) {
402
+ errors.push(`Field method ${receiver}.${method} is not supported`);
403
+ }
404
+ }
405
+ }
406
+ return errors;
407
+ }
408
+
409
+ // The fields the validator records a criteria reading (usedFields): the
410
+ // names it reads, and the receivers of field methods.
411
+ function usedFields(tokens: Token[], allowed?: ReadonlySet<string>): string[] {
412
+ const known = (name: string) => !allowed || allowed.has(name);
413
+ return tokens.flatMap((token) => {
414
+ if (token.role === 'field') return known(token.text) ? [token.text] : [];
415
+ if (token.role !== 'call') return [];
416
+ const [receiver] = splitCall(token);
417
+ const field = receiver !== 'Expr' && !isClassName(receiver) && !receiver.includes('.');
418
+ return field && known(receiver) ? [receiver] : [];
419
+ });
420
+ }
421
+
422
+ // The INVALID_CRITERIA TEXT GSF sends for a criteria in this engine's
423
+ // dialect, and where its first dialect token is; undefined when it uses none.
424
+ // `allowed`: the fields the query exposes, when known; `types`: their
425
+ // Genesis types, when known (they name the getters a compilation error
426
+ // quotes). The criteria needn't parse in this grammar, only tokenize: every
427
+ // dialect token, wherever it stands, is one GSF refuses.
428
+ export function criteriaDialect(
429
+ criteria: string,
430
+ allowed?: ReadonlySet<string>,
431
+ types?: ReadonlyMap<string, string>,
432
+ ): { text: string; at: number } | undefined {
433
+ let tokens: Token[];
434
+ try {
435
+ tokens = classify(criteria);
436
+ } catch {
437
+ return undefined;
438
+ }
439
+ const first = tokens.find(isDialect);
440
+ if (!first) return undefined;
441
+ const refused = (text: string) => ({ text, at: first.offset });
442
+
443
+ // The lexer, then the parser, then the validator.
444
+ const dollar = tokens.find((token) => token.badDollarAt !== undefined);
445
+ if (dollar) {
446
+ const at = dollar.badDollarAt!;
447
+ return refused(syntaxError(criteria, `token recognition error at: '${criteria[at]}'`, at));
448
+ }
449
+ const compileError = parseError(criteria, tokens) ?? semanticError(criteria, tokens);
450
+ if (compileError) return refused(compileError);
451
+ const errors = validationErrors(tokens, allowed);
452
+ if (errors?.length) return refused(`${VALIDATION_FAILED}${errors.join(',')}`);
453
+ // The validator passes it, so the @CompileStatic compilation refuses it
454
+ // (a method call on a Boolean: `Expr.f(x) AND y`, `(A) AND B`, `!A AND B`).
455
+ if (errors) return refused(compileFailureText(criteria, usedFields(tokens, allowed), types));
456
+ // Not modelled: still refused, as GSF refuses it, with a text naming the
457
+ // first dialect token.
458
+ return refused(syntaxError(criteria, `Unexpected input: '${first.text}'`, first.offset));
459
+ }
460
+
461
+ export function dialectRefusal(
462
+ criteria: string,
463
+ allowed?: ReadonlySet<string>,
464
+ types?: ReadonlyMap<string, string>,
465
+ ): string | undefined {
466
+ return criteriaDialect(criteria, allowed, types)?.text;
467
+ }
468
+
469
+ // What strict criteria refuse: a Groovy syntax error (groovySyntax.ts) or
470
+ // the dialect, whichever Groovy meets first — the INVALID_CRITERIA TEXT, or
471
+ // undefined.
472
+ export function strictCriteriaRefusal(
473
+ criteria: string,
474
+ allowed?: ReadonlySet<string>,
475
+ types?: ReadonlyMap<string, string>,
476
+ ): string | undefined {
477
+ const syntax = groovySyntaxError(criteria);
478
+ // Groovy this grammar doesn't read (a list, arithmetic, a ternary, `new`):
479
+ // GSF's validator answers it whole.
480
+ const groovy = syntax ? undefined : groovyValidation(criteria, allowed);
481
+ if (groovy) return groovy.kind === 'refused' ? groovy.text : undefined;
482
+ const dialect = criteriaDialect(criteria, allowed, types);
483
+ if (syntax && (!dialect || syntax.column - 1 < dialect.at)) {
484
+ return groovyCompileErrorText(criteria, syntax);
485
+ }
486
+ return dialect?.text;
487
+ }
@@ -0,0 +1,72 @@
1
+ // What the criteria evaluators (criteria.ts, legacyCriteria.ts) and the
2
+ // dialect check (criteriaDialect.ts) share: the token grammar, its errors,
3
+ // and the lenient date reading this engine has always had.
4
+
5
+ // A criteria this grammar can't parse: unbalanced parentheses, a missing
6
+ // operand, text it can't tokenize. GSF fails to compile those too.
7
+ export class CriteriaSyntaxError extends Error {
8
+ override name = 'CriteriaSyntaxError';
9
+ }
10
+
11
+ // A well-formed call to a function this engine doesn't have. It may well be
12
+ // valid Groovy, so callers that refuse malformed criteria still fail open on
13
+ // this one.
14
+ export class UnsupportedCriteriaError extends Error {
15
+ override name = 'UnsupportedCriteriaError';
16
+ }
17
+
18
+ const TOKEN_RE =
19
+ /\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
20
+
21
+ // One token of this grammar, and where it starts in the criteria.
22
+ export interface CriteriaToken {
23
+ text: string;
24
+ offset: number;
25
+ }
26
+
27
+ // The criteria's tokens as this grammar reads them: a quoted string is one
28
+ // token, and so is a name (NOTES is never NOT). Throws CriteriaSyntaxError
29
+ // for text it can't tokenize.
30
+ export function tokenizeCriteria(input: string): CriteriaToken[] {
31
+ const tokens: CriteriaToken[] = [];
32
+ let match: RegExpExecArray | null;
33
+ let lastIndex = 0;
34
+ TOKEN_RE.lastIndex = 0;
35
+ while ((match = TOKEN_RE.exec(input))) {
36
+ if (match.index !== lastIndex) {
37
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
38
+ }
39
+ tokens.push({ text: match[1], offset: TOKEN_RE.lastIndex - match[1].length });
40
+ lastIndex = TOKEN_RE.lastIndex;
41
+ }
42
+ if (lastIndex !== input.length) {
43
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
44
+ }
45
+ return tokens;
46
+ }
47
+
48
+ const MS_DIGITS = 3; // '.5' means 500ms, not 5ms — right-pad to full precision
49
+
50
+ // Genesis date/datetime values serialize as 'YYYYMMDD-HH:MM[:SS[.mmm]]'. Row
51
+ // values may already be epoch millis (number) or an ISO string; anything
52
+ // else Date.parse reads is taken too. Returns null if unparseable. (A
53
+ // REQUEST's DATE fields are read with it, and fidelity: 'legacy' criteria.)
54
+ export function parseGenesisDateTime(value: unknown): number | null {
55
+ if (typeof value === 'number') return value;
56
+ if (typeof value !== 'string') return null;
57
+ const match = value.match(/^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/);
58
+ if (match) {
59
+ const [, y, mo, d, h, mi, s, ms] = match;
60
+ return Date.UTC(
61
+ Number(y),
62
+ Number(mo) - 1,
63
+ Number(d),
64
+ Number(h),
65
+ Number(mi),
66
+ Number(s ?? '0'),
67
+ Number((ms ?? '0').padEnd(MS_DIGITS, '0')),
68
+ );
69
+ }
70
+ const parsed = Date.parse(value);
71
+ return Number.isNaN(parsed) ? null : parsed;
72
+ }