@genesislcap/mock-server 15.52.0 → 15.53.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 (48) hide show
  1. package/README.md +210 -27
  2. package/dist/db/criteria.d.ts +12 -0
  3. package/dist/db/criteria.js +39 -29
  4. package/dist/db/ordering.d.ts +8 -0
  5. package/dist/db/ordering.js +49 -0
  6. package/dist/db/schema.d.ts +17 -1
  7. package/dist/db/schema.js +131 -16
  8. package/dist/db/store.d.ts +8 -2
  9. package/dist/db/store.js +120 -44
  10. package/dist/handlers/commitEvent.js +32 -8
  11. package/dist/handlers/crudEvents.d.ts +2 -0
  12. package/dist/handlers/crudEvents.js +138 -0
  13. package/dist/handlers/dataLogon.js +2 -23
  14. package/dist/handlers/eventValidation.d.ts +5 -0
  15. package/dist/handlers/eventValidation.js +29 -0
  16. package/dist/handlers/jsonSchema.d.ts +3 -2
  17. package/dist/handlers/jsonSchema.js +23 -17
  18. package/dist/handlers/meta.js +13 -9
  19. package/dist/handlers/requestReply.js +518 -40
  20. package/dist/handlers/resources.js +3 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/protocol/fieldTypes.d.ts +1 -0
  23. package/dist/protocol/fieldTypes.js +6 -0
  24. package/dist/protocol/msgNack.d.ts +2 -0
  25. package/dist/protocol/msgNack.js +10 -0
  26. package/dist/protocol/schemaValidation.d.ts +11 -0
  27. package/dist/protocol/schemaValidation.js +186 -0
  28. package/dist/server.js +5 -1
  29. package/dist/types.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/db/criteria.ts +52 -27
  32. package/src/db/ordering.ts +56 -0
  33. package/src/db/schema.ts +164 -15
  34. package/src/db/store.ts +146 -46
  35. package/src/handlers/commitEvent.ts +39 -8
  36. package/src/handlers/crudEvents.ts +181 -0
  37. package/src/handlers/dataLogon.ts +2 -19
  38. package/src/handlers/eventValidation.ts +43 -0
  39. package/src/handlers/jsonSchema.ts +31 -19
  40. package/src/handlers/meta.ts +14 -9
  41. package/src/handlers/requestReply.ts +625 -41
  42. package/src/handlers/resources.ts +3 -0
  43. package/src/index.ts +1 -0
  44. package/src/protocol/fieldTypes.ts +7 -0
  45. package/src/protocol/msgNack.ts +12 -0
  46. package/src/protocol/schemaValidation.ts +208 -0
  47. package/src/server.ts +5 -1
  48. package/src/types.ts +56 -18
