@genesislcap/mock-server 15.53.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 +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/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** —
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
615
|
-
|
|
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
|
|
723
|
-
|
|
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
|
|
1012
|
-
|
|
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
|
|
71
|
-
// selected columns typed from the joined table
|
|
72
|
-
//
|
|
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
|
-
|
|
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
|
|
98
|
+
for (const join of plan.joins) {
|
|
81
99
|
const joined = resolveSourceSchema(join.table, config, store).fields;
|
|
82
|
-
|
|
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
|
-
|
|
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);
|
package/dist/db/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
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
|
|
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.
|
|
444
|
-
|
|
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>;
|