@genesislcap/mock-server 15.60.0 → 15.62.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 +287 -40
- package/dist/db/criteria.d.ts +28 -9
- package/dist/db/criteria.js +856 -258
- package/dist/db/criteriaDialect.d.ts +6 -0
- package/dist/db/criteriaDialect.js +441 -0
- package/dist/db/criteriaTokens.d.ts +12 -0
- package/dist/db/criteriaTokens.js +53 -0
- package/dist/db/groovySyntax.d.ts +26 -0
- package/dist/db/groovySyntax.js +481 -0
- package/dist/db/groovyValidator.d.ts +11 -0
- package/dist/db/groovyValidator.js +523 -0
- package/dist/db/gsfExpr.d.ts +21 -0
- package/dist/db/gsfExpr.js +333 -0
- package/dist/db/identity.d.ts +2 -1
- package/dist/db/identity.js +24 -3
- package/dist/db/legacyCriteria.d.ts +7 -0
- package/dist/db/legacyCriteria.js +280 -0
- package/dist/db/ordering.d.ts +2 -0
- package/dist/db/ordering.js +24 -3
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +34 -3
- package/dist/db/store.d.ts +7 -0
- package/dist/db/store.js +55 -7
- package/dist/db/values.d.ts +19 -0
- package/dist/db/values.js +243 -0
- package/dist/handlers/commitEvent.js +3 -2
- package/dist/handlers/dataLogon.d.ts +2 -1
- package/dist/handlers/dataLogon.js +108 -51
- package/dist/handlers/eventValidation.d.ts +2 -1
- package/dist/handlers/eventValidation.js +33 -6
- package/dist/handlers/requestReply.js +43 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/connection.d.ts +1 -1
- package/dist/server.js +15 -8
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +979 -290
- package/src/db/criteriaDialect.ts +487 -0
- package/src/db/criteriaTokens.ts +72 -0
- package/src/db/groovySyntax.ts +500 -0
- package/src/db/groovyValidator.ts +546 -0
- package/src/db/gsfExpr.ts +392 -0
- package/src/db/identity.ts +22 -4
- package/src/db/legacyCriteria.ts +301 -0
- package/src/db/ordering.ts +27 -3
- package/src/db/schema.ts +43 -8
- package/src/db/store.ts +65 -7
- package/src/db/values.ts +247 -0
- package/src/handlers/commitEvent.ts +3 -2
- package/src/handlers/dataLogon.ts +134 -56
- package/src/handlers/eventValidation.ts +40 -13
- package/src/handlers/requestReply.ts +47 -14
- package/src/index.ts +6 -1
- package/src/protocol/connection.ts +3 -4
- package/src/server.ts +16 -15
- package/src/types.ts +7 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { compileCriteria, criteriaCompileRefusal, criteriaFields, filterByCriteria, } from "../db/criteria.js";
|
|
2
|
+
import { strictCriteriaRefusal } from "../db/criteriaDialect.js";
|
|
3
|
+
import { byRecordId, rowComparator } from "../db/ordering.js";
|
|
4
|
+
import { isDeclared, queryCriteriaTypes, queryFieldNames, querySchema } from "../db/schema.js";
|
|
5
|
+
import { bigDecimalFields, encodeRow } from "../db/values.js";
|
|
4
6
|
import { isLegacyFidelity } from "../protocol/fidelity.js";
|
|
5
7
|
import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
|
|
6
8
|
import { nonExistentSubscriptionNack, resourceNotMappedNack, subscriptionAlreadyExistsNack, } from "../protocol/msgNack.js";
|
|
@@ -87,6 +89,53 @@ function requestedFieldNames(details, query, exposed, config, store) {
|
|
|
87
89
|
function indexComparator(index) {
|
|
88
90
|
return rowComparator(index.fields.map((field) => ({ field, descending: false })));
|
|
89
91
|
}
|
|
92
|
+
// The order a snapshot and its MORE_ROWS pages come in. With ORDER_BY, the
|
|
93
|
+
// index ascending, and REVERSE flips it. Without one the dataserver reads its
|
|
94
|
+
// primary index, RECORD_ID, newest first, and REVERSE alone gives oldest
|
|
95
|
+
// first — the order the rows were written in (PrimaryKeyDispenser.kt
|
|
96
|
+
// buildPrimaryRange: `if (reverse) Order.ASC else Order.DESC`, GSF v8.15.29).
|
|
97
|
+
function snapshotOrder(index, reverse) {
|
|
98
|
+
const ascending = index ? indexComparator(index) : byRecordId;
|
|
99
|
+
const flipped = index ? reverse : !reverse;
|
|
100
|
+
return flipped ? (a, b) => ascending(b, a) : ascending;
|
|
101
|
+
}
|
|
102
|
+
// Without ORDER_BY, the index a criteria makes the dataserver read through:
|
|
103
|
+
// the query index whose leading fields the criteria uses most (the first such
|
|
104
|
+
// one on a tie), or none (CriteriaFilterBuilder.findBestMatchingIndex with no
|
|
105
|
+
// default index; QueryClientFactory.build adopts it as the client's index,
|
|
106
|
+
// GSF v8.15.29). The rows then come in that index's order, as under ORDER_BY.
|
|
107
|
+
function criteriaIndex(query, fields) {
|
|
108
|
+
let best;
|
|
109
|
+
let bestCount = 0;
|
|
110
|
+
for (const index of query.indexes ?? []) {
|
|
111
|
+
const unmatched = index.fields.findIndex((field) => !fields.includes(field));
|
|
112
|
+
const count = unmatched < 0 ? index.fields.length : unmatched;
|
|
113
|
+
if (count > bestCount)
|
|
114
|
+
[best, bestCount] = [index, count];
|
|
115
|
+
}
|
|
116
|
+
return best;
|
|
117
|
+
}
|
|
118
|
+
// A flag as GenesisSet.getBoolean reads it: true, or anything whose text is
|
|
119
|
+
// "true" in any case (obj.toString().toBoolean()).
|
|
120
|
+
function readBoolean(value) {
|
|
121
|
+
return value === true || (value !== undefined && value !== null && /^true$/i.test(String(value)));
|
|
122
|
+
}
|
|
123
|
+
// How a query's rows go on the wire: each BIGDECIMAL column as a string with
|
|
124
|
+
// its scale, as GSF's serializer writes a BigDecimal (db/values.ts).
|
|
125
|
+
// undefined when the query has none — known without resolving the schema
|
|
126
|
+
// for an undeclared source with no BIGDECIMAL fieldTypes or derived types.
|
|
127
|
+
export function rowEncoder(query, config, store) {
|
|
128
|
+
const view = store.views.get(query.source) ?? config.views?.[query.source];
|
|
129
|
+
const overridden = [
|
|
130
|
+
...Object.values(query.fieldTypes ?? {}),
|
|
131
|
+
...Object.values(view?.derivedTypes ?? {}).map((entry) => typeof entry === 'string' ? entry : entry.type),
|
|
132
|
+
];
|
|
133
|
+
if (!isDeclared(query.source, config, store) && !overridden.includes('BIGDECIMAL')) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const fields = bigDecimalFields(querySchema(query, config, store).fields);
|
|
137
|
+
return fields && ((row) => encodeRow(row, fields));
|
|
138
|
+
}
|
|
90
139
|
// DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
|
|
91
140
|
// LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
|
|
92
141
|
// registered so MORE_ROWS can page the rest and later inserts/amends/deletes
|
|
@@ -121,27 +170,59 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
121
170
|
// criteria may name and all a row carries; DETAILS.FIELDS narrows the rows.
|
|
122
171
|
// Legacy subscriptions ignore all of it, and ORDER_BY, as 15.47 did.
|
|
123
172
|
const exposed = queryFieldNames(query, config, store);
|
|
173
|
+
// Legacy subscriptions keep the store's insertion order.
|
|
124
174
|
let compare;
|
|
125
|
-
|
|
175
|
+
// How the criteria reads the rows: by the fields' declared types, as GSF
|
|
176
|
+
// compiles it against its entity; legacy as up to 15.47.
|
|
177
|
+
const criteriaOptions = legacy
|
|
178
|
+
? { lenient: true }
|
|
179
|
+
: { types: queryCriteriaTypes(query, config, store) };
|
|
126
180
|
if (!legacy) {
|
|
181
|
+
// ORDER_BY names one of the query's own indexes (QueryDef.indexes). GSF
|
|
182
|
+
// checks it before it looks at the criteria
|
|
183
|
+
// (ConnectionManager.verifyClientOptions, v8.15.29).
|
|
184
|
+
let index;
|
|
185
|
+
if (details.ORDER_BY) {
|
|
186
|
+
index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
|
|
187
|
+
if (!index) {
|
|
188
|
+
connection.reply(message, logonNack('INVALID_INDEX', `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// GSF parses the criteria as Groovy first, validates it and compiles it,
|
|
193
|
+
// refusing one that fails with the message (CriteriaFilterBuilder
|
|
194
|
+
// getCriteriaFilter, v8.15.29): no subscription opens. Refused here: the
|
|
195
|
+
// syntax errors Groovy would report too, and this engine's own dialect —
|
|
196
|
+
// AND / OR / NOT, `<>`, a single `=`, dotted names, `$` in double quotes,
|
|
197
|
+
// calls off GSF's whitelists (db/criteriaDialect.ts). A criteria outside
|
|
198
|
+
// this engine's grammar that
|
|
199
|
+
// may well be valid Groovy still fails open below.
|
|
200
|
+
const refusal = typeof criteria === 'string'
|
|
201
|
+
? strictCriteriaRefusal(criteria, exposed && new Set(exposed), criteriaOptions.types)
|
|
202
|
+
: undefined;
|
|
203
|
+
if (refusal) {
|
|
204
|
+
connection.reply(message, logonNack('INVALID_CRITERIA', refusal));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const parsedFields = criteriaFields(criteria);
|
|
127
208
|
// The real dataserver lets a criteria read only the query's own fields
|
|
128
209
|
// (QueryClientFactory.kt). Unknown columns are only checked when the
|
|
129
210
|
// query's fields are known; an inferred query fails open as before.
|
|
130
|
-
const outside = exposed &&
|
|
211
|
+
const outside = exposed && parsedFields?.find((field) => !exposed.includes(field));
|
|
131
212
|
if (outside) {
|
|
132
213
|
connection.reply(message, logonNack('INVALID_CRITERIA', `GenericCriteriaValidationException : Criteria validation failed: Variable ${outside} is not allowed in criteria expression`));
|
|
133
214
|
return;
|
|
134
215
|
}
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
reverse = details.REVERSE === true;
|
|
216
|
+
// Then GSF compiles it, refusing a call no overload of the function
|
|
217
|
+
// takes (db/criteria.ts criteriaCompileRefusal).
|
|
218
|
+
const uncompiled = typeof criteria === 'string'
|
|
219
|
+
? criteriaCompileRefusal(criteria, criteriaOptions.types)
|
|
220
|
+
: undefined;
|
|
221
|
+
if (uncompiled) {
|
|
222
|
+
connection.reply(message, logonNack('INVALID_CRITERIA', uncompiled));
|
|
223
|
+
return;
|
|
144
224
|
}
|
|
225
|
+
compare = snapshotOrder(index ?? criteriaIndex(query, parsedFields ?? []), readBoolean(details.REVERSE));
|
|
145
226
|
}
|
|
146
227
|
// Order matters: the query's own filter (the where-clause equivalent) and
|
|
147
228
|
// the user's row permissioning narrow the set BEFORE the client's
|
|
@@ -156,12 +237,9 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
156
237
|
// The query's own filter is the where clause the index is built over, so
|
|
157
238
|
// the count is taken after it — an assumption, as no recording covers it.
|
|
158
239
|
const rowsCount = rows.length;
|
|
159
|
-
rows = filterByCriteria(rows, criteria);
|
|
160
|
-
if (compare)
|
|
240
|
+
rows = filterByCriteria(rows, criteria, criteriaOptions);
|
|
241
|
+
if (compare)
|
|
161
242
|
rows = [...rows].sort(compare);
|
|
162
|
-
if (reverse)
|
|
163
|
-
rows.reverse();
|
|
164
|
-
}
|
|
165
243
|
// Permissioning is applied per row so the ROW_REF can still be computed from
|
|
166
244
|
// the UNSTRIPPED row — pk fields may themselves be hidden from this user,
|
|
167
245
|
// and a snapshot row with no ROW_REF can never be matched to the later
|
|
@@ -172,25 +250,23 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
172
250
|
const fieldNames = legacy
|
|
173
251
|
? undefined
|
|
174
252
|
: requestedFieldNames(details, query, exposed, config, store);
|
|
253
|
+
// A BigDecimal goes out as a string with its column's scale (db/values.ts).
|
|
254
|
+
const encode = legacy ? undefined : rowEncoder(query, config, store);
|
|
175
255
|
const stampedRows = [];
|
|
176
|
-
// Each sent-or-waiting row as stored, by pending key, for ORDER_BY's live
|
|
177
|
-
// INSERT placement below.
|
|
178
|
-
const rawRows = new Map();
|
|
179
256
|
for (const row of rows) {
|
|
180
257
|
const permitted = permittedQueryRow(row, query.auth, user, fieldNames);
|
|
181
258
|
if (!permitted)
|
|
182
259
|
continue;
|
|
183
260
|
const rowRef = getRowRef(row);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
rawRows.set(key, row);
|
|
187
|
-
stampedRows.push(stampRow(dataserverRow(permitted, row, query.fields), rowRef, 'INSERT', legacy));
|
|
261
|
+
const sent = dataserverRow(permitted, row, query.fields);
|
|
262
|
+
stampedRows.push(stampRow(encode ? encode(sent) : sent, rowRef, 'INSERT', legacy));
|
|
188
263
|
}
|
|
189
264
|
const subscription = {
|
|
190
265
|
resourceName,
|
|
191
266
|
getRowRef,
|
|
192
|
-
predicate: compileCriteria(criteria),
|
|
267
|
+
predicate: compileCriteria(criteria, criteriaOptions),
|
|
193
268
|
sequenceId: 1,
|
|
269
|
+
...(encode ? { encode } : {}),
|
|
194
270
|
};
|
|
195
271
|
if (legacy) {
|
|
196
272
|
connection.subscribe(sourceRef, subscription);
|
|
@@ -207,6 +283,11 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
207
283
|
// MAX_ROWS of them into `held`. Rows the view has no room for (past
|
|
208
284
|
// MAX_VIEW) keep waiting, so MORE_ROWS stays true — as on the real server,
|
|
209
285
|
// which then answers every MORE_ROWS with an empty {MORE_ROWS: true} page.
|
|
286
|
+
// A live INSERT is pushed straight away whatever the order, even when it
|
|
287
|
+
// sorts among the rows still waiting, and a later page doesn't repeat it:
|
|
288
|
+
// the real ClientIndex.processInsertUpdate sends it as soon as it arrives,
|
|
289
|
+
// and NextRowsStrategy skips rows the client index already holds (GSF
|
|
290
|
+
// v8.15.29). See server.ts trackClientView.
|
|
210
291
|
const { maxRows, maxView } = pagingOptions(details);
|
|
211
292
|
const tracked = {
|
|
212
293
|
...subscription,
|
|
@@ -216,30 +297,6 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
216
297
|
maxView,
|
|
217
298
|
fieldNames,
|
|
218
299
|
};
|
|
219
|
-
// Under ORDER_BY a live INSERT that sorts among the rows still waiting for
|
|
220
|
-
// MORE_ROWS joins them in order rather than being pushed out of order; one
|
|
221
|
-
// that sorts before them lands in what the client has, and is pushed. (A
|
|
222
|
-
// MODIFY of a waiting row keeps its place.)
|
|
223
|
-
if (compare) {
|
|
224
|
-
const ascending = compare;
|
|
225
|
-
const sortCompare = reverse ? (a, b) => ascending(b, a) : ascending;
|
|
226
|
-
tracked.queueInsert = (key, stamped, row) => {
|
|
227
|
-
const waiting = [...tracked.pending];
|
|
228
|
-
const first = waiting[0] && rawRows.get(waiting[0][0]);
|
|
229
|
-
if (!first || sortCompare(row, first) < 0)
|
|
230
|
-
return false;
|
|
231
|
-
const at = waiting.findIndex(([waitingKey]) => {
|
|
232
|
-
const other = rawRows.get(waitingKey);
|
|
233
|
-
return other !== undefined && sortCompare(row, other) < 0;
|
|
234
|
-
});
|
|
235
|
-
waiting.splice(at < 0 ? waiting.length : at, 0, [key, stamped]);
|
|
236
|
-
tracked.pending.clear();
|
|
237
|
-
for (const [waitingKey, waitingRow] of waiting)
|
|
238
|
-
tracked.pending.set(waitingKey, waitingRow);
|
|
239
|
-
rawRows.set(key, row);
|
|
240
|
-
return true;
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
300
|
const firstPage = takePage(tracked);
|
|
244
301
|
const pending = tracked.pending;
|
|
245
302
|
connection.subscribe(sourceRef, tracked);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Store } from '../db/store.ts';
|
|
2
2
|
import type { Connection } from '../protocol/connection.ts';
|
|
3
3
|
import type { NackErrorInput } from '../protocol/errors.ts';
|
|
4
|
-
import type { GenesisMessage, MockServerConfig } from '../types.ts';
|
|
4
|
+
import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
|
|
5
5
|
export declare function validateEventMessage(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): NackErrorInput[];
|
|
6
|
+
export declare function eventDetails(message: GenesisMessage, config: MockServerConfig, store: Store): Row;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { eventFields, isDeclared, resolveEventTarget } from "../db/schema.js";
|
|
2
|
+
import { eventValue } from "../db/values.js";
|
|
2
3
|
import { schemaFieldErrors, validateSchema } from "../protocol/schemaValidation.js";
|
|
3
4
|
import { eventInboundSchema } from "./jsonSchema.js";
|
|
4
5
|
// The schema check every event goes through on the real server before its
|
|
@@ -14,16 +15,42 @@ import { eventInboundSchema } from "./jsonSchema.js";
|
|
|
14
15
|
// real server accepts.
|
|
15
16
|
export function validateEventMessage(message, connection, config, store) {
|
|
16
17
|
const name = message.MESSAGE_TYPE;
|
|
17
|
-
const
|
|
18
|
-
if (!target)
|
|
19
|
-
return [];
|
|
20
|
-
const declared = target.kind === 'dto' ||
|
|
21
|
-
(target.table !== undefined && isDeclared(target.table, config, store));
|
|
18
|
+
const declared = declaredEvent(name, config, store);
|
|
22
19
|
if (!declared)
|
|
23
20
|
return [];
|
|
24
|
-
const inbound = eventInboundSchema(name, target,
|
|
21
|
+
const inbound = eventInboundSchema(name, declared.target, declared.fields, config.appName);
|
|
25
22
|
// The router stamps the session's user on everything it forwards, so the
|
|
26
23
|
// schema's required USER_NAME is always there by the time it is checked.
|
|
27
24
|
const forwarded = { ...message, USER_NAME: connection.userName ?? '' };
|
|
28
25
|
return schemaFieldErrors(validateSchema(inbound, forwarded));
|
|
29
26
|
}
|
|
27
|
+
// The event's target and DETAILS fields, when its DETAILS class is declared.
|
|
28
|
+
function declaredEvent(name, config, store) {
|
|
29
|
+
const target = resolveEventTarget(name, config);
|
|
30
|
+
if (!target)
|
|
31
|
+
return undefined;
|
|
32
|
+
const declared = target.kind === 'dto' ||
|
|
33
|
+
(target.table !== undefined && isDeclared(target.table, config, store));
|
|
34
|
+
return declared ? { target, fields: eventFields(target, config, store) } : undefined;
|
|
35
|
+
}
|
|
36
|
+
// DETAILS as the handler gets them, after the schema check: a declared
|
|
37
|
+
// BigDecimal field's string is a number, as GSF hands its handler a
|
|
38
|
+
// deserialized BigDecimal (db/values.ts eventValue). Anything else, and any
|
|
39
|
+
// undeclared event's DETAILS, as sent.
|
|
40
|
+
export function eventDetails(message, config, store) {
|
|
41
|
+
const details = message.DETAILS ?? {};
|
|
42
|
+
const declared = declaredEvent(message.MESSAGE_TYPE, config, store);
|
|
43
|
+
if (!declared)
|
|
44
|
+
return details;
|
|
45
|
+
let converted;
|
|
46
|
+
for (const field of declared.fields) {
|
|
47
|
+
if (!Object.hasOwn(details, field.name))
|
|
48
|
+
continue;
|
|
49
|
+
const value = eventValue(field, details[field.name]);
|
|
50
|
+
if (value === details[field.name])
|
|
51
|
+
continue;
|
|
52
|
+
converted ??= { ...details };
|
|
53
|
+
converted[field.name] = value;
|
|
54
|
+
}
|
|
55
|
+
return converted ?? details;
|
|
56
|
+
}
|
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
// there are more rows.
|
|
15
15
|
//
|
|
16
16
|
// See the README's "Request servers" for every rule and its evidence.
|
|
17
|
-
import { CriteriaSyntaxError, filterByCriteria, parseCriteria, parseGenesisDateTime, UnsupportedCriteriaError, } from "../db/criteria.js";
|
|
17
|
+
import { criteriaCompileRefusal, CriteriaSyntaxError, filterByCriteria, parseCriteria, parseGenesisDateTime, UnsupportedCriteriaError, } from "../db/criteria.js";
|
|
18
|
+
import { strictCriteriaRefusal } from "../db/criteriaDialect.js";
|
|
19
|
+
import { checkGroovySyntax } from "../db/groovySyntax.js";
|
|
18
20
|
import { withoutRecordFields } from "../db/identity.js";
|
|
19
21
|
import { compareValues, parseOrderBy, rowComparator } from "../db/ordering.js";
|
|
20
22
|
import { pkFieldsOf, primaryKeyIndexName, projectRow, requestReplyFieldNames, requestReplySourceSchema, } from "../db/schema.js";
|
|
23
|
+
import { bigDecimalFields, decimalText, encodeRow } from "../db/values.js";
|
|
21
24
|
import { ErrorCode, NackError, nackPayload } from "../protocol/errors.js";
|
|
22
25
|
import { isLegacyFidelity } from "../protocol/fidelity.js";
|
|
23
26
|
import { daoClassOf, viewEntityClassOf } from "../protocol/fieldTypes.js";
|
|
@@ -60,6 +63,9 @@ function sourceOf(requestReply, config, store) {
|
|
|
60
63
|
name,
|
|
61
64
|
fields: new Map(schema.fields.map((field) => [field.name, field])),
|
|
62
65
|
pkFields: store.getPkFields(name),
|
|
66
|
+
...(schema.declared
|
|
67
|
+
? { types: new Map(schema.fields.map((field) => [field.name, field.type])) }
|
|
68
|
+
: {}),
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
function isView(name, config, store) {
|
|
@@ -71,30 +77,48 @@ function parametricTypeOf(name, config, store) {
|
|
|
71
77
|
return isView(name, config, store) ? viewEntityClassOf(name) : daoClassOf(name);
|
|
72
78
|
}
|
|
73
79
|
const byPrimaryKey = (source) => source.pkFields.map((field) => ({ field, descending: false }));
|
|
80
|
+
// The REP_ rows as they go on the wire: each BigDecimal a string with its
|
|
81
|
+
// column's scale, as GSF's serializer writes it (db/values.ts).
|
|
82
|
+
function wireRows(rows, source) {
|
|
83
|
+
const fields = bigDecimalFields([...source.fields.values()]);
|
|
84
|
+
return fields ? rows.map((row) => encodeRow(row, fields)) : rows;
|
|
85
|
+
}
|
|
74
86
|
// CRITERIA_MATCH against the entity. A malformed one fails as `onSyntaxError`
|
|
75
|
-
// says (the two server kinds differ)
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
87
|
+
// says (the two server kinds differ): one Groovy refuses (db/groovySyntax.ts),
|
|
88
|
+
// or one this grammar can't parse that the Groovy check can't vouch for
|
|
89
|
+
// either. A criteria naming a field the entity doesn't have matches nothing:
|
|
90
|
+
// GSF evaluates it outside the database, where the unknown variable never
|
|
91
|
+
// compares true (recorded: REPLY: []). Valid Groovy this grammar doesn't model
|
|
92
|
+
// (a bare boolean field, `in [...]`, a function it doesn't know) fails open
|
|
93
|
+
// (logged), as everywhere else in the engine.
|
|
80
94
|
function criteriaFilter(criteria, source, onSyntaxError) {
|
|
81
95
|
if (criteria.trim() === '')
|
|
82
96
|
return { kind: 'all' };
|
|
97
|
+
// What GSF can't compile: a syntax error Groovy reports, this engine's
|
|
98
|
+
// dialect (AND, OR, NOT, `<>`, `=`, a call off GSF's whitelist, ...), or a
|
|
99
|
+
// call no overload takes, with GSF's text.
|
|
100
|
+
const refusal = strictCriteriaRefusal(criteria, new Set(source.fields.keys()), source.types) ??
|
|
101
|
+
criteriaCompileRefusal(criteria, source.types);
|
|
102
|
+
if (refusal)
|
|
103
|
+
throw onSyntaxError(new CriteriaSyntaxError(refusal));
|
|
104
|
+
const groovy = checkGroovySyntax(criteria);
|
|
105
|
+
let parsed;
|
|
83
106
|
try {
|
|
84
|
-
|
|
85
|
-
if (fields.some((field) => !source.fields.has(field)))
|
|
86
|
-
return { kind: 'none' };
|
|
87
|
-
return { kind: 'match', predicate };
|
|
107
|
+
parsed = parseCriteria(criteria, { types: source.types });
|
|
88
108
|
}
|
|
89
109
|
catch (error) {
|
|
90
|
-
if (error instanceof CriteriaSyntaxError)
|
|
110
|
+
if (error instanceof CriteriaSyntaxError && groovy.kind !== 'valid') {
|
|
91
111
|
throw onSyntaxError(error);
|
|
92
|
-
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof CriteriaSyntaxError || error instanceof UnsupportedCriteriaError) {
|
|
93
114
|
console.warn(`[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`, error);
|
|
94
115
|
return { kind: 'all' };
|
|
95
116
|
}
|
|
96
117
|
throw error;
|
|
97
118
|
}
|
|
119
|
+
if (parsed.fields.some((field) => !source.fields.has(field)))
|
|
120
|
+
return { kind: 'none' };
|
|
121
|
+
return { kind: 'match', predicate: parsed.predicate };
|
|
98
122
|
}
|
|
99
123
|
function applyCriteria(rows, filter) {
|
|
100
124
|
if (filter.kind === 'all')
|
|
@@ -250,7 +274,7 @@ function criteriaOnlyReply(resourceName, requestReply, message, config, store, u
|
|
|
250
274
|
page = pageOf(kept, maxRows, offset, readCount - 1, !byView);
|
|
251
275
|
}
|
|
252
276
|
return {
|
|
253
|
-
REPLY: page.rows,
|
|
277
|
+
REPLY: wireRows(page.rows, source),
|
|
254
278
|
PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
|
|
255
279
|
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
256
280
|
...(byView && page.rows.length > 0 ? { NEXT_VIEW: viewNumber + 1 } : {}),
|
|
@@ -300,10 +324,13 @@ function convertValue(value, field) {
|
|
|
300
324
|
}
|
|
301
325
|
}
|
|
302
326
|
// What Kotlin's toString() makes of a row value, for wildcard matching: a
|
|
303
|
-
// Double keeps its fraction ("2.0"), a
|
|
327
|
+
// Double keeps its fraction ("2.0"), a BigDecimal its scale ("15.00000"), a
|
|
328
|
+
// date prints as ISO-8601, null is "null".
|
|
304
329
|
function kotlinString(value, field) {
|
|
305
330
|
if (value === undefined || value === null)
|
|
306
331
|
return 'null';
|
|
332
|
+
if (field?.type === 'BIGDECIMAL')
|
|
333
|
+
return decimalText(value, field.scale) ?? String(value);
|
|
307
334
|
if (typeof value === 'number') {
|
|
308
335
|
if ((field?.type === 'DATE' || field?.type === 'DATETIME') && Number.isFinite(value)) {
|
|
309
336
|
return new Date(value).toISOString();
|
|
@@ -446,7 +473,7 @@ function plainReply(resourceName, requestReply, message, config, store, user) {
|
|
|
446
473
|
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
447
474
|
SOURCE_REF: message.SOURCE_REF,
|
|
448
475
|
PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
|
|
449
|
-
REPLY: reply,
|
|
476
|
+
REPLY: wireRows(reply, source),
|
|
450
477
|
};
|
|
451
478
|
}
|
|
452
479
|
// --- resolvers (custom request servers) -------------------------------------
|
|
@@ -548,7 +575,7 @@ function legacyRequestReply(resourceName, requestReply, message, connection, sto
|
|
|
548
575
|
}
|
|
549
576
|
const criteria = message.DETAILS?.CRITERIA_MATCH ?? message.CRITERIA_MATCH;
|
|
550
577
|
if (criteria)
|
|
551
|
-
rows = filterByCriteria(rows, criteria);
|
|
578
|
+
rows = filterByCriteria(rows, criteria, { lenient: true });
|
|
552
579
|
if (requestReply.filter)
|
|
553
580
|
rows = rows.filter(requestReply.filter);
|
|
554
581
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { launchStack, type LaunchStackOptions, type LaunchedStack } from './laun
|
|
|
3
3
|
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
|
-
export { compileCriteria, filterByCriteria, type Predicate } from './db/criteria.ts';
|
|
6
|
+
export { compileCriteria, type CriteriaOptions, filterByCriteria, type Predicate, } from './db/criteria.ts';
|
|
7
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/index.js
CHANGED
|
@@ -3,4 +3,4 @@ export { launchStack } from "./launch.js";
|
|
|
3
3
|
export { Store } from "./db/store.js";
|
|
4
4
|
export { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from "./db/identity.js";
|
|
5
5
|
export { ErrorCode, NackError, statusCodeFor, } from "./protocol/errors.js";
|
|
6
|
-
export { compileCriteria, filterByCriteria } from "./db/criteria.js";
|
|
6
|
+
export { compileCriteria, filterByCriteria, } from "./db/criteria.js";
|
|
@@ -10,7 +10,7 @@ export interface Subscription {
|
|
|
10
10
|
maxRows?: number;
|
|
11
11
|
maxView?: number;
|
|
12
12
|
fieldNames?: string[];
|
|
13
|
-
|
|
13
|
+
encode?: (row: Row) => Row;
|
|
14
14
|
}
|
|
15
15
|
export declare class Connection {
|
|
16
16
|
readonly ws: SocketLike;
|
package/dist/server.js
CHANGED
|
@@ -23,7 +23,9 @@ function sameValues(a, b) {
|
|
|
23
23
|
return true;
|
|
24
24
|
}
|
|
25
25
|
export function createMockServer(config) {
|
|
26
|
-
|
|
26
|
+
// Declared columns are held by type (db/values.ts), except under
|
|
27
|
+
// fidelity: 'legacy', which keeps rows as given.
|
|
28
|
+
const store = new Store({ normalizeValues: () => !isLegacyFidelity(config) });
|
|
27
29
|
for (const [tableName, tableDef] of Object.entries(config.tables ?? {})) {
|
|
28
30
|
store.registerTable(tableName, tableDef);
|
|
29
31
|
}
|
|
@@ -51,6 +53,12 @@ export function createMockServer(config) {
|
|
|
51
53
|
return 'error';
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
// A row as one subscription sends it: its columns (dataserverRow), each
|
|
57
|
+
// BigDecimal a scaled string (Subscription.encode).
|
|
58
|
+
function wireRow(subscription, permitted, row) {
|
|
59
|
+
const sent = dataserverRow(permitted, row, config.queries?.[subscription.resourceName]?.fields);
|
|
60
|
+
return subscription.encode ? subscription.encode(sent) : sent;
|
|
61
|
+
}
|
|
54
62
|
// The operation to push to one subscription, or null to skip it entirely.
|
|
55
63
|
// A MODIFY that moved the row out of this subscription's CRITERIA_MATCH
|
|
56
64
|
// becomes a DELETE so the client drops it rather than keeping a stale ghost
|
|
@@ -79,7 +87,10 @@ export function createMockServer(config) {
|
|
|
79
87
|
// - A held row gets the MODIFY, or a DELETE when it is gone or out of scope.
|
|
80
88
|
// - A row the client doesn't hold that is (now) in scope is pushed as an
|
|
81
89
|
// INSERT and becomes held: a brand-new row, or one a MODIFY brought back
|
|
82
|
-
// into the criteria. Out of scope, the client is told nothing.
|
|
90
|
+
// into the criteria. Out of scope, the client is told nothing. The
|
|
91
|
+
// snapshot's order doesn't matter: a new row that sorts among the rows
|
|
92
|
+
// still waiting is pushed now too, as GSF's client index does, and no
|
|
93
|
+
// later page repeats it.
|
|
83
94
|
// An INSERT is pushed even when the view already holds MAX_VIEW rows; the
|
|
84
95
|
// real server would also evict the oldest row in the same frame (moving
|
|
85
96
|
// view), which this engine doesn't, so the view can grow past MAX_VIEW.
|
|
@@ -97,7 +108,7 @@ export function createMockServer(config) {
|
|
|
97
108
|
: permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
|
|
98
109
|
if (pending.has(key)) {
|
|
99
110
|
if (permitted)
|
|
100
|
-
pending.set(key, stampRow(
|
|
111
|
+
pending.set(key, stampRow(wireRow(subscription, permitted, row), rowRef, 'INSERT'));
|
|
101
112
|
else
|
|
102
113
|
pending.delete(key);
|
|
103
114
|
return null;
|
|
@@ -110,10 +121,6 @@ export function createMockServer(config) {
|
|
|
110
121
|
}
|
|
111
122
|
if (!permitted)
|
|
112
123
|
return null;
|
|
113
|
-
// Under ORDER_BY, a new row that sorts among the waiting ones waits too,
|
|
114
|
-
// so MORE_ROWS pages stay in order.
|
|
115
|
-
if (subscription.queueInsert?.(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'), row))
|
|
116
|
-
return null;
|
|
117
124
|
held.add(key);
|
|
118
125
|
return 'INSERT';
|
|
119
126
|
}
|
|
@@ -165,7 +172,7 @@ export function createMockServer(config) {
|
|
|
165
172
|
const permitted = permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
|
|
166
173
|
if (!permitted)
|
|
167
174
|
continue;
|
|
168
|
-
outRow =
|
|
175
|
+
outRow = wireRow(subscription, permitted, row);
|
|
169
176
|
}
|
|
170
177
|
subscription.sequenceId += 1;
|
|
171
178
|
// A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED