@genesislcap/mock-server 15.52.0 → 15.53.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.
Files changed (48) hide show
  1. package/README.md +210 -27
  2. package/dist/db/criteria.d.ts +12 -0
  3. package/dist/db/criteria.js +39 -29
  4. package/dist/db/ordering.d.ts +8 -0
  5. package/dist/db/ordering.js +49 -0
  6. package/dist/db/schema.d.ts +17 -1
  7. package/dist/db/schema.js +131 -16
  8. package/dist/db/store.d.ts +8 -2
  9. package/dist/db/store.js +120 -44
  10. package/dist/handlers/commitEvent.js +32 -8
  11. package/dist/handlers/crudEvents.d.ts +2 -0
  12. package/dist/handlers/crudEvents.js +138 -0
  13. package/dist/handlers/dataLogon.js +2 -23
  14. package/dist/handlers/eventValidation.d.ts +5 -0
  15. package/dist/handlers/eventValidation.js +29 -0
  16. package/dist/handlers/jsonSchema.d.ts +3 -2
  17. package/dist/handlers/jsonSchema.js +23 -17
  18. package/dist/handlers/meta.js +13 -9
  19. package/dist/handlers/requestReply.js +518 -40
  20. package/dist/handlers/resources.js +3 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/protocol/fieldTypes.d.ts +1 -0
  23. package/dist/protocol/fieldTypes.js +6 -0
  24. package/dist/protocol/msgNack.d.ts +2 -0
  25. package/dist/protocol/msgNack.js +10 -0
  26. package/dist/protocol/schemaValidation.d.ts +11 -0
  27. package/dist/protocol/schemaValidation.js +186 -0
  28. package/dist/server.js +5 -1
  29. package/dist/types.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/db/criteria.ts +52 -27
  32. package/src/db/ordering.ts +56 -0
  33. package/src/db/schema.ts +164 -15
  34. package/src/db/store.ts +146 -46
  35. package/src/handlers/commitEvent.ts +39 -8
  36. package/src/handlers/crudEvents.ts +181 -0
  37. package/src/handlers/dataLogon.ts +2 -19
  38. package/src/handlers/eventValidation.ts +43 -0
  39. package/src/handlers/jsonSchema.ts +31 -19
  40. package/src/handlers/meta.ts +14 -9
  41. package/src/handlers/requestReply.ts +625 -41
  42. package/src/handlers/resources.ts +3 -0
  43. package/src/index.ts +1 -0
  44. package/src/protocol/fieldTypes.ts +7 -0
  45. package/src/protocol/msgNack.ts +12 -0
  46. package/src/protocol/schemaValidation.ts +208 -0
  47. package/src/server.ts +5 -1
  48. package/src/types.ts +56 -18
@@ -1,25 +1,587 @@
1
- import { filterByCriteria } from '../db/criteria.ts';
1
+ // REQ_<NAME> -> REP_<NAME>, as GSF 8.15.29's request server answers it
2
+ // (genesis-pal-requestserver). A request server is one of two kinds, and they
3
+ // read a request differently:
4
+ //
5
+ // - criteria-only (`criteriaOnly`, criteriaOnlyRequest = true:
6
+ // EntityDbRequestReply.kt): REQUEST must be present but its fields are
7
+ // ignored; CRITERIA_MATCH, ORDER_BY, OFFSET / VIEW_NUMBER and MAX_ROWS run
8
+ // as one database read. Every reply carries MORE_ROWS, and NEXT_OFFSET while
9
+ // there are more (the SQL flavour, as the recordings show).
10
+ // - plain (RequestReply.kt): REQUEST's fields select rows — exact values,
11
+ // `*` wildcards (WildcardFilter.kt), FIELD_FROM/FIELD_TO ranges — and an
12
+ // array REQUEST runs each object and concatenates the results. MAX_ROWS
13
+ // caps each one; OFFSET and ORDER_BY are ignored, and nothing says whether
14
+ // there are more rows.
15
+ //
16
+ // See the README's "Request servers" for every rule and its evidence.
17
+
18
+ import {
19
+ CriteriaSyntaxError,
20
+ filterByCriteria,
21
+ parseCriteria,
22
+ parseGenesisDateTime,
23
+ UnsupportedCriteriaError,
24
+ type Predicate,
25
+ } from '../db/criteria.ts';
2
26
  import { withoutRecordFields } from '../db/identity.ts';
27
+ import { compareValues, parseOrderBy, rowComparator, type OrderedField } from '../db/ordering.ts';
28
+ import {
29
+ pkFieldsOf,
30
+ primaryKeyIndexName,
31
+ projectRow,
32
+ requestReplyFieldNames,
33
+ requestReplySourceSchema,
34
+ type ResolvedField,
35
+ } from '../db/schema.ts';
3
36
  import type { Store } from '../db/store.ts';
