@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/README.md CHANGED
@@ -136,10 +136,11 @@ covers the server-side behaviors real projects lean on:
136
136
  }
137
137
  ```
138
138
 
139
- - **Join predicates and anti-joins** — a view join takes a `where(joinRow,
140
- baseRow)` predicate for matches the `on` equality alone can't express
141
- (side-specific price joins), and `antiJoins` keeps only base rows with NO
142
- match (the `USER_WITHOUT_DETAIL` pattern):
139
+ - **Join predicates and anti-joins** — beyond the GSF join model (see
140
+ [Views: the join model](#views-the-join-model); a side-specific price join
141
+ is a constant link there), a view join takes a `where(joinRow, viewRow,
142
+ { tables })` predicate for matches no link can express, and `antiJoins`
143
+ keeps only base rows with NO match (the `USER_WITHOUT_DETAIL` pattern):
143
144
 
144
145
  ```ts
145
146
  views: {
@@ -162,6 +163,112 @@ covers the server-side behaviors real projects lean on:
162
163
  `store.rowKey(table, row)` to `broadcastTableChange`; sequence generation
163
164
  still requires a single `pkField`.
164
165
 
166
+ ## Views: the join model
167
+
168
+ A `ViewDef` is a GSF view (`view("TRADE_VIEW", TRADE) { joins { } fields { } }`)
169
+ and behaves as GSF 8.15.x views do (genesis-pal `view/join/*.kt`,
170
+ `ViewStructureReaderImpl.kt`; genesis-codegen `ViewRepoConfigGenerator.kt`):
171
+
172
+ ```ts
173
+ views: {
174
+ TRADE_VIEW: {
175
+ base: 'TRADE',
176
+ select: { TRADE_PRICE: 'PRICE' }, // TRADE.PRICE withAlias "TRADE_PRICE"
177
+ joins: [
178
+ // joining(COUNTERPARTY, backwardsJoin = true) {
179
+ // on(TRADE.COUNTERPARTY_ID to COUNTERPARTY { COUNTERPARTY_ID }) }
180
+ {
181
+ table: 'COUNTERPARTY',
182
+ on: ['COUNTERPARTY_ID', 'COUNTERPARTY_ID'],
183
+ backwardsJoin: true,
184
+ select: { COUNTERPARTY_NAME: 'NAME' }, // COUNTERPARTY.NAME withAlias "COUNTERPARTY_NAME"
185
+ },
186
+ // joining(INSTRUMENT, JoinType.INNER) { on(...)
187
+ // .joining(EXCHANGE) { on(INSTRUMENT { EXCHANGE_ID } to EXCHANGE { EXCHANGE_ID }) } }
188
+ { table: 'INSTRUMENT', type: 'INNER', on: ['INSTRUMENT_ID', 'INSTRUMENT_ID'] },
189
+ {
190
+ table: 'EXCHANGE',
191
+ fromAlias: 'INSTRUMENT',
192
+ on: ['EXCHANGE_ID', 'EXCHANGE_ID'],
193
+ select: { EXCHANGE_CITY: 'CITY' },
194
+ },
195
+ // val BID = INSTRUMENT_PRICE withAlias "BID"
196
+ // joining(BID) { on(TRADE.INSTRUMENT_ID to BID { INSTRUMENT_ID }).and(BID { SIDE } to "BID") }
197
+ {
198
+ table: 'INSTRUMENT_PRICE',
199
+ alias: 'BID',
200
+ on: [['INSTRUMENT_ID', 'INSTRUMENT_ID'], { to: 'SIDE', value: 'BID' }],
201
+ },
202
+ ],
203
+ // derivedField("SPREAD", DOUBLE) { withInput(TRADE.PRICE, BID.PX) { price, bid -> ... } }
204
+ derived: { SPREAD: (row, { tables }) => (tables.BID ? row.TRADE_PRICE - tables.BID.PX : null) },
205
+ derivedTypes: { SPREAD: 'DOUBLE' },
206
+ },
207
+ }
208
+ ```
209
+
210
+ | GSF view DSL | `ViewDef` / `JoinDef` |
211
+ |---|---|
212
+ | `view("V", TRADE)` | `base: 'TRADE'`. `baseAlias` names it inside the view (default: the table name) |
213
+ | `joining(T)`, `joining(T, JoinType.INNER)` | `{ table: 'T' }`, `type: 'INNER'`. The default is `'OUTER'`, as in GSF |
214
+ | `val A = T withAlias "A"`, `joining(A)` | `alias: 'A'`, needed to join one table twice. Default: the table name |
215
+ | `on(TRADE.F to T { G })` | `on: ['F', 'G']`: the source row's field, then the joined table's |
216
+ | `.and(...)` | `on: [['F', 'G'], ['F2', 'G2']]`: every link must hold |
217
+ | `.and(OTHER { F } to T { G })`, a link from another table of the view | `{ fromAlias: 'OTHER', from: 'F', to: 'G' }` |
218
+ | `.and(T { G } to "VALUE")` | `{ to: 'G', value: 'VALUE' }`, compared with `===`, so use the column's own type |
219
+ | `.joining(U) { ... }` nested in T's join | a later join with `fromAlias: 'T'` |
220
+ | `backwardsJoin = true` | `backwardsJoin: true` (see the live changes below) |
221
+ | `T.F withAlias "X"`, `T.F withPrefix "P"`, `T.F` | the join's `select`: `{ X: 'F' }`, `{ P_F: 'F' }`, `{ F: 'F' }`. Base-table aliases go in `ViewDef.select` |
222
+ | `derivedField("D", DOUBLE) { withInput(A.F, B.G) { ... } }` | `derived: { D: (row, { tables }) => ... }` and `derivedTypes: { D: 'DOUBLE' }` |
223
+ | `fields { ... }` | `fields`: the output names, in order |
224
+
225
+ **Rows.**
226
+ - Joins run in order. A join with `fromAlias` reads its key fields from that table's row.
227
+ - A join without `fromAlias` reads them from the view row built so far: the base row's columns, overlaid by the selects of the joins before it. For a GSF view that is the base row's field. It also lets a join key on an earlier join's selected column, which is how chained joins were written before `fromAlias`.
228
+ - A null or missing key value matches nothing, because GSF builds no join request from it. Falsy values such as `0` and `''` do match.
229
+ - GSF only lets a dataserver read a view whose joins are one-to-one (their links cover a unique index), so a valid view has at most one match. When there are more, the last one in table order wins, or with a `where` the first that passes, as before this model. One-to-many fan-out is not modelled.
230
+ - An OUTER join that finds nothing keeps the row, with that join's columns unset (`null` on the wire for a declared view).
231
+ - An INNER join that finds nothing drops the row.
232
+ - A join whose source table found nothing finds nothing either. So an INNER join nested under an OUTER one also drops the row when the outer join misses.
233
+ - `derived(row, { tables })` and `where(joinRow, viewRow, { tables })`:
234
+ - `row` / `viewRow` is the view row so far: the base columns plus the selects.
235
+ - `tables` holds every table's source row by alias, so a derived field can read columns the view doesn't expose. An OUTER miss is `undefined`.
236
+ - Registration throws on a definition GSF would refuse to compile: a join with no link, an alias given twice, or a `fromAlias` (of a join or a link) that is neither the base nor an earlier join.
237
+ - A table joined twice, or the base table joined to itself, with no alias still works, as it did before aliases existed. The later join goes by no name, and the name keeps meaning the first table. Give it an `alias` to read it by name.
238
+
239
+ **Metadata.**
240
+ - Every column takes its type, enum values and size from its source table, through aliases and nested joins.
241
+ - A joined column keeps its own nullability only when its join is reached from the base through INNER joins alone. Any other joined column is nullable (GSF `transformOutputFields`).
242
+ - Derived fields take `derivedTypes`.
243
+
244
+ **Live changes** (fidelity `'gsf'`):
245
+ - **Base-table change.** The view row is rebuilt from the changed base row.
246
+ - A MODIFY that makes an INNER join miss is pushed as a `DELETE`.
247
+ - One that makes it match is pushed as an `INSERT`.
248
+ - An INSERT that doesn't make it into the view pushes nothing.
249
+ - This is GSF's mapping of view updates (genesis-db `Bulk.kt` `mapNullable`).
250
+ - **Joined-table change.** It is pushed only through joins with `backwardsJoin: true`, at any depth. GSF's view docs say that by default only root-table updates are published, and a joined table's need a backwards join (`ViewStructureReaderImpl.kt`; `JoinRepo.kt` listens to a joined table only for one). Without one, the client sees the joined table's new values when the view row's base row next changes.
251
+ - **What a backwards join pushes.** The view is compared as it was before the change and as it is after:
252
+ - an `INSERT` for a row an INNER join now lets in;
253
+ - a `DELETE` for a row it drops;
254
+ - a `MODIFY` for a row joined to the changed record (before or after), or whose values changed;
255
+ - nothing for the rest.
256
+ - **A row joined to the changed record is pushed even when its values look unchanged.** The "before" is the store's: each record as it was before its first modify that no broadcast has announced yet, plus the records a key lost. A client may hold newer values than that, for example from a snapshot taken after an unannounced change.
257
+ - GSF's client index compares against what each client holds, and sends nothing for a modify that changes no exposed field.
258
+ - So here a change to a column the view doesn't expose still pushes the rows joined to it. The values are unchanged, which clients take as a no-op.
259
+ - **Anti-joins** are not a GSF construct, so a change to an anti-joined table is always pushed, as `INSERT`s and `DELETE`s.
260
+ - **A DELETE broadcast before its row is removed** counts the row as gone.
261
+ - **A view that joins its own base table** gets each changed base row once, from the base-table path.
262
+ - **Change rows through the store and broadcast them.** Use `insertRow` / `updateRow` / `deleteRow` and call `broadcastTableChange`. A stored row mutated in place leaves no earlier state to compare against.
263
+
264
+ **Behaviour change.** Up to 15.53, a change to any joined table pushed every held view row as a MODIFY. Now a join needs `backwardsJoin: true`, as on GSF, and only the rows it inserts, deletes or joins are pushed. `fidelity: 'legacy'` keeps the old behaviour and ignores `backwardsJoin`.
265
+
266
+ **Not modelled:**
267
+ - parameterised joins (`asParameter`) and lambda joins;
268
+ - `withFormat`;
269
+ - one-to-many views (GSF dataservers can't read them);
270
+ - a dataserver query's own `backwardsJoin = false` (GSF's default is `true`).
271
+
165
272
  ## Record identity, ROW_REF and sequences
166
273
 
167
274
  Every stored row carries `RECORD_ID` and `TIMESTAMP`, as every Genesis table
@@ -196,7 +303,8 @@ row does (genesis-db `InsertOperation.kt`):
196
303
  `broadcastTableChange`, and a pk-only row passed to `broadcast()` is matched
197
304
  to its record, a just-deleted one included. A view row is rebuilt from the
198
305
  changed base row, so a view's own `pkField` doesn't matter, and a MODIFY that
199
- takes the row out of the view (an anti-join) is pushed as a DELETE.
306
+ takes the row out of the view (an INNER join or an anti-join) is pushed as a
307
+ DELETE.
200
308
  - **Live DELETE** is `ROW: [{DETAILS: {OPERATION: 'DELETE', ROW_REF}}]` and
201
309
  nothing else. The store remembers every record a key lost until a broadcast
202
310
  announces it (`broadcastTableChange`, or `broadcast()` with a DELETE), so a
@@ -560,6 +668,7 @@ createMockServer({ ...config, fidelity: 'legacy' });
560
668
  | `META_REQUEST` content | The GSF shapes below; event META too | `{NAME, TYPE}` fields, `KEY: []` on dataservers, `REPLY_FIELD` alone on request servers; events are unknown |
561
669
  | `JSON_SCHEMA_REQUEST` content | Per resource type, as below | One minimal `{DETAILS: {properties: {F: {type}}, required}}` as both INBOUND and OUTBOUND; a query or request-server name resolves to its source table |
562
670
  | Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
671
+ | A change to a table a view joins | Pushed only through joins with `backwardsJoin: true`, as the view rows it inserts, deletes or changes ([Views](#views-the-join-model)) | Every view row pushed as a MODIFY, whatever the join |
563
672
  | `DATA_LOGON` `ORDER_BY` / `REVERSE` / `FIELDS`, criteria on unexposed fields | Honoured / `LOGON_NACK` | Ignored |
564
673
  | `REQ_` | As in [Request servers](#request-servers-req_): `PARAMETRIC_TYPE`, criteria-only paging and ordering, `REQUEST` wildcards, ranges and arrays, a reply block, `MSG_NACK` failures | Every matching row in insertion order; `REQUEST` from the top level or `DETAILS`, `'*'`/`null`/`''` match anything, an array value any of its values; `CRITERIA_MATCH` fails open; no `RECORD_ID`/`TIMESTAMP`; errors as a `REP_` with `ERROR` |
565
674
  | Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
@@ -611,8 +720,10 @@ appName: 'showcase', // enum class names: global.genesis.gen.dao.enums.<appName>
611
720
  server puts there (leave it unset on dates: foundation-forms spots a date by
612
721
  `org.joda.time.DateTime`). `optional` decides whether an event payload may leave the field
613
722
  out (see events below); an ENUM with no `values` sends no `VALID_VALUES` and no `enum`.
614
- - **Views** take their base table's fields, each join's `select` columns typed from the joined
615
- table (nullable), and `derivedTypes` for derived fields (inferred from rows when missing).
723
+ - **Views** take their base table's fields and `select` aliases, each join's `select` columns
724
+ typed from the joined table at any depth (nullable unless reached from the base through INNER
725
+ joins only; see [Views: the join model](#views-the-join-model)), and `derivedTypes` for
726
+ derived fields (inferred from rows when missing).
616
727
  `ViewDef.fields` is the view's `fields { }` block: the view then exposes only those, in that
617
728
  order (keep the key in it).
618
729
  - **`QueryDef.fields`** is the query's `fields { }` block: rows **and** metadata carry only those
@@ -719,8 +830,8 @@ past `MAX_VIEW`). A row is in scope while it passes the query `filter`,
719
830
  So a new row arrives as an `INSERT`, a row a MODIFY brings back into the
720
831
  criteria arrives as an `INSERT` (recorded: `DELETE` on leaving, `INSERT` on
721
832
  re-entering, same `ROW_REF`), and rows the client never got — including rows
722
- cut off by `MAX_VIEW`, and the hundreds of view rows a change to a joined table
723
- re-materializes — get no MODIFYs or DELETEs at all. A waiting row's page later
833
+ cut off by `MAX_VIEW`, and the view rows a change to a joined table touches —
834
+ get no MODIFYs or DELETEs at all. A waiting row's page later
724
835
  delivers its current values. Nothing absorbed uses a `SEQUENCE_ID`.
725
836
 
726
837
  An `INSERT` is pushed even when the view already holds `MAX_VIEW` rows. The
@@ -1008,8 +1119,10 @@ Where this engine still differs from the real server:
1008
1119
 
1009
1120
  - Live MODIFYs carry the **full row**. The real server sends only the changed
1010
1121
  fields; a full row is a superset, which clients tolerate.
1011
- - A change to a **joined table** pushes a MODIFY for every held view row, not
1012
- only the rows whose values changed.
1122
+ - A change through a view's **backwards join** pushes a MODIFY to every held
1123
+ row joined to the changed record, even when no column the view exposes
1124
+ changed. The real server compares against what the client holds and sends
1125
+ nothing then (see [Views](#views-the-join-model)).
1013
1126
  - There is no **moving view**. Once the view holds `MAX_VIEW` rows, live
1014
1127
  INSERTs are still pushed instead of also evicting the oldest row.
1015
1128
  - Without `ORDER_BY`, snapshots are in **insertion order** (with or without
@@ -1037,7 +1150,8 @@ the criteria grammar, store, metadata inference, and event-table inference
1037
1150
  plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
1038
1151
  delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
1039
1152
  `test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
1040
- fidelity mode; `test/requestReply.test.ts` covers both kinds of request server)
1153
+ fidelity mode; `test/requestReply.test.ts` covers both kinds of request server,
1154
+ `test/views.test.ts` the view join model and its live pushes)
1041
1155
  via `node --test`, no build step. Only `test/**/*.test.ts`
1042
1156
  files are run, so shared helpers (`test/support.ts`) can sit beside them.
1043
1157
 
package/dist/db/schema.js CHANGED
@@ -7,6 +7,7 @@ import { isLegacyFidelity } from "../protocol/fidelity.js";
7
7
  import { daoClassOf, titleCase } from "../protocol/fieldTypes.js";
8
8
  import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from "./identity.js";
9
9
  import { deriveFields } from "./metadata.js";
10
+ import { planView } from "./views.js";
10
11
  export function pkFieldsOf(def) {
11
12
  return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
12
13
  }
@@ -67,27 +68,39 @@ function derivedTypeDef(name, entry) {
67
68
  function viewOf(name, config, store) {
68
69
  return store.views.get(name) ?? config.views?.[name];
69
70
  }
70
- // A view over a declared base table: the base table's columns, each join's
71
- // selected columns typed from the joined table (nullable, since a base row
72
- // may have no match), and the derived fields typed from derivedTypes (or
71
+ // A view over a declared base table: the base table's columns and its
72
+ // `select` aliases, each join's selected columns typed from the joined table
73
+ // (at any depth), and the derived fields typed from derivedTypes (or
73
74
  // inferred from the view's rows when not given) — narrowed to the view's
74
- // fields block when it has one.
75
+ // fields block when it has one. A joined column keeps its own nullability
76
+ // only when its join is reached from the base through INNER joins alone;
77
+ // any other may find no match, so it is nullable (GSF's
78
+ // transformOutputFields).
75
79
  function declaredViewFields(viewName, view, config, store) {
80
+ const plan = planView(viewName, view);
76
81
  const fields = new Map();
77
- for (const field of resolveSourceSchema(view.base, config, store).fields) {
82
+ const baseFields = resolveSourceSchema(view.base, config, store).fields;
83
+ for (const field of baseFields)
78
84
  fields.set(field.name, field);
85
+ const aliased = (name, source, nullable) => {
86
+ const { title: _title, ...rest } = source ?? {
87
+ name,
88
+ type: 'STRING',
89
+ nullable: true,
90
+ generated: false,
91
+ };
92
+ return { ...rest, name, nullable: nullable ?? rest.nullable, generated: false };
93
+ };
94
+ for (const [name, sourceField] of Object.entries(view.select ?? {})) {
95
+ const source = baseFields.find((field) => field.name === sourceField);
96
+ fields.set(name, aliased(name, source, undefined));
79
97
  }
80
- for (const join of view.joins ?? []) {
98
+ for (const join of plan.joins) {
81
99
  const joined = resolveSourceSchema(join.table, config, store).fields;
82
- for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
100
+ const nullable = join.alwaysMatched ? undefined : true;
101
+ for (const [name, sourceField] of Object.entries(join.def.select ?? {})) {
83
102
  const source = joined.find((field) => field.name === sourceField);
84
- const { title: _title, ...rest } = source ?? {
85
- name: alias,
86
- type: 'STRING',
87
- nullable: true,
88
- generated: false,
89
- };
90
- fields.set(alias, { ...rest, name: alias, nullable: true, generated: false });
103
+ fields.set(name, aliased(name, source, nullable));
91
104
  }
92
105
  }
93
106
  const derivedNames = Object.keys(view.derived ?? {});
@@ -284,6 +297,8 @@ function sourceFieldNames(source, config, store) {
284
297
  if (!base)
285
298
  return view.fields;
286
299
  const names = new Set(base);
300
+ for (const alias of Object.keys(view.select ?? {}))
301
+ names.add(alias);
287
302
  for (const join of view.joins ?? []) {
288
303
  for (const alias of Object.keys(join.select ?? {}))
289
304
  names.add(alias);
@@ -1,4 +1,9 @@
1
1
  import type { Row, TableDef, ViewDef } from '../types.ts';
2
+ import { type PlannedAntiJoin, type PlannedJoin, type ViewEntry } from './views.ts';
3
+ export interface ViewRowsSource {
4
+ base?: Row[];
5
+ rowsOf?: (entry: PlannedJoin | PlannedAntiJoin) => Row[] | undefined;
6
+ }
2
7
  type Pk = string;
3
8
  export declare class Store {
4
9
  private tables;
@@ -8,6 +13,7 @@ export declare class Store {
8
13
  private clock;
9
14
  private seedRows;
10
15
  private removedRows;
16
+ private previousRows;
11
17
  readonly views: Map<string, ViewDef>;
12
18
  registerTable(tableName: string, definition: TableDef): this;
13
19
  private counterOf;
@@ -17,6 +23,7 @@ export declare class Store {
17
23
  private stampSeedRow;
18
24
  private stampNewRow;
19
25
  private noteRemoved;
26
+ private notePrevious;
20
27
  private observeGeneratedValues;
21
28
  nextSequenceValue(sequencePrefix: string, width: number): string;
22
29
  private generateSequenceValue;
@@ -30,9 +37,12 @@ export declare class Store {
30
37
  updateRow(tableName: string, pk: Pk, patch: Row): Row | undefined;
31
38
  deleteRow(tableName: string, pk: Pk): boolean;
32
39
  takeRemovedRows(tableName: string, key: Pk): Row[];
40
+ takePreviousRecord(tableName: string, row: Row): Row | undefined;
33
41
  takeRemovedRecordsFor(resourceName: string, row: Row): Row[];
34
42
  getViewRows(viewName: string, tables?: Record<string, Row[]>): Row[];
35
- private materializeView;
43
+ getViewRowsFrom(viewName: string, source: ViewRowsSource): Row[];
44
+ getViewEntriesFrom(viewName: string, source: ViewRowsSource): ViewEntry[];
45
+ private viewOf;
36
46
  getResourceRows(resourceName: string): Row[];
37
47
  getMetadataRows(resourceName: string): Row[];
38
48
  getPkFields(resourceName: string): string[];
package/dist/db/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // In-memory tables + views + sequence generation. No persistence, no external deps.
2
2
  // This is the "database" — disposable and rebuilt from seed data on every server start.
3
3
  import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from "./identity.js";
4
+ import { materializeEntries, planView, } from "./views.js";
4
5
  // The generators of a table, the key's own sequence first. A SEQUENCE with
5
6
  // neither its own prefix nor the table's can't mint anything and is left to
6
7
  // the caller.
@@ -82,6 +83,10 @@ export class Store {
82
83
  // MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
83
84
  // for handlers that delete without ever broadcasting.
84
85
  removedRows = new Map();
86
+ // Per table, each record a modify changed since a broadcast last announced
87
+ // it, as it was before the first such modify (by RECORD_ID) — see
88
+ // takePreviousRecord. Bounded like removedRows.
89
+ previousRows = new Map();
85
90
  views = new Map();
86
91
  registerTable(tableName, definition) {
87
92
  const { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [], } = definition;
@@ -109,6 +114,7 @@ export class Store {
109
114
  this.tables.set(tableName, table);
110
115
  this.tableConfig.set(tableName, config);
111
116
  this.removedRows.delete(tableName);
117
+ this.previousRows.delete(tableName);
112
118
  this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
113
119
  // Each counter starts after the highest value the seed rows carry (two
114
120
  // fields of one table sharing a prefix share the counter). The key's
@@ -145,7 +151,9 @@ export class Store {
145
151
  }
146
152
  return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
147
153
  }
154
+ // Throws on a join model GSF would refuse to compile (see planView).
148
155
  registerView(viewName, definition) {
156
+ planView(viewName, definition);
149
157
  this.views.set(viewName, definition);
150
158
  return this;
151
159
  }
@@ -196,6 +204,21 @@ export class Store {
196
204
  if (removed.size > MAX_REMOVED_KEYS)
197
205
  removed.delete(removed.keys().next().value);
198
206
  }
207
+ notePrevious(tableName, row) {
208
+ const id = recordKey(row);
209
+ if (id === undefined)
210
+ return;
211
+ let previous = this.previousRows.get(tableName);
212
+ if (!previous) {
213
+ previous = new Map();
214
+ this.previousRows.set(tableName, previous);
215
+ }
216
+ if (previous.has(id))
217
+ return;
218
+ previous.set(id, row);
219
+ if (previous.size > MAX_REMOVED_KEYS)
220
+ previous.delete(previous.keys().next().value);
221
+ }
199
222
  // A supplied value inside a generator's own range must advance its counter
200
223
  // too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
201
224
  // 2, and the next generated insert mints 'TR002' again and silently
@@ -326,6 +349,7 @@ export class Store {
326
349
  if (!table || !config || !table.has(pk))
327
350
  return undefined;
328
351
  const existing = table.get(pk);
352
+ this.notePrevious(tableName, existing);
329
353
  const updated = { ...existing, ...patch };
330
354
  updated[RECORD_ID] = existing[RECORD_ID];
331
355
  updated[TIMESTAMP] = this.clock.next();
@@ -364,61 +388,57 @@ export class Store {
364
388
  removed?.delete(key);
365
389
  return records;
366
390
  }
391
+ // Reads and clears how a record was before the modifies no broadcast has
392
+ // announced yet changed it (undefined when none did): with the removed
393
+ // records, what a live change to a joined table is diffed against.
394
+ takePreviousRecord(tableName, row) {
395
+ const id = recordKey(row);
396
+ const previous = this.previousRows.get(tableName);
397
+ if (id === undefined || !previous)
398
+ return undefined;
399
+ const record = previous.get(id);
400
+ previous.delete(id);
401
+ return record;
402
+ }
367
403
  // The same, for a row of a table or view given by its (base) pk fields —
368
404
  // what a public broadcast(resource, 'DELETE', pkStub) names.
369
405
  takeRemovedRecordsFor(resourceName, row) {
370
406
  const located = this.locateRecord(resourceName, row);
371
407
  return located ? this.takeRemovedRows(located.tableName, located.key) : [];
372
408
  }
373
- // Materializes a view: base table left-joined to related tables (optionally
374
- // narrowed by per-join `where` predicates), minus anti-joined rows, plus
375
- // derived fields. `tables` substitutes the rows of any table the view reads
376
- // (its base or a joined one) — e.g. one base row, to build that row's view
377
- // row, or a table's earlier state, to rebuild the view as it was.
409
+ // Materializes a view (see the README's "Views: the join model"): base
410
+ // rows minus anti-joined ones, joined in order — OUTER keeps a row with no
411
+ // match, INNER drops it — plus the select aliases and derived fields.
412
+ // `tables` substitutes the rows of any table the view reads (its base or a
413
+ // joined one) — e.g. a table's earlier state, to rebuild the view as it
414
+ // was.
378
415
  getViewRows(viewName, tables = {}) {
416
+ const rowsOf = (tableName) => Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName);
417
+ return this.getViewRowsFrom(viewName, {
418
+ base: rowsOf(this.viewOf(viewName).base),
419
+ rowsOf: (entry) => rowsOf(entry.table),
420
+ });
421
+ }
422
+ // The same, with the base rows given apart from the joined ones — e.g. one
423
+ // base row, to build that row's view row when the base table is also joined
424
+ // — and a join's or anti-join's rows given by its planned entry (undefined:
425
+ // the table as it is).
426
+ getViewRowsFrom(viewName, source) {
427
+ return this.getViewEntriesFrom(viewName, source).map((entry) => entry.row);
428
+ }
429
+ // The same, each row with the row every join matched (in join order).
430
+ getViewEntriesFrom(viewName, source) {
431
+ const view = this.viewOf(viewName);
432
+ return materializeEntries(viewName, view, {
433
+ base: source.base ?? this.getAllRows(view.base),
434
+ rowsOf: (entry) => source.rowsOf?.(entry) ?? this.getAllRows(entry.table),
435
+ });
436
+ }
437
+ viewOf(viewName) {
379
438
  const view = this.views.get(viewName);
380
439
  if (!view)
381
440
  throw new Error(`Unknown view: ${viewName}`);
382
- return this.materializeView(view, (tableName) => Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName));
383
- }
384
- materializeView(view, getRows) {
385
- let rows = getRows(view.base).map((row) => ({ ...row }));
386
- for (const antiJoin of view.antiJoins ?? []) {
387
- const joinRows = getRows(antiJoin.table);
388
- rows = rows.filter((row) => !joinRows.some((joinRow) => joinRow[antiJoin.on[1]] === row[antiJoin.on[0]] &&
389
- (antiJoin.where?.(joinRow, row) ?? true)));
390
- }
391
- for (const join of view.joins ?? []) {
392
- const joinRows = getRows(join.table);
393
- // A `where` predicate sees the base row, so an index keyed on the `on`
394
- // field alone can't answer it — scan per base row instead.
395
- const joinIndex = join.where ? undefined : new Map();
396
- if (joinIndex) {
397
- for (const row of joinRows) {
398
- joinIndex.set(row[join.on[1]], row);
399
- }
400
- }
401
- rows = rows.map((row) => {
402
- const match = joinIndex
403
- ? joinIndex.get(row[join.on[0]])
404
- : joinRows.find((joinRow) => joinRow[join.on[1]] === row[join.on[0]] && join.where(joinRow, row));
405
- const merged = { ...row };
406
- for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
407
- merged[alias] = match?.[sourceField];
408
- }
409
- return merged;
410
- });
411
- }
412
- if (view.derived) {
413
- rows = rows.map((row) => {
414
- const merged = { ...row };
415
- for (const [field, compute] of Object.entries(view.derived)) {
416
- merged[field] = compute(row);
417
- }
418
- return merged;
419
- });
420
- }
421
- return rows;
441
+ return view;
422
442
  }
423
443
  // Resolves a queryable resource (table or view) by name to its current rows.
424
444
  getResourceRows(resourceName) {
@@ -439,9 +459,13 @@ export class Store {
439
459
  if (live.length > 0)
440
460
  return live.map(withoutRecordFields);
441
461
  const view = this.views.get(resourceName);
462
+ const seedOf = (tableName) => this.seedRows.get(tableName) ?? [];
442
463
  const seed = view
443
- ? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
444
- : (this.seedRows.get(resourceName) ?? []);
464
+ ? this.getViewRowsFrom(resourceName, {
465
+ base: seedOf(view.base),
466
+ rowsOf: (entry) => seedOf(entry.table),
467
+ })
468
+ : seedOf(resourceName);
445
469
  return seed.map(withoutRecordFields);
446
470
  }
447
471
  // Row-identity fields for a table or view; empty when unknown.
@@ -0,0 +1,42 @@
1
+ import type { AntiJoinDef, JoinDef, Row, ViewDef } from '../types.ts';
2
+ export type PlannedLink = {
3
+ kind: 'field';
4
+ fromAlias: string | undefined;
5
+ from: string;
6
+ to: string;
7
+ } | {
8
+ kind: 'value';
9
+ to: string;
10
+ value: unknown;
11
+ };
12
+ export interface PlannedJoin {
13
+ def: JoinDef;
14
+ table: string;
15
+ alias: string | undefined;
16
+ fromAlias: string;
17
+ links: PlannedLink[];
18
+ inner: boolean;
19
+ backwardsJoin: boolean;
20
+ alwaysMatched: boolean;
21
+ }
22
+ export interface PlannedAntiJoin {
23
+ def: AntiJoinDef;
24
+ table: string;
25
+ links: PlannedLink[];
26
+ }
27
+ export interface ViewPlan {
28
+ baseAlias: string;
29
+ joins: PlannedJoin[];
30
+ antiJoins: PlannedAntiJoin[];
31
+ }
32
+ export declare function planView(viewName: string, view: ViewDef): ViewPlan;
33
+ export interface ViewRowSource {
34
+ base: Row[];
35
+ rowsOf: (entry: PlannedJoin | PlannedAntiJoin) => Row[];
36
+ }
37
+ export interface ViewEntry {
38
+ row: Row;
39
+ matches: Array<Row | undefined>;
40
+ }
41
+ export declare function materializeEntries(viewName: string, view: ViewDef, source: ViewRowSource): ViewEntry[];
42
+ export declare function entriesReading(plan: ViewPlan, tableName: string): Array<PlannedJoin | PlannedAntiJoin>;