@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.
- package/README.md +408 -39
- package/dist/db/criteria.d.ts +13 -0
- package/dist/db/criteria.js +60 -21
- package/dist/db/identity.d.ts +11 -0
- package/dist/db/identity.js +46 -0
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +62 -0
- package/dist/db/schema.js +490 -0
- package/dist/db/store.d.ts +20 -3
- package/dist/db/store.js +300 -50
- package/dist/handlers/commitEvent.js +47 -9
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +105 -6
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +4 -2
- package/dist/handlers/jsonSchema.js +222 -61
- package/dist/handlers/meta.d.ts +6 -1
- package/dist/handlers/meta.js +168 -21
- package/dist/handlers/requestReply.js +519 -30
- package/dist/handlers/resourceAuth.d.ts +1 -0
- package/dist/handlers/resourceAuth.js +14 -0
- package/dist/handlers/resources.js +3 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/protocol/connection.d.ts +2 -0
- package/dist/protocol/errors.d.ts +100 -2
- package/dist/protocol/errors.js +300 -12
- package/dist/protocol/fieldTypes.d.ts +10 -0
- package/dist/protocol/fieldTypes.js +107 -0
- package/dist/protocol/gsfSchemas.d.ts +9 -0
- package/dist/protocol/gsfSchemas.js +423 -0
- package/dist/protocol/messageTypes.d.ts +1 -0
- package/dist/protocol/messageTypes.js +1 -0
- package/dist/protocol/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/rowUpdate.d.ts +5 -3
- package/dist/protocol/rowUpdate.js +33 -15
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +95 -29
- package/dist/types.d.ts +45 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +72 -21
- package/src/db/identity.ts +53 -0
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +672 -0
- package/src/db/store.ts +343 -50
- package/src/handlers/commitEvent.ts +54 -10
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +127 -7
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +302 -69
- package/src/handlers/meta.ts +227 -23
- package/src/handlers/requestReply.ts +626 -33
- package/src/handlers/resourceAuth.ts +18 -0
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +17 -1
- package/src/protocol/connection.ts +10 -2
- package/src/protocol/errors.ts +361 -13
- package/src/protocol/fieldTypes.ts +115 -0
- package/src/protocol/gsfSchemas.ts +505 -0
- package/src/protocol/messageTypes.ts +1 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/rowUpdate.ts +40 -20
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +106 -28
- package/src/types.ts +187 -5
package/src/db/criteria.ts
CHANGED
|
@@ -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
|
|
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
|
|
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() !== ')')
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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() !== ')')
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
+
}
|