@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.
@@ -0,0 +1,193 @@
1
+ // The view join model, as GSF 8.15.x views behave (genesis-pal
2
+ // view/join/*.kt, ViewStructureReaderImpl.kt; genesis-codegen
3
+ // ViewRepoConfigGenerator.kt): a base table, joins that each start from the
4
+ // base or an earlier join, matched on one or more key links, INNER or OUTER,
5
+ // plus derived fields. A ViewDef is planned into the form the store
6
+ // materializes from, the schema types from and live propagation reads.
7
+ const isFieldPair = (on) => on.length === 2 && typeof on[0] === 'string' && typeof on[1] === 'string';
8
+ // A pair, and an object link with no `fromAlias`, read from the join's own
9
+ // `fromAlias`. A join with none reads them from the view row built so far —
10
+ // the base row's columns, overlaid by the selects of the joins before it —
11
+ // as joins always have, so one written before `fromAlias` existed can still
12
+ // key on an earlier join's selected column. For a GSF view that is the base
13
+ // row's field (an alias never repeats a base column the view exposes); name
14
+ // `fromAlias` to read one table's own row.
15
+ function linksOf(on, fromAlias) {
16
+ const links = isFieldPair(on) ? [on] : on;
17
+ return links.map((link) => {
18
+ if (Array.isArray(link))
19
+ return { kind: 'field', fromAlias, from: link[0], to: link[1] };
20
+ if ('value' in link)
21
+ return { kind: 'value', to: link.to, value: link.value };
22
+ return { kind: 'field', fromAlias: link.fromAlias ?? fromAlias, from: link.from, to: link.to };
23
+ });
24
+ }
25
+ // The plan of a view. Planned on every use (it costs next to nothing), so a
26
+ // definition changed in place takes effect, as it always has. Throws on a
27
+ // definition GSF would refuse to compile: a join with no `on` link, an alias
28
+ // given twice, or a join (or one of its links) reading from a table that is
29
+ // neither the base nor an earlier join.
30
+ //
31
+ // Before aliases existed, a table could be joined twice (or the base table
32
+ // joined to itself) with no alias, and such configs keep working: a join
33
+ // with no alias whose table name an earlier table already goes by (one
34
+ // without an explicit alias either) is joined as before, but goes by no name
35
+ // — the name keeps meaning the earlier table. Give it an `alias` to read it.
36
+ export function planView(viewName, view) {
37
+ const fail = (message) => {
38
+ throw new Error(`[mock-server] View ${viewName}: ${message}`);
39
+ };
40
+ const baseAlias = view.baseAlias ?? view.base;
41
+ // Every name so far: whether it was given explicitly, and whether a row
42
+ // under it always exists when the view row does.
43
+ const names = new Map([
44
+ [baseAlias, { explicit: view.baseAlias !== undefined, alwaysMatched: true }],
45
+ ]);
46
+ const checkSource = (label, alias) => {
47
+ if (alias !== undefined && !names.has(alias)) {
48
+ fail(`${label} reads from '${alias}', which is neither the base nor an earlier join`);
49
+ }
50
+ };
51
+ const joins = (view.joins ?? []).map((def, index) => {
52
+ const name = def.alias ?? def.table;
53
+ const fromAlias = def.fromAlias ?? baseAlias;
54
+ const label = `join ${index + 1} (${name})`;
55
+ checkSource(label, fromAlias);
56
+ const links = linksOf(def.on, def.fromAlias);
57
+ if (links.length === 0)
58
+ fail(`${label} has no 'on' link`);
59
+ for (const link of links)
60
+ if (link.kind === 'field')
61
+ checkSource(label, link.fromAlias);
62
+ const taken = names.get(name);
63
+ if (taken && (taken.explicit || def.alias !== undefined)) {
64
+ fail(`${label}: alias '${name}' is already used; give each table its own alias`);
65
+ }
66
+ const inner = def.type === 'INNER';
67
+ const alwaysMatched = inner && names.get(fromAlias).alwaysMatched;
68
+ if (!taken)
69
+ names.set(name, { explicit: def.alias !== undefined, alwaysMatched });
70
+ return {
71
+ def,
72
+ table: def.table,
73
+ alias: taken ? undefined : name,
74
+ fromAlias,
75
+ links,
76
+ inner,
77
+ backwardsJoin: def.backwardsJoin === true,
78
+ alwaysMatched,
79
+ };
80
+ });
81
+ const antiJoins = (view.antiJoins ?? []).map((def, index) => {
82
+ const label = `anti-join ${index + 1} (${def.table})`;
83
+ const links = linksOf(def.on, undefined);
84
+ if (links.length === 0)
85
+ fail(`${label} has no 'on' link`);
86
+ for (const link of links) {
87
+ if (link.kind === 'field' && link.fromAlias !== undefined && link.fromAlias !== baseAlias) {
88
+ fail(`${label} can only read from the base table`);
89
+ }
90
+ }
91
+ return { def, table: def.table, links };
92
+ });
93
+ return { baseAlias, joins, antiJoins };
94
+ }
95
+ const KEY_SEPARATOR = '\u0000';
96
+ // One index key for a list of values, or undefined when one is null or
97
+ // missing: GSF builds no join request from a null key, so it matches nothing.
98
+ // Typed, so 1 and '1' differ, as === tells them apart.
99
+ function keyOf(values) {
100
+ if (values.some((value) => value === undefined || value === null))
101
+ return undefined;
102
+ return values.map((value) => `${typeof value}:${String(value)}`).join(KEY_SEPARATOR);
103
+ }
104
+ // Finds a join's match for one view row: the joined rows whose fields equal
105
+ // the source fields (an index over them, built once), narrowed by the
106
+ // constants and the `where` predicate. GSF only lets a dataserver read a view
107
+ // whose joins are one-to-one (their links cover a unique index), so a valid
108
+ // view has at most one match; when there are more, the last one in table
109
+ // order wins — with a `where`, the first that passes — as before this model.
110
+ function matcher(links, where, rows) {
111
+ const fieldLinks = links.flatMap((link) => (link.kind === 'field' ? [link] : []));
112
+ const valueLinks = links.flatMap((link) => (link.kind === 'value' ? [link] : []));
113
+ let index;
114
+ const candidatesFor = (viewRow, sources) => {
115
+ if (fieldLinks.length === 0)
116
+ return rows;
117
+ if (!index) {
118
+ index = new Map();
119
+ for (const row of rows) {
120
+ const key = keyOf(fieldLinks.map((link) => row[link.to]));
121
+ if (key === undefined)
122
+ continue;
123
+ const bucket = index.get(key);
124
+ if (bucket)
125
+ bucket.push(row);
126
+ else
127
+ index.set(key, [row]);
128
+ }
129
+ }
130
+ const key = keyOf(fieldLinks.map((link) => link.fromAlias === undefined
131
+ ? viewRow[link.from]
132
+ : sources.tables[link.fromAlias]?.[link.from]));
133
+ return key === undefined ? [] : (index.get(key) ?? []);
134
+ };
135
+ const constantsHold = (row) => valueLinks.every((link) => row[link.to] === link.value);
136
+ return (viewRow, sources) => {
137
+ const candidates = candidatesFor(viewRow, sources);
138
+ if (where) {
139
+ return candidates.find((row) => constantsHold(row) && where(row, viewRow, sources));
140
+ }
141
+ return candidates.findLast(constantsHold);
142
+ };
143
+ }
144
+ // The view's rows: each base row without an anti-joined match, joined in
145
+ // order (an INNER join with no match drops the row; a join whose source
146
+ // found nothing finds nothing either), plus the base `select` aliases, each
147
+ // join's `select` and the derived fields.
148
+ export function materializeEntries(viewName, view, source) {
149
+ const plan = planView(viewName, view);
150
+ const antiJoins = plan.antiJoins.map((antiJoin) => matcher(antiJoin.links, antiJoin.def.where, source.rowsOf(antiJoin)));
151
+ const joins = plan.joins.map((join) => matcher(join.links, join.def.where, source.rowsOf(join)));
152
+ const entries = [];
153
+ for (const base of source.base) {
154
+ const sources = { tables: { [plan.baseAlias]: base } };
155
+ if (antiJoins.some((match) => match(base, sources)))
156
+ continue;
157
+ const row = { ...base };
158
+ for (const [name, field] of Object.entries(view.select ?? {}))
159
+ row[name] = base[field];
160
+ const matches = [];
161
+ let dropped = false;
162
+ for (const [index, join] of plan.joins.entries()) {
163
+ // `where` sees the view row so far, as it always has: the base row's
164
+ // columns and the selects before this join.
165
+ const match = sources.tables[join.fromAlias] ? joins[index](row, sources) : undefined;
166
+ if (!match && join.inner) {
167
+ dropped = true;
168
+ break;
169
+ }
170
+ matches.push(match);
171
+ if (join.alias !== undefined)
172
+ sources.tables[join.alias] = match;
173
+ for (const [name, field] of Object.entries(join.def.select ?? {}))
174
+ row[name] = match?.[field];
175
+ }
176
+ if (dropped)
177
+ continue;
178
+ if (!view.derived) {
179
+ entries.push({ row, matches });
180
+ continue;
181
+ }
182
+ const derived = { ...row };
183
+ for (const [name, compute] of Object.entries(view.derived)) {
184
+ derived[name] = compute(row, sources);
185
+ }
186
+ entries.push({ row: derived, matches });
187
+ }
188
+ return entries;
189
+ }
190
+ // The planned joins and anti-joins that read a table.
191
+ export function entriesReading(plan, tableName) {
192
+ return [...plan.joins, ...plan.antiJoins].filter((entry) => entry.table === tableName);
193
+ }
package/dist/index.d.ts CHANGED
@@ -4,4 +4,4 @@ export { Store } from './db/store.ts';
4
4
  export { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './db/identity.ts';
5
5
  export { ErrorCode, NackError, statusCodeFor, type ErrorCodeName, type GenesisErrorType, type NackErrorEntry, type NackErrorInput, type NackErrorOptions, } from './protocol/errors.ts';
6
6
  export { compileCriteria, filterByCriteria, type Predicate } from './db/criteria.ts';
7
- export type { AntiJoinDef, AuthConfig, AuthUser, BroadcastFn, BroadcastTableChangeFn, CrudEventVerb, EventHandler, EventHandlerCtx, EventHandlerResult, EventSchemaOverride, FidelityMode, FieldDef, FieldMetadata, GeneratedFieldDef, GenesisFieldType, GenesisMessage, HttpRouteDef, IndexDef, JoinDef, MockServerConfig, OnMessageCtx, OnMessageHook, QueryDef, RequestingUser, RequestReplyDef, RequestReplyResolverCtx, ResourceAuthDef, ResourceItem, Row, SequenceFormat, SocketLike, SsoIdentityProvider, TableDef, ViewDef, } from './types.ts';
7
+ export type { AntiJoinDef, AuthConfig, AuthUser, BroadcastFn, BroadcastTableChangeFn, CrudEventVerb, EventHandler, EventHandlerCtx, EventHandlerResult, EventSchemaOverride, FidelityMode, FieldDef, FieldMetadata, GeneratedFieldDef, GenesisFieldType, GenesisMessage, HttpRouteDef, IndexDef, JoinDef, JoinFieldLink, JoinLink, JoinType, JoinValueLink, MockServerConfig, OnMessageCtx, OnMessageHook, QueryDef, RequestingUser, RequestReplyDef, RequestReplyResolverCtx, ResourceAuthDef, ResourceItem, Row, SequenceFormat, SocketLike, SsoIdentityProvider, TableDef, ViewDef, ViewRowSources, } from './types.ts';
package/dist/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import http from 'node:http';
2
2
  import { WebSocketServer } from 'ws';
3
+ import { recordKey } from "./db/identity.js";
3
4
  import { Store } from "./db/store.js";
5
+ import { entriesReading, planView, } from "./db/views.js";
4
6
  import { crudEventHandler } from "./handlers/crudEvents.js";
5
7
  import { pendingKey } from "./handlers/dataLogon.js";
6
8
  import { createHttpRequestListener } from "./handlers/http.js";
@@ -9,6 +11,17 @@ import { Connection } from "./protocol/connection.js";
9
11
  import { isLegacyFidelity } from "./protocol/fidelity.js";
10
12
  import { routeMessage } from "./protocol/router.js";
11
13
  import { dataserverRow, stampRow } from "./protocol/rowUpdate.js";
14
+ // Whether two rows carry the same columns and values (a value compared as
15
+ // JSON when it isn't the same primitive).
16
+ function sameValues(a, b) {
17
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
18
+ for (const key of keys) {
19
+ if (!Object.is(a[key], b[key]) && JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
20
+ return false;
21
+ }
22
+ }
23
+ return true;
24
+ }
12
25
  export function createMockServer(config) {
13
26
  const store = new Store();
14
27
  for (const [tableName, tableDef] of Object.entries(config.tables ?? {})) {
@@ -189,6 +202,101 @@ export function createMockServer(config) {
189
202
  for (const record of records)
190
203
  pushChange(resourceName, 'DELETE', record);
191
204
  };
205
+ // A change being broadcast to a table, as the views that join it see it:
206
+ // the table's rows before and after it, and the RECORD_IDs it touched (the
207
+ // changed record and every record its key lost). The store's
208
+ // previous-record entries are consumed straight away, so the next change
209
+ // starts from this one; the rows are only gathered if a view needs them.
210
+ function tableChange(tableName, operation, current, removed) {
211
+ const currentId = recordKey(current);
212
+ const previous = current ? store.takePreviousRecord(tableName, current) : undefined;
213
+ const lost = new Map(removed.map((record) => [
214
+ recordKey(record),
215
+ store.takePreviousRecord(tableName, record) ?? record,
216
+ ]));
217
+ // The changed record was in the table before as it was before its first
218
+ // modify — or as it is, when nothing modified it. An INSERT's wasn't, nor
219
+ // was one that replaced another record under its key.
220
+ const currentWasThere = operation !== 'INSERT' && (previous !== undefined || lost.size === 0);
221
+ const records = new Set([currentId, ...lost.keys()].filter((id) => id !== undefined));
222
+ let before;
223
+ return {
224
+ records,
225
+ before: () => {
226
+ if (before)
227
+ return before;
228
+ const restored = new Set();
229
+ before = store.getAllRows(tableName).flatMap((row) => {
230
+ const id = recordKey(row);
231
+ if (id !== undefined && id === currentId) {
232
+ return currentWasThere ? [previous ?? row] : [];
233
+ }
234
+ // A record a DELETE names before it is removed.
235
+ if (lost.has(id)) {
236
+ restored.add(id);
237
+ return [lost.get(id)];
238
+ }
239
+ return [row];
240
+ });
241
+ for (const [id, record] of lost)
242
+ if (!restored.has(id))
243
+ before.push(record);
244
+ return before;
245
+ },
246
+ // Without the records the key lost, even one still stored when its
247
+ // DELETE is broadcast.
248
+ after: () => store.getAllRows(tableName).filter((row) => !lost.has(recordKey(row))),
249
+ };
250
+ }
251
+ // A live change to a table a view joins (at any depth) or anti-joins, under
252
+ // fidelity 'gsf'. Only the joins that read the table with `backwardsJoin`
253
+ // listen to it — GSF listens to no other joined table — and every
254
+ // anti-join does. The view is rebuilt through them as it was before the
255
+ // change and as it is after it, and compared, row by base record (the
256
+ // ROW_REF): a row the change brought in (an INNER join that now matches) is
257
+ // pushed as an INSERT, one it took out as a DELETE, and one joined to a
258
+ // changed record before or after, or whose values changed, as a MODIFY.
259
+ // A row joined to the changed record is pushed even when its values look
260
+ // the same: the before-state is the store's (a record as it was before its
261
+ // first unannounced modify), and a client may hold newer values than that.
262
+ // A view that joins its own base table gets the changed base rows
263
+ // themselves from broadcastTableChange's base path, not from here.
264
+ function joinedViewChanges(viewName, view, tableName, change) {
265
+ const plan = planView(viewName, view);
266
+ const listening = entriesReading(plan, tableName).filter((entry) => !('backwardsJoin' in entry) || entry.backwardsJoin);
267
+ if (listening.length === 0)
268
+ return [];
269
+ // By definition: every materialization plans the view afresh.
270
+ const listeningDefs = new Set(listening.map((entry) => entry.def));
271
+ const listeningJoins = plan.joins.flatMap((join, index) => listeningDefs.has(join.def) ? [index] : []);
272
+ const touched = ({ matches }) => listeningJoins.some((index) => {
273
+ const id = recordKey(matches[index]);
274
+ return id !== undefined && change.records.has(id);
275
+ });
276
+ const rowsOf = (rows) => (entry) => listeningDefs.has(entry.def) ? rows() : undefined;
277
+ const was = store.getViewEntriesFrom(viewName, { rowsOf: rowsOf(change.before) });
278
+ const now = store.getViewEntriesFrom(viewName, { rowsOf: rowsOf(change.after) });
279
+ const ownRow = (entry) => view.base === tableName && change.records.has(recordKey(entry.row) ?? '');
280
+ const wasByRecord = new Map(was.map((entry) => [recordKey(entry.row), entry]));
281
+ const nowRecords = new Set(now.map((entry) => recordKey(entry.row)));
282
+ const changes = [];
283
+ for (const entry of now) {
284
+ if (ownRow(entry))
285
+ continue;
286
+ const old = wasByRecord.get(recordKey(entry.row));
287
+ if (!old)
288
+ changes.push(['INSERT', entry.row]);
289
+ else if (touched(old) || touched(entry) || !sameValues(old.row, entry.row)) {
290
+ changes.push(['MODIFY', entry.row]);
291
+ }
292
+ }
293
+ for (const entry of was) {
294
+ if (!ownRow(entry) && !nowRecords.has(recordKey(entry.row))) {
295
+ changes.push(['DELETE', entry.row]);
296
+ }
297
+ }
298
+ return changes;
299
+ }
192
300
  // Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
193
301
  // a view built on top of it (e.g. ALL_TRADES -> TRADE_VIEW). This finds every
194
302
  // query resource backed by that table — directly or via a view — and pushes
@@ -201,9 +309,12 @@ export function createMockServer(config) {
201
309
  // re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
202
310
  // the new one goes out. A view row is built from the changed base row
203
311
  // alone, so it is found whatever the view's own pkField; a MODIFY that takes
204
- // it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
205
- // are the key itself, so there a DELETE is the pk stub and a replacement is
206
- // the one INSERT/MODIFY, as before.
312
+ // it out of the view (an INNER join or an anti-join that no longer lets it
313
+ // through) is pushed as a DELETE, and an INSERT that doesn't make it into
314
+ // the view isn't pushed at all. A table a view joins is handled by
315
+ // joinedViewChanges. Legacy ROW_REFs are the key itself, so there a DELETE
316
+ // is the pk stub and a replacement is the one INSERT/MODIFY, as before; and
317
+ // a change to a joined table pushes every view row as a MODIFY.
207
318
  const broadcastTableChange = (tableName, operation, pk) => {
208
319
  const legacy = isLegacyFidelity(config);
209
320
  const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
@@ -214,30 +325,38 @@ export function createMockServer(config) {
214
325
  const live = store.getRow(tableName, pk);
215
326
  if (operation === 'DELETE' && removed.length === 0 && live)
216
327
  removed = [live];
328
+ const change = tableChange(tableName, operation, current, removed);
217
329
  if (legacy)
218
330
  removed = [];
331
+ const viewChanges = new Map();
219
332
  for (const [queryName, query] of Object.entries(config.queries ?? {})) {
220
333
  const isDirect = query.source === tableName;
221
334
  const view = store.views.get(query.source);
222
335
  const isViewOnTable = view?.base === tableName;
223
- // A view that JOINS (or anti-joins) the mutated table denormalizes its
224
- // columns into every matching base row, so one change can affect any
225
- // number of view rows — none of them keyed by `pk`. Re-materialize and
226
- // push the refreshed view instead of dropping the change, or e.g.
227
- // renaming a COUNTERPARTY never updates COUNTERPARTY_NAME in an open
228
- // ALL_TRADES grid.
229
- const isViewJoiningTable = !isDirect &&
336
+ if (view && !legacy) {
337
+ let changes = viewChanges.get(query.source);
338
+ if (!changes) {
339
+ changes = joinedViewChanges(query.source, view, tableName, change);
340
+ viewChanges.set(query.source, changes);
341
+ }
342
+ for (const [rowOperation, row] of changes)
343
+ pushChange(queryName, rowOperation, row);
344
+ }
345
+ // Legacy: a view that JOINS (or anti-joins) the mutated table
346
+ // denormalizes its columns into any number of view rows, so the view is
347
+ // re-materialized and every row pushed as a MODIFY.
348
+ if (legacy &&
349
+ !isDirect &&
230
350
  !isViewOnTable &&
231
351
  ((view?.joins ?? []).some((join) => join.table === tableName) ||
232
- (view?.antiJoins ?? []).some((antiJoin) => antiJoin.table === tableName));
233
- if (!isDirect && !isViewOnTable && !isViewJoiningTable)
234
- continue;
235
- if (isViewJoiningTable) {
352
+ (view?.antiJoins ?? []).some((antiJoin) => antiJoin.table === tableName))) {
236
353
  for (const viewRow of store.getViewRows(query.source)) {
237
354
  pushChange(queryName, 'MODIFY', viewRow);
238
355
  }
239
356
  continue;
240
357
  }
358
+ if (!isDirect && !isViewOnTable)
359
+ continue;
241
360
  // The removed base records carry the RECORD_ID a view row's ROW_REF
242
361
  // comes from too; the DELETE body is DETAILS only either way.
243
362
  for (const record of removed)
@@ -256,7 +375,9 @@ export function createMockServer(config) {
256
375
  pushChange(queryName, operation, current);
257
376
  continue;
258
377
  }
259
- const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
378
+ // Only the base read is narrowed: a view that also joins its base table
379
+ // still joins every row of it.
380
+ const viewRow = store.getViewRowsFrom(query.source, { base: [current] })[0];
260
381
  if (viewRow) {
261
382
  pushChange(queryName, operation, viewRow);
262
383
  }
package/dist/types.d.ts CHANGED
@@ -44,23 +44,44 @@ export interface TableDef {
44
44
  events?: CrudEventVerb[];
45
45
  }
46
46
  export type SequenceFormat = 'prefix' | 'genesis';
47
+ export type JoinFieldLink = [string, string];
48
+ export interface JoinAliasedFieldLink {
49
+ fromAlias?: string;
50
+ from: string;
51
+ to: string;
52
+ }
53
+ export interface JoinValueLink {
54
+ to: string;
55
+ value: unknown;
56
+ }
57
+ export type JoinLink = JoinFieldLink | JoinAliasedFieldLink | JoinValueLink;
58
+ export type JoinType = 'INNER' | 'OUTER';
59
+ export interface ViewRowSources {
60
+ tables: Record<string, Row | undefined>;
61
+ }
47
62
  export interface JoinDef {
48
63
  table: string;
49
- on: [string, string];
64
+ alias?: string;
65
+ fromAlias?: string;
66
+ on: JoinFieldLink | JoinLink[];
67
+ type?: JoinType;
68
+ backwardsJoin?: boolean;
50
69
  select?: Record<string, string>;
51
- where?: (joinRow: Row, baseRow: Row) => boolean;
70
+ where?: (joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean;
52
71
  }
53
72
  export interface AntiJoinDef {
54
73
  table: string;
55
- on: [string, string];
56
- where?: (joinRow: Row, baseRow: Row) => boolean;
74
+ on: JoinFieldLink | JoinLink[];
75
+ where?: (joinRow: Row, baseRow: Row, sources: ViewRowSources) => boolean;
57
76
  }
58
77
  export interface ViewDef {
59
78
  base: string;
79
+ baseAlias?: string;
60
80
  pkField?: string | string[];
61
81
  joins?: JoinDef[];
62
82
  antiJoins?: AntiJoinDef[];
63
- derived?: Record<string, (row: Row) => any>;
83
+ select?: Record<string, string>;
84
+ derived?: Record<string, (row: Row, sources: ViewRowSources) => any>;
64
85
  derivedTypes?: Record<string, GenesisFieldType | Omit<FieldDef, 'name'>>;
65
86
  fields?: string[];
66
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genesislcap/mock-server",
3
- "version": "15.54.0",
3
+ "version": "15.56.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",
package/src/db/schema.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './identity.ts';
22
22
  import { deriveFields } from './metadata.ts';
23
23
  import type { Store } from './store.ts';
24
+ import { planView } from './views.ts';
24
25
 
25
26
  export interface ResolvedField {
26
27
  name: string;
@@ -137,32 +138,47 @@ function viewOf(name: string, config: MockServerConfig, store: Store): ViewDef |
137
138
  return store.views.get(name) ?? config.views?.[name];
138
139
  }
139
140
 
140
- // A view over a declared base table: the base table's columns, each join's
141
- // selected columns typed from the joined table (nullable, since a base row
142
- // may have no match), and the derived fields typed from derivedTypes (or
141
+ // A view over a declared base table: the base table's columns and its
142
+ // `select` aliases, each join's selected columns typed from the joined table
143
+ // (at any depth), and the derived fields typed from derivedTypes (or
143
144
  // inferred from the view's rows when not given) — narrowed to the view's
144
- // fields block when it has one.
145
+ // fields block when it has one. A joined column keeps its own nullability
146
+ // only when its join is reached from the base through INNER joins alone;
147
+ // any other may find no match, so it is nullable (GSF's
148
+ // transformOutputFields).
145
149
  function declaredViewFields(
146
150
  viewName: string,
147
151
  view: ViewDef,
148
152
  config: MockServerConfig,
149
153
  store: Store,
150
154
  ): ResolvedField[] {
155
+ const plan = planView(viewName, view);
151
156
  const fields = new Map<string, ResolvedField>();
152
- for (const field of resolveSourceSchema(view.base, config, store).fields) {
153
- fields.set(field.name, field);
157
+ const baseFields = resolveSourceSchema(view.base, config, store).fields;
158
+ for (const field of baseFields) fields.set(field.name, field);
159
+ const aliased = (
160
+ name: string,
161
+ source: ResolvedField | undefined,
162
+ nullable: boolean | undefined,
163
+ ): ResolvedField => {
164
+ const { title: _title, ...rest } = source ?? {
165
+ name,
166
+ type: 'STRING',
167
+ nullable: true,
168
+ generated: false,
169
+ };
170
+ return { ...rest, name, nullable: nullable ?? rest.nullable, generated: false };
171
+ };
172
+ for (const [name, sourceField] of Object.entries(view.select ?? {})) {
173
+ const source = baseFields.find((field) => field.name === sourceField);
174
+ fields.set(name, aliased(name, source, undefined));
154
175
  }
155
- for (const join of view.joins ?? []) {
176
+ for (const join of plan.joins) {
156
177
  const joined = resolveSourceSchema(join.table, config, store).fields;
157
- for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
178
+ const nullable = join.alwaysMatched ? undefined : true;
179
+ for (const [name, sourceField] of Object.entries(join.def.select ?? {})) {
158
180
  const source = joined.find((field) => field.name === sourceField);
159
- const { title: _title, ...rest } = source ?? {
160
- name: alias,
161
- type: 'STRING',
162
- nullable: true,
163
- generated: false,
164
- };
165
- fields.set(alias, { ...rest, name: alias, nullable: true, generated: false });
181
+ fields.set(name, aliased(name, source, nullable));
166
182
  }
167
183
  }
168
184
  const derivedNames = Object.keys(view.derived ?? {});
@@ -408,6 +424,7 @@ function sourceFieldNames(
408
424
  // An undeclared base: the view's own fields block is all that is known.
409
425
  if (!base) return view.fields;
410
426
  const names = new Set(base);
427
+ for (const alias of Object.keys(view.select ?? {})) names.add(alias);
411
428
  for (const join of view.joins ?? []) {
412
429
  for (const alias of Object.keys(join.select ?? {})) names.add(alias);
413
430
  }