@genesislcap/mock-server 15.54.0 → 15.56.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 +126 -12
- package/dist/db/schema.js +29 -14
- package/dist/db/store.d.ts +11 -1
- package/dist/db/store.js +71 -47
- package/dist/db/views.d.ts +42 -0
- package/dist/db/views.js +193 -0
- package/dist/index.d.ts +1 -1
- package/dist/server.js +136 -15
- package/dist/types.d.ts +26 -5
- package/package.json +1 -1
- package/src/db/schema.ts +32 -15
- package/src/db/store.ts +85 -60
- package/src/db/views.ts +269 -0
- package/src/index.ts +5 -0
- package/src/server.ts +171 -15
- package/src/types.ts +93 -15
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
|
|
274
|
-
//
|
|
275
|
-
// the
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
//
|
|
142
|
-
// (
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
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). `
|
|
150
|
-
//
|
|
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: [
|
|
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
|
-
|
|
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
|
|
170
|
-
// join's select, then the derived fields. Keep the
|
|
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
|
|