@@ -0,0 +1,138 @@
1
+ // The CRUD event handlers a table registers through TableDef.events: the
2
+ // ones Genesis Create generates for a table's `crud` event handlers
3
+ // (codegen-service JsonToKtsEventHandlersGenerator) — eventHandler<Table>
4
+ // "<TABLE>_INSERT" / "_MODIFY" calling entityDb.insert / modify, and
5
+ // eventHandler<Table.ById> "<TABLE>_DELETE" calling entityDb.delete, with no
6
+ // onValidate. They are what the showcase server runs (its recordings come
7
+ // from them) and what Create's real tier deploys. genesis-eventhandler's own
8
+ // GenericInsert/Modify/DeleteEventHandler (EventHandlerBuilder
9
+ // .registerCUDEvents) differ in GENERATED and VALIDATE; see the README. The
10
+ // inbound schema check runs before them, as for any event
11
+ // (eventValidation.ts).
12
+ import { RECORD_ID, TIMESTAMP } from "../db/identity.js";
13
+ import { crudEventOf, generatedFieldList, pkFieldsOf, primaryKeyIndexName, secondaryUniqueIndexes, } from "../db/schema.js";
14
+ import { NackError } from "../protocol/errors.js";
15
+ const isMissing = (value) => value === undefined || value === null;
16
+ // What DUPLICATE_KEY's FIELD shows after '<INDEX> -> ': the index's values,
17
+ // nulls left out, joined with ' | ' (DbUtil.CREATE_DUPLICATE_KEY_ERROR_MESSAGE).
18
+ function indexValueText(fields, row) {
19
+ return fields
20
+ .map((field) => row[field])
21
+ .filter((value) => !isMissing(value))
22
+ .map(String)
23
+ .join(' | ');
24
+ }
25
+ // The database's unique constraints: the primary key (unless `self` is the
26
+ // record being modified, whose key can't change) and every other unique
27
+ // index. A null in an index's fields never clashes, as in SQL.
28
+ function checkUnique(table, def, row, store, self) {
29
+ const pkFields = pkFieldsOf(def);
30
+ if (self === undefined && pkFields.every((field) => !isMissing(row[field]))) {
31
+ if (store.getRow(table, store.rowKey(table, row))) {
32
+ throw NackError.duplicateKey(table, primaryKeyIndexName(table, def), indexValueText(pkFields, row));
33
+ }
34
+ }
35
+ for (const index of secondaryUniqueIndexes(def)) {
36
+ if (index.fields.some((field) => isMissing(row[field])))
37
+ continue;
38
+ const clash = store
39
+ .getAllRows(table)
40
+ .some((other) => store.rowKey(table, other) !== self &&
41
+ index.fields.every((field) => other[field] === row[field]));
42
+ if (clash)
43
+ throw NackError.duplicateKey(table, index.name, indexValueText(index.fields, row));
44
+ }
45
+ }
46
+ const declaredNames = (def) => def.fields?.map((field) => field.name);
47
+ // The record an event's DETAILS deserialize into: a declared table's
48
+ // generated DAO gives every field the DETAILS leave out its default, or null
49
+ // (a missing not-null field without one never gets here: the schema check
50
+ // NACKs it). Generated fields stay unset for the database to fill — or, on a
51
+ // modify, to keep. An undeclared table has no DAO to go by: an insert takes
52
+ // DETAILS as they are, and a modify resets the record's other stored columns.
53
+ function defaultsFor(def, details, fieldNames) {
54
+ const generated = new Set(generatedFieldList(def));
55
+ const pkFields = pkFieldsOf(def);
56
+ const record = { ...details };
57
+ for (const name of fieldNames) {
58
+ if (Object.hasOwn(details, name) || generated.has(name) || pkFields.includes(name))
59
+ continue;
60
+ if (name === RECORD_ID || name === TIMESTAMP)
61
+ continue;
62
+ const declared = def.fields?.find((field) => field.name === name);
63
+ record[name] = declared?.default !== undefined ? declared.default : null;
64
+ }
65
+ return record;
66
+ }
67
+ // entityDb.insert(details), then ack(listOf(mapOf(<each generated field,
68
+ // sorted by name> to insertedRow.record.<field>))): every generated field's
69
+ // stored value, supplied or generated — [] for a table with none. With no
70
+ // onValidate, VALIDATE acks without touching the database.
71
+ function insert(table, def, details, ctx) {
72
+ if (ctx.validate)
73
+ return {};
74
+ const { store } = ctx;
75
+ const record = defaultsFor(def, details, declaredNames(def) ?? []);
76
+ const row = store.withGeneratedValues(table, record);
77
+ checkUnique(table, def, row, store);
78
+ const inserted = store.insertRow(table, row);
79
+ ctx.broadcastTableChange(table, 'INSERT', store.rowKey(table, inserted));
80
+ const generatedFields = generatedFieldList(def).sort();
81
+ if (generatedFields.length === 0)
82
+ return {};
83
+ return {
84
+ generated: [
85
+ Object.fromEntries(generatedFields.map((field) => [field, inserted[field] ?? null])),
86
+ ],
87
+ };
88
+ }
89
+ // entityDb.modify(details): the record is found by its primary key
90
+ // (RECORD_NOT_FOUND naming the key's index, recorded) and DETAILS are written
91
+ // over it. DETAILS are the whole record: a field they leave out is reset to
92
+ // its default or null, as the deserialized DAO's value overwrites the stored
93
+ // one (recorded: a TRADE modify without CREATED_AT, ENTERED_BY and
94
+ // TRADE_STATUS nulls them). Generated fields left out keep their values;
95
+ // RECORD_ID stays, TIMESTAMP moves on. VALIDATE acks without a lookup.
96
+ function modify(table, def, details, ctx) {
97
+ if (ctx.validate)
98
+ return {};
99
+ const { store } = ctx;
100
+ const key = store.rowKey(table, details);
101
+ const found = store.getRow(table, key);
102
+ if (!found)
103
+ throw NackError.recordNotFound(table, primaryKeyIndexName(table, def));
104
+ const patch = defaultsFor(def, details, declaredNames(def) ?? Object.keys(found));
105
+ checkUnique(table, def, { ...found, ...patch }, store, key);
106
+ store.updateRow(table, key, patch);
107
+ ctx.broadcastTableChange(table, 'MODIFY', key);
108
+ return {};
109
+ }
110
+ // entityDb.delete(<Table>.ById): a missing record is RECORD_NOT_FOUND with
111
+ // FIELD '' (recorded). VALIDATE acks without a lookup.
112
+ function remove(table, _def, details, ctx) {
113
+ if (ctx.validate)
114
+ return {};
115
+ const { store } = ctx;
116
+ const key = store.rowKey(table, details);
117
+ if (!store.getRow(table, key))
118
+ throw NackError.recordNotFound(table);
119
+ store.deleteRow(table, key);
120
+ ctx.broadcastTableChange(table, 'DELETE', key);
121
+ return {};
122
+ }
123
+ const VERBS = { INSERT: insert, MODIFY: modify, DELETE: remove };
124
+ // The generic handler for an event a table registers through
125
+ // TableDef.events, or undefined. It reads the table's definition on every
126
+ // call, so setTable() takes effect straight away.
127
+ export function crudEventHandler(eventName, config) {
128
+ const crud = crudEventOf(eventName, config);
129
+ if (!crud)
130
+ return undefined;
131
+ const { table, verb } = crud;
132
+ return (details, ctx) => {
133
+ const def = config.tables?.[table];
134
+ if (!def)
135
+ throw new Error(`Unknown table: ${table}`);
136
+ return VERBS[verb](table, def, details, ctx);
137
+ };
138
+ }
@@ -1,5 +1,5 @@
1
1
  import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
