@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/db/store.ts CHANGED
@@ -3,6 +3,21 @@
3
3
 
4
4
  import type { Row, SequenceFormat, TableDef, ViewDef } from '../types.ts';
5
5
  import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from './identity.ts';
6
+ import {
7
+ materializeEntries,
8
+ planView,
9
+ type PlannedAntiJoin,
10
+ type PlannedJoin,
11
+ type ViewEntry,
12
+ } from './views.ts';
13
+
14
+ // What a view is materialized from: the base rows, and a join's or
15
+ // anti-join's rows by its planned entry (undefined: the table as it is).
16
+ // Either left out reads the tables as they are.
17
+ export interface ViewRowsSource {
18
+ base?: Row[];
19
+ rowsOf?: (entry: PlannedJoin | PlannedAntiJoin) => Row[] | undefined;
20
+ }
6
21
 
7
22
  type Pk = string;
8
23
 
@@ -117,6 +132,10 @@ export class Store {
117
132
  // MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
118
133
  // for handlers that delete without ever broadcasting.
119
134
  private removedRows = new Map<string, Map<Pk, Row[]>>();
135
+ // Per table, each record a modify changed since a broadcast last announced
136
+ // it, as it was before the first such modify (by RECORD_ID) — see
137
+ // takePreviousRecord. Bounded like removedRows.
138
+ private previousRows = new Map<string, Map<string, Row>>();
120
139
  readonly views = new Map<string, ViewDef>();
121
140
 
122
141
  registerTable(tableName: string, definition: TableDef): this {
@@ -152,6 +171,7 @@ export class Store {
152
171
  this.tables.set(tableName, table);
153
172
  this.tableConfig.set(tableName, config);
154
173
  this.removedRows.delete(tableName);
174
+ this.previousRows.delete(tableName);
155
175
  this.seedRows.set(
156
176
  tableName,
157
177
  rows.map((row) => ({ ...row })),
@@ -199,7 +219,9 @@ export class Store {
199
219
  return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
200
220
  }
201
221
 
222
+ // Throws on a join model GSF would refuse to compile (see planView).
202
223
  registerView(viewName: string, definition: ViewDef): this {
224
+ planView(viewName, definition);
203
225
  this.views.set(viewName, definition);
204
226
  return this;
205
227
  }
@@ -252,6 +274,19 @@ export class Store {
252
274
  if (removed.size > MAX_REMOVED_KEYS) removed.delete(removed.keys().next().value!);
253
275
  }
254
276
 
277
+ private notePrevious(tableName: string, row: Row): void {
278
+ const id = recordKey(row);
279
+ if (id === undefined) return;
280
+ let previous = this.previousRows.get(tableName);
281
+ if (!previous) {
282
+ previous = new Map();
283
+ this.previousRows.set(tableName, previous);
284
+ }
285
+ if (previous.has(id)) return;
286
+ previous.set(id, row);
287
+ if (previous.size > MAX_REMOVED_KEYS) previous.delete(previous.keys().next().value!);
288
+ }
289
+
255
290
  // A supplied value inside a generator's own range must advance its counter
256
291
  // too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
257
292
  // 2, and the next generated insert mints 'TR002' again and silently
@@ -387,6 +422,7 @@ export class Store {
387
422
  const config = this.tableConfig.get(tableName);
388
423
  if (!table || !config || !table.has(pk)) return undefined;
389
424
  const existing = table.get(pk)!;
425
+ this.notePrevious(tableName, existing);
390
426
  const updated = { ...existing, ...patch };
391
427
  updated[RECORD_ID] = existing[RECORD_ID];
392
428
  updated[TIMESTAMP] = this.clock.next();
@@ -426,6 +462,18 @@ export class Store {
426
462
  return records;
427
463
  }
428
464
 
465
+ // Reads and clears how a record was before the modifies no broadcast has
466
+ // announced yet changed it (undefined when none did): with the removed
467
+ // records, what a live change to a joined table is diffed against.
468
+ takePreviousRecord(tableName: string, row: Row): Row | undefined {
469
+ const id = recordKey(row);
470
+ const previous = this.previousRows.get(tableName);
471
+ if (id === undefined || !previous) return undefined;
472
+ const record = previous.get(id);
473
+ previous.delete(id);
474
+ return record;
475
+ }
476
+
429
477
  // The same, for a row of a table or view given by its (base) pk fields —
430
478
  // what a public broadcast(resource, 'DELETE', pkStub) names.
431
479
  takeRemovedRecordsFor(resourceName: string, row: Row): Row[] {
@@ -433,69 +481,42 @@ export class Store {
433
481
  return located ? this.takeRemovedRows(located.tableName, located.key) : [];
434
482
  }
435
483
 
436
- // Materializes a view: base table left-joined to related tables (optionally
437
- // narrowed by per-join `where` predicates), minus anti-joined rows, plus
438
- // derived fields. `tables` substitutes the rows of any table the view reads
439
- // (its base or a joined one) — e.g. one base row, to build that row's view
440
- // row, or a table's earlier state, to rebuild the view as it was.
484
+ // Materializes a view (see the README's "Views: the join model"): base
485
+ // rows minus anti-joined ones, joined in order — OUTER keeps a row with no
486
+ // match, INNER drops it — plus the select aliases and derived fields.
487
+ // `tables` substitutes the rows of any table the view reads (its base or a
488
+ // joined one) — e.g. a table's earlier state, to rebuild the view as it
489
+ // was.
441
490
  getViewRows(viewName: string, tables: Record<string, Row[]> = {}): Row[] {
442
- const view = this.views.get(viewName);
443
- if (!view) throw new Error(`Unknown view: ${viewName}`);
444
- return this.materializeView(view, (tableName) =>
445
- Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName),
446
- );
491
+ const rowsOf = (tableName: string) =>
492
+ Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName);
493
+ return this.getViewRowsFrom(viewName, {
494
+ base: rowsOf(this.viewOf(viewName).base),
495
+ rowsOf: (entry) => rowsOf(entry.table),
496
+ });
447
497
  }
448
498
 
449
- private materializeView(view: ViewDef, getRows: (tableName: string) => Row[]): Row[] {
450
- let rows = getRows(view.base).map((row) => ({ ...row }));
451
-
452
- for (const antiJoin of view.antiJoins ?? []) {
453
- const joinRows = getRows(antiJoin.table);
454
- rows = rows.filter(
455
- (row) =>
456
- !joinRows.some(
457
- (joinRow) =>
458
- joinRow[antiJoin.on[1]] === row[antiJoin.on[0]] &&
459
- (antiJoin.where?.(joinRow, row) ?? true),
460
- ),
461
- );
462
- }
463
-
464
- for (const join of view.joins ?? []) {
465
- const joinRows = getRows(join.table);
466
- // A `where` predicate sees the base row, so an index keyed on the `on`
467
- // field alone can't answer it — scan per base row instead.
468
- const joinIndex = join.where ? undefined : new Map<unknown, Row>();
469
- if (joinIndex) {
470
- for (const row of joinRows) {
471
- joinIndex.set(row[join.on[1]], row);
472
- }
473
- }
474
- rows = rows.map((row) => {
475
- const match = joinIndex
476
- ? joinIndex.get(row[join.on[0]])
477
- : joinRows.find(
478
- (joinRow) => joinRow[join.on[1]] === row[join.on[0]] && join.where!(joinRow, row),
479
- );
480
- const merged = { ...row };
481
- for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
482
- merged[alias] = match?.[sourceField];
483
- }
484
- return merged;
485
- });
486
- }
499
+ // The same, with the base rows given apart from the joined ones — e.g. one
500
+ // base row, to build that row's view row when the base table is also joined
501
+ // — and a join's or anti-join's rows given by its planned entry (undefined:
502
+ // the table as it is).
503
+ getViewRowsFrom(viewName: string, source: ViewRowsSource): Row[] {
504
+ return this.getViewEntriesFrom(viewName, source).map((entry) => entry.row);
505
+ }
487
506
 
488
- if (view.derived) {
489
- rows = rows.map((row) => {
490
- const merged = { ...row };
491
- for (const [field, compute] of Object.entries(view.derived!)) {
492
- merged[field] = compute(row);
493
- }
494
- return merged;
495
- });
496
- }
507
+ // The same, each row with the row every join matched (in join order).
508
+ getViewEntriesFrom(viewName: string, source: ViewRowsSource): ViewEntry[] {
509
+ const view = this.viewOf(viewName);
510
+ return materializeEntries(viewName, view, {
511
+ base: source.base ?? this.getAllRows(view.base),
512
+ rowsOf: (entry) => source.rowsOf?.(entry) ?? this.getAllRows(entry.table),
513
+ });
514
+ }
497
515
 
498
- return rows;
516
+ private viewOf(viewName: string): ViewDef {
517
+ const view = this.views.get(viewName);
518
+ if (!view) throw new Error(`Unknown view: ${viewName}`);
519
+ return view;
499
520
  }
500
521
 
501
522
  // Resolves a queryable resource (table or view) by name to its current rows.
@@ -516,9 +537,13 @@ export class Store {
516
537
  const live = this.getResourceRows(resourceName);
517
538
  if (live.length > 0) return live.map(withoutRecordFields);
518
539
  const view = this.views.get(resourceName);
540
+ const seedOf = (tableName: string) => this.seedRows.get(tableName) ?? [];
519
541
  const seed = view
520
- ? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
521
- : (this.seedRows.get(resourceName) ?? []);
542
+ ? this.getViewRowsFrom(resourceName, {
543
+ base: seedOf(view.base),
544
+ rowsOf: (entry) => seedOf(entry.table),
545
+ })
546
+ : seedOf(resourceName);
522
547
  return seed.map(withoutRecordFields);
523
548
  }
524
549
 
@@ -0,0 +1,269 @@
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
+
8
+ import type {
9
+ AntiJoinDef,
10
+ JoinDef,
11
+ JoinFieldLink,
12
+ JoinLink,
13
+ Row,
14
+ ViewDef,
15
+ ViewRowSources,
16
+ } from '../types.ts';
17
+
18
+ // A field link reads its source field from the table `fromAlias` names, or,
19
+ // with none, from the view row built so far (see linksOf).
20
+ export type PlannedLink =
21
+ | { kind: 'field'; fromAlias: string | undefined; from: string; to: string }
22
+ | { kind: 'value'; to: string; value: unknown };
23
+
24
+ export interface PlannedJoin {
25
+ def: JoinDef;
26
+ table: string;
27
+ // The name `tables` holds its row under, and joins and links read it by.
28
+ // Undefined for a join with no alias whose table name an earlier table of
29
+ // the view already goes by (see planView).
30
+ alias: string | undefined;
31
+ // The table the join starts from: its own `fromAlias`, else the base.
32
+ fromAlias: string;
33
+ links: PlannedLink[];
34
+ inner: boolean;
35
+ backwardsJoin: boolean;
36
+ // Whether its columns keep their own nullability: true when the join is
37
+ // reached from the base through INNER joins only. Any other join's columns
38
+ // are nullable, as an OUTER join (or anything under one) may find nothing
39
+ // (ViewStructureReaderImpl.kt transformOutputFields / getInnerJoinAliases,
40
+ // GSF v8.15.29).
41
+ alwaysMatched: boolean;
42
+ }
43
+
44
+ export interface PlannedAntiJoin {
45
+ def: AntiJoinDef;
46
+ table: string;
47
+ links: PlannedLink[];
48
+ }
49
+
50
+ export interface ViewPlan {
51
+ baseAlias: string;
52
+ joins: PlannedJoin[];
53
+ antiJoins: PlannedAntiJoin[];
54
+ }
55
+
56
+ const isFieldPair = (on: JoinDef['on']): on is JoinFieldLink =>
57
+ on.length === 2 && typeof on[0] === 'string' && typeof on[1] === 'string';
58
+
59
+ // A pair, and an object link with no `fromAlias`, read from the join's own
60
+ // `fromAlias`. A join with none reads them from the view row built so far —
61
+ // the base row's columns, overlaid by the selects of the joins before it —
62
+ // as joins always have, so one written before `fromAlias` existed can still
63
+ // key on an earlier join's selected column. For a GSF view that is the base
64
+ // row's field (an alias never repeats a base column the view exposes); name
65
+ // `fromAlias` to read one table's own row.
66
+ function linksOf(on: JoinDef['on'], fromAlias: string | undefined): PlannedLink[] {
67
+ const links: JoinLink[] = isFieldPair(on) ? [on] : on;
68
+ return links.map((link): PlannedLink => {
69
+ if (Array.isArray(link)) return { kind: 'field', fromAlias, from: link[0], to: link[1] };
70
+ if ('value' in link) return { kind: 'value', to: link.to, value: link.value };
71
+ return { kind: 'field', fromAlias: link.fromAlias ?? fromAlias, from: link.from, to: link.to };
72
+ });
73
+ }
74
+
75
+ // The plan of a view. Planned on every use (it costs next to nothing), so a
76
+ // definition changed in place takes effect, as it always has. Throws on a
77
+ // definition GSF would refuse to compile: a join with no `on` link, an alias
78
+ // given twice, or a join (or one of its links) reading from a table that is
79
+ // neither the base nor an earlier join.
80
+ //
81
+ // Before aliases existed, a table could be joined twice (or the base table
82
+ // joined to itself) with no alias, and such configs keep working: a join
83
+ // with no alias whose table name an earlier table already goes by (one
84
+ // without an explicit alias either) is joined as before, but goes by no name
85
+ // — the name keeps meaning the earlier table. Give it an `alias` to read it.
86
+ export function planView(viewName: string, view: ViewDef): ViewPlan {
87
+ const fail = (message: string): never => {
88
+ throw new Error(`[mock-server] View ${viewName}: ${message}`);
89
+ };
90
+ const baseAlias = view.baseAlias ?? view.base;
91
+ // Every name so far: whether it was given explicitly, and whether a row
92
+ // under it always exists when the view row does.
93
+ const names = new Map<string, { explicit: boolean; alwaysMatched: boolean }>([
94
+ [baseAlias, { explicit: view.baseAlias !== undefined, alwaysMatched: true }],
95
+ ]);
96
+ const checkSource = (label: string, alias: string | undefined) => {
97
+ if (alias !== undefined && !names.has(alias)) {
98
+ fail(`${label} reads from '${alias}', which is neither the base nor an earlier join`);
99
+ }
100
+ };
101
+ const joins = (view.joins ?? []).map((def, index): PlannedJoin => {
102
+ const name = def.alias ?? def.table;
103
+ const fromAlias = def.fromAlias ?? baseAlias;
104
+ const label = `join ${index + 1} (${name})`;
105
+ checkSource(label, fromAlias);
106
+ const links = linksOf(def.on, def.fromAlias);
107
+ if (links.length === 0) fail(`${label} has no 'on' link`);
108
+ for (const link of links) if (link.kind === 'field') checkSource(label, link.fromAlias);
109
+ const taken = names.get(name);
110
+ if (taken && (taken.explicit || def.alias !== undefined)) {
111
+ fail(`${label}: alias '${name}' is already used; give each table its own alias`);
112
+ }
113
+ const inner = def.type === 'INNER';
114
+ const alwaysMatched = inner && names.get(fromAlias)!.alwaysMatched;
115
+ if (!taken) names.set(name, { explicit: def.alias !== undefined, alwaysMatched });
116
+ return {
117
+ def,
118
+ table: def.table,
119
+ alias: taken ? undefined : name,
120
+ fromAlias,
121
+ links,
122
+ inner,
123
+ backwardsJoin: def.backwardsJoin === true,
124
+ alwaysMatched,
125
+ };
126
+ });
127
+ const antiJoins = (view.antiJoins ?? []).map((def, index): PlannedAntiJoin => {
128
+ const label = `anti-join ${index + 1} (${def.table})`;
129
+ const links = linksOf(def.on, undefined);
130
+ if (links.length === 0) fail(`${label} has no 'on' link`);
131
+ for (const link of links) {
132
+ if (link.kind === 'field' && link.fromAlias !== undefined && link.fromAlias !== baseAlias) {
133
+ fail(`${label} can only read from the base table`);
134
+ }
135
+ }
136
+ return { def, table: def.table, links };
137
+ });
138
+ return { baseAlias, joins, antiJoins };
139
+ }
140
+
141
+ // --- materialization ---------------------------------------------------------
142
+
143
+ type Where = ((joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean) | undefined;
144
+
145
+ const KEY_SEPARATOR = '\u0000';
146
+
147
+ // One index key for a list of values, or undefined when one is null or
148
+ // missing: GSF builds no join request from a null key, so it matches nothing.
149
+ // Typed, so 1 and '1' differ, as === tells them apart.
150
+ function keyOf(values: unknown[]): string | undefined {
151
+ if (values.some((value) => value === undefined || value === null)) return undefined;
152
+ return values.map((value) => `${typeof value}:${String(value)}`).join(KEY_SEPARATOR);
153
+ }
154
+
155
+ // Finds a join's match for one view row: the joined rows whose fields equal
156
+ // the source fields (an index over them, built once), narrowed by the
157
+ // constants and the `where` predicate. GSF only lets a dataserver read a view
158
+ // whose joins are one-to-one (their links cover a unique index), so a valid
159
+ // view has at most one match; when there are more, the last one in table
160
+ // order wins — with a `where`, the first that passes — as before this model.
161
+ function matcher(
162
+ links: PlannedLink[],
163
+ where: Where,
164
+ rows: Row[],
165
+ ): (viewRow: Row, sources: ViewRowSources) => Row | undefined {
166
+ const fieldLinks = links.flatMap((link) => (link.kind === 'field' ? [link] : []));
167
+ const valueLinks = links.flatMap((link) => (link.kind === 'value' ? [link] : []));
168
+ let index: Map<string, Row[]> | undefined;
169
+ const candidatesFor = (viewRow: Row, sources: ViewRowSources): Row[] => {
170
+ if (fieldLinks.length === 0) return rows;
171
+ if (!index) {
172
+ index = new Map();
173
+ for (const row of rows) {
174
+ const key = keyOf(fieldLinks.map((link) => row[link.to]));
175
+ if (key === undefined) continue;
176
+ const bucket = index.get(key);
177
+ if (bucket) bucket.push(row);
178
+ else index.set(key, [row]);
179
+ }
180
+ }
181
+ const key = keyOf(
182
+ fieldLinks.map((link) =>
183
+ link.fromAlias === undefined
184
+ ? viewRow[link.from]
185
+ : sources.tables[link.fromAlias]?.[link.from],
186
+ ),
187
+ );
188
+ return key === undefined ? [] : (index.get(key) ?? []);
189
+ };
190
+ const constantsHold = (row: Row) => valueLinks.every((link) => row[link.to] === link.value);
191
+ return (viewRow, sources) => {
192
+ const candidates = candidatesFor(viewRow, sources);
193
+ if (where) {
194
+ return candidates.find((row) => constantsHold(row) && where(row, viewRow, sources));
195
+ }
196
+ return candidates.findLast(constantsHold);
197
+ };
198
+ }
199
+
200
+ // Where a materialization reads its rows: the base table's, and each join's
201
+ // and anti-join's by its planned entry (so one alias of a table can read a
202
+ // different state of it than another).
203
+ export interface ViewRowSource {
204
+ base: Row[];
205
+ rowsOf: (entry: PlannedJoin | PlannedAntiJoin) => Row[];
206
+ }
207
+
208
+ // A view row, with the row each planned join matched (undefined for none),
209
+ // in plan order.
210
+ export interface ViewEntry {
211
+ row: Row;
212
+ matches: Array<Row | undefined>;
213
+ }
214
+
215
+ // The view's rows: each base row without an anti-joined match, joined in
216
+ // order (an INNER join with no match drops the row; a join whose source
217
+ // found nothing finds nothing either), plus the base `select` aliases, each
218
+ // join's `select` and the derived fields.
219
+ export function materializeEntries(
220
+ viewName: string,
221
+ view: ViewDef,
222
+ source: ViewRowSource,
223
+ ): ViewEntry[] {
224
+ const plan = planView(viewName, view);
225
+ const antiJoins = plan.antiJoins.map((antiJoin) =>
226
+ matcher(antiJoin.links, antiJoin.def.where, source.rowsOf(antiJoin)),
227
+ );
228
+ const joins = plan.joins.map((join) => matcher(join.links, join.def.where, source.rowsOf(join)));
229
+ const entries: ViewEntry[] = [];
230
+ for (const base of source.base) {
231
+ const sources: ViewRowSources = { tables: { [plan.baseAlias]: base } };
232
+ if (antiJoins.some((match) => match(base, sources))) continue;
233
+ const row: Row = { ...base };
234
+ for (const [name, field] of Object.entries(view.select ?? {})) row[name] = base[field];
235
+ const matches: Array<Row | undefined> = [];
236
+ let dropped = false;
237
+ for (const [index, join] of plan.joins.entries()) {
238
+ // `where` sees the view row so far, as it always has: the base row's
239
+ // columns and the selects before this join.
240
+ const match = sources.tables[join.fromAlias] ? joins[index](row, sources) : undefined;
241
+ if (!match && join.inner) {
242
+ dropped = true;
243
+ break;
244
+ }
245
+ matches.push(match);
246
+ if (join.alias !== undefined) sources.tables[join.alias] = match;
247
+ for (const [name, field] of Object.entries(join.def.select ?? {})) row[name] = match?.[field];
248
+ }
249
+ if (dropped) continue;
250
+ if (!view.derived) {
251
+ entries.push({ row, matches });
252
+ continue;
253
+ }
254
+ const derived: Row = { ...row };
255
+ for (const [name, compute] of Object.entries(view.derived)) {
256
+ derived[name] = compute(row, sources);
257
+ }
258
+ entries.push({ row: derived, matches });
259
+ }
260
+ return entries;
261
+ }
262
+
263
+ // The planned joins and anti-joins that read a table.
264
+ export function entriesReading(
265
+ plan: ViewPlan,
266
+ tableName: string,
267
+ ): Array<PlannedJoin | PlannedAntiJoin> {
268
+ return [...plan.joins, ...plan.antiJoins].filter((entry) => entry.table === tableName);
269
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,10 @@ export type {
33
33
  HttpRouteDef,
34
34
  IndexDef,
35
35
  JoinDef,
36
+ JoinFieldLink,
37
+ JoinLink,
38
+ JoinType,
39
+ JoinValueLink,
36
40
  MockServerConfig,
37
41
  OnMessageCtx,
38
42
  OnMessageHook,
@@ -48,4 +52,5 @@ export type {
48
52
  SsoIdentityProvider,
49
53
  TableDef,
50
54
  ViewDef,
55
+ ViewRowSources,
51
56
  } from './types.ts';