@genesislcap/mock-server 15.51.1 → 15.53.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 (70) hide show
  1. package/README.md +408 -39
  2. package/dist/db/criteria.d.ts +13 -0
  3. package/dist/db/criteria.js +60 -21
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/ordering.d.ts +8 -0
  7. package/dist/db/ordering.js +49 -0
  8. package/dist/db/schema.d.ts +62 -0
  9. package/dist/db/schema.js +490 -0
  10. package/dist/db/store.d.ts +20 -3
  11. package/dist/db/store.js +300 -50
  12. package/dist/handlers/commitEvent.js +47 -9
  13. package/dist/handlers/crudEvents.d.ts +2 -0
  14. package/dist/handlers/crudEvents.js +138 -0
  15. package/dist/handlers/dataLogon.js +105 -6
  16. package/dist/handlers/eventValidation.d.ts +5 -0
  17. package/dist/handlers/eventValidation.js +29 -0
  18. package/dist/handlers/jsonSchema.d.ts +4 -2
  19. package/dist/handlers/jsonSchema.js +222 -61
  20. package/dist/handlers/meta.d.ts +6 -1
  21. package/dist/handlers/meta.js +168 -21
  22. package/dist/handlers/requestReply.js +519 -30
  23. package/dist/handlers/resourceAuth.d.ts +1 -0
  24. package/dist/handlers/resourceAuth.js +14 -0
  25. package/dist/handlers/resources.js +3 -0
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.js +2 -1
  28. package/dist/protocol/connection.d.ts +2 -0
  29. package/dist/protocol/errors.d.ts +100 -2
  30. package/dist/protocol/errors.js +300 -12
  31. package/dist/protocol/fieldTypes.d.ts +10 -0
  32. package/dist/protocol/fieldTypes.js +107 -0
  33. package/dist/protocol/gsfSchemas.d.ts +9 -0
  34. package/dist/protocol/gsfSchemas.js +423 -0
  35. package/dist/protocol/messageTypes.d.ts +1 -0
  36. package/dist/protocol/messageTypes.js +1 -0
  37. package/dist/protocol/msgNack.d.ts +2 -0
  38. package/dist/protocol/msgNack.js +10 -0
  39. package/dist/protocol/rowUpdate.d.ts +5 -3
  40. package/dist/protocol/rowUpdate.js +33 -15
  41. package/dist/protocol/schemaValidation.d.ts +11 -0
  42. package/dist/protocol/schemaValidation.js +186 -0
  43. package/dist/server.js +95 -29
  44. package/dist/types.d.ts +45 -0
  45. package/package.json +1 -1
  46. package/src/db/criteria.ts +72 -21
  47. package/src/db/identity.ts +53 -0
  48. package/src/db/ordering.ts +56 -0
  49. package/src/db/schema.ts +672 -0
  50. package/src/db/store.ts +343 -50
  51. package/src/handlers/commitEvent.ts +54 -10
  52. package/src/handlers/crudEvents.ts +181 -0
  53. package/src/handlers/dataLogon.ts +127 -7
  54. package/src/handlers/eventValidation.ts +43 -0
  55. package/src/handlers/jsonSchema.ts +302 -69
  56. package/src/handlers/meta.ts +227 -23
  57. package/src/handlers/requestReply.ts +626 -33
  58. package/src/handlers/resourceAuth.ts +18 -0
  59. package/src/handlers/resources.ts +3 -0
  60. package/src/index.ts +17 -1
  61. package/src/protocol/connection.ts +10 -2
  62. package/src/protocol/errors.ts +361 -13
  63. package/src/protocol/fieldTypes.ts +115 -0
  64. package/src/protocol/gsfSchemas.ts +505 -0
  65. package/src/protocol/messageTypes.ts +1 -0
  66. package/src/protocol/msgNack.ts +12 -0
  67. package/src/protocol/rowUpdate.ts +40 -20
  68. package/src/protocol/schemaValidation.ts +208 -0
  69. package/src/server.ts +106 -28
  70. package/src/types.ts +187 -5
@@ -20,6 +20,19 @@ import type { Row } from '../types.ts';
20
20
 
21
21
  export type Predicate = (record: Row) => boolean;
22
22
 
23
+ // A criteria this grammar can't parse: unbalanced parentheses, a missing
24
+ // operand, text it can't tokenize. GSF fails to compile those too.
25
+ export class CriteriaSyntaxError extends Error {
26
+ override name = 'CriteriaSyntaxError';
27
+ }
28
+
29
+ // A well-formed call to a function EXPR_FUNCTIONS doesn't have. It may well be
30
+ // valid Groovy, so callers that refuse malformed criteria still fail open on
31
+ // this one.
32
+ export class UnsupportedCriteriaError extends Error {
33
+ override name = 'UnsupportedCriteriaError';
34
+ }
35
+
23
36
  const TOKEN_RE =