4
37
  import type { Connection } from '../protocol/connection.ts';
5
- import { nackPayload } from '../protocol/errors.ts';
38
+ import { ErrorCode, NackError, nackPayload } from '../protocol/errors.ts';
6
39
  import { isLegacyFidelity } from '../protocol/fidelity.ts';
40
+ import { daoClassOf, viewEntityClassOf } from '../protocol/fieldTypes.ts';
7
41
  import { ERROR_CODE } from '../protocol/messageTypes.ts';
8
- import { resourceNotMappedNack } from '../protocol/msgNack.ts';
9
- import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
10
- import { applyResourceAuth, requestingUser } from './resourceAuth.ts';
11
-
12
- // Keyed lookups: foundation-comms' createRequestMessage spreads RequestParams
13
- // onto the message, so REQUEST is a *top-level* field ({FIELD: value} pairs
14
- // naming the request server's index fields). A real request server only
15
- // returns rows matching them; '*' (Genesis's wildcard), null/undefined, and
16
- // '' mean "any". Arrays match any listed value.
17
- function matchesRequestFields(row: Row, request: Row): boolean {
18
- return Object.entries(request).every(([field, value]) => {
19
- if (value === undefined || value === null || value === '*' || value === '') return true;
20
- if (Array.isArray(value)) return value.length === 0 || value.includes(row[field]);
21
- return row[field] === value;
42
+ import { resourceNotMappedNack, serviceMsgNack } from '../protocol/msgNack.ts';
43
+ import type {
44
+ GenesisMessage,
45
+ MockServerConfig,
46
+ RequestingUser,
47
+ RequestReplyDef,
48
+ Row,
49
+ } from '../types.ts';
50
+ import { applyResourceAuth, permittedQueryRow, requestingUser } from './resourceAuth.ts';
51
+
52
+ // RequestReply.DEFAULT_MAXIMUM_ROWS: the rows a REP_ carries when neither the
53
+ // request (DETAILS.MAX_ROWS) nor the server (rowLimit) says otherwise.
54
+ const DEFAULT_ROW_LIMIT = 10000;
55
+ // Request.DEFAULT_VIEW_NUMBER: 0 is "not paging by view".
56
+ const DEFAULT_VIEW_NUMBER = 0;
57
+ const WILDCARD = '*';
58
+
59
+ // GSF answers an IllegalArgumentException from the request server (a failed
60
+ // require(), a bad ORDER_BY) with StandardError REQUEST_FAILED and its message.
61
+ const requestFailed = (text: string) => NackError.standard(ErrorCode.REQUEST_FAILED, text);
62
+ // Anything else a criteria-only server hits — a REQUEST that doesn't
63
+ // deserialize, a criteria Groovy can't compile — is INTERNAL_ERROR with the
64
+ // code's own text ("Unexpected server error"), the message only in the log.
65
+ const internalError = () => NackError.standard(ErrorCode.INTERNAL_ERROR);
66
+
67
+ const isPlainObject = (value: unknown): value is Row =>
68
+ typeof value === 'object' && value !== null && !Array.isArray(value);
69
+
70
+ // --- request details ---------------------------------------------------------
71
+
72
+ type Strictness = 'strict' | 'lenient';
73
+
74
+ // An integer DETAILS value. Jackson coerces numeric strings and truncates
75
+ // fractions; anything else fails to deserialize the request (strict: a
76
+ // criteria-only server's Request<Unit>) or reads as absent (lenient: a plain
77
+ // server's GenesisSet.getInteger).
78
+ function readInteger(value: unknown, strictness: Strictness): number | undefined {
79
+ if (value === undefined || value === null) return undefined;
80
+ const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
81
+ if (typeof number === 'number' && Number.isFinite(number)) return Math.trunc(number);
82
+ if (strictness === 'strict') throw internalError();
83
+ return undefined;
84
+ }
85
+
86
+ const readText = (value: unknown): string => (typeof value === 'string' ? value : '');
87
+
88
+ // --- the source --------------------------------------------------------------
89
+
90
+ interface Source {
91
+ name: string;
92
+ // Every field the entity has (its columns, RECORD_ID and TIMESTAMP), by name.
93
+ fields: Map<string, ResolvedField>;
94
+ pkFields: string[];
95
+ }
96
+
97
+ function sourceOf(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): Source {
98
+ const name = requestReply.source!;
99
+ const schema = requestReplySourceSchema(requestReply, config, store);
100
+ return {
101
+ name,
102
+ fields: new Map(schema.fields.map((field) => [field.name, field])),
103
+ pkFields: store.getPkFields(name),
104
+ };
105
+ }
106
+
107
+ function isView(name: string, config: MockServerConfig, store: Store): boolean {
108
+ return store.views.has(name) || !!config.views?.[name];
109
+ }
110
+
111
+ // The generated entity class the rows are (definition.source.entityType):
112
+ // the DAO for a table, the view entity for a view.
113
+ function parametricTypeOf(name: string, config: MockServerConfig, store: Store): string {
114
+ return isView(name, config, store) ? viewEntityClassOf(name) : daoClassOf(name);
115
+ }
116
+
117
+ const byPrimaryKey = (source: Source): OrderedField[] =>
118
+ source.pkFields.map((field) => ({ field, descending: false }));
119
+
120
+ // --- criteria ----------------------------------------------------------------
121
+
122
+ type CriteriaFilter = { kind: 'all' } | { kind: 'none' } | { kind: 'match'; predicate: Predicate };
123
+
124
+ // CRITERIA_MATCH against the entity. A malformed one fails as `onSyntaxError`
125
+ // says (the two server kinds differ). A criteria naming a field the entity
126
+ // doesn't have matches nothing: GSF evaluates it outside the database, where
127
+ // the unknown variable never compares true (recorded: REPLY: []). A call to a
128
+ // function this grammar doesn't model may be valid Groovy, so it still fails
129
+ // open (logged), as everywhere else in the engine.
130
+ function criteriaFilter(
131
+ criteria: string,
132
+ source: Source,
133
+ onSyntaxError: (error: CriteriaSyntaxError) => Error,
134
+ ): CriteriaFilter {
135
+ if (criteria.trim() === '') return { kind: 'all' };
136
+ try {
137
+ const { predicate, fields } = parseCriteria(criteria);
138
+ if (fields.some((field) => !source.fields.has(field))) return { kind: 'none' };
139
+ return { kind: 'match', predicate };
140
+ } catch (error) {
141
+ if (error instanceof CriteriaSyntaxError) throw onSyntaxError(error);
142
+ if (error instanceof UnsupportedCriteriaError) {
143
+ console.warn(
144
+ `[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`,
145
+ error,
146
+ );
147
+ return { kind: 'all' };
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ function applyCriteria(rows: Row[], filter: CriteriaFilter): Row[] {
154
+ if (filter.kind === 'all') return rows;
155
+ if (filter.kind === 'none') return [];
156
+ return rows.filter((row) => {
157
+ try {
158
+ return filter.predicate(row);
159
+ } catch {
160
+ return false;
161
+ }
162
+ });
163
+ }
164
+
165
+ // --- ORDER_BY ----------------------------------------------------------------
166
+
167
+ // The index names ORDER_BY may use instead of fields (OrderSpec.kt
168
+ // replaceIndexWithFields): 'PK', the primary key's index and the table's
169
+ // declared indexes — a view's, its base table's.
170
+ function orderIndexes(
171
+ source: Source,
172
+ config: MockServerConfig,
173
+ store: Store,
174
+ ): Map<string, string[]> {
175
+ const indexes = new Map<string, string[]>([['PK', source.pkFields]]);
176
+ const tableName = isView(source.name, config, store)
177
+ ? (store.views.get(source.name) ?? config.views?.[source.name])?.base
178
+ : source.name;
179
+ const table = tableName ? config.tables?.[tableName] : undefined;
180
+ if (table && tableName) {
181
+ indexes.set(primaryKeyIndexName(tableName, table), pkFieldsOf(table));
182
+ for (const index of table.indexes ?? []) indexes.set(index.name, index.fields);
183
+ }
184
+ return indexes;
185
+ }
186
+
187
+ // DETAILS.ORDER_BY on a criteria-only server: fields, each optionally ASC or
188
+ // DESC ('INT_FIELD DESC', 'A, B DESC'). An index name (or 'PK') that isn't
189
+ // also a field stands for its fields. A malformed string, or a field the
190
+ // entity doesn't have, is REQUEST_FAILED with GSF's text
191
+ // (GetByCriteriaOperation.kt prints the Kotlin list of OrderedFields).
192
+ function orderingOf(
193
+ orderBy: string,
194
+ source: Source,
195
+ config: MockServerConfig,
196
+ store: Store,
197
+ ): OrderedField[] | undefined {
198
+ if (orderBy.trim() === '') return undefined;
199
+ let fields: OrderedField[];
200
+ try {
201
+ fields = parseOrderBy(orderBy);
202
+ } catch (error) {
203
+ throw requestFailed((error as Error).message);
204
+ }
205
+ const indexes = orderIndexes(source, config, store);
206
+ const namesIndex = ({ field }: OrderedField) => !source.fields.has(field) && indexes.has(field);
207
+ if (fields.some(namesIndex)) {
208
+ fields = fields.flatMap((ordered) =>
209
+ namesIndex(ordered)
210
+ ? indexes.get(ordered.field)!.map((field) => ({ field, descending: ordered.descending }))
211
+ : [ordered],
212
+ );
213
+ }
214
+ const missing = fields.filter(({ field }) => !source.fields.has(field));
215
+ if (missing.length > 0) {
216
+ const listed = missing
217
+ .map(({ field, descending }) => `${descending ? 'Desc' : 'Asc'}(field=${field})`)
218
+ .join(', ');
219
+ throw requestFailed(`Order fields [${listed}] not found on table ${source.name}`);
220
+ }
221
+ return fields;
222
+ }
223
+
224
+ // --- criteria-only servers (EntityDbRequestReply.kt) -------------------------
225
+
226
+ interface Page {
227
+ rows: Row[];
228
+ nextOffset?: number;
229
+ moreRows?: boolean;
230
+ }
231
+
232
+ // EntityDbRequestReply.data(): one row past MAX_ROWS says there are more.
233
+ // NEXT_OFFSET is the request's OFFSET plus how far the read got (offsetShift),
234
+ // or that alone when no OFFSET was sent. A view-numbered page can't tell when
235
+ // it was read through a filter (isNextTokenKnowable false): no MORE_ROWS then.
236
+ function pageOf(
237
+ rows: Row[],
238
+ maxRows: number,
239
+ offset: number | undefined,
240
+ offsetShift: number,
241
+ nextKnowable: boolean,
242
+ ): Page {
243
+ if (rows.length === 0) return { rows: [], moreRows: false };
244
+ if (!nextKnowable) return { rows };
245
+ if (rows.length > maxRows) {
246
+ return {
247
+ rows: rows.slice(0, maxRows),
248
+ nextOffset: offset === undefined ? offsetShift : offset + offsetShift,
249
+ moreRows: true,
250
+ };
251
+ }
252
+ return { rows, moreRows: false };
253
+ }
254
+
255
+ function criteriaOnlyReply(
256
+ resourceName: string,
257
+ requestReply: RequestReplyDef,
258
+ message: GenesisMessage,
259
+ config: MockServerConfig,
260
+ store: Store,
261
+ user: RequestingUser,
262
+ ): Row {
263
+ // The request deserializes as Request<Unit>: without a REQUEST object
264
+ // (missing, null, nested in DETAILS) Jackson throws and the reply is
265
+ // INTERNAL_ERROR (recorded: KotlinInvalidNullException in the server log).
266
+ if (!isPlainObject(message.REQUEST)) throw internalError();
267
+ const details: Row = isPlainObject(message.DETAILS) ? message.DETAILS : {};
268
+ const offset = readInteger(details.OFFSET, 'strict');
269
+ const viewNumber = readInteger(details.VIEW_NUMBER, 'strict') ?? DEFAULT_VIEW_NUMBER;
270
+ const maxRows =
271
+ readInteger(details.MAX_ROWS, 'strict') ?? requestReply.rowLimit ?? DEFAULT_ROW_LIMIT;
272
+
273
+ // EntityDbRequestReply.process's require()s, in order.
274
+ if (offset !== undefined && viewNumber !== DEFAULT_VIEW_NUMBER) {
275
+ throw requestFailed(
276
+ `You must not supply both offset and viewNumber != ${DEFAULT_VIEW_NUMBER}, ` +
277
+ 'unable to combine offset and page request',
278
+ );
279
+ }
280
+ if (offset !== undefined && offset < 0) {
281
+ throw requestFailed(
282
+ 'When providing an offset, you must provide an offset greater than or equal to 0',
283
+ );
284
+ }
285
+ if (viewNumber < 0) {
286
+ throw requestFailed(
287
+ 'When providing a view number, You must provide a view number greater than or equal to 0',
288
+ );
289
+ }
290
+ if (maxRows <= 0) throw requestFailed('maxRows should be greater than 0');
291
+
292
+ const source = sourceOf(requestReply, config, store);
293
+ const criteria = criteriaFilter(readText(details.CRITERIA_MATCH), source, internalError);
294
+ const ordering = orderingOf(readText(details.ORDER_BY), source, config, store);
295
+ const byView = viewNumber !== DEFAULT_VIEW_NUMBER;
296
+ // A read that skips rows (an OFFSET past 0, or a view number) is a paged
297
+ // read: GetByCriteriaOperation orders it by the primary key when ORDER_BY
298
+ // doesn't say. An unpaged read comes in the table's own order, oldest
299
+ // first (recorded).
300
+ const paged = byView || (offset ?? 0) > 0;
301
+ if (criteria.kind === 'none' && paged) {
302
+ // Criteria evaluated outside the database can't be paged
303
+ // (PagingSpec.pagingNotSupported).
304
+ throw requestFailed('Paging not supported on lazy criteria evaluation');
305
+ }
306
+
307
+ let rows = applyCriteria(store.getResourceRows(source.name), criteria);
308
+ const order = ordering ?? (paged ? byPrimaryKey(source) : undefined);
309
+ if (order) rows = [...rows].sort(rowComparator(order));
310
+
311
+ const fieldNames = requestReplyFieldNames(requestReply, config, store);
312
+ let page: Page;
313
+ if (!requestReply.filter && !requestReply.auth) {
314
+ // Eager: the database does the paging, reading one row past the page.
315
+ const start = byView ? maxRows * (viewNumber - 1) : (offset ?? 0);
316
+ const read = rows.slice(start, start + maxRows + 1);
317
+ page = pageOf(read, maxRows, offset, maxRows, true);
318
+ page.rows = page.rows.map((row) => projectRow(row, fieldNames));
319
+ } else {
320
+ // Lazy: the filter and permissions run on the rows the database returns,
321
+ // so the read goes on until one row past the page has passed them, and
322
+ // NEXT_OFFSET says how many rows that took. A view-numbered read gets one
323
+ // database page and keeps what passes.
324
+ const window = byView
325
+ ? rows.slice(maxRows * (viewNumber - 1), maxRows * viewNumber)
326
+ : rows.slice(offset ?? 0);
327
+ const kept: Row[] = [];
328
+ let readCount = 0;
329
+ for (const row of window) {
330
+ readCount += 1;
331
+ if (requestReply.filter && !requestReply.filter(row)) continue;
332
+ const permitted = permittedQueryRow(row, requestReply.auth, user, fieldNames);
333
+ if (!permitted) continue;
334
+ kept.push(permitted);
335
+ if (kept.length > maxRows) break;
336
+ }
337
+ page = pageOf(kept, maxRows, offset, readCount - 1, !byView);
338
+ }
339
+
340
+ return {
341
+ REPLY: page.rows,
342
+ PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
343
+ MESSAGE_TYPE: `REP_${resourceName}`,
344
+ ...(byView && page.rows.length > 0 ? { NEXT_VIEW: viewNumber + 1 } : {}),
345
+ ...(page.nextOffset !== undefined ? { NEXT_OFFSET: page.nextOffset } : {}),
346
+ ...(page.moreRows !== undefined ? { MORE_ROWS: page.moreRows } : {}),
347
+ };
348
+ }
349
+
350
+ // --- plain servers (RequestReply.kt) ----------------------------------------
351
+
352
+ // A REQUEST value as the field's type (DataTypeConverter.convert). Fails for a
353
+ // value the type can't take.
354
+ function convertValue(value: unknown, field: ResolvedField): unknown {
355
+ const fail = () => {
356
+ throw requestFailed(`Unable to convert ${String(value)} to ${field.type} for ${field.name}`);
357
+ };
358
+ switch (field.type) {
359
+ case 'INT':
360
+ case 'SHORT':
361
+ case 'LONG':
362
+ case 'NANO_TIMESTAMP':
363
+ case 'DOUBLE':
364
+ case 'BIGDECIMAL': {
365
+ const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
366
+ return typeof number === 'number' && Number.isFinite(number) ? number : fail();
367
+ }
368
+ case 'BOOLEAN':
369
+ if (typeof value === 'boolean') return value;
370
+ if (typeof value === 'string' && /^(true|false)$/i.test(value)) {
371
+ return value.toLowerCase() === 'true';
372
+ }
373
+ return fail();
374
+ case 'DATE':
375
+ case 'DATETIME': {
376
+ // yyyyMMdd as well as the criteria formats (yyyyMMdd-HH:mm[:ss[.SSS]],
377
+ // ISO-8601, epoch millis).
378
+ const day = typeof value === 'string' ? /^(\d{4})(\d{2})(\d{2})$/.exec(value) : null;
379
+ if (day) return Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
380
+ return parseGenesisDateTime(value) ?? fail();
381
+ }
382
+ case 'STRING':
383
+ case 'ENUM':
384
+ case 'RAW':
385
+ return typeof value === 'object' ? fail() : String(value);
386
+ default:
387
+ return value;
388
+ }
389
+ }
390
+
391
+ // What Kotlin's toString() makes of a row value, for wildcard matching: a
392
+ // Double keeps its fraction ("2.0"), a date prints as ISO-8601, null is "null".
393
+ function kotlinString(value: unknown, field: ResolvedField | undefined): string {
394
+ if (value === undefined || value === null) return 'null';
395
+ if (typeof value === 'number') {
396
+ if ((field?.type === 'DATE' || field?.type === 'DATETIME') && Number.isFinite(value)) {
397
+ return new Date(value).toISOString();
398
+ }
399
+ if (field?.type === 'DOUBLE' && Number.isInteger(value)) return value.toFixed(1);
400
+ }
401
+ return String(value);
402
+ }
403
+
404
+ // WildcardFilter.matchesWildCard: '*' at both ends is "contains", at the start
405
+ // "ends with", at the end "starts with" — all case-sensitive, after trimming
406
+ // every leading and trailing '*' ('*' alone matches anything, null included).
407
+ // A '*' only in the middle matches nothing.
408
+ function matchesWildcard(text: string, wildcard: string): boolean {
409
+ const starts = wildcard.startsWith(WILDCARD);
410
+ const ends = wildcard.endsWith(WILDCARD);
411
+ const trimmed = wildcard.replace(/^\*+|\*+$/g, '');
412
+ if (starts && ends) return text.includes(trimmed);
413
+ return (starts && text.endsWith(trimmed)) || (ends && text.startsWith(trimmed));
414
+ }
415
+
416
+ const RANGE_SUFFIXES = ['_FROM', '_TO'];
417
+
418
+ // tryRemoveRangeSuffix: a key that isn't a field may name one's range end.
419
+ function fieldOfKey(key: string, source: Source): string {
420
+ if (source.fields.has(key)) return key;
421
+ let field = key;
422
+ for (const suffix of RANGE_SUFFIXES) {
423
+ if (field.endsWith(suffix)) field = field.slice(0, -suffix.length);
424
+ }
425
+ return field;
426
+ }
427
+
428
+ // One REQUEST object as a row predicate (RequestReplyBuilderImpl
429
+ // buildFieldPredicates + FieldPredicate.buildPredicate, then the
430
+ // WildcardFilter): undefined when it can't match anything (NoData).
431
+ // - A string containing '*' is a wildcard on that field.
432
+ // - Any other value must equal the field's (converted to its type; null
433
+ // matches null, and nothing on a non-null field).
434
+ // - FIELD_FROM and FIELD_TO together are an inclusive range; either alone,
435
+ // or a value it can't convert, matches nothing.
436
+ // - A field the entity doesn't have matches nothing — unless its value is
437
+ // null (always true of an unknown field) or a wildcard (it reads as "null").
438
+ function requestPredicate(request: Row, source: Source): Predicate | undefined {
439
+ const checks: Predicate[] = [];
440
+ const byField = new Map<string, Array<[string, unknown]>>();
441
+ for (const [key, value] of Object.entries(request)) {
442
+ if (typeof value === 'string' && value.includes(WILDCARD)) {
443
+ const field = source.fields.get(key);
444
+ checks.push((row) => matchesWildcard(kotlinString(row[key], field), value));
445
+ continue;
446
+ }
447
+ const field = fieldOfKey(key, source);
448
+ byField.set(field, [...(byField.get(field) ?? []), [key, value]]);
449
+ }
450
+ for (const [name, values] of byField) {
451
+ const field = source.fields.get(name);
452
+ if (!field) {
453
+ if (values[0][1] === null) continue;
454
+ return undefined;
455
+ }
456
+ if (values.length === 1) {
457
+ const [key, value] = values[0];
458
+ if (key !== name) return undefined; // an incomplete range
459
+ if (value === null) {
460
+ if (!field.nullable) return undefined;
461
+ checks.push((row) => row[name] === undefined || row[name] === null);
462
+ continue;
463
+ }
464
+ const expected = convertValue(value, field);
465
+ checks.push((row) => compareValues(row[name], expected) === 0);
466
+ continue;
467
+ }
468
+ if (values.length > 2) return undefined;
469
+ const lookup = new Map(values);
470
+ const range = [lookup.get(`${name}_FROM`), lookup.get(`${name}_TO`)].map((value) => {
471
+ if (value === undefined || value === null) return undefined;
472
+ try {
473
+ return convertValue(value, field);
474
+ } catch {
475
+ return undefined;
476
+ }
477
+ });
478
+ const [from, to] = range;
479
+ if (from === undefined || to === undefined) return undefined;
480
+ checks.push((row) => {
481
+ const value = row[name];
482
+ if (value === undefined || value === null) return false;
483
+ return compareValues(value, from) >= 0 && compareValues(value, to) <= 0;
484
+ });
485
+ }
486
+ return (row) => checks.every((check) => check(row));
487
+ }
488
+
489
+ function plainReply(
490
+ resourceName: string,
491
+ requestReply: RequestReplyDef,
492
+ message: GenesisMessage,
493
+ config: MockServerConfig,
494
+ store: Store,
495
+ user: RequestingUser,
496
+ ): Row {
497
+ const details: Row = isPlainObject(message.DETAILS) ? message.DETAILS : {};
498
+ const rowLimit =
499
+ readInteger(details.MAX_ROWS, 'lenient') ?? requestReply.rowLimit ?? DEFAULT_ROW_LIMIT;
500
+ // Flowable.take(-1)'s IllegalArgumentException.
501
+ if (rowLimit < 0) throw requestFailed(`count >= 0 required but it was ${rowLimit}`);
502
+ // REQUEST is top level only (DETAILS.REQUEST is never read). An array runs
503
+ // each of its objects in turn; anything else is one request, empty when
504
+ // missing.
505
+ const requests: Row[] = Array.isArray(message.REQUEST)
506
+ ? message.REQUEST.filter((request) => request !== null && request !== undefined).map(
507
+ (request) => (isPlainObject(request) ? request : {}),
508
+ )
509
+ : [isPlainObject(message.REQUEST) ? message.REQUEST : {}];
510
+
511
+ const source = sourceOf(requestReply, config, store);
512
+ // Here a criteria that doesn't compile fails the whole request with its
513
+ // message (handleThrowable: REQUEST_FAILED).
514
+ const criteria = criteriaFilter(readText(details.CRITERIA_MATCH), source, (error) =>
515
+ requestFailed(error.message),
516
+ );
517
+ const fieldNames = requestReplyFieldNames(requestReply, config, store);
518
+ // The rows come through the table's (or the view's root table's) primary
519
+ // key, as getBulk reads them (recorded: auth's RIGHT, in CODE order).
520
+ const ordered = [...store.getResourceRows(source.name)].sort(rowComparator(byPrimaryKey(source)));
521
+ const candidates = requestReply.filter ? ordered.filter(requestReply.filter) : ordered;
522
+
523
+ const reply = requests.flatMap((request) => {
524
+ const matches = requestPredicate(request, source);
525
+ if (!matches) return [];
526
+ const rows: Row[] = [];
527
+ for (const row of applyCriteria(candidates.filter(matches), criteria)) {
528
+ if (rows.length >= rowLimit) break;
529
+ const permitted = permittedQueryRow(row, requestReply.auth, user, fieldNames);
530
+ if (permitted) rows.push(permitted);
531
+ }
532
+ return rows;
22
533
  });
534
+
535
+ // In RequestReply.kt's order: SOURCE_REF straight after MESSAGE_TYPE
536
+ // (connection.reply overwrites it with the same value, keeping its place).
537
+ return {
538
+ MESSAGE_TYPE: `REP_${resourceName}`,
539
+ SOURCE_REF: message.SOURCE_REF,
540
+ PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
541
+ REPLY: reply,
542
+ };
543
+ }
544
+
545
+ // --- resolvers (custom request servers) -------------------------------------
546
+
547
+ function resolverReply(
548
+ resourceName: string,
549
+ requestReply: RequestReplyDef,
550
+ message: GenesisMessage,
551
+ store: Store,
552
+ user: RequestingUser,
553
+ ): Row {
554
+ // The resolver owns REQUEST interpretation entirely — no generic
555
+ // REQUEST/CRITERIA filtering is applied on top, since a computed reply's
556
+ // rows needn't echo the request's fields at all. Its rows go out exactly as
557
+ // returned.
558
+ const request: Row =
559
+ (typeof message.REQUEST === 'object' && message.REQUEST) || message.DETAILS?.REQUEST || {};
560
+ const rows = requestReply.resolver!({ request, message, user, store });
561
+ return {
562
+ MESSAGE_TYPE: `REP_${resourceName}`,
563
+ REPLY: applyResourceAuth(rows, requestReply.auth, user),
564
+ };
565
+ }
566
+
567
+ // A failed request is a MSG_NACK. A NackError keeps its items. Anything else
568
+ // is what each kind of server makes of an unexpected exception: a custom
569
+ // request server's GENERIC_ERROR with the message (AbstractCustomReqRep.kt), a
570
+ // plain one's REQUEST_FAILED with the message (RequestReply.kt
571
+ // handleThrowable), a criteria-only one's INTERNAL_ERROR.
572
+ function failureReply(error: unknown, requestReply: RequestReplyDef): Row {
573
+ if (error instanceof NackError) return serviceMsgNack(error.errors, error.warnings);
574
+ const message = error instanceof Error ? error.message : String(error);
575
+ const code = requestReply.resolver
576
+ ? ErrorCode.GENERIC_ERROR
577
+ : requestReply.criteriaOnly
578
+ ? ErrorCode.INTERNAL_ERROR
579
+ : ErrorCode.REQUEST_FAILED;
580
+ const nack = NackError.standard(
581
+ code,
582
+ code === ErrorCode.INTERNAL_ERROR ? undefined : message || undefined,
583
+ );
584
+ return serviceMsgNack(nack.errors, nack.warnings);
23
585
  }
24
586
 
25
587
  export function handleRequestReply(
@@ -49,46 +611,68 @@ export function handleRequestReply(
49
611
  return;
50
612
  }
51
613
 
52
- const legacy = isLegacyFidelity(config);
614
+ if (isLegacyFidelity(config)) {
615
+ legacyRequestReply(resourceName, requestReply, message, connection, store);
616
+ return;
617
+ }
618
+
619
+ const user = requestingUser(connection);
620
+ let reply: Row;
621
+ try {
622
+ if (requestReply.resolver) {
623
+ reply = resolverReply(resourceName, requestReply, message, store, user);
624
+ } else if (requestReply.criteriaOnly) {
625
+ reply = criteriaOnlyReply(resourceName, requestReply, message, config, store, user);
626
+ } else {
627
+ reply = plainReply(resourceName, requestReply, message, config, store, user);
628
+ }
629
+ } catch (error) {
630
+ reply = failureReply(error, requestReply);
631
+ }
632
+ connection.reply(message, reply);
633
+ }
634
+
635
+ // --- fidelity: 'legacy' ------------------------------------------------------
636
+ // What this engine answered up to 15.47: REQUEST from the top level or
637
+ // DETAILS, '*', null and '' matching anything, an array value matching any of
638
+ // its values, CRITERIA_MATCH failing open, every row (no MAX_ROWS, OFFSET or
639
+ // ORDER_BY), no PARAMETRIC_TYPE or MORE_ROWS, rows without RECORD_ID and
640
+ // TIMESTAMP, and errors as a REP_ with ERROR.
641
+
642
+ function matchesLegacyRequestFields(row: Row, request: Row): boolean {
643
+ return Object.entries(request).every(([field, value]) => {
644
+ if (value === undefined || value === null || value === WILDCARD || value === '') return true;
645
+ if (Array.isArray(value)) return value.length === 0 || value.includes(row[field]);
646
+ return row[field] === value;
647
+ });
648
+ }
649
+
650
+ function legacyRequestReply(
651
+ resourceName: string,
652
+ requestReply: RequestReplyDef,
653
+ message: GenesisMessage,
654
+ connection: Connection,
655
+ store: Store,
656
+ ): void {
53
657
  const user = requestingUser(connection);
54
658
  const request: Row =
55
659
  (typeof message.REQUEST === 'object' && message.REQUEST) || message.DETAILS?.REQUEST || {};
56
-
57
660
  try {
58
661
  let rows: Row[];
59
662
  if (requestReply.resolver) {
60
- // Computed reply (date calcs, holiday roll, composite lookups...): the
61
- // resolver owns REQUEST interpretation entirely — no generic REQUEST/
62
- // CRITERIA filtering is applied on top, since a computed reply's rows
63
- // needn't echo the request's fields at all.
64
663
  rows = requestReply.resolver({ request, message, user, store });
65
664
  } else {
66
665
  rows = store.getResourceRows(requestReply.source!);
67
666
  if (Object.keys(request).length > 0) {
68
- rows = rows.filter((row) => matchesRequestFields(row, request));
667
+ rows = rows.filter((row) => matchesLegacyRequestFields(row, request));
69
668
  }
70
669
  const criteria = message.DETAILS?.CRITERIA_MATCH ?? message.CRITERIA_MATCH;
71
670
  if (criteria) rows = filterByCriteria(rows, criteria);
72
671
  if (requestReply.filter) rows = rows.filter(requestReply.filter);
73
672
  }
74
- rows = applyResourceAuth(rows, requestReply.auth, user);
75
- // Rows read from a table or view carry their RECORD_ID and TIMESTAMP, as
76
- // a criteria-only request server's do on GSF. Deliberately on every
77
- // source-backed server, criteriaOnly or not (README: a deviation until
78
- // OFFSET paging, item 1.9): grid-pro's client-side REQUEST_SERVER
79
- // datasource keys rows by RECORD_ID and gives up without it. A
80
- // resolver's rows go out exactly as returned. Legacy mode sent none, a
81
- // resolver's included (one returning stored rows would now have them).
82
- if (legacy) rows = rows.map(withoutRecordFields);
83
-
84
- connection.reply(message, {
85
- MESSAGE_TYPE: `REP_${resourceName}`,
86
- REPLY: rows,
87
- });
673
+ rows = applyResourceAuth(rows, requestReply.auth, user).map(withoutRecordFields);
674
+ connection.reply(message, { MESSAGE_TYPE: `REP_${resourceName}`, REPLY: rows });
88
675
  } catch (error) {
89
- connection.reply(message, {
90
- MESSAGE_TYPE: `REP_${resourceName}`,
91
- ...nackPayload(error, legacy),
92
- });
676
+ connection.reply(message, { MESSAGE_TYPE: `REP_${resourceName}`, ...nackPayload(error, true) });
93
677
  }
94
678
  }