@genesislcap/mock-server 15.60.0 → 15.61.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
@@ -0,0 +1,247 @@
1
+ // Values by declared field type: what the store holds, and what goes on the
2
+ // wire.
3
+ //
4
+ // GSF holds typed values (DateTime, BigDecimal, enums) and its serializer
5
+ // writes a DateTime as epoch millis and a BigDecimal as a string,
6
+ // `value.toPlainString()` (GenesisSetMaskingJsonSerializer.kt, GSF v8.15.29;
7
+ // recorded: COMMISSION "15.00000"). On a SQL database a BIGDECIMAL column is
8
+ // NUMERIC(precision, scale), (20, 5) unless the dictionary says otherwise
9
+ // (BigDecimalSqlPrecisionConfig, SQLBaseTableGenerator.kt), so a value comes
10
+ // back with exactly that many decimals; SendIt rounds a seed value to it
11
+ // HALF_UP before writing (BigDecimalCsvTransformer.kt).
12
+ //
13
+ // The store holds a declared column's value the way JavaScript can compare
14
+ // and compute with it: dates as epoch millis, BigDecimals as numbers rounded
15
+ // to the column scale, numbers and booleans as such (a seed or a handler may
16
+ // give them as strings). Criteria, ORDER_BY and derived fields therefore see
17
+ // numbers, as GSF's typed values compare. A BigDecimal becomes the scaled
18
+ // string only on the wire (encodeRow). Precision is a JavaScript number's:
19
+ // about 15 significant digits.
20
+
21
+ import type { GenesisFieldType } from '../types.ts';
22
+
23
+ // BigDecimalSqlPrecisionConfig's default scale.
24
+ export const DEFAULT_BIGDECIMAL_SCALE = 5;
25
+
26
+ // The scale a table column's values are written with: a BIGDECIMAL's own,
27
+ // else the default; undefined for any other type.
28
+ export function columnScale(field: { type: string; scale?: number }): number | undefined {
29
+ return field.type === 'BIGDECIMAL' ? (field.scale ?? DEFAULT_BIGDECIMAL_SCALE) : undefined;
30
+ }
31
+
32
+ // What a value needs to be normalized or encoded: its type, and for a
33
+ // BIGDECIMAL the scale (undefined: as many decimals as the value has).
34
+ export interface ValueField {
35
+ name: string;
36
+ type: GenesisFieldType | string;
37
+ scale?: number;
38
+ values?: string[];
39
+ }
40
+
41
+ const DECIMAL = /^([+-])?(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
42
+ // Exponents past this are no number JavaScript holds; such a value is left
43
+ // as given rather than spelled out digit by digit.
44
+ const MAX_EXPONENT = 1000;
45
+
46
+ // A decimal digit string plus one, carrying ('199' -> '200', '99' -> '100').
47
+ function incremented(digits: string): string {
48
+ const chars = digits.split('');
49
+ for (let at = chars.length - 1; at >= 0; at -= 1) {
50
+ if (chars[at] !== '9') {
51
+ chars[at] = String(Number(chars[at]) + 1);
52
+ return chars.join('');
53
+ }
54
+ chars[at] = '0';
55
+ }
56
+ return `1${chars.join('')}`;
57
+ }
58
+
59
+ // A number or numeric string as BigDecimal.toPlainString() writes it: no
60
+ // exponent, and with `scale`, exactly that many decimals, rounded HALF_UP
61
+ // (half away from zero) as setScale does. A number is read as its shortest
62
+ // decimal form (String(n)), so 1.005 rounds to "1.01" as its literal would.
63
+ // undefined for anything that isn't a finite decimal.
64
+ export function decimalText(value: unknown, scale?: number): string | undefined {
65
+ let text: string;
66
+ if (typeof value === 'number') {
67
+ if (!Number.isFinite(value)) return undefined;
68
+ text = String(value);
69
+ } else if (typeof value === 'bigint') {
70
+ text = value.toString();
71
+ } else if (typeof value === 'string') {
72
+ text = value.trim();
73
+ } else {
74
+ return undefined;
75
+ }
76
+ const match = DECIMAL.exec(text);
77
+ if (!match) return undefined;
78
+ const [, sign = '', whole = '', fraction = '', exponent = '0'] = match;
79
+ if (whole === '' && fraction === '') return undefined;
80
+ if (Math.abs(Number(exponent)) > MAX_EXPONENT) return undefined;
81
+ const digits = whole + fraction;
82
+ const point = whole.length + Number(exponent);
83
+ let integerPart =
84
+ point <= 0
85
+ ? '0'
86
+ : digits
87
+ .slice(0, point)
88
+ .padEnd(point, '0')
89
+ .replace(/^0+(?=\d)/, '');
90
+ let fractionPart = point <= 0 ? '0'.repeat(-point) + digits : digits.slice(point);
91
+ if (integerPart === '') integerPart = '0';
92
+ if (scale !== undefined) {
93
+ const roundUp = fractionPart.length > scale && fractionPart[scale] >= '5';
94
+ fractionPart = fractionPart.slice(0, scale).padEnd(scale, '0');
95
+ if (roundUp) {
96
+ const rounded = incremented(integerPart + fractionPart);
97
+ integerPart = rounded.slice(0, rounded.length - scale) || '0';
98
+ fractionPart = rounded.slice(rounded.length - scale);
99
+ }
100
+ }
101
+ const zero = /^0*$/.test(integerPart + fractionPart);
102
+ return `${sign === '-' && !zero ? '-' : ''}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`;
103
+ }
104
+
105
+ const NUMERIC = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
106
+ const INTEGER = /^[+-]?\d+$/;
107
+ // yyyyMMdd; Genesis's yyyyMMdd-HH:mm[:ss[.SSS]]; ISO-8601 (a space for the T
108
+ // too), the offset optional: without one it is UTC, not the machine's zone.
109
+ const DAY = /^(\d{4})(\d{2})(\d{2})$/;
110
+ const GENESIS_DATE_TIME = /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
111
+ const ISO_DATE_TIME =
112
+ /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
113
+ const MS_DIGITS = 3;
114
+ // The matched parts utcMillis takes: year, month, day, hour, minute, second,
115
+ // then the fraction; an ISO match has the offset after them.
116
+ const FRACTION_PART = 6;
117
+ const OFFSET_PART = FRACTION_PART + 2;
118
+ const MINUTES_PER_HOUR = 60;
119
+ const MS_PER_MINUTE = 60_000;
120
+
121
+ // Epoch millis of a UTC date and time given as matched digits, or undefined
122
+ // when a part is out of range (month 13, 30 February, 25:00 ...).
123
+ function utcMillis(parts: Array<string | undefined>): number | undefined {
124
+ const [year, month, day, hour = 0, minute = 0, second = 0] = parts
125
+ .slice(0, FRACTION_PART)
126
+ .map((part) => (part === undefined ? undefined : Number(part)));
127
+ const ms = Number((parts[FRACTION_PART] ?? '0').padEnd(MS_DIGITS, '0').slice(0, MS_DIGITS));
128
+ const time = Date.UTC(year!, month! - 1, day!, hour, minute, second, ms);
129
+ const date = new Date(time);
130
+ const exact =
131
+ date.getUTCFullYear() === year &&
132
+ date.getUTCMonth() === month! - 1 &&
133
+ date.getUTCDate() === day &&
134
+ date.getUTCHours() === hour &&
135
+ date.getUTCMinutes() === minute &&
136
+ date.getUTCSeconds() === second;
137
+ return exact ? time : undefined;
138
+ }
139
+
140
+ // An ISO offset ('Z', '+01:00', '-0530', '+02') in minutes east of UTC.
141
+ function offsetMinutes(offset: string | undefined): number {
142
+ if (!offset || offset.toUpperCase() === 'Z') return 0;
143
+ const digits = offset.slice(1).replace(':', '');
144
+ const minutes = Number(digits.slice(0, 2)) * MINUTES_PER_HOUR + Number(digits.slice(2) || '0');
145
+ return offset.startsWith('-') ? -minutes : minutes;
146
+ }
147
+
148
+ // A date as epoch millis: a number, a Date, an integer, yyyyMMdd, Genesis's
149
+ // yyyyMMdd-HH:mm[:ss[.SSS]] or ISO-8601. undefined for anything else, or a
150
+ // date that doesn't exist; nothing is guessed (no Date.parse).
151
+ export function epochMillis(value: unknown): number | undefined {
152
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
153
+ if (value instanceof Date) {
154
+ const time = value.getTime();
155
+ return Number.isNaN(time) ? undefined : time;
156
+ }
157
+ if (typeof value !== 'string') return undefined;
158
+ const text = value.trim();
159
+ // Eight digits are a day (yyyyMMdd), not a moment in January 1970.
160
+ const day = DAY.exec(text);
161
+ if (day) return utcMillis(day.slice(1));
162
+ if (INTEGER.test(text)) {
163
+ const millis = Number(text);
164
+ return Number.isSafeInteger(millis) ? millis : undefined;
165
+ }
166
+ const genesis = GENESIS_DATE_TIME.exec(text);
167
+ if (genesis) return utcMillis(genesis.slice(1));
168
+ const iso = ISO_DATE_TIME.exec(text);
169
+ if (!iso) return undefined;
170
+ const time = utcMillis(iso.slice(1, OFFSET_PART));
171
+ return time === undefined ? undefined : time - offsetMinutes(iso[OFFSET_PART]) * MS_PER_MINUTE;
172
+ }
173
+
174
+ const INTEGER_TYPES = new Set(['INT', 'SHORT', 'LONG', 'NANO_TIMESTAMP']);
175
+ const DATE_TYPES = new Set(['DATE', 'DATETIME']);
176
+
177
+ // A finite number from decimal text, or undefined.
178
+ function finite(text: string | undefined): number | undefined {
179
+ if (text === undefined) return undefined;
180
+ const number = Number(text);
181
+ return Number.isFinite(number) ? number : undefined;
182
+ }
183
+
184
+ // A value written to a declared column, as the store holds it (see the
185
+ // header). A value that can't be read as its type is kept as given; an empty
186
+ // string in a non-text column is null, as SendIt reads an empty cell.
187
+ export function storedValue(field: ValueField, value: unknown): unknown {
188
+ if (value === undefined || value === null) return value;
189
+ const { type } = field;
190
+ const textual = type === 'STRING' || type === 'ENUM' || type === 'RAW';
191
+ if (!textual && value === '') return null;
192
+ if (type === 'BIGDECIMAL') return finite(decimalText(value, field.scale)) ?? value;
193
+ if (DATE_TYPES.has(type)) return epochMillis(value) ?? value;
194
+ if (typeof value !== 'string') return value;
195
+ // An integer beyond 2^53 has no exact number: it stays the string it was.
196
+ if (INTEGER_TYPES.has(type)) {
197
+ const number = INTEGER.test(value.trim()) ? Number(value) : undefined;
198
+ return number !== undefined && Number.isSafeInteger(number) ? number : value;
199
+ }
200
+ if (type === 'DOUBLE') {
201
+ return NUMERIC.test(value.trim()) ? (finite(value.trim()) ?? value) : value;
202
+ }
203
+ if (type === 'BOOLEAN') {
204
+ const lower = value.trim().toLowerCase();
205
+ return lower === 'true' ? true : lower === 'false' ? false : value;
206
+ }
207
+ return value;
208
+ }
209
+
210
+ // An ENUM value its field doesn't list, or undefined. A field with no
211
+ // values accepts anything.
212
+ export function unlistedEnumValue(field: ValueField, value: unknown): unknown {
213
+ if (field.type !== 'ENUM' || !field.values?.length) return undefined;
214
+ if (value === undefined || value === null) return undefined;
215
+ return field.values.includes(value as string) ? undefined : value;
216
+ }
217
+
218
+ // An event's BigDecimal DETAILS as the handler gets them: a number, as GSF
219
+ // deserializes the schema's string into a BigDecimal (not rounded: the
220
+ // column's scale applies when it is written).
221
+ export function eventValue(field: ValueField, value: unknown): unknown {
222
+ if (field.type !== 'BIGDECIMAL' || typeof value !== 'string') return value;
223
+ return finite(decimalText(value)) ?? value;
224
+ }
225
+
226
+ // A row as it goes on the wire: each BIGDECIMAL a string with its scale. The
227
+ // row itself when nothing changes.
228
+ export function encodeRow(row: Record<string, any>, fields: ValueField[]): Record<string, any> {
229
+ let encoded: Record<string, any> | undefined;
230
+ for (const field of fields) {
231
+ if (field.type !== 'BIGDECIMAL') continue;
232
+ const value = row[field.name];
233
+ if (value === undefined || value === null) continue;
234
+ const text = decimalText(value, field.scale);
235
+ if (text === undefined || text === value) continue;
236
+ encoded ??= { ...row };
237
+ encoded[field.name] = text;
238
+ }
239
+ return encoded ?? row;
240
+ }
241
+
242
+ // The BIGDECIMAL fields among these, for encodeRow; undefined when there are
243
+ // none, so a caller can skip encoding altogether.
244
+ export function bigDecimalFields(fields: ValueField[]): ValueField[] | undefined {
245
+ const found = fields.filter((field) => field.type === 'BIGDECIMAL');
246
+ return found.length > 0 ? found : undefined;
247
+ }
@@ -12,7 +12,7 @@ import type {
12
12
  MockServerConfig,
13
13
  } from '../types.ts';
14
14
  import { crudEventHandler } from './crudEvents.ts';
15
- import { validateEventMessage } from './eventValidation.ts';
15
+ import { eventDetails, validateEventMessage } from './eventValidation.ts';
16
16
 
17
17
  export interface CommitEventCtx {
18
18
  broadcast: BroadcastFn;
@@ -173,8 +173,9 @@ export function handleCommitEvent(
173
173
  if (!legacy) transaction = openTransaction(store, ctx);
174
174
  const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
175
175
  if (schemaErrors.length > 0) throw new NackError(schemaErrors);
176
+ const details = legacy ? (message.DETAILS ?? {}) : eventDetails(message, config, store);
176
177
  const result =
177
- handler(message.DETAILS ?? {}, {
178
+ handler(details, {
178
179
  connection,
179
180
  store,
180
181
  message,
@@ -1,7 +1,15 @@
1
- import { compileCriteria, criteriaFields, filterByCriteria } from '../db/criteria.ts';
2
- import { rowComparator } from '../db/ordering.ts';
3
- import { queryFieldNames, querySchema } from '../db/schema.ts';
1
+ import {
2
+ compileCriteria,
3
+ criteriaCompileRefusal,
4
+ criteriaFields,
5
+ type CriteriaOptions,
6
+ filterByCriteria,
7
+ } from '../db/criteria.ts';
8
+ import { strictCriteriaRefusal } from '../db/criteriaDialect.ts';
9
+ import { byRecordId, rowComparator } from '../db/ordering.ts';
10
+ import { isDeclared, queryCriteriaTypes, queryFieldNames, querySchema } from '../db/schema.ts';
4
11
  import type { Store } from '../db/store.ts';
12
+ import { bigDecimalFields, encodeRow } from '../db/values.ts';
5
13
  import type { Connection, Subscription } from '../protocol/connection.ts';
6
14
  import { isLegacyFidelity } from '../protocol/fidelity.ts';
7
15
  import { MESSAGE_TYPE, ERROR_CODE } from '../protocol/messageTypes.ts';
@@ -115,6 +123,62 @@ function indexComparator(index: IndexDef): (a: Row, b: Row) => number {
115
123
  return rowComparator(index.fields.map((field) => ({ field, descending: false })));
116
124
  }
117
125
 
126
+ // The order a snapshot and its MORE_ROWS pages come in. With ORDER_BY, the
127
+ // index ascending, and REVERSE flips it. Without one the dataserver reads its
128
+ // primary index, RECORD_ID, newest first, and REVERSE alone gives oldest
129
+ // first — the order the rows were written in (PrimaryKeyDispenser.kt
130
+ // buildPrimaryRange: `if (reverse) Order.ASC else Order.DESC`, GSF v8.15.29).
131
+ function snapshotOrder(index: IndexDef | undefined, reverse: boolean): (a: Row, b: Row) => number {
132
+ const ascending = index ? indexComparator(index) : byRecordId;
133
+ const flipped = index ? reverse : !reverse;
134
+ return flipped ? (a, b) => ascending(b, a) : ascending;
135
+ }
136
+
137
+ // Without ORDER_BY, the index a criteria makes the dataserver read through:
138
+ // the query index whose leading fields the criteria uses most (the first such
139
+ // one on a tie), or none (CriteriaFilterBuilder.findBestMatchingIndex with no
140
+ // default index; QueryClientFactory.build adopts it as the client's index,
141
+ // GSF v8.15.29). The rows then come in that index's order, as under ORDER_BY.
142
+ function criteriaIndex(query: QueryDef, fields: string[]): IndexDef | undefined {
143
+ let best: IndexDef | undefined;
144
+ let bestCount = 0;
145
+ for (const index of query.indexes ?? []) {
146
+ const unmatched = index.fields.findIndex((field) => !fields.includes(field));
147
+ const count = unmatched < 0 ? index.fields.length : unmatched;
148
+ if (count > bestCount) [best, bestCount] = [index, count];
149
+ }
150
+ return best;
151
+ }
152
+
153
+ // A flag as GenesisSet.getBoolean reads it: true, or anything whose text is
154
+ // "true" in any case (obj.toString().toBoolean()).
155
+ function readBoolean(value: unknown): boolean {
156
+ return value === true || (value !== undefined && value !== null && /^true$/i.test(String(value)));
157
+ }
158
+
159
+ // How a query's rows go on the wire: each BIGDECIMAL column as a string with
160
+ // its scale, as GSF's serializer writes a BigDecimal (db/values.ts).
161
+ // undefined when the query has none — known without resolving the schema
162
+ // for an undeclared source with no BIGDECIMAL fieldTypes or derived types.
163
+ export function rowEncoder(
164
+ query: QueryDef,
165
+ config: MockServerConfig,
166
+ store: Store,
167
+ ): ((row: Row) => Row) | undefined {
168
+ const view = store.views.get(query.source) ?? config.views?.[query.source];
169
+ const overridden = [
170
+ ...Object.values(query.fieldTypes ?? {}),
171
+ ...Object.values(view?.derivedTypes ?? {}).map((entry) =>
172
+ typeof entry === 'string' ? entry : entry.type,
173
+ ),
174
+ ];
175
+ if (!isDeclared(query.source, config, store) && !overridden.includes('BIGDECIMAL')) {
176
+ return undefined;
177
+ }
178
+ const fields = bigDecimalFields(querySchema(query, config, store).fields);
179
+ return fields && ((row: Row) => encodeRow(row, fields));
180
+ }
181
+
118
182
  // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
119
183
  // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
120
184
  // registered so MORE_ROWS can page the rest and later inserts/amends/deletes
@@ -160,13 +224,52 @@ export function handleDataLogon(
160
224
  // criteria may name and all a row carries; DETAILS.FIELDS narrows the rows.
161
225
  // Legacy subscriptions ignore all of it, and ORDER_BY, as 15.47 did.
162
226
  const exposed = queryFieldNames(query, config, store);
227
+ // Legacy subscriptions keep the store's insertion order.
163
228
  let compare: ((a: Row, b: Row) => number) | undefined;
164
- let reverse = false;
229
+ // How the criteria reads the rows: by the fields' declared types, as GSF
230
+ // compiles it against its entity; legacy as up to 15.47.
231
+ const criteriaOptions: CriteriaOptions = legacy
232
+ ? { lenient: true }
233
+ : { types: queryCriteriaTypes(query, config, store) };
165
234
  if (!legacy) {
235
+ // ORDER_BY names one of the query's own indexes (QueryDef.indexes). GSF
236
+ // checks it before it looks at the criteria
237
+ // (ConnectionManager.verifyClientOptions, v8.15.29).
238
+ let index: IndexDef | undefined;
239
+ if (details.ORDER_BY) {
240
+ index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
241
+ if (!index) {
242
+ connection.reply(
243
+ message,
244
+ logonNack(
245
+ 'INVALID_INDEX',
246
+ `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`,
247
+ ),
248
+ );
249
+ return;
250
+ }
251
+ }
252
+ // GSF parses the criteria as Groovy first, validates it and compiles it,
253
+ // refusing one that fails with the message (CriteriaFilterBuilder
254
+ // getCriteriaFilter, v8.15.29): no subscription opens. Refused here: the
255
+ // syntax errors Groovy would report too, and this engine's own dialect —
256
+ // AND / OR / NOT, `<>`, a single `=`, dotted names, `$` in double quotes,
257
+ // calls off GSF's whitelists (db/criteriaDialect.ts). A criteria outside
258
+ // this engine's grammar that
259
+ // may well be valid Groovy still fails open below.
260
+ const refusal =
261
+ typeof criteria === 'string'
262
+ ? strictCriteriaRefusal(criteria, exposed && new Set(exposed), criteriaOptions.types)
263
+ : undefined;
264
+ if (refusal) {
265
+ connection.reply(message, logonNack('INVALID_CRITERIA', refusal));
266
+ return;
267
+ }
268
+ const parsedFields = criteriaFields(criteria);
166
269
  // The real dataserver lets a criteria read only the query's own fields
167
270
  // (QueryClientFactory.kt). Unknown columns are only checked when the
168
271
  // query's fields are known; an inferred query fails open as before.
169
- const outside = exposed && criteriaFields(criteria)?.find((field) => !exposed.includes(field));
272
+ const outside = exposed && parsedFields?.find((field) => !exposed.includes(field));
170
273
  if (outside) {
171
274
  connection.reply(
172
275
  message,
@@ -177,22 +280,20 @@ export function handleDataLogon(
177
280
  );
178
281
  return;
179
282
  }
180
- // ORDER_BY names one of the query's own indexes (QueryDef.indexes).
181
- if (details.ORDER_BY) {
182
- const index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
183
- if (!index) {
184
- connection.reply(
185
- message,
186
- logonNack(
187
- 'INVALID_INDEX',
188
- `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`,
189
- ),
190
- );
191
- return;
192
- }
193
- compare = indexComparator(index);
194
- reverse = details.REVERSE === true;
283
+ // Then GSF compiles it, refusing a call no overload of the function
284
+ // takes (db/criteria.ts criteriaCompileRefusal).
285
+ const uncompiled =
286
+ typeof criteria === 'string'
287
+ ? criteriaCompileRefusal(criteria, criteriaOptions.types)
288
+ : undefined;
289
+ if (uncompiled) {
290
+ connection.reply(message, logonNack('INVALID_CRITERIA', uncompiled));
291
+ return;
195
292
  }
293
+ compare = snapshotOrder(
294
+ index ?? criteriaIndex(query, parsedFields ?? []),
295
+ readBoolean(details.REVERSE),
296
+ );
196
297
  }
197
298
  // Order matters: the query's own filter (the where-clause equivalent) and
198
299
  // the user's row permissioning narrow the set BEFORE the client's
@@ -206,11 +307,8 @@ export function handleDataLogon(
206
307
  // The query's own filter is the where clause the index is built over, so
207
308
  // the count is taken after it — an assumption, as no recording covers it.
208
309
  const rowsCount = rows.length;
209
- rows = filterByCriteria(rows, criteria);
210
- if (compare) {
211
- rows = [...rows].sort(compare);
212
- if (reverse) rows.reverse();
213
- }
310
+ rows = filterByCriteria(rows, criteria, criteriaOptions);
311
+ if (compare) rows = [...rows].sort(compare);
214
312
  // Permissioning is applied per row so the ROW_REF can still be computed from
215
313
  // the UNSTRIPPED row — pk fields may themselves be hidden from this user,
216
314
  // and a snapshot row with no ROW_REF can never be matched to the later
@@ -222,26 +320,23 @@ export function handleDataLogon(
222
320
  const fieldNames = legacy
223
321
  ? undefined
224
322
  : requestedFieldNames(details, query, exposed, config, store);
323
+ // A BigDecimal goes out as a string with its column's scale (db/values.ts).
324
+ const encode = legacy ? undefined : rowEncoder(query, config, store);
225
325
  const stampedRows: Row[] = [];
226
- // Each sent-or-waiting row as stored, by pending key, for ORDER_BY's live
227
- // INSERT placement below.
228
- const rawRows = new Map<string, Row>();
229
326
  for (const row of rows) {
230
327
  const permitted = permittedQueryRow(row, query.auth, user, fieldNames);
231
328
  if (!permitted) continue;
232
329
  const rowRef = getRowRef(row);
233
- const key = pendingKey(rowRef);
234
- if (key !== undefined) rawRows.set(key, row);
235
- stampedRows.push(
236
- stampRow(dataserverRow(permitted, row, query.fields), rowRef, 'INSERT', legacy),
237
- );
330
+ const sent = dataserverRow(permitted, row, query.fields);
331
+ stampedRows.push(stampRow(encode ? encode(sent) : sent, rowRef, 'INSERT', legacy));
238
332
  }
239
333
 
240
334
  const subscription: Subscription = {
241
335
  resourceName,
242
336
  getRowRef,
243
- predicate: compileCriteria(criteria),
337
+ predicate: compileCriteria(criteria, criteriaOptions),
244
338
  sequenceId: 1,
339
+ ...(encode ? { encode } : {}),
245
340
  };
246
341
 
247
342
  if (legacy) {
@@ -260,6 +355,11 @@ export function handleDataLogon(
260
355
  // MAX_ROWS of them into `held`. Rows the view has no room for (past
261
356
  // MAX_VIEW) keep waiting, so MORE_ROWS stays true — as on the real server,
262
357
  // which then answers every MORE_ROWS with an empty {MORE_ROWS: true} page.
358
+ // A live INSERT is pushed straight away whatever the order, even when it
359
+ // sorts among the rows still waiting, and a later page doesn't repeat it:
360
+ // the real ClientIndex.processInsertUpdate sends it as soon as it arrives,
361
+ // and NextRowsStrategy skips rows the client index already holds (GSF
362
+ // v8.15.29). See server.ts trackClientView.
263
363
  const { maxRows, maxView } = pagingOptions(details);
264
364
  const tracked: Subscription = {
265
365
  ...subscription,
@@ -269,28 +369,6 @@ export function handleDataLogon(
269
369
  maxView,
270
370
  fieldNames,
271
371
  };
272
- // Under ORDER_BY a live INSERT that sorts among the rows still waiting for
273
- // MORE_ROWS joins them in order rather than being pushed out of order; one
274
- // that sorts before them lands in what the client has, and is pushed. (A
275
- // MODIFY of a waiting row keeps its place.)
276
- if (compare) {
277
- const ascending = compare;
278
- const sortCompare = reverse ? (a: Row, b: Row) => ascending(b, a) : ascending;
279
- tracked.queueInsert = (key, stamped, row) => {
280
- const waiting = [...tracked.pending!];
281
- const first = waiting[0] && rawRows.get(waiting[0][0]);
282
- if (!first || sortCompare(row, first) < 0) return false;
283
- const at = waiting.findIndex(([waitingKey]) => {
284
- const other = rawRows.get(waitingKey);
285
- return other !== undefined && sortCompare(row, other) < 0;
286
- });
287
- waiting.splice(at < 0 ? waiting.length : at, 0, [key, stamped]);
288
- tracked.pending!.clear();
289
- for (const [waitingKey, waitingRow] of waiting) tracked.pending!.set(waitingKey, waitingRow);
290
- rawRows.set(key, row);
291
- return true;
292
- };
293
- }
294
372
  const firstPage = takePage(tracked);
295
373
  const pending = tracked.pending!;
296
374
  connection.subscribe(sourceRef, tracked);
@@ -1,9 +1,10 @@
1
- import { eventFields, isDeclared, resolveEventTarget } from '../db/schema.ts';
1
+ import { eventFields, isDeclared, resolveEventTarget, type ResolvedField } from '../db/schema.ts';
2
2
  import type { Store } from '../db/store.ts';
3
+ import { eventValue } from '../db/values.ts';
3
4
  import type { Connection } from '../protocol/connection.ts';
4
5
  import type { NackErrorInput } from '../protocol/errors.ts';
5
6
  import { schemaFieldErrors, validateSchema } from '../protocol/schemaValidation.ts';
6
- import type { GenesisMessage, MockServerConfig } from '../types.ts';
7
+ import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
7
8
  import { eventInboundSchema } from './jsonSchema.ts';
8
9
 
9
10
  // The schema check every event goes through on the real server before its
@@ -24,20 +25,46 @@ export function validateEventMessage(
24
25
  store: Store,
25
26
  ): NackErrorInput[] {
26
27
  const name = message.MESSAGE_TYPE;
27
- const target = resolveEventTarget(name, config);
28
- if (!target) return [];
29
- const declared =
30
- target.kind === 'dto' ||
31
- (target.table !== undefined && isDeclared(target.table, config, store));
28
+ const declared = declaredEvent(name, config, store);
32
29
  if (!declared) return [];
33
- const inbound = eventInboundSchema(
34
- name,
35
- target,
36
- eventFields(target, config, store),
37
- config.appName,
38
- );
30
+ const inbound = eventInboundSchema(name, declared.target, declared.fields, config.appName);
39
31
  // The router stamps the session's user on everything it forwards, so the
40
32
  // schema's required USER_NAME is always there by the time it is checked.
41
33
  const forwarded = { ...message, USER_NAME: connection.userName ?? '' };
42
34
  return schemaFieldErrors(validateSchema(inbound, forwarded));
43
35
  }
36
+
37
+ // The event's target and DETAILS fields, when its DETAILS class is declared.
38
+ function declaredEvent(
39
+ name: string,
40
+ config: MockServerConfig,
41
+ store: Store,
42
+ ):
43
+ | { target: NonNullable<ReturnType<typeof resolveEventTarget>>; fields: ResolvedField[] }
44
+ | undefined {
45
+ const target = resolveEventTarget(name, config);
46
+ if (!target) return undefined;
47
+ const declared =
48
+ target.kind === 'dto' ||
49
+ (target.table !== undefined && isDeclared(target.table, config, store));
50
+ return declared ? { target, fields: eventFields(target, config, store) } : undefined;
51
+ }
52
+
53
+ // DETAILS as the handler gets them, after the schema check: a declared
54
+ // BigDecimal field's string is a number, as GSF hands its handler a
55
+ // deserialized BigDecimal (db/values.ts eventValue). Anything else, and any
56
+ // undeclared event's DETAILS, as sent.
57
+ export function eventDetails(message: GenesisMessage, config: MockServerConfig, store: Store): Row {
58
+ const details: Row = message.DETAILS ?? {};
59
+ const declared = declaredEvent(message.MESSAGE_TYPE, config, store);
60
+ if (!declared) return details;
61
+ let converted: Row | undefined;
62
+ for (const field of declared.fields) {
63
+ if (!Object.hasOwn(details, field.name)) continue;
64
+ const value = eventValue(field, details[field.name]);
65
+ if (value === details[field.name]) continue;
66
+ converted ??= { ...details };
67
+ converted[field.name] = value;
68
+ }
69
+ return converted ?? details;
70
+ }