@genesislcap/mock-server 15.51.1 → 15.52.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 (53) hide show
  1. package/README.md +217 -31
  2. package/dist/db/criteria.d.ts +1 -0
  3. package/dist/db/criteria.js +29 -0
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/schema.d.ts +46 -0
  7. package/dist/db/schema.js +375 -0
  8. package/dist/db/store.d.ts +13 -2
  9. package/dist/db/store.js +208 -34
  10. package/dist/handlers/commitEvent.js +20 -6
  11. package/dist/handlers/dataLogon.js +126 -6
  12. package/dist/handlers/jsonSchema.d.ts +2 -1
  13. package/dist/handlers/jsonSchema.js +216 -61
  14. package/dist/handlers/meta.d.ts +6 -1
  15. package/dist/handlers/meta.js +164 -21
  16. package/dist/handlers/requestReply.js +16 -5
  17. package/dist/handlers/resourceAuth.d.ts +1 -0
  18. package/dist/handlers/resourceAuth.js +14 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/protocol/connection.d.ts +2 -0
  22. package/dist/protocol/errors.d.ts +100 -2
  23. package/dist/protocol/errors.js +300 -12
  24. package/dist/protocol/fieldTypes.d.ts +9 -0
  25. package/dist/protocol/fieldTypes.js +101 -0
  26. package/dist/protocol/gsfSchemas.d.ts +9 -0
  27. package/dist/protocol/gsfSchemas.js +423 -0
  28. package/dist/protocol/messageTypes.d.ts +1 -0
  29. package/dist/protocol/messageTypes.js +1 -0
  30. package/dist/protocol/rowUpdate.d.ts +5 -3
  31. package/dist/protocol/rowUpdate.js +33 -15
  32. package/dist/server.js +90 -28
  33. package/dist/types.d.ts +40 -0
  34. package/package.json +1 -1
  35. package/src/db/criteria.ts +26 -0
  36. package/src/db/identity.ts +53 -0
  37. package/src/db/schema.ts +523 -0
  38. package/src/db/store.ts +225 -32
  39. package/src/handlers/commitEvent.ts +20 -7
  40. package/src/handlers/dataLogon.ts +144 -7
  41. package/src/handlers/jsonSchema.ts +290 -69
  42. package/src/handlers/meta.ts +222 -23
  43. package/src/handlers/requestReply.ts +15 -6
  44. package/src/handlers/resourceAuth.ts +18 -0
  45. package/src/index.ts +16 -1
  46. package/src/protocol/connection.ts +10 -2
  47. package/src/protocol/errors.ts +361 -13
  48. package/src/protocol/fieldTypes.ts +108 -0
  49. package/src/protocol/gsfSchemas.ts +505 -0
  50. package/src/protocol/messageTypes.ts +1 -0
  51. package/src/protocol/rowUpdate.ts +40 -20
  52. package/src/server.ts +101 -27
  53. package/src/types.ts +145 -1
package/dist/server.js CHANGED
@@ -3,11 +3,11 @@ import { WebSocketServer } from 'ws';
3
3
  import { Store } from "./db/store.js";
4
4
  import { pendingKey } from "./handlers/dataLogon.js";
5
5
  import { createHttpRequestListener } from "./handlers/http.js";
6
- import { applyResourceAuth, requestingUser } from "./handlers/resourceAuth.js";
6
+ import { permittedQueryRow, requestingUser } from "./handlers/resourceAuth.js";
7
7
  import { Connection } from "./protocol/connection.js";
8
8
  import { isLegacyFidelity } from "./protocol/fidelity.js";
9
9
  import { routeMessage } from "./protocol/router.js";
