@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.
- package/README.md +287 -40
- package/dist/db/criteria.d.ts +28 -9
- package/dist/db/criteria.js +856 -258
- package/dist/db/criteriaDialect.d.ts +6 -0
- package/dist/db/criteriaDialect.js +441 -0
- package/dist/db/criteriaTokens.d.ts +12 -0
- package/dist/db/criteriaTokens.js +53 -0
- package/dist/db/groovySyntax.d.ts +26 -0
- package/dist/db/groovySyntax.js +481 -0
- package/dist/db/groovyValidator.d.ts +11 -0
- package/dist/db/groovyValidator.js +523 -0
- package/dist/db/gsfExpr.d.ts +21 -0
- package/dist/db/gsfExpr.js +333 -0
- package/dist/db/identity.d.ts +2 -1
- package/dist/db/identity.js +24 -3
- package/dist/db/legacyCriteria.d.ts +7 -0
- package/dist/db/legacyCriteria.js +280 -0
- package/dist/db/ordering.d.ts +2 -0
- package/dist/db/ordering.js +24 -3
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +34 -3
- package/dist/db/store.d.ts +7 -0
- package/dist/db/store.js +55 -7
- package/dist/db/values.d.ts +19 -0
- package/dist/db/values.js +243 -0
- package/dist/handlers/commitEvent.js +3 -2
- package/dist/handlers/dataLogon.d.ts +2 -1
- package/dist/handlers/dataLogon.js +108 -51
- package/dist/handlers/eventValidation.d.ts +2 -1
- package/dist/handlers/eventValidation.js +33 -6
- package/dist/handlers/requestReply.js +43 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol/connection.d.ts +1 -1
- package/dist/server.js +15 -8
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +979 -290
- package/src/db/criteriaDialect.ts +487 -0
- package/src/db/criteriaTokens.ts +72 -0
- package/src/db/groovySyntax.ts +500 -0
- package/src/db/groovyValidator.ts +546 -0
- package/src/db/gsfExpr.ts +392 -0
- package/src/db/identity.ts +22 -4
- package/src/db/legacyCriteria.ts +301 -0
- package/src/db/ordering.ts +27 -3
- package/src/db/schema.ts +43 -8
- package/src/db/store.ts +65 -7
- package/src/db/values.ts +247 -0
- package/src/handlers/commitEvent.ts +3 -2
- package/src/handlers/dataLogon.ts +134 -56
- package/src/handlers/eventValidation.ts +40 -13
- package/src/handlers/requestReply.ts +47 -14
- package/src/index.ts +6 -1
- package/src/protocol/connection.ts +3 -4
- package/src/server.ts +16 -15
- package/src/types.ts +7 -0
package/dist/db/ordering.js
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
|
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
|
|
52
|
+
return byRecordId(a, b);
|
|
32
53
|
};
|
|
33
54
|
}
|
|
34
55
|
const ORDER_SEPARATOR = /\s*,\s*/;
|
package/dist/db/schema.d.ts
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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) {
|
package/dist/db/store.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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;
|