@genesislcap/mock-server 15.59.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.
- package/README.md +287 -40
- package/dist/db/criteria.d.ts +28 -9
- package/dist/db/criteria.js +856 -258
- package/dist/db/criteriaDialect.d.ts +6 -0
- package/dist/db/criteriaDialect.js +441 -0
- package/dist/db/criteriaTokens.d.ts +12 -0
- package/dist/db/criteriaTokens.js +53 -0
- package/dist/db/groovySyntax.d.ts +26 -0
- package/dist/db/groovySyntax.js +481 -0
- package/dist/db/groovyValidator.d.ts +11 -0
- package/dist/db/groovyValidator.js +523 -0
- package/dist/db/gsfExpr.d.ts +21 -0
- package/dist/db/gsfExpr.js +333 -0
- package/dist/db/identity.d.ts +2 -1
- package/dist/db/identity.js +24 -3
- package/dist/db/legacyCriteria.d.ts +7 -0
- package/dist/db/legacyCriteria.js +280 -0
- package/dist/db/ordering.d.ts +2 -0
- package/dist/db/ordering.js +24 -3
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +34 -3
- package/dist/db/store.d.ts +7 -0
- package/dist/db/store.js +55 -7
- package/dist/db/values.d.ts +19 -0
- package/dist/db/values.js +243 -0
- package/dist/handlers/commitEvent.js +3 -2
- package/dist/handlers/dataLogon.d.ts +2 -1
- package/dist/handlers/dataLogon.js +108 -51
- package/dist/handlers/eventValidation.d.ts +2 -1
- package/dist/handlers/eventValidation.js +33 -6
- package/dist/handlers/requestReply.js +43 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/connection.d.ts +1 -1
- package/dist/server.js +15 -8
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +979 -290
- package/src/db/criteriaDialect.ts +487 -0
- package/src/db/criteriaTokens.ts +72 -0
- package/src/db/groovySyntax.ts +500 -0
- package/src/db/groovyValidator.ts +546 -0
- package/src/db/gsfExpr.ts +392 -0
- package/src/db/identity.ts +22 -4
- package/src/db/legacyCriteria.ts +301 -0
- package/src/db/ordering.ts +27 -3
- package/src/db/schema.ts +43 -8
- package/src/db/store.ts +65 -7
- package/src/db/values.ts +247 -0
- package/src/handlers/commitEvent.ts +3 -2
- package/src/handlers/dataLogon.ts +134 -56
- package/src/handlers/eventValidation.ts +40 -13
- package/src/handlers/requestReply.ts +47 -14
- package/src/index.ts +6 -1
- package/src/protocol/connection.ts +3 -4
- package/src/server.ts +16 -15
- package/src/types.ts +7 -0
package/src/db/values.ts
ADDED
|
@@ -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(
|
|
178
|
+
handler(details, {
|
|
178
179
|
connection,
|
|
179
180
|
store,
|
|
180
181
|
message,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
234
|
-
|
|
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
|
|
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
|
+
}
|