10
- import { stampRow } from "./protocol/rowUpdate.js";
10
+ import { dataserverRow, stampRow } from "./protocol/rowUpdate.js";
11
11
  export function createMockServer(config) {
12
12
  const store = new Store();
13
13
  for (const [tableName, tableDef] of Object.entries(config.tables ?? {})) {
@@ -80,10 +80,10 @@ export function createMockServer(config) {
80
80
  return null;
81
81
  const permitted = operation === 'DELETE'
82
82
  ? undefined
83
- : applyResourceAuth([row], query?.auth, requestingUser(connection))[0];
83
+ : permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
84
84
  if (pending.has(key)) {
85
85
  if (permitted)
86
- pending.set(key, stampRow(permitted, rowRef, 'INSERT'));
86
+ pending.set(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
87
87
  else
88
88
  pending.delete(key);
89
89
  return null;
@@ -96,10 +96,14 @@ export function createMockServer(config) {
96
96
  }
97
97
  if (!permitted)
98
98
  return null;
99
+ // Under ORDER_BY, a new row that sorts among the waiting ones waits too,
100
+ // so MORE_ROWS pages stay in order.
101
+ if (subscription.queueInsert?.(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'), row))
102
+ return null;
99
103
  held.add(key);
100
104
  return 'INSERT';
101
105
  }
102
- const broadcast = (resourceName, operation, row) => {
106
+ function pushChange(resourceName, operation, row) {
103
107
  const query = config.queries?.[resourceName];
104
108
  // The query's own filter (where-clause equivalent) applies to live pushes
105
109
  // too — a row the snapshot would exclude must not leak in via broadcast.
@@ -129,21 +133,25 @@ export function createMockServer(config) {
129
133
  : resolved;
130
134
  if (rowOperation === null)
131
135
  continue;
136
+ const legacy = isLegacyFidelity(config);
132
137
  let outRow = row;
133
138
  if (rowOperation === 'DELETE') {
134
- // A change turned into a DELETE: send the row identity only — it is
135
- // all the client needs to drop the row, and the full row may carry
136
- // columns this user's hideFields strips. (A real DELETE's row is a
137
- // stub already.)
138
- if (operation !== 'DELETE') {
139
+ // GSF's DELETE row is DETAILS {OPERATION, ROW_REF} and nothing else.
140
+ // Legacy mode sent the pk fields: the stub it was given for a real
141
+ // DELETE, or one rebuilt from the key for a change that left the
142
+ // subscription (the full row may carry columns hideFields strips).
143
+ if (!legacy)
144
+ outRow = {};
145
+ else if (operation !== 'DELETE') {
139
146
  outRow = query ? store.rowStubFromKey(query.source, rowRef) : row;
140
147
  }
141
148
  }
142
- else if (query?.auth) {
143
- const [permitted] = applyResourceAuth([row], query.auth, requestingUser(connection));
149
+ else {
150
+ // Only the subscription's columns, minus the ones this user can't see.
151
+ const permitted = permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
144
152
  if (!permitted)
145
153
  continue;
146
- outRow = permitted;
154
+ outRow = dataserverRow(permitted, row, query?.fields);
147
155
  }
148
156
  subscription.sequenceId += 1;
149
157
  // A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
@@ -151,20 +159,62 @@ export function createMockServer(config) {
151
159
  connection.send({
152
160
  MESSAGE_TYPE: 'QUERY_UPDATE',
153
161
  SOURCE_REF: sourceRef,
154
- ROW: [stampRow(outRow, rowRef, rowOperation)],
155
- ...(isLegacyFidelity(config) ? { ROWS_COUNT: 1 } : {}),
162
+ ROW: [stampRow(outRow, rowRef, rowOperation, legacy)],
163
+ ...(legacy ? { ROWS_COUNT: 1 } : {}),
156
164
  SEQUENCE_ID: subscription.sequenceId,
157
165
  });
158
166
  }
159
167
  }
168
+ }
169
+ // Pushes an unsolicited row update to every subscription on the resource
170
+ // (see pushChange). A DELETE given as a pk-only row is resolved to the
171
+ // record(s) removed from that key that no broadcast has announced yet —
172
+ // their RECORD_IDs are the ROW_REFs a client may hold — and those are
173
+ // consumed, so a later delete of a re-inserted key names its own record.
174
+ const broadcast = (resourceName, operation, row) => {
175
+ const source = config.queries?.[resourceName]?.source;
176
+ if (operation !== 'DELETE' || !source) {
177
+ pushChange(resourceName, operation, row);
178
+ return;
179
+ }
180
+ const removed = store.takeRemovedRecordsFor(source, row);
181
+ if (isLegacyFidelity(config)) {
182
+ pushChange(resourceName, operation, row);
183
+ return;
184
+ }
185
+ const records = row.RECORD_ID === undefined || row.RECORD_ID === null ? removed : [row];
186
+ if (records.length === 0)
187
+ records.push(row);
188
+ for (const record of records)
189
+ pushChange(resourceName, 'DELETE', record);
160
190
  };
161
191
  // Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
162
192
  // a view built on top of it (e.g. ALL_TRADES -> TRADE_VIEW). This finds every
163
193
  // query resource backed by that table — directly or via a view — and pushes
164
194
  // the (re-joined, for views) row to each one's subscribers. `pk` is the
165
195
  // store key: the single pk field's value, or store.rowKey(...) for
166
- // composite-keyed tables.
196
+ // composite-keyed tables. ROW_REF is the record's RECORD_ID, so a DELETE is
197
+ // broadcast with every record the key lost that no broadcast announced yet
198
+ // (Store.takeRemovedRows; held-rows tracking drops the ones a client
199
+ // doesn't hold), and a record replaced under the same key (deleted, then
200
+ // re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
201
+ // the new one goes out. A view row is built from the changed base row
202
+ // alone, so it is found whatever the view's own pkField; a MODIFY that takes
203
+ // it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
204
+ // are the key itself, so there a DELETE is the pk stub and a replacement is
205
+ // the one INSERT/MODIFY, as before.
167
206
  const broadcastTableChange = (tableName, operation, pk) => {
207
+ const legacy = isLegacyFidelity(config);
208
+ const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
209
+ let removed = store
210
+ .takeRemovedRows(tableName, pk)
211
+ .filter((record) => record.RECORD_ID !== current?.RECORD_ID);
212
+ // A DELETE broadcast before the row is removed still names it.
213
+ const live = store.getRow(tableName, pk);
214
+ if (operation === 'DELETE' && removed.length === 0 && live)
215
+ removed = [live];
216
+ if (legacy)
217
+ removed = [];
168
218
  for (const [queryName, query] of Object.entries(config.queries ?? {})) {
169
219
  const isDirect = query.source === tableName;
170
220
  const view = store.views.get(query.source);
@@ -183,25 +233,37 @@ export function createMockServer(config) {
183
233
  continue;
184
234
  if (isViewJoiningTable) {
185
235
  for (const viewRow of store.getViewRows(query.source)) {
186
- broadcast(queryName, 'MODIFY', viewRow);
236
+ pushChange(queryName, 'MODIFY', viewRow);
187
237
  }
188
238
  continue;
189
239
  }
190
- let row;
240
+ // The removed base records carry the RECORD_ID a view row's ROW_REF
241
+ // comes from too; the DELETE body is DETAILS only either way.
242
+ for (const record of removed)
243
+ pushChange(queryName, 'DELETE', record);
191
244
  if (operation === 'DELETE') {
192
- const stub = store.rowStubFromKey(query.source, pk);
193
- row = Object.keys(stub).length > 0 ? stub : undefined;
245
+ if (legacy) {
246
+ const stub = store.rowStubFromKey(query.source, pk);
247
+ if (Object.keys(stub).length > 0)
248
+ pushChange(queryName, 'DELETE', stub);
249
+ }
250
+ continue;
251
+ }
252
+ if (!current)
253
+ continue;
254
+ if (isDirect) {
255
+ pushChange(queryName, operation, current);
256
+ continue;
194
257
  }
195
- else if (isDirect) {
196
- row = store.getRow(tableName, pk);
258
+ const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
259
+ if (viewRow) {
260
+ pushChange(queryName, operation, viewRow);
197
261
  }
198
- else {
199
- row = store
200
- .getViewRows(query.source)
201
- .find((r) => String(store.getRowRef(query.source, r)) === String(pk));
262
+ else if (operation === 'MODIFY') {
263
+ pushChange(queryName, 'DELETE', legacy
264
+ ? store.rowStubFromKey(query.source, store.getRowRef(query.source, current))
265
+ : current);
202
266
  }
203
- if (row)
204
- broadcast(queryName, operation, row);
205
267
  }
206
268
  };
207
269
  const httpSessions = new Map(); // sessionId -> username, for the HTTP login transport (see handlers/http.ts)
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { Store } from './db/store.ts';
3
+ import type { NackErrorInput } from './protocol/errors.ts';
3
4
  export type Row = Record<string, any>;
4
5
  export interface GenesisMessage {
5
6
  MESSAGE_TYPE: string;
@@ -7,12 +8,40 @@ export interface GenesisMessage {
7
8
  DETAILS?: Row;
8
9
  [key: string]: any;
9
10
  }
11
+ export type GenesisFieldType = 'STRING' | 'ENUM' | 'INT' | 'SHORT' | 'LONG' | 'DOUBLE' | 'BIGDECIMAL' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'NANO_TIMESTAMP' | 'RAW';
12
+ export interface FieldDef {
13
+ name: string;
14
+ type: GenesisFieldType;
15
+ nullable?: boolean;
16
+ values?: string[];
17
+ default?: unknown;
18
+ maxSize?: number;
19
+ minLength?: number;
20
+ description?: string;
21
+ title?: string;
22
+ optional?: boolean;
23
+ }
24
+ export interface GeneratedFieldDef {
25
+ field: string;
26
+ kind: 'SEQUENCE' | 'AUTO_INCREMENT';
27
+ prefix?: string;
28
+ }
29
+ export interface IndexDef {
30
+ name: string;
31
+ fields: string[];
32
+ unique?: boolean;
33
+ }
10
34
  export interface TableDef {
11
35
  pkField: string | string[];
12
36
  sequencePrefix?: string;
13
37
  sequenceWidth?: number;
38
+ sequenceFormat?: SequenceFormat;
14
39
  rows?: Row[];
40
+ fields?: FieldDef[];
41
+ generated?: GeneratedFieldDef[];
42
+ indexes?: IndexDef[];
15
43
  }
44
+ export type SequenceFormat = 'prefix' | 'genesis';
16
45
  export interface JoinDef {
17
46
  table: string;
18
47
  on: [string, string];
@@ -30,6 +59,8 @@ export interface ViewDef {
30
59
  joins?: JoinDef[];
31
60
  antiJoins?: AntiJoinDef[];
32
61
  derived?: Record<string, (row: Row) => any>;
62
+ derivedTypes?: Record<string, GenesisFieldType | Omit<FieldDef, 'name'>>;
63
+ fields?: string[];
33
64
  }
34
65
  export interface RequestingUser {
35
66
  userName?: string;
@@ -43,6 +74,8 @@ export interface ResourceAuthDef {
43
74
  export interface QueryDef {
44
75
  source: string;
45
76
  fieldTypes?: Record<string, string>;
77
+ fields?: string[];
78
+ indexes?: IndexDef[];
46
79
  filter?: (row: Row) => boolean;
47
80
  auth?: ResourceAuthDef;
48
81
  }
@@ -59,11 +92,15 @@ export interface RequestReplyDef {
59
92
  fieldTypes?: Record<string, string>;
60
93
  filter?: (row: Row) => boolean;
61
94
  auth?: ResourceAuthDef;
95
+ criteriaOnly?: boolean;
96
+ requestFields?: string[];
62
97
  }
63
98
  export interface EventSchemaOverride {
64
99
  table?: string;
65
100
  pkOnly?: boolean;
66
101
  fieldTypes?: Record<string, string>;
102
+ fields?: FieldDef[];
103
+ className?: string;
67
104
  }
68
105
  export interface ResourceItem {
69
106
  RESOURCE_NAME: string;
@@ -104,6 +141,7 @@ export interface EventHandlerCtx {
104
141
  }
105
142
  export type EventHandlerResult = {
106
143
  generated?: Row[];
144
+ warnings?: NackErrorInput[];
107
145
  } | void;
108
146
  export type EventHandler = (details: Row, ctx: EventHandlerCtx) => EventHandlerResult;
109
147
  export interface HttpRouteDef {
@@ -133,10 +171,12 @@ export interface MockServerConfig {
133
171
  httpRoutes?: HttpRouteDef[];
134
172
  onMessage?: OnMessageHook;
135
173
  productName?: string;
174
+ appName?: string;
136
175
  }
137
176
  export interface FieldMetadata {
138
177
  NAME: string;
139
178
  TYPE: string;
179
+ VALID_VALUES?: string[];
140
180
  }
141
181
  export interface SocketLike {
142
182
  readyState: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genesislcap/mock-server",
3
- "version": "15.51.1",
3
+ "version": "15.52.0",
4
4
  "description": "Reusable in-memory Genesis WebSocket protocol mock server for client automation/CI, without a JVM or database.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -188,6 +188,9 @@ function compareByTime(
188
188
  class Parser {
189
189
  private tokens: string[];
190
190
  private pos = 0;
191
+ // Every field the expression reads: bare identifiers in comparisons, and
192
+ // function-call arguments and receivers.
193
+ readonly fields = new Set<string>();
191
194
 
192
195
  constructor(tokens: string[]) {
193
196
  this.tokens = tokens;
@@ -273,6 +276,8 @@ class Parser {
273
276
  throw new Error('Expected operand on the right side of comparison');
274
277
  }
275
278
 
279
+ this.noteField(leftToken);
280
+ this.noteField(rightToken);
276
281
  const resolveLeft = (record: Row) => this.resolveValue(leftToken, record);
277
282
  const resolveRight = (record: Row) => this.resolveValue(rightToken, record);
278
283
 
@@ -300,6 +305,10 @@ class Parser {
300
305
  };
301
306
  }
302
307
 
308
+ noteField(token: string): void {
309
+ if (parseLiteral(token) === undefined) this.fields.add(token);
310
+ }
311
+
303
312
  resolveValue(token: string, record: Row): unknown {
304
313
  const literal = parseLiteral(token);
305
314
  if (literal !== undefined) return literal;
@@ -331,6 +340,7 @@ class Parser {
331
340
  const handler = EXPR_FUNCTIONS[fnName];
332
341
  if (!handler) throw new Error(`Unsupported criteria function: ${name}`);
333
342
  const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
343
+ for (const arg of allArgs) this.noteField(arg);
334
344
 
335
345
  const resolve: ExprResolver = (token, record) => this.resolveValue(token, record);
336
346
  return (record: Row) => handler(record, allArgs, resolve);
@@ -364,6 +374,22 @@ export function compileCriteria(criteria?: string | null): Predicate {
364
374
  }
365
375
  }
366
376
 
377
+ // The fields a CRITERIA_MATCH reads, or undefined when it doesn't parse
378
+ // (compileCriteria then fails open). The real dataserver refuses a criteria
379
+ // naming a field its query doesn't expose (INVALID_CRITERIA).
380
+ export function criteriaFields(criteria?: string | null): string[] | undefined {
381
+ if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') return [];
382
+ try {
383
+ const tokens = tokenize(criteria.trim());
384
+ const parser = new Parser(tokens);
385
+ parser.parseExpression();
386
+ if (parser.position !== tokens.length) return undefined;
387
+ return [...parser.fields];
388
+ } catch {
389
+ return undefined;
390
+ }
391
+ }
392
+
367
393
  export function filterByCriteria(rows: Row[], criteria?: string | null): Row[] {
368
394
  const predicate = compileCriteria(criteria);
369
395
  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
+ }