@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.
- 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
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// The criteria evaluator of fidelity: 'legacy' (CriteriaOptions.lenient):
|
|
2
|
+
// this engine's own semantics up to 15.47, kept as they were — JavaScript
|
|
3
|
+
// comparisons (`QTY > -1` holds for a null QTY, `QTY == null` misses a
|
|
4
|
+
// missing field), functions that answer false rather than throw (so
|
|
5
|
+
// `!STATUS.equals('NEW')` keeps a row with no STATUS), inclusive
|
|
6
|
+
// dateTimeIsAfter / dateTimeIsBefore, dates read by Date.parse too, and the
|
|
7
|
+
// AND / OR / NOT, `<>` and `=` dialect. GSF's semantics are criteria.ts's.
|
|
8
|
+
//
|
|
9
|
+
// Only addition: the GSF functions 15.47 didn't have and so failed open on —
|
|
10
|
+
// containsWordsStartingWithIgnoreCase, isNullOrBlank, notNullOrBlank,
|
|
11
|
+
// dateIsAfter, dateIsBefore, dateTimeIsInRange — in the same false-not-throw
|
|
12
|
+
// style.
|
|
13
|
+
|
|
14
|
+
import type { Row } from '../types.ts';
|
|
15
|
+
import {
|
|
16
|
+
CriteriaSyntaxError,
|
|
17
|
+
parseGenesisDateTime,
|
|
18
|
+
tokenizeCriteria,
|
|
19
|
+
UnsupportedCriteriaError,
|
|
20
|
+
} from './criteriaTokens.ts';
|
|
21
|
+
import * as gsf from './gsfExpr.ts';
|
|
22
|
+
|
|
23
|
+
type Predicate = (record: Row) => boolean;
|
|
24
|
+
|
|
25
|
+
function parseLiteral(token: string): unknown {
|
|
26
|
+
if (/^['"]/.test(token)) return token.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
27
|
+
if (/^-?\d+(?:\.\d+)?$/.test(token)) return Number(token);
|
|
28
|
+
if (token === 'true') return true;
|
|
29
|
+
if (token === 'false') return false;
|
|
30
|
+
if (token === 'null') return null;
|
|
31
|
+
return undefined; // not a literal -> treat as a field reference
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
|
|
35
|
+
|
|
36
|
+
function toEpochDay(value: unknown): number | null {
|
|
37
|
+
const epoch = parseGenesisDateTime(value);
|
|
38
|
+
return epoch === null ? null : gsf.epochDay(epoch);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ExprResolver = (token: string, record: Row) => unknown;
|
|
42
|
+
type LegacyFunction = (record: Row, args: string[], resolve: ExprResolver) => boolean;
|
|
43
|
+
|
|
44
|
+
function compareByDay(cmp: (a: number, b: number) => boolean): LegacyFunction {
|
|
45
|
+
return (record, args, resolve) => {
|
|
46
|
+
const a = toEpochDay(resolve(args[0], record));
|
|
47
|
+
const b = toEpochDay(resolve(args[1], record));
|
|
48
|
+
return a !== null && b !== null && cmp(a, b);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compareByTime(cmp: (a: number, b: number) => boolean): LegacyFunction {
|
|
53
|
+
return (record, args, resolve) => {
|
|
54
|
+
const a = parseGenesisDateTime(resolve(args[0], record));
|
|
55
|
+
const b = parseGenesisDateTime(resolve(args[1], record));
|
|
56
|
+
return a !== null && b !== null && cmp(a, b);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isBlankValue = (value: unknown) =>
|
|
61
|
+
value === null || value === undefined || (typeof value === 'string' && gsf.isBlank(value));
|
|
62
|
+
|
|
63
|
+
// Expr.<fn>(field, value), and the same functions as <FIELD>.<fn>(value)
|
|
64
|
+
// with the receiver as the first argument.
|
|
65
|
+
const LEGACY_FUNCTIONS: Record<string, LegacyFunction> = {
|
|
66
|
+
// Multi-value fields (e.g. DEAL_CURRENCIES) may hold an array — treat
|
|
67
|
+
// "contains" as membership there, substring match on strings.
|
|
68
|
+
contains: (record, args, resolve) => {
|
|
69
|
+
const value = resolve(args[0], record);
|
|
70
|
+
const needle = resolve(args[1], record);
|
|
71
|
+
if (typeof needle !== 'string') return false;
|
|
72
|
+
if (Array.isArray(value)) return value.some((v) => typeof v === 'string' && v === needle);
|
|
73
|
+
return typeof value === 'string' && value.includes(needle);
|
|
74
|
+
},
|
|
75
|
+
containsIgnoreCase: (record, args, resolve) => {
|
|
76
|
+
const value = resolve(args[0], record);
|
|
77
|
+
const needle = resolve(args[1], record);
|
|
78
|
+
if (typeof needle !== 'string') return false;
|
|
79
|
+
const lowered = needle.toLowerCase();
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return value.some((v) => typeof v === 'string' && v.toLowerCase() === lowered);
|
|
82
|
+
}
|
|
83
|
+
return typeof value === 'string' && value.toLowerCase().includes(lowered);
|
|
84
|
+
},
|
|
85
|
+
equals: (record, args, resolve) => resolve(args[0], record) === resolve(args[1], record),
|
|
86
|
+
equalsIgnoreCase: (record, args, resolve) => {
|
|
87
|
+
const value = resolve(args[0], record);
|
|
88
|
+
const other = resolve(args[1], record);
|
|
89
|
+
if (typeof value !== 'string' || typeof other !== 'string') return value === other;
|
|
90
|
+
return value.toLowerCase() === other.toLowerCase();
|
|
91
|
+
},
|
|
92
|
+
startsWith: (record, args, resolve) => {
|
|
93
|
+
const value = resolve(args[0], record);
|
|
94
|
+
const prefix = resolve(args[1], record);
|
|
95
|
+
return typeof value === 'string' && typeof prefix === 'string' && value.startsWith(prefix);
|
|
96
|
+
},
|
|
97
|
+
endsWith: (record, args, resolve) => {
|
|
98
|
+
const value = resolve(args[0], record);
|
|
99
|
+
const suffix = resolve(args[1], record);
|
|
100
|
+
return typeof value === 'string' && typeof suffix === 'string' && value.endsWith(suffix);
|
|
101
|
+
},
|
|
102
|
+
dateIsToday: (record, args, resolve) => {
|
|
103
|
+
const day = toEpochDay(resolve(args[0], record));
|
|
104
|
+
return day !== null && day === toEpochDay(Date.now());
|
|
105
|
+
},
|
|
106
|
+
dateIsEqual: compareByDay((a, b) => a === b),
|
|
107
|
+
dateIsGreaterEqual: compareByDay((a, b) => a >= b),
|
|
108
|
+
dateIsLessEqual: compareByDay((a, b) => a <= b),
|
|
109
|
+
dateTimeIsEqual: compareByTime((a, b) => a === b),
|
|
110
|
+
dateTimeIsGreaterEqual: compareByTime((a, b) => a >= b),
|
|
111
|
+
dateTimeIsLessEqual: compareByTime((a, b) => a <= b),
|
|
112
|
+
dateTimeIsAfter: compareByTime((a, b) => a >= b),
|
|
113
|
+
dateTimeIsBefore: compareByTime((a, b) => a <= b),
|
|
114
|
+
// GSF's, which 15.47 didn't have.
|
|
115
|
+
dateIsAfter: compareByDay((a, b) => a > b),
|
|
116
|
+
dateIsBefore: compareByDay((a, b) => a < b),
|
|
117
|
+
isNullOrBlank: (record, args, resolve) => isBlankValue(resolve(args[0], record)),
|
|
118
|
+
notNullOrBlank: (record, args, resolve) => !isBlankValue(resolve(args[0], record)),
|
|
119
|
+
containsWordsStartingWithIgnoreCase: (record, args, resolve) => {
|
|
120
|
+
const value = resolve(args[0], record);
|
|
121
|
+
const search = resolve(args[1], record);
|
|
122
|
+
const delimiter = args.length > 2 ? resolve(args[2], record) : ' ';
|
|
123
|
+
if (typeof value !== 'string' || typeof search !== 'string') return false;
|
|
124
|
+
if (typeof delimiter !== 'string' || delimiter === '') return false;
|
|
125
|
+
return gsf.containsWordsStartingWithIgnoreCase(value, search, delimiter[0]);
|
|
126
|
+
},
|
|
127
|
+
dateTimeIsInRange: (record, args, resolve) => {
|
|
128
|
+
const millis = parseGenesisDateTime(resolve(args[0], record));
|
|
129
|
+
const [range, context] = [resolve(args[1], record), resolve(args[2], record)];
|
|
130
|
+
if (millis === null || typeof range !== 'string' || typeof context !== 'string') return false;
|
|
131
|
+
try {
|
|
132
|
+
return gsf.dateTimeIsInRange(millis, range, context);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
class Parser {
|
|
140
|
+
private tokens: string[];
|
|
141
|
+
private pos = 0;
|
|
142
|
+
readonly fields = new Set<string>();
|
|
143
|
+
|
|
144
|
+
constructor(tokens: string[]) {
|
|
145
|
+
this.tokens = tokens;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get position(): number {
|
|
149
|
+
return this.pos;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private peek(): string | undefined {
|
|
153
|
+
return this.tokens[this.pos];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private next(): string | undefined {
|
|
157
|
+
const token = this.tokens[this.pos];
|
|
158
|
+
this.pos += 1;
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
parseOr(): Predicate {
|
|
163
|
+
let left = this.parseAnd();
|
|
164
|
+
while (this.peek() === '||' || this.peek()?.toUpperCase() === 'OR') {
|
|
165
|
+
this.next();
|
|
166
|
+
const right = this.parseAnd();
|
|
167
|
+
const prev = left;
|
|
168
|
+
left = (record) => prev(record) || right(record);
|
|
169
|
+
}
|
|
170
|
+
return left;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private parseAnd(): Predicate {
|
|
174
|
+
let left = this.parseNot();
|
|
175
|
+
while (this.peek() === '&&' || this.peek()?.toUpperCase() === 'AND') {
|
|
176
|
+
this.next();
|
|
177
|
+
const right = this.parseNot();
|
|
178
|
+
const prev = left;
|
|
179
|
+
left = (record) => prev(record) && right(record);
|
|
180
|
+
}
|
|
181
|
+
return left;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private parseNot(): Predicate {
|
|
185
|
+
const next = this.peek();
|
|
186
|
+
if (next === '!' || next?.toUpperCase() === 'NOT') {
|
|
187
|
+
this.next();
|
|
188
|
+
const operand = this.parseNot();
|
|
189
|
+
return (record) => !operand(record);
|
|
190
|
+
}
|
|
191
|
+
return this.parseComparison();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private parseComparison(): Predicate {
|
|
195
|
+
if (this.peek() === '(') {
|
|
196
|
+
this.next();
|
|
197
|
+
const inner = this.parseOr();
|
|
198
|
+
if (this.next() !== ')')
|
|
199
|
+
throw new CriteriaSyntaxError('Missing closing parenthesis in criteria');
|
|
200
|
+
return inner;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const leftToken = this.next();
|
|
204
|
+
if (leftToken === undefined) {
|
|
205
|
+
throw new CriteriaSyntaxError('Expected operand on the left side of comparison');
|
|
206
|
+
}
|
|
207
|
+
if (leftToken.includes('.') && this.peek() === '(') return this.parseFunctionCall(leftToken);
|
|
208
|
+
|
|
209
|
+
const op = this.peek();
|
|
210
|
+
if (!op || !COMPARATORS.has(op))
|
|
211
|
+
throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`);
|
|
212
|
+
this.next();
|
|
213
|
+
const rightToken = this.next();
|
|
214
|
+
if (rightToken === undefined) {
|
|
215
|
+
throw new CriteriaSyntaxError('Expected operand on the right side of comparison');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.noteField(leftToken);
|
|
219
|
+
this.noteField(rightToken);
|
|
220
|
+
const resolveLeft = (record: Row) => this.resolveValue(leftToken, record);
|
|
221
|
+
const resolveRight = (record: Row) => this.resolveValue(rightToken, record);
|
|
222
|
+
|
|
223
|
+
return (record: Row) => {
|
|
224
|
+
const a = resolveLeft(record);
|
|
225
|
+
const b = resolveRight(record);
|
|
226
|
+
switch (op) {
|
|
227
|
+
case '==':
|
|
228
|
+
case '=':
|
|
229
|
+
return a === b;
|
|
230
|
+
case '!=':
|
|
231
|
+
case '<>':
|
|
232
|
+
return a !== b;
|
|
233
|
+
case '>':
|
|
234
|
+
return (a as any) > (b as any);
|
|
235
|
+
case '>=':
|
|
236
|
+
return (a as any) >= (b as any);
|
|
237
|
+
case '<':
|
|
238
|
+
return (a as any) < (b as any);
|
|
239
|
+
case '<=':
|
|
240
|
+
return (a as any) <= (b as any);
|
|
241
|
+
default:
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private noteField(token: string): void {
|
|
248
|
+
if (parseLiteral(token) === undefined) this.fields.add(token);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private resolveValue(token: string, record: Row): unknown {
|
|
252
|
+
const literal = parseLiteral(token);
|
|
253
|
+
if (literal !== undefined) return literal;
|
|
254
|
+
return record[token];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private parseFunctionCall(name: string): Predicate {
|
|
258
|
+
this.next(); // consume '('
|
|
259
|
+
const args: string[] = [];
|
|
260
|
+
if (this.peek() !== ')') {
|
|
261
|
+
const arg = this.next();
|
|
262
|
+
if (arg === undefined)
|
|
263
|
+
throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
|
|
264
|
+
args.push(arg);
|
|
265
|
+
while (this.peek() === ',') {
|
|
266
|
+
this.next();
|
|
267
|
+
const nextArg = this.next();
|
|
268
|
+
if (nextArg === undefined) {
|
|
269
|
+
throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
|
|
270
|
+
}
|
|
271
|
+
args.push(nextArg);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (this.next() !== ')')
|
|
275
|
+
throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`);
|
|
276
|
+
|
|
277
|
+
const dotIndex = name.lastIndexOf('.');
|
|
278
|
+
const receiver = name.slice(0, dotIndex);
|
|
279
|
+
const fnName = name.slice(dotIndex + 1);
|
|
280
|
+
const handler = Object.hasOwn(LEGACY_FUNCTIONS, fnName) ? LEGACY_FUNCTIONS[fnName] : undefined;
|
|
281
|
+
if (!handler) throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
|
|
282
|
+
const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
|
|
283
|
+
for (const arg of allArgs) this.noteField(arg);
|
|
284
|
+
|
|
285
|
+
const resolve: ExprResolver = (token, record) => this.resolveValue(token, record);
|
|
286
|
+
return (record: Row) => handler(record, allArgs, resolve);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A criteria as fidelity: 'legacy' evaluates it: its predicate and the fields
|
|
291
|
+
// it reads. Throws CriteriaSyntaxError for what this grammar can't parse
|
|
292
|
+
// and UnsupportedCriteriaError for a function it doesn't have.
|
|
293
|
+
export function parseLegacyCriteria(criteria: string): { predicate: Predicate; fields: string[] } {
|
|
294
|
+
const tokens = tokenizeCriteria(criteria.trim()).map((token) => token.text);
|
|
295
|
+
const parser = new Parser(tokens);
|
|
296
|
+
const predicate = parser.parseOr();
|
|
297
|
+
if (parser.position !== tokens.length) {
|
|
298
|
+
throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
|
|
299
|
+
}
|
|
300
|
+
return { predicate, fields: [...parser.fields] };
|
|
301
|
+
}
|
package/src/db/ordering.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Row ordering shared by the dataserver (DATA_LOGON ORDER_BY, an index name
|
|
2
|
-
// and request servers (REQ_ DETAILS.ORDER_BY, fields
|
|
1
|
+
// Row ordering shared by the dataserver (DATA_LOGON ORDER_BY, an index name;
|
|
2
|
+
// without one, RECORD_ID) and request servers (REQ_ DETAILS.ORDER_BY, fields
|
|
3
|
+
// with a direction).
|
|
3
4
|
|
|
4
5
|
import type { Row } from '../types.ts';
|
|
5
6
|
import { RECORD_ID } from './identity.ts';
|
|
@@ -22,6 +23,29 @@ export interface OrderedField {
|
|
|
22
23
|
descending: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const INTEGER_TEXT = /^-?\d+$/;
|
|
27
|
+
|
|
28
|
+
// RECORD_IDs compared as the integers they are: the engine's own stamps are
|
|
29
|
+
// numbers, but a seed may bring the real server's ~7.5e18 ids as strings,
|
|
30
|
+
// which neither a string nor a float comparison orders correctly. Anything
|
|
31
|
+
// that isn't an integer falls back to compareValues.
|
|
32
|
+
export function compareRecordIds(a: unknown, b: unknown): number {
|
|
33
|
+
const asInteger = (value: unknown) =>
|
|
34
|
+
typeof value === 'number' && Number.isSafeInteger(value)
|
|
35
|
+
? BigInt(value)
|
|
36
|
+
: typeof value === 'string' && INTEGER_TEXT.test(value)
|
|
37
|
+
? BigInt(value)
|
|
38
|
+
: undefined;
|
|
39
|
+
const [left, right] = [asInteger(a), asInteger(b)];
|
|
40
|
+
if (left === undefined || right === undefined) return compareValues(a, b);
|
|
41
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Oldest first: RECORD_ID ascending, the order rows were written in.
|
|
45
|
+
export function byRecordId(a: Row, b: Row): number {
|
|
46
|
+
return compareRecordIds(a[RECORD_ID], b[RECORD_ID]);
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
// Sorts by the fields in turn, then by RECORD_ID ascending: the tie-breaker
|
|
26
50
|
// column GSF's SQL ordering always appends (GetByCriteriaOperation.kt,
|
|
27
51
|
// tieBreakerColumn = "RECORD_ID", v8.15.29), whatever the direction.
|
|
@@ -31,7 +55,7 @@ export function rowComparator(fields: OrderedField[]): (a: Row, b: Row) => numbe
|
|
|
31
55
|
const result = compareValues(a[field], b[field]);
|
|
32
56
|
if (result !== 0) return descending ? -result : result;
|
|
33
57
|
}
|
|
34
|
-
return
|
|
58
|
+
return byRecordId(a, b);
|
|
35
59
|
};
|
|
36
60
|
}
|
|
37
61
|
|
package/src/db/schema.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './identity.ts';
|
|
22
22
|
import { deriveFields } from './metadata.ts';
|
|
23
23
|
import type { Store } from './store.ts';
|
|
24
|
+
import { columnScale } from './values.ts';
|
|
24
25
|
import { planView } from './views.ts';
|
|
25
26
|
|
|
26
27
|
export interface ResolvedField {
|
|
@@ -35,6 +36,9 @@ export interface ResolvedField {
|
|
|
35
36
|
default?: unknown;
|
|
36
37
|
maxSize?: number;
|
|
37
38
|
minLength?: number;
|
|
39
|
+
// A BIGDECIMAL's scale: a table column's always (default 5), a derived or
|
|
40
|
+
// DTO field's only when declared. See db/values.ts.
|
|
41
|
+
scale?: number;
|
|
38
42
|
description?: string;
|
|
39
43
|
title?: string;
|
|
40
44
|
// FieldDef.optional; for an event field, resolved by eventFields.
|
|
@@ -90,6 +94,7 @@ function fromFieldDef(
|
|
|
90
94
|
...(field.default !== undefined ? { default: field.default } : {}),
|
|
91
95
|
...(field.maxSize !== undefined ? { maxSize: field.maxSize } : {}),
|
|
92
96
|
...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
|
|
97
|
+
...(field.scale !== undefined ? { scale: field.scale } : {}),
|
|
93
98
|
...(field.description !== undefined ? { description: field.description } : {}),
|
|
94
99
|
...(field.title !== undefined ? { title: field.title } : {}),
|
|
95
100
|
...(field.optional !== undefined ? { optional: field.optional } : {}),
|
|
@@ -100,14 +105,16 @@ function fromFieldDef(
|
|
|
100
105
|
function declaredTableFields(tableName: string, def: TableDef): ResolvedField[] {
|
|
101
106
|
const pkFields = pkFieldsOf(def);
|
|
102
107
|
const generated = generatedFieldsOf(def);
|
|
103
|
-
return (def.fields ?? []).map((field) =>
|
|
104
|
-
fromFieldDef(
|
|
108
|
+
return (def.fields ?? []).map((field) => {
|
|
109
|
+
const resolved = fromFieldDef(
|
|
105
110
|
field,
|
|
106
111
|
tableName,
|
|
107
112
|
pkFields.includes(field.name) || generated.has(field.name),
|
|
108
113
|
generated.has(field.name),
|
|
109
|
-
)
|
|
110
|
-
|
|
114
|
+
);
|
|
115
|
+
const scale = columnScale(field);
|
|
116
|
+
return scale === undefined ? resolved : { ...resolved, scale };
|
|
117
|
+
});
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
// Inference from rows: a type per column (metadata.ts), nothing nullable but
|
|
@@ -264,7 +271,9 @@ export function resolveSourceSchema(
|
|
|
264
271
|
return { fields, declared: false };
|
|
265
272
|
}
|
|
266
273
|
|
|
267
|
-
// fieldTypes overrides the TYPE of the named fields, declared or inferred.
|
|
274
|
+
// fieldTypes overrides the TYPE of the named fields, declared or inferred. A
|
|
275
|
+
// table column made a BIGDECIMAL takes the default scale, as every column of
|
|
276
|
+
// that type has one (see db/values.ts).
|
|
268
277
|
export function withTypeOverrides(
|
|
269
278
|
schema: ResolvedSchema,
|
|
270
279
|
fieldTypes: Record<string, string> | undefined,
|
|
@@ -272,9 +281,12 @@ export function withTypeOverrides(
|
|
|
272
281
|
if (!fieldTypes) return schema;
|
|
273
282
|
return {
|
|
274
283
|
...schema,
|
|
275
|
-
fields: schema.fields.map((field) =>
|
|
276
|
-
|
|
277
|
-
|
|
284
|
+
fields: schema.fields.map((field) => {
|
|
285
|
+
const type = fieldTypes[field.name];
|
|
286
|
+
if (!type) return field;
|
|
287
|
+
const scale = field.table ? columnScale({ type, scale: field.scale }) : field.scale;
|
|
288
|
+
return { ...field, type, ...(scale !== undefined ? { scale } : {}) };
|
|
289
|
+
}),
|
|
278
290
|
};
|
|
279
291
|
}
|
|
280
292
|
|
|
@@ -303,6 +315,29 @@ export function querySchema(
|
|
|
303
315
|
);
|
|
304
316
|
}
|
|
305
317
|
|
|
318
|
+
// The Genesis type of each field a query's criteria may read, where it is
|
|
319
|
+
// known — what GSF compiles a criteria against (criteria.ts): every column of
|
|
320
|
+
// a declared source, else only the fields its fieldTypes, or its view's
|
|
321
|
+
// derivedTypes, name. A type inferred from the rows is only a guess.
|
|
322
|
+
export function queryCriteriaTypes(
|
|
323
|
+
query: QueryDef,
|
|
324
|
+
config: MockServerConfig,
|
|
325
|
+
store: Store,
|
|
326
|
+
): Map<string, string> {
|
|
327
|
+
if (isDeclared(query.source, config, store)) {
|
|
328
|
+
return new Map(
|
|
329
|
+
querySchema(query, config, store).fields.map((field) => [field.name, field.type]),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const types = new Map<string, string>();
|
|
333
|
+
const derived = viewOf(query.source, config, store)?.derivedTypes ?? {};
|
|
334
|
+
for (const [name, entry] of Object.entries(derived)) {
|
|
335
|
+
types.set(name, typeof entry === 'string' ? entry : entry.type);
|
|
336
|
+
}
|
|
337
|
+
for (const [name, type] of Object.entries(query.fieldTypes ?? {})) types.set(name, type);
|
|
338
|
+
return types;
|
|
339
|
+
}
|
|
340
|
+
|
|
306
341
|
// A resolver-only request server has no table: its fields come from the
|
|
307
342
|
// def's metadataRows sample.
|
|
308
343
|
export function requestReplySchema(
|
package/src/db/store.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Row, SequenceFormat, TableDef, ViewDef } from '../types.ts';
|
|
5
5
|
import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from './identity.ts';
|
|
6
|
+
import { columnScale, storedValue, unlistedEnumValue, type ValueField } from './values.ts';
|
|
6
7
|
import {
|
|
7
8
|
materializeEntries,
|
|
8
9
|
planView,
|
|
@@ -33,6 +34,15 @@ interface TableConfig {
|
|
|
33
34
|
sequenceWidth: number;
|
|
34
35
|
sequenceFormat: SequenceFormat;
|
|
35
36
|
generators: Generator[];
|
|
37
|
+
// The declared columns (TableDef.fields), whose values are held by type.
|
|
38
|
+
columns?: ValueField[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StoreOptions {
|
|
42
|
+
// Whether values written to a declared column are held by its type (see
|
|
43
|
+
// db/values.ts). Read on every write; the mock server turns it off under
|
|
44
|
+
// fidelity: 'legacy', where rows are kept as given. Default: on.
|
|
45
|
+
normalizeValues?: () => boolean;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
// The generators of a table, the key's own sequence first. A SEQUENCE with
|
|
@@ -155,6 +165,13 @@ export class Store {
|
|
|
155
165
|
// as it was before.
|
|
156
166
|
private savepoint: Map<string, TableSavepoint> | undefined;
|
|
157
167
|
readonly views = new Map<string, ViewDef>();
|
|
168
|
+
private readonly normalizeValues: () => boolean;
|
|
169
|
+
// ENUM values already warned about, as 'TABLE.FIELD=value'.
|
|
170
|
+
private readonly unlistedWarned = new Set<string>();
|
|
171
|
+
|
|
172
|
+
constructor({ normalizeValues = () => true }: StoreOptions = {}) {
|
|
173
|
+
this.normalizeValues = normalizeValues;
|
|
174
|
+
}
|
|
158
175
|
|
|
159
176
|
// Opens a transaction: the commit-event path wraps every handler in one
|
|
160
177
|
// (handlers/commitEvent.ts), so an event that NACKs leaves the tables as
|
|
@@ -219,11 +236,26 @@ export class Store {
|
|
|
219
236
|
sequenceWidth,
|
|
220
237
|
sequenceFormat,
|
|
221
238
|
generators: generatorsOf(pkFields, definition),
|
|
239
|
+
...(definition.fields
|
|
240
|
+
? {
|
|
241
|
+
// Key columns stay as given: every lookup (getRow, updateRow,
|
|
242
|
+
// broadcastTableChange) names a row by the key the caller has.
|
|
243
|
+
columns: definition.fields
|
|
244
|
+
.filter((field) => !pkFields.includes(field.name))
|
|
245
|
+
.map((field) => ({
|
|
246
|
+
name: field.name,
|
|
247
|
+
type: field.type,
|
|
248
|
+
scale: columnScale(field),
|
|
249
|
+
values: field.values,
|
|
250
|
+
})),
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
222
253
|
};
|
|
254
|
+
const seeded = rows.map((row) => this.withStoredValues(tableName, config, { ...row }));
|
|
223
255
|
const table = new Map<Pk, Row>();
|
|
224
256
|
const seenIds = new Set<string>();
|
|
225
257
|
let restamped = 0;
|
|
226
|
-
for (const row of
|
|
258
|
+
for (const row of seeded) {
|
|
227
259
|
const stamped = this.stampSeedRow({ ...row }, seenIds);
|
|
228
260
|
if (stamped.restamped) restamped += 1;
|
|
229
261
|
table.set(keyFor(pkFields, row), stamped.row);
|
|
@@ -242,7 +274,7 @@ export class Store {
|
|
|
242
274
|
this.previousRows.delete(tableName);
|
|
243
275
|
this.seedRows.set(
|
|
244
276
|
tableName,
|
|
245
|
-
|
|
277
|
+
seeded.map((row) => ({ ...row })),
|
|
246
278
|
);
|
|
247
279
|
|
|
248
280
|
// Each counter starts after the highest value the seed rows carry (two
|
|
@@ -251,7 +283,7 @@ export class Store {
|
|
|
251
283
|
const starts = new Map<string, number>();
|
|
252
284
|
for (const generator of config.generators) {
|
|
253
285
|
let maxN = 0;
|
|
254
|
-
for (const row of
|
|
286
|
+
for (const row of seeded) {
|
|
255
287
|
const n = this.generatedNumber(config, generator, row[generator.field]);
|
|
256
288
|
if (n !== undefined) maxN = Math.max(maxN, n);
|
|
257
289
|
}
|
|
@@ -284,7 +316,10 @@ export class Store {
|
|
|
284
316
|
if (generator.kind === 'SEQUENCE') {
|
|
285
317
|
return sequenceNumber(generator.prefix, config.sequenceFormat, value);
|
|
286
318
|
}
|
|
287
|
-
|
|
319
|
+
// A key column keeps a seeded '2' as given, but the counter still has to
|
|
320
|
+
// start after it.
|
|
321
|
+
const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
|
322
|
+
return typeof number === 'number' && Number.isSafeInteger(number) ? number : undefined;
|
|
288
323
|
}
|
|
289
324
|
|
|
290
325
|
// Throws on a join model GSF would refuse to compile (see planView).
|
|
@@ -318,6 +353,28 @@ export class Store {
|
|
|
318
353
|
return { row, restamped: !missing && !keep };
|
|
319
354
|
}
|
|
320
355
|
|
|
356
|
+
// Each declared column's value as the store holds it (db/values.ts
|
|
357
|
+
// storedValue): dates as epoch millis, BigDecimals rounded to their scale,
|
|
358
|
+
// numbers and booleans given as strings read as such. An ENUM value its
|
|
359
|
+
// field doesn't list is kept, with a warning (once per value): the real
|
|
360
|
+
// table could never hold it. Changes `row` in place and returns it.
|
|
361
|
+
private withStoredValues(tableName: string, config: TableConfig, row: Row): Row {
|
|
362
|
+
if (!config.columns || !this.normalizeValues()) return row;
|
|
363
|
+
for (const column of config.columns) {
|
|
364
|
+
if (!Object.hasOwn(row, column.name)) continue;
|
|
365
|
+
row[column.name] = storedValue(column, row[column.name]);
|
|
366
|
+
const unlisted = unlistedEnumValue(column, row[column.name]);
|
|
367
|
+
const warning = `${tableName}.${column.name}=${String(unlisted)}`;
|
|
368
|
+
if (unlisted === undefined || this.unlistedWarned.has(warning)) continue;
|
|
369
|
+
this.unlistedWarned.add(warning);
|
|
370
|
+
console.warn(
|
|
371
|
+
`[mock-server] ${tableName}.${column.name}: ${JSON.stringify(unlisted)} is not one of ` +
|
|
372
|
+
`its ENUM values (${column.values!.join(', ')})`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return row;
|
|
376
|
+
}
|
|
377
|
+
|
|
321
378
|
// A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
|
|
322
379
|
// it was given — as on the real server — appended after its own fields.
|
|
323
380
|
private stampNewRow(row: Row): Row {
|
|
@@ -457,7 +514,8 @@ export class Store {
|
|
|
457
514
|
|
|
458
515
|
// Generates every missing (undefined or null) sequence and auto-increment
|
|
459
516
|
// field — the sequencePrefix key and TableDef.generated — and keeps a
|
|
460
|
-
// supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP.
|
|
517
|
+
// supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. Declared columns
|
|
518
|
+
// are held by type (withStoredValues), as updateRow's patch is. A row already
|
|
461
519
|
// stored under the same key is replaced (the real database would reject the
|
|
462
520
|
// insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
|
|
463
521
|
// for that, as the generic CRUD events do).
|
|
@@ -467,7 +525,7 @@ export class Store {
|
|
|
467
525
|
if (!table || !config) throw new Error(`Unknown table: ${tableName}`);
|
|
468
526
|
|
|
469
527
|
this.save(tableName);
|
|
470
|
-
const row = { ...record };
|
|
528
|
+
const row = this.withStoredValues(tableName, config, { ...record });
|
|
471
529
|
this.generateValues(tableName, config, row);
|
|
472
530
|
const missing = config.pkFields.filter((field) => row[field] === undefined);
|
|
473
531
|
if (missing.length > 0) {
|
|
@@ -493,7 +551,7 @@ export class Store {
|
|
|
493
551
|
this.save(tableName);
|
|
494
552
|
const existing = table.get(pk)!;
|
|
495
553
|
this.notePrevious(tableName, existing);
|
|
496
|
-
const updated = { ...existing, ...patch };
|
|
554
|
+
const updated = { ...existing, ...this.withStoredValues(tableName, config, { ...patch }) };
|
|
497
555
|
updated[RECORD_ID] = existing[RECORD_ID];
|
|
498
556
|
updated[TIMESTAMP] = this.clock.next();
|
|
499
557
|
// A patch that changes a pk field (e.g. an entity-management form posting
|