24
37
  /\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
25
38
 
@@ -30,13 +43,13 @@ function tokenize(input: string): string[] {
30
43
  TOKEN_RE.lastIndex = 0;
31
44
  while ((match = TOKEN_RE.exec(input))) {
32
45
  if (match.index !== lastIndex) {
33
- throw new Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
46
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
34
47
  }
35
48
  tokens.push(match[1]);
36
49
  lastIndex = TOKEN_RE.lastIndex;
37
50
  }
38
51
  if (lastIndex !== input.length) {
39
- throw new Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
52
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
40
53
  }
41
54
  return tokens;
42
55
  }
@@ -58,7 +71,7 @@ const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
58
71
  // them makes every Expr.dateTimeIs* comparison silently false (date-filtered
59
72
  // grids render zero rows with no warning). Row values may already be epoch
60
73
  // millis (number) or an ISO string. Returns null if unparseable.
61
- function parseGenesisDateTime(value: unknown): number | null {
74
+ export function parseGenesisDateTime(value: unknown): number | null {
62
75
  if (typeof value === 'number') return value;
63
76
  if (typeof value !== 'string') return null;
64
77
  const match = value.match(/^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/);
@@ -188,6 +201,9 @@ function compareByTime(
188
201
  class Parser {
189
202
  private tokens: string[];
190
203
  private pos = 0;
204
+ // Every field the expression reads: bare identifiers in comparisons, and
205
+ // function-call arguments and receivers.
206
+ readonly fields = new Set<string>();
191
207
 
192
208
  constructor(tokens: string[]) {
193
209
  this.tokens = tokens;
@@ -247,13 +263,14 @@ class Parser {
247
263
  if (this.peek() === '(') {
248
264
  this.next();
249
265
  const inner = this.parseExpression();
250
- if (this.next() !== ')') throw new Error('Missing closing parenthesis in criteria');
266
+ if (this.next() !== ')')
267
+ throw new CriteriaSyntaxError('Missing closing parenthesis in criteria');
251
268
  return inner;
252
269
  }
253
270
 
254
271
  const leftToken = this.next();
255
272
  if (leftToken === undefined) {
256
- throw new Error('Expected operand on the left side of comparison');
273
+ throw new CriteriaSyntaxError('Expected operand on the left side of comparison');
257
274
  }
258
275
 
259
276
  // Both function-call dialects arrive as one dotted token followed by '(':
@@ -265,14 +282,16 @@ class Parser {
265
282
 
266
283
  const op = this.peek();
267
284
  if (!op || !COMPARATORS.has(op)) {
268
- throw new Error(`Expected comparator, got: ${op}`);
285
+ throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`);
269
286
  }
270
287
  this.next();
271
288
  const rightToken = this.next();
272
289
  if (rightToken === undefined) {
273
- throw new Error('Expected operand on the right side of comparison');
290
+ throw new CriteriaSyntaxError('Expected operand on the right side of comparison');
274
291
  }
275
292
 
293
+ this.noteField(leftToken);
294
+ this.noteField(rightToken);
276
295
  const resolveLeft = (record: Row) => this.resolveValue(leftToken, record);
277
296
  const resolveRight = (record: Row) => this.resolveValue(rightToken, record);
278
297
 
@@ -300,6 +319,10 @@ class Parser {
300
319
  };
301
320
  }
302
321
 
322
+ noteField(token: string): void {
323
+ if (parseLiteral(token) === undefined) this.fields.add(token);
324
+ }
325
+
303
326
  resolveValue(token: string, record: Row): unknown {
304
327
  const literal = parseLiteral(token);
305
328
  if (literal !== undefined) return literal;
@@ -311,16 +334,19 @@ class Parser {
311
334
  const args: string[] = [];
312
335
  if (this.peek() !== ')') {
313
336
  const arg = this.next();
314
- if (arg === undefined) throw new Error(`Unterminated argument list in ${name}(...)`);
337
+ if (arg === undefined)
338
+ throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
315
339
  args.push(arg);
316
340
  while (this.peek() === ',') {
317
341
  this.next();
318
342
  const nextArg = this.next();
319
- if (nextArg === undefined) throw new Error(`Unterminated argument list in ${name}(...)`);
343
+ if (nextArg === undefined)
344
+ throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
320
345
  args.push(nextArg);
321
346
  }
322
347
  }
323
- if (this.next() !== ')') throw new Error(`Missing closing parenthesis in ${name}(...)`);
348
+ if (this.next() !== ')')
349
+ throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`);
324
350
 
325
351
  // 'Expr.<fn>(field, value)' passes args through as-is; the method-call
326
352
  // form '<FIELD>.<fn>(value)' makes the receiver field the implicit first
@@ -329,32 +355,46 @@ class Parser {
329
355
  const receiver = name.slice(0, dotIndex);
330
356
  const fnName = name.slice(dotIndex + 1);
331
357
  const handler = EXPR_FUNCTIONS[fnName];
332
- if (!handler) throw new Error(`Unsupported criteria function: ${name}`);
358
+ if (!handler) throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
333
359
  const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
360
+ for (const arg of allArgs) this.noteField(arg);
334
361
 
335
362
  const resolve: ExprResolver = (token, record) => this.resolveValue(token, record);
336
363
  return (record: Row) => handler(record, allArgs, resolve);
337
364
  }
338
365
  }
339
366
 
340
- /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
341
- export function compileCriteria(criteria?: string | null): Predicate {
367
+ export interface ParsedCriteria {
368
+ predicate: Predicate;
369
+ // Every field the expression reads.
370
+ fields: string[];
371
+ }
372
+
373
+ // Parses a CRITERIA_MATCH without failing open: throws CriteriaSyntaxError for
374
+ // a malformed one and UnsupportedCriteriaError for a call to a function the
375
+ // grammar doesn't have. A blank criteria matches everything.
376
+ export function parseCriteria(criteria?: string | null): ParsedCriteria {
342
377
  if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') {
343
- return () => true;
378
+ return { predicate: () => true, fields: [] };
344
379
  }
380
+ const tokens = tokenize(criteria.trim());
381
+ const parser = new Parser(tokens);
382
+ const predicate = parser.parseExpression();
383
+ if (parser.position !== tokens.length) {
384
+ throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
385
+ }
386
+ return { predicate, fields: [...parser.fields] };
387
+ }
388
+
389
+ /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
390
+ export function compileCriteria(criteria?: string | null): Predicate {
345
391
  // Real Genesis clients can emit CRITERIA_MATCH syntax this grammar doesn't
346
392
  // cover yet (e.g. function-call criteria like Expr.dateTimeIsGreaterEqual(...)
347
393
  // from date-range filters). Failing open — matching everything and logging —
348
394
  // keeps DATA_LOGON/broadcast working (unfiltered) instead of crashing the
349
395
  // handler and leaving the client's grid stuck on "Loading...".
350
396
  try {
351
- const tokens = tokenize(criteria.trim());
352
- const parser = new Parser(tokens);
353
- const predicate = parser.parseExpression();
354
- if (parser.position !== tokens.length) {
355
- throw new Error(`Unexpected trailing tokens in criteria: ${criteria}`);
356
- }
357
- return predicate;
397
+ return parseCriteria(criteria).predicate;
358
398
  } catch (error) {
359
399
  console.warn(
360
400
  `[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`,
@@ -364,6 +404,17 @@ export function compileCriteria(criteria?: string | null): Predicate {
364
404
  }
365
405
  }
366
406
 
407
+ // The fields a CRITERIA_MATCH reads, or undefined when it doesn't parse
408
+ // (compileCriteria then fails open). The real dataserver refuses a criteria
409
+ // naming a field its query doesn't expose (INVALID_CRITERIA).
410
+ export function criteriaFields(criteria?: string | null): string[] | undefined {
411
+ try {
412
+ return parseCriteria(criteria).fields;
413
+ } catch {
414
+ return undefined;
415
+ }
416
+ }
417
+
367
418
  export function filterByCriteria(rows: Row[], criteria?: string | null): Row[] {
368
419
  const predicate = compileCriteria(criteria);
369
420
  return rows.filter((row) => {
@@ -0,0 +1,53 @@
1
+ // Record identity: the RECORD_ID and TIMESTAMP every Genesis table row carries.
2
+ //
3
+ // GSF sets TIMESTAMP from a high-resolution clock on every write and
4
+ // RECORD_ID = TIMESTAMP on insert (genesis-db InsertOperation.kt); a modify
5
+ // moves TIMESTAMP on and keeps RECORD_ID. The real values are around 7.5e18:
6
+ // epoch millis shifted left 22 bits, plus a counter. That is far above 2^53,
7
+ // so a JavaScript client's JSON.parse rounds neighbouring rows onto the same
8
+ // number. The mock deliberately stays below that: its stamps are a counter
9
+ // seeded from epoch millis that only goes up, so they stay exact, strictly
10
+ // increasing safe integers, and a client that reads TIMESTAMP as a date (the
11
+ // AI assistant shows a NANO_TIMESTAMP as epoch millis) still gets roughly the
12
+ // write time. The same order, a different magnitude.
13
+
14
+ import type { Row } from '../types.ts';
15
+
16
+ export const RECORD_ID = 'RECORD_ID';
17
+ export const TIMESTAMP = 'TIMESTAMP';
18
+ export const RECORD_FIELDS: readonly string[] = [RECORD_ID, TIMESTAMP];
19
+
20
+ // Hands out RECORD_ID/TIMESTAMP values: the current epoch millis, or one more
21
+ // than the last value when that is not ahead of it (seeding a thousand rows
22
+ // runs the counter a second or so ahead of the clock).
23
+ export class RecordClock {
24
+ private last = 0;
25
+
26
+ next(): number {
27
+ this.last = Math.max(Date.now(), this.last + 1);
28
+ return this.last;
29
+ }
30
+
31
+ // Keeps later stamps above a value a seed row brought with it.
32
+ observe(value: unknown): void {
33
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > this.last) {
34
+ this.last = value;
35
+ }
36
+ }
37
+ }
38
+
39
+ // A row without its record identity — what a dataserver sends (the real
40
+ // dataserver never puts RECORD_ID or TIMESTAMP in a row, only
41
+ // DETAILS.ROW_REF) and what field metadata is derived from.
42
+ export function withoutRecordFields(row: Row): Row {
43
+ if (!(RECORD_ID in row) && !(TIMESTAMP in row)) return row;
44
+ const { [RECORD_ID]: _recordId, [TIMESTAMP]: _timestamp, ...rest } = row;
45
+ return rest;
46
+ }
47
+
48
+ // The Map key a record is tracked under. RECORD_IDs are numbers here, but a
49
+ // seed may bring a recorded string, so they are compared as strings.
50
+ export function recordKey(row: Row | undefined | null): string | undefined {
51
+ const id = row?.[RECORD_ID];
52
+ return id === undefined || id === null ? undefined : String(id);
53
+ }
@@ -0,0 +1,56 @@
1
+ // Row ordering shared by the dataserver (DATA_LOGON ORDER_BY, an index name)
2
+ // and request servers (REQ_ DETAILS.ORDER_BY, fields with a direction).
3
+
4
+ import type { Row } from '../types.ts';
5
+ import { RECORD_ID } from './identity.ts';
6
+
7
+ // Nulls sort low (first ascending, last descending), as H2's default null
8
+ // ordering does; numbers numerically, booleans false before true, anything
9
+ // else by its string form (UTF-16 order, as Java's String.compareTo).
10
+ export function compareValues(a: unknown, b: unknown): number {
11
+ if (a === b) return 0;
12
+ if (a === undefined || a === null) return -1;
13
+ if (b === undefined || b === null) return 1;
14
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
15
+ if (typeof a === 'boolean' && typeof b === 'boolean') return a ? 1 : -1;
16
+ const [left, right] = [String(a), String(b)];
17
+ return left < right ? -1 : left > right ? 1 : 0;
18
+ }
19
+
20
+ export interface OrderedField {
21
+ field: string;
22
+ descending: boolean;
23
+ }
24
+
25
+ // Sorts by the fields in turn, then by RECORD_ID ascending: the tie-breaker
26
+ // column GSF's SQL ordering always appends (GetByCriteriaOperation.kt,
27
+ // tieBreakerColumn = "RECORD_ID", v8.15.29), whatever the direction.
28
+ export function rowComparator(fields: OrderedField[]): (a: Row, b: Row) => number {
29
+ return (a, b) => {
30
+ for (const { field, descending } of fields) {
31
+ const result = compareValues(a[field], b[field]);
32
+ if (result !== 0) return descending ? -result : result;
33
+ }
34
+ return compareValues(a[RECORD_ID], b[RECORD_ID]);
35
+ };
36
+ }
37
+
38
+ const ORDER_SEPARATOR = /\s*,\s*/;
39
+ const ORDER_FIELD = /^([A-Za-z]\w+)(?:\s+(ASC|DESC))?$/i;
40
+
41
+ // DETAILS.ORDER_BY as a request server reads it (OrderSpec.kt OrderedString,
42
+ // GSF v8.15.29): comma-separated fields, each with an optional ASC or DESC in
43
+ // any case. Throws GSF's IllegalArgumentException text for anything else,
44
+ // which the request server answers as REQUEST_FAILED.
45
+ export function parseOrderBy(orderBy: string): OrderedField[] {
46
+ return orderBy.split(ORDER_SEPARATOR).map((part) => {
47
+ const match = ORDER_FIELD.exec(part);
48
+ if (!match) {
49
+ throw new Error(
50
+ `${orderBy} is not a valid ORDER string, Fields should be comma separated, with followed ` +
51
+ "by an optional ASC or DESC. E.g. 'TRADER, TRADE_DATE DESC'",
52
+ );
53
+ }
54
+ return { field: match[1], descending: match[2]?.toUpperCase() === 'DESC' };
55
+ });
56
+ }