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