@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/dist/db/views.js
ADDED
|
@@ -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
|
|
205
|
-
//
|
|
206
|
-
// the
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
70
|
+
where?: (joinRow: Row, viewRow: Row, sources: ViewRowSources) => boolean;
|
|
52
71
|
}
|
|
53
72
|
export interface AntiJoinDef {
|
|
54
73
|
table: string;
|
|
55
|
-
on: [
|
|
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
|
-
|
|
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
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
|
|
141
|
-
// selected columns typed from the joined table
|
|
142
|
-
//
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
176
|
+
for (const join of plan.joins) {
|
|
156
177
|
const joined = resolveSourceSchema(join.table, config, store).fields;
|
|
157
|
-
|
|
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
|
-
|
|
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
|
}
|