@genesislcap/mock-server 15.54.0 → 15.55.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/src/server.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import http from 'node:http';
2
2
  import { WebSocketServer, type WebSocket } from 'ws';
3
+ import { recordKey } from './db/identity.ts';
3
4
  import { Store } from './db/store.ts';
5
+ import {
6
+ entriesReading,
7
+ planView,
8
+ type PlannedAntiJoin,
9
+ type PlannedJoin,
10
+ type ViewEntry,
11
+ } from './db/views.ts';
4
12
  import { crudEventHandler } from './handlers/crudEvents.ts';
5
13
  import { pendingKey } from './handlers/dataLogon.ts';
6
14
  import { createHttpRequestListener } from './handlers/http.ts';
@@ -21,6 +29,28 @@ import type {
21
29
  ViewDef,
22
30
  } from './types.ts';
23
31
 
32
+ // One view row a live change pushes, and how.
33
+ type ViewChange = [RowOperation, Row];
34
+
35
+ // A broadcast change to one table (see tableChange in createMockServer).
36
+ interface TableChange {
37
+ before: () => Row[];
38
+ after: () => Row[];
39
+ records: Set<string>;
40
+ }
41
+
42
+ // Whether two rows carry the same columns and values (a value compared as
43
+ // JSON when it isn't the same primitive).
44
+ function sameValues(a: Row, b: Row): boolean {
45
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
46
+ for (const key of keys) {
47
+ if (!Object.is(a[key], b[key]) && JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
48
+ return false;
49
+ }
50
+ }
51
+ return true;
52
+ }
53
+
24
54
  export interface MockServer {
25
55
  start(port: number): WebSocketServer;
26
56
  // Like start(), but resolves with the ACTUAL bound port once listening —
@@ -258,6 +288,118 @@ export function createMockServer(config: MockServerConfig): MockServer {
258
288
  for (const record of records) pushChange(resourceName, 'DELETE', record);
259
289
  };
260
290
 
291
+ // A change being broadcast to a table, as the views that join it see it:
292
+ // the table's rows before and after it, and the RECORD_IDs it touched (the
293
+ // changed record and every record its key lost). The store's
294
+ // previous-record entries are consumed straight away, so the next change
295
+ // starts from this one; the rows are only gathered if a view needs them.
296
+ function tableChange(
297
+ tableName: string,
298
+ operation: RowOperation,
299
+ current: Row | undefined,
300
+ removed: Row[],
301
+ ): TableChange {
302
+ const currentId = recordKey(current);
303
+ const previous = current ? store.takePreviousRecord(tableName, current) : undefined;
304
+ const lost = new Map(
305
+ removed.map((record) => [
306
+ recordKey(record),
307
+ store.takePreviousRecord(tableName, record) ?? record,
308
+ ]),
309
+ );
310
+ // The changed record was in the table before as it was before its first
311
+ // modify — or as it is, when nothing modified it. An INSERT's wasn't, nor
312
+ // was one that replaced another record under its key.
313
+ const currentWasThere = operation !== 'INSERT' && (previous !== undefined || lost.size === 0);
314
+ const records = new Set(
315
+ [currentId, ...lost.keys()].filter((id): id is string => id !== undefined),
316
+ );
317
+ let before: Row[] | undefined;
318
+ return {
319
+ records,
320
+ before: () => {
321
+ if (before) return before;
322
+ const restored = new Set<string | undefined>();
323
+ before = store.getAllRows(tableName).flatMap((row) => {
324
+ const id = recordKey(row);
325
+ if (id !== undefined && id === currentId) {
326
+ return currentWasThere ? [previous ?? row] : [];
327
+ }
328
+ // A record a DELETE names before it is removed.
329
+ if (lost.has(id)) {
330
+ restored.add(id);
331
+ return [lost.get(id)!];
332
+ }
333
+ return [row];
334
+ });
335
+ for (const [id, record] of lost) if (!restored.has(id)) before.push(record);
336
+ return before;
337
+ },
338
+ // Without the records the key lost, even one still stored when its
339
+ // DELETE is broadcast.
340
+ after: () => store.getAllRows(tableName).filter((row) => !lost.has(recordKey(row))),
341
+ };
342
+ }
343
+
344
+ // A live change to a table a view joins (at any depth) or anti-joins, under
345
+ // fidelity 'gsf'. Only the joins that read the table with `backwardsJoin`
346
+ // listen to it — GSF listens to no other joined table — and every
347
+ // anti-join does. The view is rebuilt through them as it was before the
348
+ // change and as it is after it, and compared, row by base record (the
349
+ // ROW_REF): a row the change brought in (an INNER join that now matches) is
350
+ // pushed as an INSERT, one it took out as a DELETE, and one joined to a
351
+ // changed record before or after, or whose values changed, as a MODIFY.
352
+ // A row joined to the changed record is pushed even when its values look
353
+ // the same: the before-state is the store's (a record as it was before its
354
+ // first unannounced modify), and a client may hold newer values than that.
355
+ // A view that joins its own base table gets the changed base rows
356
+ // themselves from broadcastTableChange's base path, not from here.
357
+ function joinedViewChanges(
358
+ viewName: string,
359
+ view: ViewDef,
360
+ tableName: string,
361
+ change: TableChange,
362
+ ): ViewChange[] {
363
+ const plan = planView(viewName, view);
364
+ const listening = entriesReading(plan, tableName).filter(
365
+ (entry) => !('backwardsJoin' in entry) || entry.backwardsJoin,
366
+ );
367
+ if (listening.length === 0) return [];
368
+ // By definition: every materialization plans the view afresh.
369
+ const listeningDefs = new Set<object>(listening.map((entry) => entry.def));
370
+ const listeningJoins = plan.joins.flatMap((join, index) =>
371
+ listeningDefs.has(join.def) ? [index] : [],
372
+ );
373
+ const touched = ({ matches }: ViewEntry) =>
374
+ listeningJoins.some((index) => {
375
+ const id = recordKey(matches[index]);
376
+ return id !== undefined && change.records.has(id);
377
+ });
378
+ const rowsOf = (rows: () => Row[]) => (entry: PlannedJoin | PlannedAntiJoin) =>
379
+ listeningDefs.has(entry.def) ? rows() : undefined;
380
+ const was = store.getViewEntriesFrom(viewName, { rowsOf: rowsOf(change.before) });
381
+ const now = store.getViewEntriesFrom(viewName, { rowsOf: rowsOf(change.after) });
382
+ const ownRow = (entry: ViewEntry) =>
383
+ view.base === tableName && change.records.has(recordKey(entry.row) ?? '');
384
+ const wasByRecord = new Map(was.map((entry) => [recordKey(entry.row), entry]));
385
+ const nowRecords = new Set(now.map((entry) => recordKey(entry.row)));
386
+ const changes: ViewChange[] = [];
387
+ for (const entry of now) {
388
+ if (ownRow(entry)) continue;
389
+ const old = wasByRecord.get(recordKey(entry.row));
390
+ if (!old) changes.push(['INSERT', entry.row]);
391
+ else if (touched(old) || touched(entry) || !sameValues(old.row, entry.row)) {
392
+ changes.push(['MODIFY', entry.row]);
393
+ }
394
+ }
395
+ for (const entry of was) {
396
+ if (!ownRow(entry) && !nowRecords.has(recordKey(entry.row))) {
397
+ changes.push(['DELETE', entry.row]);
398
+ }
399
+ }
400
+ return changes;
401
+ }
402
+
261
403
  // Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
262
404
  // a view built on top of it (e.g. ALL_TRADES -> TRADE_VIEW). This finds every
263
405
  // query resource backed by that table — directly or via a view — and pushes
@@ -270,9 +412,12 @@ export function createMockServer(config: MockServerConfig): MockServer {
270
412
  // re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
271
413
  // the new one goes out. A view row is built from the changed base row
272
414
  // alone, so it is found whatever the view's own pkField; a MODIFY that takes
273
- // it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
274
- // are the key itself, so there a DELETE is the pk stub and a replacement is
275
- // the one INSERT/MODIFY, as before.
415
+ // it out of the view (an INNER join or an anti-join that no longer lets it
416
+ // through) is pushed as a DELETE, and an INSERT that doesn't make it into
417
+ // the view isn't pushed at all. A table a view joins is handled by
418
+ // joinedViewChanges. Legacy ROW_REFs are the key itself, so there a DELETE
419
+ // is the pk stub and a replacement is the one INSERT/MODIFY, as before; and
420
+ // a change to a joined table pushes every view row as a MODIFY.
276
421
  const broadcastTableChange: BroadcastTableChangeFn = (tableName, operation, pk) => {
277
422
  const legacy = isLegacyFidelity(config);
278
423
  const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
@@ -282,30 +427,39 @@ export function createMockServer(config: MockServerConfig): MockServer {
282
427
  // A DELETE broadcast before the row is removed still names it.
283
428
  const live = store.getRow(tableName, pk);
284
429
  if (operation === 'DELETE' && removed.length === 0 && live) removed = [live];
430
+ const change = tableChange(tableName, operation, current, removed);
285
431
  if (legacy) removed = [];
432
+ const viewChanges = new Map<string, ViewChange[]>();
286
433
  for (const [queryName, query] of Object.entries(config.queries ?? {})) {
287
434
  const isDirect = query.source === tableName;
288
435
  const view = store.views.get(query.source);
289
436
  const isViewOnTable = view?.base === tableName;
290
- // A view that JOINS (or anti-joins) the mutated table denormalizes its
291
- // columns into every matching base row, so one change can affect any
292
- // number of view rows — none of them keyed by `pk`. Re-materialize and
293
- // push the refreshed view instead of dropping the change, or e.g.
294
- // renaming a COUNTERPARTY never updates COUNTERPARTY_NAME in an open
295
- // ALL_TRADES grid.
296
- const isViewJoiningTable =
437
+
438
+ if (view && !legacy) {
439
+ let changes = viewChanges.get(query.source);
440
+ if (!changes) {
441
+ changes = joinedViewChanges(query.source, view, tableName, change);
442
+ viewChanges.set(query.source, changes);
443
+ }
444
+ for (const [rowOperation, row] of changes) pushChange(queryName, rowOperation, row);
445
+ }
446
+
447
+ // Legacy: a view that JOINS (or anti-joins) the mutated table
448
+ // denormalizes its columns into any number of view rows, so the view is
449
+ // re-materialized and every row pushed as a MODIFY.
450
+ if (
451
+ legacy &&
297
452
  !isDirect &&
298
453
  !isViewOnTable &&
299
454
  ((view?.joins ?? []).some((join) => join.table === tableName) ||
300
- (view?.antiJoins ?? []).some((antiJoin) => antiJoin.table === tableName));
301
- if (!isDirect && !isViewOnTable && !isViewJoiningTable) continue;
302
-
303
- if (isViewJoiningTable) {
455
+ (view?.antiJoins ?? []).some((antiJoin) => antiJoin.table === tableName))
456
+ ) {
304
457
  for (const viewRow of store.getViewRows(query.source)) {
305
458
  pushChange(queryName, 'MODIFY', viewRow);
306
459
  }
307
460
  continue;
308
461
  }
462
+ if (!isDirect && !isViewOnTable) continue;
309
463
 
310
464
  // The removed base records carry the RECORD_ID a view row's ROW_REF
311
465
  // comes from too; the DELETE body is DETAILS only either way.
@@ -322,7 +476,9 @@ export function createMockServer(config: MockServerConfig): MockServer {
322
476
  pushChange(queryName, operation, current);
323
477
  continue;
324
478
  }
325
- const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
479
+ // Only the base read is narrowed: a view that also joins its base table
480
+ // still joins every row of it.
481
+ const viewRow = store.getViewRowsFrom(query.source, { base: [current] })[0];
326
482
  if (viewRow) {
327
483
  pushChange(queryName, operation, viewRow);
328
484
  } else if (operation === 'MODIFY') {
package/src/types.ts CHANGED
@@ -133,42 +133,120 @@ export interface TableDef {
133
133
  // '000000000000001TRLO1'.
134
134
  export type SequenceFormat = 'prefix' | 'genesis';
135
135
 
136
+ // A join's key: `[sourceField, joinedField]` — the field of the row the join
137
+ // starts from that must equal the field of the joined table (GPAL
138
+ // `on(TRADE.COUNTERPARTY_ID to COUNTERPARTY { COUNTERPARTY_ID })`). With the
139
+ // join's `fromAlias`, the source is that table's row; without one, the view
140
+ // row built so far — the base row's columns, overlaid by the selects of the
141
+ // joins before — so a join can also key on an earlier join's column, as
142
+ // chained joins were written before `fromAlias`.
143
+ export type JoinFieldLink = [string, string];
144
+
145
+ // The same as an object, whose source may be any table joined before this
146
+ // one (GPAL `.and(TRADE { INSTRUMENT_ID } to IP { BID_PRICE })` inside a
147
+ // join nested under INSTRUMENT). `fromAlias` defaults to the join's own.
148
+ export interface JoinAliasedFieldLink {
149
+ fromAlias?: string;
150
+ from: string;
151
+ to: string;
152
+ }
153
+
154
+ // A joined-table field that must equal a constant (GPAL
155
+ // `and(ALT_INSTRUMENT_ID.ALTERNATE_TYPE to "BLOOMBERG")`), compared with ===:
156
+ // give it the column's own type (a number for an INT, not "1").
157
+ export interface JoinValueLink {
158
+ to: string;
159
+ value: unknown;
160
+ }
161
+
162
+ export type JoinLink = JoinFieldLink | JoinAliasedFieldLink | JoinValueLink;
163
+
164
+ // INNER drops a view row when the join finds no match; OUTER (GSF's default)
165
+ // keeps it, with the join's columns null.
166
+ export type JoinType = 'INNER' | 'OUTER';
167
+
168
+ // What a `where` predicate and a derived field see beside the rows: every
169
+ // table of the view row by its alias (the base table's, then each join's
170
+ // joined so far), undefined for an OUTER join that found no match.
171
+ export interface ViewRowSources {
172
+ tables: Record<string, Row | undefined>;
173
+ }
174
+
175
+ // One `joining(...)` of a GSF view. See the README's "Views: the join model".
136
176
  export interface JoinDef {
137
177
  table: string;
138
- on: [string, string];
178
+ // The table's name inside this view (GPAL `TABLE withAlias "ALIAS"`): what
179
+ // a nested join's `fromAlias` and the derived fields' `tables` call it.
180
+ // Defaults to `table`. A table joined twice, or the base table joined to
181
+ // itself, needs one to be read by name: without one, a join whose table
182
+ // name is taken still joins, but goes by no name (as before aliases).
183
+ alias?: string;
184
+ // The table the join starts from: the base table (the default) or an
185
+ // earlier join's alias — a nested join (GPAL `.joining(...)` inside a join).
186
+ fromAlias?: string;
187
+ // One key pair, or several (GPAL `on(...).and(...)`): field pairs, field
188
+ // links from another table of the view, constants. All of them must hold.
189
+ on: JoinFieldLink | JoinLink[];
190
+ // Default 'OUTER', as in GSF.
191
+ type?: JoinType;
192
+ // GSF's `backwardsJoin = true`: a change to this join's table is pushed to
193
+ // subscribed views. Without it (GSF's default) only changes to the base
194
+ // table are, and the client sees a joined table's new values when the
195
+ // view row's base row next changes. Ignored under fidelity: 'legacy',
196
+ // where every join's changes are pushed.
197
+ backwardsJoin?: boolean;
198
+ // The view columns this join adds: { [viewField]: joinedTableField } —
199
+ // `NAME withAlias "COUNTERPARTY_NAME"` is { COUNTERPARTY_NAME: 'NAME' },
200
+ // `NAME withPrefix "CP"` is { CP_NAME: 'NAME' }, a plain `INSTRUMENT.NAME`
201
+ // is { NAME: 'NAME' }.
139
202
  select?: Record<string, string>;
140
- // Extra join predicate beyond the `on` equality — receives the candidate
141
- // join row and the base row, e.g. side-specific joins like
142
- // (price, rfq) => price.SIDE === 'SALES'. When present, matching scans the
143
- // join table per base row instead of using a prebuilt index (fine at mock
144
- // scale).
145
- where?: (joinRow: Row, baseRow: Row) => boolean;
203
+ // Extra join predicate beyond `on` (not a GSF construct: GSF expresses
204
+ // these as constant links) — receives the candidate join row, the view row
205
+ // so far (the base row's columns and the selects of the joins before this
206
+ // one) and the tables joined so far, e.g. side-specific joins like
207
+ // (price, rfq) => price.SIDE === 'SALES'. The first candidate (in table
208
+ // order) that passes is the match.
209
+ where?: (joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean;
146
210
  }
147
211
 
148
212
  // Keeps only base rows with NO match in `table` (e.g. USER_WITHOUT_DETAIL:
149
- // users lacking a USER_DETAIL row). `where` narrows what counts as a match,
150
- // same contract as JoinDef.where.
213
+ // users lacking a USER_DETAIL row). `on` and `where` match as on a join
214
+ // starting from the base table. Not a GSF view construct: a change to the
215
+ // anti-joined table is always pushed.
151
216
  export interface AntiJoinDef {
152
217
  table: string;
153
- on: [string, string];
154
- where?: (joinRow: Row, baseRow: Row) => boolean;
218
+ on: JoinFieldLink | JoinLink[];
219
+ where?: (joinRow: Row, baseRow: Row, sources: ViewRowSources) => boolean;
155
220
  }
156
221
 
222
+ // A GSF view (`view("NAME", TABLE) { joins { } fields { } }`). See the
223
+ // README's "Views: the join model".
157
224
  export interface ViewDef {
158
225
  base: string;
226
+ // The base table's name inside this view, for `fromAlias` and the derived
227
+ // fields' `tables`. Defaults to `base`.
228
+ baseAlias?: string;
159
229
  pkField?: string | string[];
230
+ // Applied in order: a join can start from an earlier one (`fromAlias`).
160
231
  joins?: JoinDef[];
161
232
  antiJoins?: AntiJoinDef[];
162
- derived?: Record<string, (row: Row) => any>;
233
+ // Aliased base-table columns, { [viewField]: baseField } (GPAL
234
+ // `TRADE.PRICE withAlias "TRADE_PRICE"`), added beside the base columns.
235
+ // List the view's `fields` to expose only the aliased name.
236
+ select?: Record<string, string>;
237
+ // Computed columns. `row` is the view row before its derived fields (base
238
+ // columns and every join's select); `tables` holds each table's source row
239
+ // by alias, for inputs the view doesn't expose (GPAL `withInput`).
240
+ derived?: Record<string, (row: Row, sources: ViewRowSources) => any>;
163
241
  // Types of the `derived` fields (`derivedField("TOTAL", DOUBLE)`): a type,
164
242
  // or a full field definition without its name. Columns from the base table
165
243
  // and from `joins[].select` take their declared types from their source
166
244
  // tables; a derived field with no entry here is inferred from the rows.
167
245
  derivedTypes?: Record<string, GenesisFieldType | Omit<FieldDef, 'name'>>;
168
246
  // The view's fields { } block: the only columns it exposes, in this order
169
- // (base, joined and derived alike). Unset: every base column, then each
170
- // join's select, then the derived fields. Keep the key fields in it — they
171
- // are the ROW_REF.
247
+ // (base, joined and derived alike). Unset: every base column, then the
248
+ // base `select`, then each join's select, then the derived fields. Keep the
249
+ // key fields in it — they are the ROW_REF.
172
250
  fields?: string[];
173
251
  }
174
252