@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.
Files changed (57) hide show
  1. package/README.md +287 -40
  2. package/dist/db/criteria.d.ts +28 -9
  3. package/dist/db/criteria.js +856 -258
  4. package/dist/db/criteriaDialect.d.ts +6 -0
  5. package/dist/db/criteriaDialect.js +441 -0
  6. package/dist/db/criteriaTokens.d.ts +12 -0
  7. package/dist/db/criteriaTokens.js +53 -0
  8. package/dist/db/groovySyntax.d.ts +26 -0
  9. package/dist/db/groovySyntax.js +481 -0
  10. package/dist/db/groovyValidator.d.ts +11 -0
  11. package/dist/db/groovyValidator.js +523 -0
  12. package/dist/db/gsfExpr.d.ts +21 -0
  13. package/dist/db/gsfExpr.js +333 -0
  14. package/dist/db/identity.d.ts +2 -1
  15. package/dist/db/identity.js +24 -3
  16. package/dist/db/legacyCriteria.d.ts +7 -0
  17. package/dist/db/legacyCriteria.js +280 -0
  18. package/dist/db/ordering.d.ts +2 -0
  19. package/dist/db/ordering.js +24 -3
  20. package/dist/db/schema.d.ts +2 -0
  21. package/dist/db/schema.js +34 -3
  22. package/dist/db/store.d.ts +7 -0
  23. package/dist/db/store.js +55 -7
  24. package/dist/db/values.d.ts +19 -0
  25. package/dist/db/values.js +243 -0
  26. package/dist/handlers/commitEvent.js +3 -2
  27. package/dist/handlers/dataLogon.d.ts +2 -1
  28. package/dist/handlers/dataLogon.js +108 -51
  29. package/dist/handlers/eventValidation.d.ts +2 -1
  30. package/dist/handlers/eventValidation.js +33 -6
  31. package/dist/handlers/requestReply.js +43 -16
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/protocol/connection.d.ts +1 -1
  35. package/dist/server.js +15 -8
  36. package/dist/types.d.ts +1 -0
  37. package/package.json +1 -1
  38. package/src/db/criteria.ts +979 -290
  39. package/src/db/criteriaDialect.ts +487 -0
  40. package/src/db/criteriaTokens.ts +72 -0
  41. package/src/db/groovySyntax.ts +500 -0
  42. package/src/db/groovyValidator.ts +546 -0
  43. package/src/db/gsfExpr.ts +392 -0
  44. package/src/db/identity.ts +22 -4
  45. package/src/db/legacyCriteria.ts +301 -0
  46. package/src/db/ordering.ts +27 -3
  47. package/src/db/schema.ts +43 -8
  48. package/src/db/store.ts +65 -7
  49. package/src/db/values.ts +247 -0
  50. package/src/handlers/commitEvent.ts +3 -2
  51. package/src/handlers/dataLogon.ts +134 -56
  52. package/src/handlers/eventValidation.ts +40 -13
  53. package/src/handlers/requestReply.ts +47 -14
  54. package/src/index.ts +6 -1
  55. package/src/protocol/connection.ts +3 -4
  56. package/src/server.ts +16 -15
  57. package/src/types.ts +7 -0
@@ -1,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
  import { RECORD_ID } from "./identity.js";
4
5
  // Nulls sort low (first ascending, last descending), as H2's default null
5
6
  // ordering does; numbers numerically, booleans false before true, anything
@@ -18,6 +19,26 @@ export function compareValues(a, b) {
18
19
  const [left, right] = [String(a), String(b)];
19
20
  return left < right ? -1 : left > right ? 1 : 0;
20
21
  }
