@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.
Files changed (57) hide show
  1. package/README.md +287 -40
  2. package/dist/db/criteria.d.ts +28 -9
  3. package/dist/db/criteria.js +856 -258
  4. package/dist/db/criteriaDialect.d.ts +6 -0
  5. package/dist/db/criteriaDialect.js +441 -0
  6. package/dist/db/criteriaTokens.d.ts +12 -0
  7. package/dist/db/criteriaTokens.js +53 -0
  8. package/dist/db/groovySyntax.d.ts +26 -0
  9. package/dist/db/groovySyntax.js +481 -0
  10. package/dist/db/groovyValidator.d.ts +11 -0
  11. package/dist/db/groovyValidator.js +523 -0
  12. package/dist/db/gsfExpr.d.ts +21 -0
  13. package/dist/db/gsfExpr.js +333 -0
  14. package/dist/db/identity.d.ts +2 -1
  15. package/dist/db/identity.js +24 -3
  16. package/dist/db/legacyCriteria.d.ts +7 -0
  17. package/dist/db/legacyCriteria.js +280 -0
  18. package/dist/db/ordering.d.ts +2 -0
  19. package/dist/db/ordering.js +24 -3
  20. package/dist/db/schema.d.ts +2 -0
  21. package/dist/db/schema.js +34 -3
  22. package/dist/db/store.d.ts +7 -0
  23. package/dist/db/store.js +55 -7
  24. package/dist/db/values.d.ts +19 -0
  25. package/dist/db/values.js +243 -0
  26. package/dist/handlers/commitEvent.js +3 -2
  27. package/dist/handlers/dataLogon.d.ts +2 -1
  28. package/dist/handlers/dataLogon.js +108 -51
  29. package/dist/handlers/eventValidation.d.ts +2 -1
  30. package/dist/handlers/eventValidation.js +33 -6
  31. package/dist/handlers/requestReply.js +43 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/protocol/connection.d.ts +1 -1
  35. package/dist/server.js +15 -8
  36. package/dist/types.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/db/criteria.ts +979 -290
  39. package/src/db/criteriaDialect.ts +487 -0
  40. package/src/db/criteriaTokens.ts +72 -0
  41. package/src/db/groovySyntax.ts +500 -0
  42. package/src/db/groovyValidator.ts +546 -0
  43. package/src/db/gsfExpr.ts +392 -0
  44. package/src/db/identity.ts +22 -4
  45. package/src/db/legacyCriteria.ts +301 -0
  46. package/src/db/ordering.ts +27 -3
  47. package/src/db/schema.ts +43 -8
  48. package/src/db/store.ts +65 -7
  49. package/src/db/values.ts +247 -0
  50. package/src/handlers/commitEvent.ts +3 -2
  51. package/src/handlers/dataLogon.ts +134 -56
  52. package/src/handlers/eventValidation.ts +40 -13
  53. package/src/handlers/requestReply.ts +47 -14
  54. package/src/index.ts +6 -1
  55. package/src/protocol/connection.ts +3 -4
  56. package/src/server.ts +16 -15
  57. package/src/types.ts +7 -0
@@ -1,6 +1,8 @@
1
- import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
2
- import { rowComparator } from "../db/ordering.js";
3
- import { queryFieldNames, querySchema } from "../db/schema.js";
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
- let reverse = false;
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 && criteriaFields(criteria)?.find((field) => !exposed.includes(field));
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
- // ORDER_BY names one of the query's own indexes (QueryDef.indexes).
136
- if (details.ORDER_BY) {
137
- const index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
138
- if (!index) {
139
- connection.reply(message, logonNack('INVALID_INDEX', `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`));
140
- return;
141
- }
142
- compare = indexComparator(index);
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 key = pendingKey(rowRef);
185
- if (key !== undefined)
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 target = resolveEventTarget(name, config);
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, eventFields(target, config, store), config.appName);
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). 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.
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
- const { predicate, fields } = parseCriteria(criteria);
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
- if (error instanceof UnsupportedCriteriaError) {
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 date prints as ISO-8601, null is "null".
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
- queueInsert?: (key: string, stamped: Row, row: Row) => boolean;
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
- const store = new Store();
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(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
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 = dataserverRow(permitted, row, query?.fields);
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
@@ -16,6 +16,7 @@ export interface FieldDef {
16
16
  values?: string[];
17
17
  default?: unknown;
18
18
  maxSize?: number;
19
+ scale?: number;
19
20
  minLength?: number;
20
21
  description?: string;
21
22
  title?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genesislcap/mock-server",
3
- "version": "15.60.0",
3
+ "version": "15.62.0",
4
4
  "description": "Reusable in-memory Genesis WebSocket protocol mock server for client automation/CI, without a JVM or database.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",