@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,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
+ }
@@ -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 with a direction).
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 compareValues(a[RECORD_ID], b[RECORD_ID]);
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
- fieldTypes[field.name] ? { ...field, type: fieldTypes[field.name] } : field,
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 rows) {
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
- rows.map((row) => ({ ...row })),
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 rows) {
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
- return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
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. A row already
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