22
+ const INTEGER_TEXT = /^-?\d+$/;
23
+ // RECORD_IDs compared as the integers they are: the engine's own stamps are
24
+ // numbers, but a seed may bring the real server's ~7.5e18 ids as strings,
25
+ // which neither a string nor a float comparison orders correctly. Anything
26
+ // that isn't an integer falls back to compareValues.
27
+ export function compareRecordIds(a, b) {
28
+ const asInteger = (value) => typeof value === 'number' && Number.isSafeInteger(value)
29
+ ? BigInt(value)
30
+ : typeof value === 'string' && INTEGER_TEXT.test(value)
31
+ ? BigInt(value)
32
+ : undefined;
33
+ const [left, right] = [asInteger(a), asInteger(b)];
34
+ if (left === undefined || right === undefined)
35
+ return compareValues(a, b);
36
+ return left < right ? -1 : left > right ? 1 : 0;
37
+ }
38
+ // Oldest first: RECORD_ID ascending, the order rows were written in.
39
+ export function byRecordId(a, b) {
40
+ return compareRecordIds(a[RECORD_ID], b[RECORD_ID]);
41
+ }
21
42
  // Sorts by the fields in turn, then by RECORD_ID ascending: the tie-breaker
22
43
  // column GSF's SQL ordering always appends (GetByCriteriaOperation.kt,
23
44
  // tieBreakerColumn = "RECORD_ID", v8.15.29), whatever the direction.
@@ -28,7 +49,7 @@ export function rowComparator(fields) {
28
49
  if (result !== 0)
29
50
  return descending ? -result : result;
30
51
  }
31
- return compareValues(a[RECORD_ID], b[RECORD_ID]);
52
+ return byRecordId(a, b);
32
53
  };
33
54
  }
34
55
  const ORDER_SEPARATOR = /\s*,\s*/;