2
- import { RECORD_ID } from "../db/identity.js";
2
+ import { rowComparator } from "../db/ordering.js";
3
3
  import { queryFieldNames, querySchema } from "../db/schema.js";
4
4
  import { isLegacyFidelity } from "../protocol/fidelity.js";
5
5
  import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
@@ -85,28 +85,7 @@ function requestedFieldNames(details, query, exposed, config, store) {
85
85
  // as the real index breaks them, so REVERSE, which flips the whole order,
86
86
  // puts the newest of equal rows first.
87
87
  function indexComparator(index) {
88
- const compareValues = (a, b) => {
89
- if (a === b)
90
- return 0;
91
- if (a === undefined || a === null)
92
- return -1;
93
- if (b === undefined || b === null)
94
- return 1;
95
- if (typeof a === 'number' && typeof b === 'number')
96
- return a - b;
97
- if (typeof a === 'boolean' && typeof b === 'boolean')
98
- return a ? 1 : -1;
99
- const [left, right] = [String(a), String(b)];
100
- return left < right ? -1 : left > right ? 1 : 0;
101
- };
102
- return (a, b) => {
103
- for (const field of index.fields) {
104
- const result = compareValues(a[field], b[field]);
105
- if (result !== 0)
106
- return result;
107
- }
108
- return compareValues(a[RECORD_ID], b[RECORD_ID]);
109
- };
88
+ return rowComparator(index.fields.map((field) => ({ field, descending: false })));
110
89
  }
111
90
  // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
112
91
  // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
@@ -0,0 +1,5 @@
1
+ import type { Store } from '../db/store.ts';
2
+ import type { Connection } from '../protocol/connection.ts';
3
+ import type { NackErrorInput } from '../protocol/errors.ts';
4
+ import type { GenesisMessage, MockServerConfig } from '../types.ts';
5
+ export declare function validateEventMessage(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): NackErrorInput[];
@@ -0,0 +1,29 @@
1
+ import { eventFields, isDeclared, resolveEventTarget } from "../db/schema.js";
2
+ import { schemaFieldErrors, validateSchema } from "../protocol/schemaValidation.js";
3
+ import { eventInboundSchema } from "./jsonSchema.js";
4
+ // The schema check every event goes through on the real server before its
5
+ // handler runs (GenesisMessageDecoder, GSF v8.15.29): the whole message
6
+ // against the event's INBOUND JSON schema — the one JSON_SCHEMA_REQUEST
7
+ // serves — each failure a FieldError VALIDATION_ERROR (see
8
+ // protocol/schemaValidation.ts). An empty list means it passed.
9
+ //
10
+ // Only an event whose DETAILS class is declared is checked: a table event on
11
+ // a table with TableDef.fields, or an eventSchemas entry with `fields`. An
12
+ // inferred schema is a guess from the seed rows (a column every row leaves
13
+ // empty isn't in it at all), and checking against it would NACK payloads the
14
+ // real server accepts.
15
+ export function validateEventMessage(message, connection, config, store) {
16
+ const name = message.MESSAGE_TYPE;
17
+ const target = resolveEventTarget(name, config);
18
+ if (!target)
19
+ return [];
20
+ const declared = target.kind === 'dto' ||
21
+ (target.table !== undefined && isDeclared(target.table, config, store));
22
+ if (!declared)
23
+ return [];
24
+ const inbound = eventInboundSchema(name, target, eventFields(target, config, store), config.appName);
25
+ // The router stamps the session's user on everything it forwards, so the
26
+ // schema's required USER_NAME is always there by the time it is checked.
27
+ const forwarded = { ...message, USER_NAME: connection.userName ?? '' };
28
+ return schemaFieldErrors(validateSchema(inbound, forwarded));
29
+ }
@@ -1,6 +1,7 @@
1
- import { inferEventTable } from '../db/schema.ts';
1
+ import { inferEventTable, type EventTarget, type ResolvedField } from '../db/schema.ts';
2
2
  import type { Store } from '../db/store.ts';
3
3
  import type { Connection } from '../protocol/connection.ts';
4
- import type { GenesisMessage, MockServerConfig } from '../types.ts';
4
+ import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
5
5
  export { inferEventTable };
6
+ export declare function eventInboundSchema(eventName: string, target: EventTarget, fields: ResolvedField[], appName: string | undefined): Row;
6
7
  export declare function handleJsonSchemaRequest(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;
@@ -1,4 +1,4 @@
1
- import { eventDefaultOf, eventFields, inferEventTable, knownValues, isDeleteEventName, querySchema, RECORD_IDENTITY_FIELDS, requestFieldsOf, requestReplySchema, resolveEventTarget, resolveSourceSchema, withTypeOverrides, } from "../db/schema.js";
1
+ import { eventDefaultOf, eventFields, inferEventTable, knownValues, isDeleteEventName, querySchema, requestFieldsOf, requestReplyReplySchema, requestReplySourceSchema, resolveEventTarget, resolveSourceSchema, withTypeOverrides, } from "../db/schema.js";
2
2
  import { isLegacyFidelity } from "../protocol/fidelity.js";
3
3
  import { BIGDECIMAL_PATTERN, enumClassOf, jsonTypeOf, jvmClassOf, rangeOf, titleCase, } from "../protocol/fieldTypes.js";
4
4
  import { dataLogonReplySchema, dataLogonSchema, eventInboundProperties, eventReplySchema, GENESIS_META_SCHEMA, REQUEST_DETAILS_SCHEMA, } from "../protocol/gsfSchemas.js";
@@ -64,7 +64,10 @@ function eventProperty(field, annotated, appName) {
64
64
  function isRequired(field) {
65
65
  return !field.optional;
66
66
  }
67
- function eventSchema(eventName, target, fields, appName) {
67
+ // An event's INBOUND schema: the message, with DETAILS as the event's class.
68
+ // It is also what an inbound event is validated against
69
+ // (handlers/eventValidation.ts), as on the real server.
70
+ export function eventInboundSchema(eventName, target, fields, appName) {
68
71
  const annotated = target.kind === 'table';
69
72
  const details = {
70
73
  properties: Object.fromEntries(fields.map((field) => [field.name, eventProperty(field, annotated, appName)])),
@@ -73,16 +76,19 @@ function eventSchema(eventName, target, fields, appName) {
73
76
  type: 'object',
74
77
  };
75
78
  return {
76
- INBOUND: {
77
- properties: eventInboundProperties(eventName, details),
78
- required: ['MESSAGE_TYPE', 'SOURCE_REF', 'USER_NAME', 'DETAILS'],
79
- additionalProperties: true,
80
- description: target.className,
81
- ...(target.title ? { title: target.title } : {}),
82
- $defs: {},
83
- $schema: GENESIS_META_SCHEMA,
84
- type: 'object',
85
- },
79
+ properties: eventInboundProperties(eventName, details),
80
+ required: ['MESSAGE_TYPE', 'SOURCE_REF', 'USER_NAME', 'DETAILS'],
81
+ additionalProperties: true,
82
+ description: target.className,
83
+ ...(target.title ? { title: target.title } : {}),
84
+ $defs: {},
85
+ $schema: GENESIS_META_SCHEMA,
86
+ type: 'object',
87
+ };
88
+ }
89
+ function eventSchema(eventName, target, fields, appName) {
90
+ return {
91
+ INBOUND: eventInboundSchema(eventName, target, fields, appName),
86
92
  OUTBOUND: eventReplySchema(),
87
93
  };
88
94
  }
@@ -110,9 +116,9 @@ function dataserverSchema(queryName, query, config, store) {
110
116
  };
111
117
  }
112
118
  function requestServerSchema(name, requestReply, config, store) {
113
- const replyFields = requestReplySchema(requestReply, config, store).fields;
119
+ const entityFields = requestReplySourceSchema(requestReply, config, store).fields;
114
120
  const requestProperties = Object.fromEntries(requestFieldsOf(requestReply, store).map((fieldName) => {
115
- const field = replyFields.find((candidate) => candidate.name === fieldName);
121
+ const field = entityFields.find((candidate) => candidate.name === fieldName);
116
122
  const type = field?.type ?? 'STRING';
117
123
  return [
118
124
  fieldName,
@@ -126,9 +132,9 @@ function requestServerSchema(name, requestReply, config, store) {
126
132
  ];
127
133
  }));
128
134
  const request = { properties: requestProperties, additionalProperties: false, type: 'object' };
129
- const fields = requestReply.criteriaOnly
130
- ? [...replyFields, ...RECORD_IDENTITY_FIELDS]
131
- : replyFields;
135
+ // The reply block, else the source's fields then RECORD_ID and TIMESTAMP,
136
+ // as REPLY_FIELD (see requestReplyReplySchema).
137
+ const fields = requestReplyReplySchema(requestReply, config, store).fields;
132
138
  return {
133
139
  INBOUND: {
134
140
  properties: {
@@ -1,4 +1,4 @@
1
- import { eventDefaultOf, eventFields, knownValues, querySchema, RECORD_IDENTITY_FIELDS, requestFieldsOf, requestReplySchema, resolveEventTarget, } from "../db/schema.js";
1
+ import { eventDefaultOf, eventFields, knownValues, querySchema, requestFieldsOf, requestReplyReplySchema, requestReplySchema, requestReplySourceSchema, resolveEventTarget, } from "../db/schema.js";
2
2
  import { isLegacyFidelity } from "../protocol/fidelity.js";
3
3
  import { enumClassOf, eventMetaTypeOf, jsonTypeOf, jvmClassOf, rangeOf, titleCase, } from "../protocol/fieldTypes.js";
4
4
  import { eventReplyMeta } from "../protocol/gsfSchemas.js";
@@ -35,26 +35,30 @@ function dataserverMeta(name, query, config, store, hidden) {
35
35
  };
36
36
  }
37
37
  // The two request-server shapes of ReqRepMetaData.kt: criteria-only
38
- // (REQUEST_FIELD: [], SORTABLE_FIELDS, CRITERIA_FIELDS, RECORD_ID and
39
- // TIMESTAMP in REPLY_FIELD, no INDICES) and plain (REQUEST_FIELD from the
40
- // request block, INDICES: [], CRITERIA_ONLY_REQUEST: false).
38
+ // (REQUEST_FIELD: [], SORTABLE_FIELDS, CRITERIA_FIELDS, no INDICES) and plain
39
+ // (REQUEST_FIELD from the request block, INDICES: [],
40
+ // CRITERIA_ONLY_REQUEST: false). REPLY_FIELD is the reply block, else the
41
+ // source's fields then RECORD_ID and TIMESTAMP, for either
42
+ // (requestReplyReplySchema). SORTABLE_FIELDS and CRITERIA_FIELDS are the
43
+ // entity's fields whatever the reply block says (the dictionary entry's
44
+ // nativeQueryFields, RequestReplyServer.kt).
41
45
  function requestServerMeta(name, requestReply, config, store, hidden) {
42
46
  const visible = (field) => !hidden.has(field.name);
43
- const replyFields = requestReplySchema(requestReply, config, store).fields.filter(visible);
47
+ const replyFields = requestReplyReplySchema(requestReply, config, store).fields.filter(visible);
48
+ const entityFields = requestReplySourceSchema(requestReply, config, store).fields;
44
49
  if (requestReply.criteriaOnly) {
45
- const fields = [...replyFields, ...RECORD_IDENTITY_FIELDS.filter(visible)];
46
- const names = fields.map((field) => field.name);
50
+ const names = entityFields.filter(visible).map((field) => field.name);
47
51
  return {
48
52
  TYPE: 'REQUEST_SERVER',
49
53
  REQUEST_FIELD: [],
50
54
  CRITERIA_ONLY_REQUEST: true,
51
55
  SORTABLE_FIELDS: names,
52
56
  CRITERIA_FIELDS: names,
53
- REPLY_FIELD: fields.map(fieldMetadata),
57
+ REPLY_FIELD: replyFields.map(fieldMetadata),
54
58
  NAME: name,
55
59
  };
56
60
  }
57
- const requestFields = requestFieldsOf(requestReply, store).map((fieldName) => replyFields.find((field) => field.name === fieldName) ?? {
61
+ const requestFields = requestFieldsOf(requestReply, store).map((fieldName) => entityFields.find((field) => field.name === fieldName) ?? {
58
62
  name: fieldName,
59
63
  type: 'STRING',
60
64
  nullable: false,