@@ -9,6 +9,7 @@ export interface ResolvedField {
9
9
  default?: unknown;
10
10
  maxSize?: number;
11
11
  minLength?: number;
12
+ scale?: number;
12
13
  description?: string;
13
14
  title?: string;
14
15
  optional?: boolean;
@@ -26,6 +27,7 @@ export declare function resolveSourceSchema(source: string, config: MockServerCo
26
27
  export declare function withTypeOverrides(schema: ResolvedSchema, fieldTypes: Record<string, string> | undefined): ResolvedSchema;
27
28
  export declare function projectSchema(schema: ResolvedSchema, names: string[] | undefined): ResolvedSchema;
28
29
  export declare function querySchema(query: QueryDef, config: MockServerConfig, store: Store): ResolvedSchema;
30
+ export declare function queryCriteriaTypes(query: QueryDef, config: MockServerConfig, store: Store): Map<string, string>;
29
31
  export declare function requestReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
30
32
  export declare const RECORD_IDENTITY_FIELDS: ResolvedField[];
31
33
  export declare function requestReplySourceSchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
package/dist/db/schema.js CHANGED
@@ -7,6 +7,7 @@ import { isLegacyFidelity } from "../protocol/fidelity.js";
7
7
  import { daoClassOf, titleCase } from "../protocol/fieldTypes.js";
8
8
  import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from "./identity.js";
9
9
  import { deriveFields } from "./metadata.js";
10
+ import { columnScale } from "./values.js";
10
11
  import { planView } from "./views.js";
11
12
  export function pkFieldsOf(def) {
12
13
  return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
@@ -40,6 +41,7 @@ function fromFieldDef(field, table, notNullByDefault, generated) {
40
41
  ...(field.default !== undefined ? { default: field.default } : {}),
41
42
  ...(field.maxSize !== undefined ? { maxSize: field.maxSize } : {}),
42
43
  ...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
44
+ ...(field.scale !== undefined ? { scale: field.scale } : {}),
43
45
  ...(field.description !== undefined ? { description: field.description } : {}),
44
46
  ...(field.title !== undefined ? { title: field.title } : {}),
45
47
  ...(field.optional !== undefined ? { optional: field.optional } : {}),
@@ -49,7 +51,11 @@ function fromFieldDef(field, table, notNullByDefault, generated) {
49
51
  function declaredTableFields(tableName, def) {
50
52
  const pkFields = pkFieldsOf(def);
51
53
  const generated = generatedFieldsOf(def);
52
- return (def.fields ?? []).map((field) => fromFieldDef(field, tableName, pkFields.includes(field.name) || generated.has(field.name), generated.has(field.name)));
54
+ return (def.fields ?? []).map((field) => {
55
+ const resolved = fromFieldDef(field, tableName, pkFields.includes(field.name) || generated.has(field.name), generated.has(field.name));
56
+ const scale = columnScale(field);
57
+ return scale === undefined ? resolved : { ...resolved, scale };
58
+ });
53
59
  }
54
60
  // Inference from rows: a type per column (metadata.ts), nothing nullable but
55
61
  // the key, no defaults or enum values (rows can't say).
@@ -170,13 +176,21 @@ export function resolveSourceSchema(source, config, store) {
170
176
  fields = pick(new Map(fields.map((field) => [field.name, field])), view.fields);
171
177
  return { fields, declared: false };
172
178
  }
173
- // fieldTypes overrides the TYPE of the named fields, declared or inferred.
179
+ // fieldTypes overrides the TYPE of the named fields, declared or inferred. A
180
+ // table column made a BIGDECIMAL takes the default scale, as every column of
181
+ // that type has one (see db/values.ts).
174
182
  export function withTypeOverrides(schema, fieldTypes) {
175
183
  if (!fieldTypes)
176
184
  return schema;
177
185
  return {
178
186
  ...schema,
179
- fields: schema.fields.map((field) => fieldTypes[field.name] ? { ...field, type: fieldTypes[field.name] } : field),
187
+ fields: schema.fields.map((field) => {
188
+ const type = fieldTypes[field.name];
189
+ if (!type)
190
+ return field;
191
+ const scale = field.table ? columnScale({ type, scale: field.scale }) : field.scale;
192
+ return { ...field, type, ...(scale !== undefined ? { scale } : {}) };
193
+ }),
180
194
  };
181
195
  }
182
196
  // A query's fields block: those fields only, in its order. Names the source
@@ -196,6 +210,23 @@ export function projectSchema(schema, names) {
196
210
  export function querySchema(query, config, store) {
197
211
  return projectSchema(withTypeOverrides(resolveSourceSchema(query.source, config, store), query.fieldTypes), isLegacyFidelity(config) ? undefined : query.fields);
198
212
  }
213
+ // The Genesis type of each field a query's criteria may read, where it is
214
+ // known — what GSF compiles a criteria against (criteria.ts): every column of
215
+ // a declared source, else only the fields its fieldTypes, or its view's
216
+ // derivedTypes, name. A type inferred from the rows is only a guess.
217
+ export function queryCriteriaTypes(query, config, store) {
218
+ if (isDeclared(query.source, config, store)) {
219
+ return new Map(querySchema(query, config, store).fields.map((field) => [field.name, field.type]));
220
+ }
221
+ const types = new Map();
222
+ const derived = viewOf(query.source, config, store)?.derivedTypes ?? {};
223
+ for (const [name, entry] of Object.entries(derived)) {
224
+ types.set(name, typeof entry === 'string' ? entry : entry.type);
225
+ }
226
+ for (const [name, type] of Object.entries(query.fieldTypes ?? {}))
227
+ types.set(name, type);
228
+ return types;
229
+ }
199
230
  // A resolver-only request server has no table: its fields come from the
200
231
  // def's metadataRows sample.
201
232
  export function requestReplySchema(requestReply, config, store) {
@@ -5,6 +5,9 @@ export interface ViewRowsSource {
5
5
  rowsOf?: (entry: PlannedJoin | PlannedAntiJoin) => Row[] | undefined;
6
6
  }
7
7
  type Pk = string;
8
+ export interface StoreOptions {
9
+ normalizeValues?: () => boolean;
10
+ }
8
11
  export declare class Store {
9
12
  private tables;
10
13
  private tableConfig;
@@ -16,6 +19,9 @@ export declare class Store {
16
19
  private previousRows;
17
20
  private savepoint;
18
21
  readonly views: Map<string, ViewDef>;
22
+ private readonly normalizeValues;
23
+ private readonly unlistedWarned;
24
+ constructor({ normalizeValues }?: StoreOptions);
19
25
  begin(): void;
20
26
  commit(): void;
21
27
  rollback(): void;
@@ -27,6 +33,7 @@ export declare class Store {
27
33
  private generatedNumber;
28
34
  registerView(viewName: string, definition: ViewDef): this;
29
35
  private stampSeedRow;
36
+ private withStoredValues;
30
37
  private stampNewRow;
31
38
  private noteRemoved;
32
39
  private notePrevious;
package/dist/db/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // In-memory tables + views + sequence generation. No persistence, no external deps.
2
2
  // This is the "database" — disposable and rebuilt from seed data on every server start.
3
3
  import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from "./identity.js";
4
+ import { columnScale, storedValue, unlistedEnumValue } from "./values.js";
4
5
  import { materializeEntries, planView, } from "./views.js";
5
6
  // The generators of a table, the key's own sequence first. A SEQUENCE with
6
7
  // neither its own prefix nor the table's can't mint anything and is left to
@@ -97,6 +98,12 @@ export class Store {
97
98
  // as it was before.
98
99
  savepoint;
99
100
  views = new Map();
101
+ normalizeValues;
102
+ // ENUM values already warned about, as 'TABLE.FIELD=value'.
103
+ unlistedWarned = new Set();
104
+ constructor({ normalizeValues = () => true } = {}) {
105
+ this.normalizeValues = normalizeValues;
106
+ }
100
107
  // Opens a transaction: the commit-event path wraps every handler in one
101
108
  // (handlers/commitEvent.ts), so an event that NACKs leaves the tables as
102
109
  // they were, as a GSF event whose onValidate NACKs never reaches its
@@ -152,11 +159,26 @@ export class Store {
152
159
  sequenceWidth,
153
160
  sequenceFormat,
154
161
  generators: generatorsOf(pkFields, definition),
162
+ ...(definition.fields
163
+ ? {
164
+ // Key columns stay as given: every lookup (getRow, updateRow,
165
+ // broadcastTableChange) names a row by the key the caller has.
166
+ columns: definition.fields
167
+ .filter((field) => !pkFields.includes(field.name))
168
+ .map((field) => ({
169
+ name: field.name,
170
+ type: field.type,
171
+ scale: columnScale(field),
172
+ values: field.values,
173
+ })),
174
+ }
175
+ : {}),
155
176
  };
177
+ const seeded = rows.map((row) => this.withStoredValues(tableName, config, { ...row }));
156
178
  const table = new Map();
157
179
  const seenIds = new Set();
158
180
  let restamped = 0;
159
- for (const row of rows) {
181
+ for (const row of seeded) {
160
182
  const stamped = this.stampSeedRow({ ...row }, seenIds);
161
183
  if (stamped.restamped)
162
184
  restamped += 1;
@@ -172,14 +194,14 @@ export class Store {
172
194
  this.savepoint?.delete(tableName);
173
195
  this.removedRows.delete(tableName);
174
196
  this.previousRows.delete(tableName);
175
- this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
197
+ this.seedRows.set(tableName, seeded.map((row) => ({ ...row })));
176
198
  // Each counter starts after the highest value the seed rows carry (two
177
199
  // fields of one table sharing a prefix share the counter). The key's
178
200
  // sequence only exists for a single-field pk.
179
201
  const starts = new Map();
180
202
  for (const generator of config.generators) {
181
203
  let maxN = 0;
182
- for (const row of rows) {
204
+ for (const row of seeded) {
183
205
  const n = this.generatedNumber(config, generator, row[generator.field]);
184
206
  if (n !== undefined)
185
207
  maxN = Math.max(maxN, n);
@@ -206,7 +228,10 @@ export class Store {
206
228
  if (generator.kind === 'SEQUENCE') {
207
229
  return sequenceNumber(generator.prefix, config.sequenceFormat, value);
208
230
  }
209
- return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
231
+ // A key column keeps a seeded '2' as given, but the counter still has to
232
+ // start after it.
233
+ const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
234
+ return typeof number === 'number' && Number.isSafeInteger(number) ? number : undefined;
210
235
  }
211
236
  // Throws on a join model GSF would refuse to compile (see planView).
212
237
  registerView(viewName, definition) {
@@ -238,6 +263,28 @@ export class Store {
238
263
  this.clock.observe(row[TIMESTAMP]);
239
264
  return { row, restamped: !missing && !keep };
240
265
  }
266
+ // Each declared column's value as the store holds it (db/values.ts
267
+ // storedValue): dates as epoch millis, BigDecimals rounded to their scale,
268
+ // numbers and booleans given as strings read as such. An ENUM value its
269
+ // field doesn't list is kept, with a warning (once per value): the real
270
+ // table could never hold it. Changes `row` in place and returns it.
271
+ withStoredValues(tableName, config, row) {
272
+ if (!config.columns || !this.normalizeValues())
273
+ return row;
274
+ for (const column of config.columns) {
275
+ if (!Object.hasOwn(row, column.name))
276
+ continue;
277
+ row[column.name] = storedValue(column, row[column.name]);
278
+ const unlisted = unlistedEnumValue(column, row[column.name]);
279
+ const warning = `${tableName}.${column.name}=${String(unlisted)}`;
280
+ if (unlisted === undefined || this.unlistedWarned.has(warning))
281
+ continue;
282
+ this.unlistedWarned.add(warning);
283
+ console.warn(`[mock-server] ${tableName}.${column.name}: ${JSON.stringify(unlisted)} is not one of ` +
284
+ `its ENUM values (${column.values.join(', ')})`);
285
+ }
286
+ return row;
287
+ }
241
288
  // A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
242
289
  // it was given — as on the real server — appended after its own fields.
243
290
  stampNewRow(row) {
@@ -374,7 +421,8 @@ export class Store {
374
421
  }
375
422
  // Generates every missing (undefined or null) sequence and auto-increment
376
423
  // field — the sequencePrefix key and TableDef.generated — and keeps a
377
- // supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already
424
+ // supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. Declared columns
425
+ // are held by type (withStoredValues), as updateRow's patch is. A row already
378
426
  // stored under the same key is replaced (the real database would reject the
379
427
  // insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
380
428
  // for that, as the generic CRUD events do).
@@ -384,7 +432,7 @@ export class Store {
384
432
  if (!table || !config)
385
433
  throw new Error(`Unknown table: ${tableName}`);
386
434
  this.save(tableName);
387
- const row = { ...record };
435
+ const row = this.withStoredValues(tableName, config, { ...record });
388
436
  this.generateValues(tableName, config, row);
389
437
  const missing = config.pkFields.filter((field) => row[field] === undefined);
390
438
  if (missing.length > 0) {
@@ -409,7 +457,7 @@ export class Store {
409
457
  this.save(tableName);
410
458
  const existing = table.get(pk);
411
459
  this.notePrevious(tableName, existing);
412
- const updated = { ...existing, ...patch };
460
+ const updated = { ...existing, ...this.withStoredValues(tableName, config, { ...patch }) };
413
461
  updated[RECORD_ID] = existing[RECORD_ID];
414
462
  updated[TIMESTAMP] = this.clock.next();
415
463
  // A patch that changes a pk field (e.g. an entity-management form posting
@@ -0,0 +1,19 @@
1
+ import type { GenesisFieldType } from '../types.ts';
2
+ export declare const DEFAULT_BIGDECIMAL_SCALE = 5;
3
+ export declare function columnScale(field: {
4
+ type: string;
5
+ scale?: number;
6
+ }): number | undefined;
7
+ export interface ValueField {
8
+ name: string;
9
+ type: GenesisFieldType | string;
10
+ scale?: number;
11
+ values?: string[];
12
+ }
13
+ export declare function decimalText(value: unknown, scale?: number): string | undefined;
14
+ export declare function epochMillis(value: unknown): number | undefined;
15
+ export declare function storedValue(field: ValueField, value: unknown): unknown;
16
+ export declare function unlistedEnumValue(field: ValueField, value: unknown): unknown;
17
+ export declare function eventValue(field: ValueField, value: unknown): unknown;
18
+ export declare function encodeRow(row: Record<string, any>, fields: ValueField[]): Record<string, any>;
19
+ export declare function bigDecimalFields(fields: ValueField[]): ValueField[] | undefined;
@@ -0,0 +1,243 @@
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
+ // BigDecimalSqlPrecisionConfig's default scale.
21
+ export const DEFAULT_BIGDECIMAL_SCALE = 5;
22
+ // The scale a table column's values are written with: a BIGDECIMAL's own,
23
+ // else the default; undefined for any other type.
24
+ export function columnScale(field) {
25
+ return field.type === 'BIGDECIMAL' ? (field.scale ?? DEFAULT_BIGDECIMAL_SCALE) : undefined;
26
+ }
27
+ const DECIMAL = /^([+-])?(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
28
+ // Exponents past this are no number JavaScript holds; such a value is left
29
+ // as given rather than spelled out digit by digit.
30
+ const MAX_EXPONENT = 1000;
31
+ // A decimal digit string plus one, carrying ('199' -> '200', '99' -> '100').
32
+ function incremented(digits) {
33
+ const chars = digits.split('');
34
+ for (let at = chars.length - 1; at >= 0; at -= 1) {
35
+ if (chars[at] !== '9') {
36
+ chars[at] = String(Number(chars[at]) + 1);
37
+ return chars.join('');
38
+ }
39
+ chars[at] = '0';
40
+ }
41
+ return `1${chars.join('')}`;
42
+ }
43
+ // A number or numeric string as BigDecimal.toPlainString() writes it: no
44
+ // exponent, and with `scale`, exactly that many decimals, rounded HALF_UP
45
+ // (half away from zero) as setScale does. A number is read as its shortest
46
+ // decimal form (String(n)), so 1.005 rounds to "1.01" as its literal would.
47
+ // undefined for anything that isn't a finite decimal.
48
+ export function decimalText(value, scale) {
49
+ let text;
50
+ if (typeof value === 'number') {
51
+ if (!Number.isFinite(value))
52
+ return undefined;
53
+ text = String(value);
54
+ }
55
+ else if (typeof value === 'bigint') {
56
+ text = value.toString();
57
+ }
58
+ else if (typeof value === 'string') {
59
+ text = value.trim();
60
+ }
61
+ else {
62
+ return undefined;
63
+ }
64
+ const match = DECIMAL.exec(text);
65
+ if (!match)
66
+ return undefined;
67
+ const [, sign = '', whole = '', fraction = '', exponent = '0'] = match;
68
+ if (whole === '' && fraction === '')
69
+ return undefined;
70
+ if (Math.abs(Number(exponent)) > MAX_EXPONENT)
71
+ return undefined;
72
+ const digits = whole + fraction;
73
+ const point = whole.length + Number(exponent);
74
+ let integerPart = point <= 0
75
+ ? '0'
76
+ : digits
77
+ .slice(0, point)
78
+ .padEnd(point, '0')
79
+ .replace(/^0+(?=\d)/, '');
80
+ let fractionPart = point <= 0 ? '0'.repeat(-point) + digits : digits.slice(point);
81
+ if (integerPart === '')
82
+ integerPart = '0';
83
+ if (scale !== undefined) {
84
+ const roundUp = fractionPart.length > scale && fractionPart[scale] >= '5';
85
+ fractionPart = fractionPart.slice(0, scale).padEnd(scale, '0');
86
+ if (roundUp) {
87
+ const rounded = incremented(integerPart + fractionPart);
88
+ integerPart = rounded.slice(0, rounded.length - scale) || '0';
89
+ fractionPart = rounded.slice(rounded.length - scale);
90
+ }
91
+ }
92
+ const zero = /^0*$/.test(integerPart + fractionPart);
93
+ return `${sign === '-' && !zero ? '-' : ''}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`;
94
+ }
95
+ const NUMERIC = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
96
+ const INTEGER = /^[+-]?\d+$/;
97
+ // yyyyMMdd; Genesis's yyyyMMdd-HH:mm[:ss[.SSS]]; ISO-8601 (a space for the T
98
+ // too), the offset optional: without one it is UTC, not the machine's zone.
99
+ const DAY = /^(\d{4})(\d{2})(\d{2})$/;
100
+ const GENESIS_DATE_TIME = /^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
101
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
102
+ const MS_DIGITS = 3;
103
+ // The matched parts utcMillis takes: year, month, day, hour, minute, second,
104
+ // then the fraction; an ISO match has the offset after them.
105
+ const FRACTION_PART = 6;
106
+ const OFFSET_PART = FRACTION_PART + 2;
107
+ const MINUTES_PER_HOUR = 60;
108
+ const MS_PER_MINUTE = 60_000;
109
+ // Epoch millis of a UTC date and time given as matched digits, or undefined
110
+ // when a part is out of range (month 13, 30 February, 25:00 ...).
111
+ function utcMillis(parts) {
112
+ const [year, month, day, hour = 0, minute = 0, second = 0] = parts
113
+ .slice(0, FRACTION_PART)
114
+ .map((part) => (part === undefined ? undefined : Number(part)));
115
+ const ms = Number((parts[FRACTION_PART] ?? '0').padEnd(MS_DIGITS, '0').slice(0, MS_DIGITS));
116
+ const time = Date.UTC(year, month - 1, day, hour, minute, second, ms);
117
+ const date = new Date(time);
118
+ const exact = date.getUTCFullYear() === year &&
119
+ date.getUTCMonth() === month - 1 &&
120
+ date.getUTCDate() === day &&
121
+ date.getUTCHours() === hour &&
122
+ date.getUTCMinutes() === minute &&
123
+ date.getUTCSeconds() === second;
124
+ return exact ? time : undefined;
125
+ }
126
+ // An ISO offset ('Z', '+01:00', '-0530', '+02') in minutes east of UTC.
127
+ function offsetMinutes(offset) {
128
+ if (!offset || offset.toUpperCase() === 'Z')
129
+ return 0;
130
+ const digits = offset.slice(1).replace(':', '');
131
+ const minutes = Number(digits.slice(0, 2)) * MINUTES_PER_HOUR + Number(digits.slice(2) || '0');
132
+ return offset.startsWith('-') ? -minutes : minutes;
133
+ }
134
+ // A date as epoch millis: a number, a Date, an integer, yyyyMMdd, Genesis's
135
+ // yyyyMMdd-HH:mm[:ss[.SSS]] or ISO-8601. undefined for anything else, or a
136
+ // date that doesn't exist; nothing is guessed (no Date.parse).
137
+ export function epochMillis(value) {
138
+ if (typeof value === 'number')
139
+ return Number.isFinite(value) ? value : undefined;
140
+ if (value instanceof Date) {
141
+ const time = value.getTime();
142
+ return Number.isNaN(time) ? undefined : time;
143
+ }
144
+ if (typeof value !== 'string')
145
+ return undefined;
146
+ const text = value.trim();
147
+ // Eight digits are a day (yyyyMMdd), not a moment in January 1970.
148
+ const day = DAY.exec(text);
149
+ if (day)
150
+ return utcMillis(day.slice(1));
151
+ if (INTEGER.test(text)) {
152
+ const millis = Number(text);
153
+ return Number.isSafeInteger(millis) ? millis : undefined;
154
+ }
155
+ const genesis = GENESIS_DATE_TIME.exec(text);
156
+ if (genesis)
157
+ return utcMillis(genesis.slice(1));
158
+ const iso = ISO_DATE_TIME.exec(text);
159
+ if (!iso)
160
+ return undefined;
161
+ const time = utcMillis(iso.slice(1, OFFSET_PART));
162
+ return time === undefined ? undefined : time - offsetMinutes(iso[OFFSET_PART]) * MS_PER_MINUTE;
163
+ }
164
+ const INTEGER_TYPES = new Set(['INT', 'SHORT', 'LONG', 'NANO_TIMESTAMP']);
165
+ const DATE_TYPES = new Set(['DATE', 'DATETIME']);
166
+ // A finite number from decimal text, or undefined.
167
+ function finite(text) {
168
+ if (text === undefined)
169
+ return undefined;
170
+ const number = Number(text);
171
+ return Number.isFinite(number) ? number : undefined;
172
+ }
173
+ // A value written to a declared column, as the store holds it (see the
174
+ // header). A value that can't be read as its type is kept as given; an empty
175
+ // string in a non-text column is null, as SendIt reads an empty cell.
176
+ export function storedValue(field, value) {
177
+ if (value === undefined || value === null)
178
+ return value;
179
+ const { type } = field;
180
+ const textual = type === 'STRING' || type === 'ENUM' || type === 'RAW';
181
+ if (!textual && value === '')
182
+ return null;
183
+ if (type === 'BIGDECIMAL')
184
+ return finite(decimalText(value, field.scale)) ?? value;
185
+ if (DATE_TYPES.has(type))
186
+ return epochMillis(value) ?? value;
187
+ if (typeof value !== 'string')
188
+ return value;
189
+ // An integer beyond 2^53 has no exact number: it stays the string it was.
190
+ if (INTEGER_TYPES.has(type)) {
191
+ const number = INTEGER.test(value.trim()) ? Number(value) : undefined;
192
+ return number !== undefined && Number.isSafeInteger(number) ? number : value;
193
+ }
194
+ if (type === 'DOUBLE') {
195
+ return NUMERIC.test(value.trim()) ? (finite(value.trim()) ?? value) : value;
196
+ }
197
+ if (type === 'BOOLEAN') {
198
+ const lower = value.trim().toLowerCase();
199
+ return lower === 'true' ? true : lower === 'false' ? false : value;
200
+ }
201
+ return value;
202
+ }
203
+ // An ENUM value its field doesn't list, or undefined. A field with no
204
+ // values accepts anything.
205
+ export function unlistedEnumValue(field, value) {
206
+ if (field.type !== 'ENUM' || !field.values?.length)
207
+ return undefined;
208
+ if (value === undefined || value === null)
209
+ return undefined;
210
+ return field.values.includes(value) ? undefined : value;
211
+ }
212
+ // An event's BigDecimal DETAILS as the handler gets them: a number, as GSF
213
+ // deserializes the schema's string into a BigDecimal (not rounded: the
214
+ // column's scale applies when it is written).
215
+ export function eventValue(field, value) {
216
+ if (field.type !== 'BIGDECIMAL' || typeof value !== 'string')
217
+ return value;
218
+ return finite(decimalText(value)) ?? value;
219
+ }
220
+ // A row as it goes on the wire: each BIGDECIMAL a string with its scale. The
221
+ // row itself when nothing changes.
222
+ export function encodeRow(row, fields) {
223
+ let encoded;
224
+ for (const field of fields) {
225
+ if (field.type !== 'BIGDECIMAL')
226
+ continue;
227
+ const value = row[field.name];
228
+ if (value === undefined || value === null)
229
+ continue;
230
+ const text = decimalText(value, field.scale);
231
+ if (text === undefined || text === value)
232
+ continue;
233
+ encoded ??= { ...row };
234
+ encoded[field.name] = text;
235
+ }
236
+ return encoded ?? row;
237
+ }
238
+ // The BIGDECIMAL fields among these, for encodeRow; undefined when there are
239
+ // none, so a caller can skip encoding altogether.
240
+ export function bigDecimalFields(fields) {
241
+ const found = fields.filter((field) => field.type === 'BIGDECIMAL');
242
+ return found.length > 0 ? found : undefined;
243
+ }
@@ -3,7 +3,7 @@ import { isLegacyFidelity } from "../protocol/fidelity.js";
3
3
  import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
4
  import { resourceNotMappedNack } from "../protocol/msgNack.js";
5
5
  import { crudEventHandler } from "./crudEvents.js";
6
- import { validateEventMessage } from "./eventValidation.js";
6
+ import { eventDetails, validateEventMessage } from "./eventValidation.js";
7
7
  // The handler serving an event: an eventHandlers entry, else the generic CRUD
8
8
  // handler a table registers through TableDef.events, else
9
9
  // defaultEventHandler.
@@ -123,7 +123,8 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
123
123
  const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
124
124
  if (schemaErrors.length > 0)
125
125
  throw new NackError(schemaErrors);
126
- const result = handler(message.DETAILS ?? {}, {
126
+ const details = legacy ? (message.DETAILS ?? {}) : eventDetails(message, config, store);
127
+ const result = handler(details, {
127
128
  connection,
128
129
  store,
129
130
  message,
@@ -1,11 +1,12 @@
1
1
  import type { Store } from '../db/store.ts';
2
2
  import type { Connection } from '../protocol/connection.ts';
3
- import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
3
+ import type { GenesisMessage, MockServerConfig, QueryDef, Row } from '../types.ts';
4
4
  export declare function pagingOptions(details: Row): {
5
5
  maxRows: number;
6
6
  maxView: number;
7
7
  };
8
8
  export declare function pendingKey(rowRef: unknown): string | undefined;
9
+ export declare function rowEncoder(query: QueryDef, config: MockServerConfig, store: Store): ((row: Row) => Row) | undefined;
9
10
  export declare function handleDataLogon(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;
10
11
  export declare function handleMoreRows(message: GenesisMessage, connection: Connection, config: MockServerConfig): void;
11
12
  export declare function handleDataLogoff(message: GenesisMessage, connection: Connection, config: MockServerConfig): void;