@genesislcap/mock-server 15.51.1 → 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 (70) hide show
  1. package/README.md +408 -39
  2. package/dist/db/criteria.d.ts +13 -0
  3. package/dist/db/criteria.js +60 -21
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/ordering.d.ts +8 -0
  7. package/dist/db/ordering.js +49 -0
  8. package/dist/db/schema.d.ts +62 -0
  9. package/dist/db/schema.js +490 -0
  10. package/dist/db/store.d.ts +20 -3
  11. package/dist/db/store.js +300 -50
  12. package/dist/handlers/commitEvent.js +47 -9
  13. package/dist/handlers/crudEvents.d.ts +2 -0
  14. package/dist/handlers/crudEvents.js +138 -0
  15. package/dist/handlers/dataLogon.js +105 -6
  16. package/dist/handlers/eventValidation.d.ts +5 -0
  17. package/dist/handlers/eventValidation.js +29 -0
  18. package/dist/handlers/jsonSchema.d.ts +4 -2
  19. package/dist/handlers/jsonSchema.js +222 -61
  20. package/dist/handlers/meta.d.ts +6 -1
  21. package/dist/handlers/meta.js +168 -21
  22. package/dist/handlers/requestReply.js +519 -30
  23. package/dist/handlers/resourceAuth.d.ts +1 -0
  24. package/dist/handlers/resourceAuth.js +14 -0
  25. package/dist/handlers/resources.js +3 -0
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.js +2 -1
  28. package/dist/protocol/connection.d.ts +2 -0
  29. package/dist/protocol/errors.d.ts +100 -2
  30. package/dist/protocol/errors.js +300 -12
  31. package/dist/protocol/fieldTypes.d.ts +10 -0
  32. package/dist/protocol/fieldTypes.js +107 -0
  33. package/dist/protocol/gsfSchemas.d.ts +9 -0
  34. package/dist/protocol/gsfSchemas.js +423 -0
  35. package/dist/protocol/messageTypes.d.ts +1 -0
  36. package/dist/protocol/messageTypes.js +1 -0
  37. package/dist/protocol/msgNack.d.ts +2 -0
  38. package/dist/protocol/msgNack.js +10 -0
  39. package/dist/protocol/rowUpdate.d.ts +5 -3
  40. package/dist/protocol/rowUpdate.js +33 -15
  41. package/dist/protocol/schemaValidation.d.ts +11 -0
  42. package/dist/protocol/schemaValidation.js +186 -0
  43. package/dist/server.js +95 -29
  44. package/dist/types.d.ts +45 -0
  45. package/package.json +1 -1
  46. package/src/db/criteria.ts +72 -21
  47. package/src/db/identity.ts +53 -0
  48. package/src/db/ordering.ts +56 -0
  49. package/src/db/schema.ts +672 -0
  50. package/src/db/store.ts +343 -50
  51. package/src/handlers/commitEvent.ts +54 -10
  52. package/src/handlers/crudEvents.ts +181 -0
  53. package/src/handlers/dataLogon.ts +127 -7
  54. package/src/handlers/eventValidation.ts +43 -0
  55. package/src/handlers/jsonSchema.ts +302 -69
  56. package/src/handlers/meta.ts +227 -23
  57. package/src/handlers/requestReply.ts +626 -33
  58. package/src/handlers/resourceAuth.ts +18 -0
  59. package/src/handlers/resources.ts +3 -0
  60. package/src/index.ts +17 -1
  61. package/src/protocol/connection.ts +10 -2
  62. package/src/protocol/errors.ts +361 -13
  63. package/src/protocol/fieldTypes.ts +115 -0
  64. package/src/protocol/gsfSchemas.ts +505 -0
  65. package/src/protocol/messageTypes.ts +1 -0
  66. package/src/protocol/msgNack.ts +12 -0
  67. package/src/protocol/rowUpdate.ts +40 -20
  68. package/src/protocol/schemaValidation.ts +208 -0
  69. package/src/server.ts +106 -28
  70. package/src/types.ts +187 -5
@@ -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,9 +1,11 @@
1
- import { compileCriteria, filterByCriteria } from "../db/criteria.js";
1
+ import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
2
+ import { rowComparator } from "../db/ordering.js";
3
+ import { queryFieldNames, querySchema } from "../db/schema.js";
2
4
  import { isLegacyFidelity } from "../protocol/fidelity.js";
3
5
  import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
4
6
  import { nonExistentSubscriptionNack, resourceNotMappedNack, subscriptionAlreadyExistsNack, } from "../protocol/msgNack.js";
5
- import { stampRow } from "../protocol/rowUpdate.js";
6
- import { applyResourceAuth, requestingUser } from "./resourceAuth.js";
7
+ import { dataserverRow, rowRefFor, stampRow } from "../protocol/rowUpdate.js";
8
+ import { permittedQueryRow, requestingUser } from "./resourceAuth.js";
7
9
  // The real dataserver's defaults (genesis-pal-dataserver ClientOptions,
8
10
  // GSF 8.15.x) — also what foundation-comms' DatasourceDefaults send.
9
11
  const DEFAULT_MAX_ROWS = 250;
@@ -57,6 +59,34 @@ function takePage(subscription) {
57
59
  }
58
60
  return page;
59
61
  }
62
+ // The dataserver's own refusal of a logon (genesis-pal-dataserver): a
63
+ // LOGON_NACK with a StandardError, sent instead of LOGON_ACK. No subscription
64
+ // is opened, so a later DATA_LOGOFF on it gets the router's 404.
65
+ function logonNack(code, text) {
66
+ return {
67
+ MESSAGE_TYPE: MESSAGE_TYPE.LOGON_NACK,
68
+ ERROR: [{ '@type': 'StandardError', CODE: code, TEXT: text, STATUS_CODE: '400 Bad Request' }],
69
+ WARNING: [],
70
+ };
71
+ }
72
+ // DETAILS.FIELDS: a space-separated projection of the query's columns, in the
73
+ // query's order. Names it doesn't expose are dropped, as the real dataserver
74
+ // drops them.
75
+ function requestedFieldNames(details, query, exposed, config, store) {
76
+ const requested = typeof details.FIELDS === 'string' && details.FIELDS.trim() !== ''
77
+ ? details.FIELDS.trim().split(/\s+/)
78
+ : undefined;
79
+ if (!requested)
80
+ return exposed;
81
+ const available = exposed ?? querySchema(query, config, store).fields.map((field) => field.name);
82
+ return available.filter((name) => requested.includes(name));
83
+ }
84
+ // Ascending by the index's fields, nulls first. Ties are broken by RECORD_ID,
85
+ // as the real index breaks them, so REVERSE, which flips the whole order,
86
+ // puts the newest of equal rows first.
87
+ function indexComparator(index) {
88
+ return rowComparator(index.fields.map((field) => ({ field, descending: false })));
89
+ }
60
90
  // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
61
91
  // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
62
92
  // registered so MORE_ROWS can page the rest and later inserts/amends/deletes
@@ -87,6 +117,32 @@ export function handleDataLogon(message, connection, config, store) {
87
117
  }
88
118
  const criteria = details.CRITERIA_MATCH;
89
119
  const user = requestingUser(connection);
120
+ // The query's fields block (or the source's declared columns) is all a
121
+ // criteria may name and all a row carries; DETAILS.FIELDS narrows the rows.
122
+ // Legacy subscriptions ignore all of it, and ORDER_BY, as 15.47 did.
123
+ const exposed = queryFieldNames(query, config, store);
124
+ let compare;
125
+ let reverse = false;
126
+ if (!legacy) {
127
+ // The real dataserver lets a criteria read only the query's own fields
128
+ // (QueryClientFactory.kt). Unknown columns are only checked when the
129
+ // query's fields are known; an inferred query fails open as before.
130
+ const outside = exposed && criteriaFields(criteria)?.find((field) => !exposed.includes(field));
131
+ if (outside) {
132
+ connection.reply(message, logonNack('INVALID_CRITERIA', `GenericCriteriaValidationException : Criteria validation failed: Variable ${outside} is not allowed in criteria expression`));
133
+ return;
134
+ }
135
+ // ORDER_BY names one of the query's own indexes (QueryDef.indexes).
136
+ if (details.ORDER_BY) {
137
+ const index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
138
+ if (!index) {
139
+ connection.reply(message, logonNack('INVALID_INDEX', `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`));
140
+ return;
141
+ }
142
+ compare = indexComparator(index);
143
+ reverse = details.REVERSE === true;
144
+ }
145
+ }
90
146
  // Order matters: the query's own filter (the where-clause equivalent) and
91
147
  // the user's row permissioning narrow the set BEFORE the client's
92
148
  // CRITERIA_MATCH; hidden fields are stripped last, off the final rows.
@@ -101,20 +157,38 @@ export function handleDataLogon(message, connection, config, store) {
101
157
  // the count is taken after it — an assumption, as no recording covers it.
102
158
  const rowsCount = rows.length;
103
159
  rows = filterByCriteria(rows, criteria);
160
+ if (compare) {
161
+ rows = [...rows].sort(compare);
162
+ if (reverse)
163
+ rows.reverse();
164
+ }
104
165
  // Permissioning is applied per row so the ROW_REF can still be computed from
105
166
  // the UNSTRIPPED row — pk fields may themselves be hidden from this user,
106
167
  // and a snapshot row with no ROW_REF can never be matched to the later
107
168
  // INSERT/MODIFY/DELETE pushes (which stamp the real ref, see server.ts).
169
+ // RECORD_ID (or, in legacy mode, the pk fields) is never sent unless the
170
+ // query's fields block names it.
171
+ const getRowRef = (row) => rowRefFor(config, store, query.source, row);
172
+ const fieldNames = legacy
173
+ ? undefined
174
+ : requestedFieldNames(details, query, exposed, config, store);
108
175
  const stampedRows = [];
176
+ // Each sent-or-waiting row as stored, by pending key, for ORDER_BY's live
177
+ // INSERT placement below.
178
+ const rawRows = new Map();
109
179
  for (const row of rows) {
110
- const [permitted] = applyResourceAuth([row], query.auth, user);
180
+ const permitted = permittedQueryRow(row, query.auth, user, fieldNames);
111
181
  if (!permitted)
112
182
  continue;
113
- stampedRows.push(stampRow(permitted, store.getRowRef(query.source, row), 'INSERT'));
183
+ const rowRef = getRowRef(row);
184
+ const key = pendingKey(rowRef);
185
+ if (key !== undefined)
186
+ rawRows.set(key, row);
187
+ stampedRows.push(stampRow(dataserverRow(permitted, row, query.fields), rowRef, 'INSERT', legacy));
114
188
  }
115
189
  const subscription = {
116
190
  resourceName,
117
- getRowRef: (row) => store.getRowRef(query.source, row),
191
+ getRowRef,
118
192
  predicate: compileCriteria(criteria),
119
193
  sequenceId: 1,
120
194
  };
@@ -140,7 +214,32 @@ export function handleDataLogon(message, connection, config, store) {
140
214
  held: new Set(),
141
215
  maxRows,
142
216
  maxView,
217
+ fieldNames,
143
218
  };
219
+ // Under ORDER_BY a live INSERT that sorts among the rows still waiting for
220
+ // MORE_ROWS joins them in order rather than being pushed out of order; one
221
+ // that sorts before them lands in what the client has, and is pushed. (A
222
+ // MODIFY of a waiting row keeps its place.)
223
+ if (compare) {
224
+ const ascending = compare;
225
+ const sortCompare = reverse ? (a, b) => ascending(b, a) : ascending;
226
+ tracked.queueInsert = (key, stamped, row) => {
227
+ const waiting = [...tracked.pending];
228
+ const first = waiting[0] && rawRows.get(waiting[0][0]);
229
+ if (!first || sortCompare(row, first) < 0)
230
+ return false;
231
+ const at = waiting.findIndex(([waitingKey]) => {
232
+ const other = rawRows.get(waitingKey);
233
+ return other !== undefined && sortCompare(row, other) < 0;
234
+ });
235
+ waiting.splice(at < 0 ? waiting.length : at, 0, [key, stamped]);
236
+ tracked.pending.clear();
237
+ for (const [waitingKey, waitingRow] of waiting)
238
+ tracked.pending.set(waitingKey, waitingRow);
239
+ rawRows.set(key, row);
240
+ return true;
241
+ };
242
+ }
144
243
  const firstPage = takePage(tracked);
145
244
  const pending = tracked.pending;
146
245
  connection.subscribe(sourceRef, tracked);
@@ -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,5 +1,7 @@
1
+ import { inferEventTable, type EventTarget, type ResolvedField } from '../db/schema.ts';
1
2
  import type { Store } from '../db/store.ts';
2
3
  import type { Connection } from '../protocol/connection.ts';
3
- import type { GenesisMessage, MockServerConfig } from '../types.ts';
4
- export declare function inferEventTable(eventName: string | undefined, tableNames: string[]): string | undefined;
4
+ import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
5
+ export { inferEventTable };
6
+ export declare function eventInboundSchema(eventName: string, target: EventTarget, fields: ResolvedField[], appName: string | undefined): Row;
5
7
  export declare function handleJsonSchemaRequest(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;
@@ -1,21 +1,217 @@
1
- import { deriveFields } from "../db/metadata.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
+ import { BIGDECIMAL_PATTERN, enumClassOf, jsonTypeOf, jvmClassOf, rangeOf, titleCase, } from "../protocol/fieldTypes.js";
4
+ import { dataLogonReplySchema, dataLogonSchema, eventInboundProperties, eventReplySchema, GENESIS_META_SCHEMA, REQUEST_DETAILS_SCHEMA, } from "../protocol/gsfSchemas.js";
3
5
  import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
6
  import { resourceNotMappedNack } from "../protocol/msgNack.js";
7
+ // Kept here for existing importers; the resolver lives in db/schema.ts.
8
+ export { inferEventTable };
5
9
  // JSON_SCHEMA_REQUEST shares its request envelope with META_REQUEST (same
6
10
  // DETAILS.FEATURE resource-name field) but its reply is a completely
7
11
  // different shape: Connect.getJSONSchema doesn't read response.DETAILS at
8
12
  // all — the response *is* the SchemaResponse ({ INBOUND, OUTBOUND }, each a
9
- // draft-07 JSON Schema). @genesislcap/foundation-entity-management's
10
- // <entity-management> calls this for every create/update form (fields come
11
- // from INBOUND.properties.DETAILS.properties) and for delete confirmation
12
- // (it reads Object.keys(...properties) to know which fields to copy from the
13
- // selected row into the commit DETAILS). Route this to handleMetaRequest by
14
- // mistake and forms silently render with zero fields, and delete can never
15
- // build a payload — no error, just a dead button. The real success reply is
16
- // JSON_SCHEMA_FEATURE_ACK with INBOUND/OUTBOUND at the top level (legacy mode
17
- // echoed JSON_SCHEMA_REQUEST).
18
- function jsonSchemaType(fieldType) {
13
+ // JSON Schema). Who reads what:
14
+ // - foundation-forms' <foundation-form> (and so entity-management's create
15
+ // and update forms) renders INBOUND.properties.DETAILS of an EVENT: its
16
+ // properties, required, enum, default, title, and genesisType / the
17
+ // org.joda.time.DateTime description for date pickers.
18
+ // - entity-management's delete copies Object.keys(INBOUND...DETAILS
19
+ // .properties) from the selected row into the commit DETAILS.
20
+ // - foundation-forms' <foundation-filters> renders a QUERY's or REQUEST
21
+ // SERVER's rows: OUTBOUND.properties.REPLY.items, else
22
+ // OUTBOUND.$defs[...QueryRow].
23
+ // Route this to handleMetaRequest by mistake and forms silently render with
24
+ // zero fields, and delete can never build a payload — no error, just a dead
25
+ // button.
26
+ // --- events -----------------------------------------------------------------
27
+ // One DETAILS property of an event's INBOUND schema. Nullable fields are
28
+ // oneOf [schema, null]; BigDecimal is a string with a pattern and no
29
+ // genesisType; dates are integers (epoch millis) described as DateTime. Only
30
+ // a table event's DAO class has titles, lengths, ranges and genesisTypes
31
+ // (see meta.ts eventMetaField).
32
+ function eventProperty(field, annotated, appName) {
33
+ const type = field.type;
34
+ const property = {};
35
+ if (annotated && type === 'STRING') {
36
+ if (field.minLength !== undefined)
37
+ property.minLength = field.minLength;
38
+ if (field.maxSize !== undefined)
39
+ property.maxLength = field.maxSize;
40
+ }
41
+ if (type === 'BIGDECIMAL')
42
+ property.pattern = BIGDECIMAL_PATTERN;
43
+ const range = annotated ? rangeOf(type) : undefined;
44
+ if (range)
45
+ [property.minimum, property.maximum] = range;
46
+ property.readOnly = false;
47
+ if (type === 'ENUM' && knownValues(field))
48
+ property.enum = knownValues(field);
49
+ property.description =
50
+ field.description ?? jvmClassOf(type, () => enumClassOf(field.table, field.name, appName));
51
+ const fieldDefault = eventDefaultOf(field);
52
+ if (fieldDefault !== undefined)
53
+ property.default = fieldDefault;
54
+ if (annotated)
55
+ property.title = field.title ?? titleCase(field.name);
56
+ if (annotated && type !== 'BIGDECIMAL')
57
+ property.genesisType = type;
58
+ property.type = jsonTypeOf(type);
59
+ return field.nullable ? { oneOf: [property, { type: 'null' }] } : property;
60
+ }
61
+ // required: the fields that aren't optional (JsonSchema.kt:478), as
62
+ // eventFields resolved them. A sequence key is optional: the database fills
63
+ // it in; a nullable DTO field with no default is still required.
64
+ function isRequired(field) {
65
+ return !field.optional;
66
+ }
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) {
71
+ const annotated = target.kind === 'table';
72
+ const details = {
73
+ properties: Object.fromEntries(fields.map((field) => [field.name, eventProperty(field, annotated, appName)])),
74
+ required: fields.filter(isRequired).map((field) => field.name),
75
+ additionalProperties: false,
76
+ type: 'object',
77
+ };
78
+ return {
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),
92
+ OUTBOUND: eventReplySchema(),
93
+ };
94
+ }
95
+ // --- dataservers and request servers ---------------------------------------
96
+ function typeSchema(field) {
97
+ return {
98
+ type: jsonTypeOf(field.type),
99
+ ...(field.type === 'ENUM' && knownValues(field) ? { enum: knownValues(field) } : {}),
100
+ };
101
+ }
102
+ // A row property in a reply schema: nullable as oneOf [null, schema], with
103
+ // an empty description and the Genesis type (BigDecimal included).
104
+ function rowProperty(field, nullable) {
105
+ return nullable
106
+ ? { oneOf: [{ type: 'null' }, typeSchema(field)], description: '', genesisType: field.type }
107
+ : { ...typeSchema(field), description: '', genesisType: field.type };
108
+ }
109
+ // Every QueryRow property is nullable: a live MODIFY carries only the fields
110
+ // that changed.
111
+ function dataserverSchema(queryName, query, config, store) {
112
+ const fields = querySchema(query, config, store).fields;
113
+ return {
114
+ INBOUND: dataLogonSchema(queryName),
115
+ OUTBOUND: dataLogonReplySchema(Object.fromEntries(fields.map((field) => [field.name, rowProperty(field, true)]))),
116
+ };
117
+ }
118
+ function requestServerSchema(name, requestReply, config, store) {
119
+ const entityFields = requestReplySourceSchema(requestReply, config, store).fields;
120
+ const requestProperties = Object.fromEntries(requestFieldsOf(requestReply, store).map((fieldName) => {
121
+ const field = entityFields.find((candidate) => candidate.name === fieldName);
122
+ const type = field?.type ?? 'STRING';
123
+ return [
124
+ fieldName,
125
+ {
126
+ readOnly: false,
127
+ ...(type === 'ENUM' && field && knownValues(field) ? { enum: knownValues(field) } : {}),
128
+ description: '',
129
+ genesisType: type,
130
+ type: jsonTypeOf(type),
131
+ },
132
+ ];
133
+ }));
134
+ const request = { properties: requestProperties, additionalProperties: false, type: 'object' };
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;
138
+ return {
139
+ INBOUND: {
140
+ properties: {
141
+ MESSAGE_TYPE: { const: `REQ_${name}`, description: 'Message type', type: 'string' },
142
+ SOURCE_REF: { description: 'Source reference', type: 'string' },
143
+ USER_NAME: { description: 'User name', type: 'string' },
144
+ REQUEST: { oneOf: [request, { items: request, type: 'array' }] },
145
+ DETAILS: REQUEST_DETAILS_SCHEMA,
146
+ },
147
+ required: ['MESSAGE_TYPE', 'SOURCE_REF', 'USER_NAME', 'REQUEST'],
148
+ additionalProperties: false,
149
+ description: `GPAL Request definition for ${name}`,
150
+ $defs: {},
151
+ type: 'object',
152
+ },
153
+ OUTBOUND: {
154
+ description: `GPAL Reply definition for ${name}`,
155
+ type: 'object',
156
+ properties: {
157
+ MESSAGE_TYPE: { const: `REP_${name}` },
158
+ SOURCE_REF: { type: 'string' },
159
+ REPLY: {
160
+ type: 'array',
161
+ items: {
162
+ type: 'object',
163
+ properties: Object.fromEntries(fields.map((field) => [field.name, rowProperty(field, field.nullable)])),
164
+ additionalProperties: false,
165
+ required: [],
166
+ },
167
+ },
168
+ NEXT_VIEW: { type: 'number' },
169
+ },
170
+ required: ['MESSAGE_TYPE', 'SOURCE_REF'],
171
+ additionalProperties: false,
172
+ },
173
+ };
174
+ }
175
+ // The FEATURE resolves, in order, as: an entry in config.eventSchemas, a
176
+ // dataserver query, a request server (by resource name, without REQ_), an
177
+ // EVENT_<TABLE>_<VERB> event, and finally a bare table name — the last is
178
+ // this engine's own fallback for clients that ask by table (the real server
179
+ // answers 404 there). Anything else gets the router's 404.
180
+ export function handleJsonSchemaRequest(message, connection, config, store) {
181
+ const feature = message.DETAILS?.FEATURE;
182
+ if (isLegacyFidelity(config)) {
183
+ handleLegacyJsonSchemaRequest(message, connection, config, store, feature);
184
+ return;
185
+ }
186
+ const overridden = feature !== undefined && !!config.eventSchemas?.[feature];
187
+ const query = !overridden && feature ? config.queries?.[feature] : undefined;
188
+ const requestReply = !overridden && feature ? config.requestReplies?.[feature] : undefined;
189
+ let schema;
190
+ if (query) {
191
+ schema = dataserverSchema(feature, query, config, store);
192
+ }
193
+ else if (requestReply) {
194
+ schema = requestServerSchema(feature, requestReply, config, store);
195
+ }
196
+ else {
197
+ const event = resolveEventTarget(feature, config, { bareTable: true });
198
+ if (event) {
199
+ schema = eventSchema(feature, event, eventFields(event, config, store), config.appName);
200
+ }
201
+ }
202
+ if (!schema) {
203
+ connection.reply(message, resourceNotMappedNack(feature));
204
+ return;
205
+ }
206
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.JSON_SCHEMA_FEATURE_ACK, ...schema });
207
+ }
208
+ // --- fidelity: 'legacy' -----------------------------------------------------
209
+ // Up to 15.47 every FEATURE resolved to a table (or view) and got the same
210
+ // minimal schema as INBOUND and OUTBOUND: {type, properties: {DETAILS:
211
+ // {type, properties: {<field>: {type}}, required}}}, `required` being the key
212
+ // on deletes and empty otherwise. A query or request-server name resolved
213
+ // through its `source`. The reply echoed JSON_SCHEMA_REQUEST.
214
+ function legacyJsonSchemaType(fieldType) {
19
215
  switch (fieldType) {
20
216
  case 'LONG':
21
217
  return 'integer';
@@ -27,10 +223,10 @@ function jsonSchemaType(fieldType) {
27
223
  return 'string';
28
224
  }
29
225
  }
30
- function buildSchema(fields, required) {
226
+ function legacySchema(fields, required) {
31
227
  const properties = {};
32
228
  for (const field of fields) {
33
- properties[field.NAME] = { type: jsonSchemaType(field.TYPE) };
229
+ properties[field.NAME] = { type: legacyJsonSchemaType(field.TYPE) };
34
230
  }
35
231
  return {
36
232
  type: 'object',
@@ -39,62 +235,27 @@ function buildSchema(fields, required) {
39
235
  },
40
236
  };
41
237
  }
42
- const DELETE_EVENT_RE = /_DELETE(_|$)/;
43
- // Zero-config default: EVENT_<TABLE>_<VERB...> maps to <TABLE> if it's a
44
- // registered table name (longest match wins, so e.g. a hypothetical
45
- // COUNTERPARTY_AUTH table can't shadow COUNTERPARTY). Covers every event
46
- // naming pattern in this codebase (INSERT, MODIFY, DELETE, plus suffixed
47
- // variants like INSERT_WITH_APPROVAL, DEMO_INSERT, MODIFY_PRICE, INSERT_FAIL)
48
- // without hand-maintaining a table per event. Override via
49
- // config.eventSchemas for names that don't follow this convention.
50
- // Exported for unit tests only.
51
- export function inferEventTable(eventName, tableNames) {
52
- if (!eventName)
53
- return undefined;
54
- // Not every caller asks by event name: entity-management resolves a grid's
55
- // schema from the bare resource/table name (FEATURE: 'TRADE'), so accept an
56
- // exact table match before requiring the EVENT_ prefix. Without this the
57
- // request errors with UNKNOWN_RESOURCE and the form renders no fields.
58
- if (tableNames.includes(eventName))
59
- return eventName;
60
- if (!eventName.startsWith('EVENT_'))
61
- return undefined;
62
- const rest = eventName.slice('EVENT_'.length);
63
- const candidates = tableNames.filter((name) => rest === name || rest.startsWith(`${name}_`));
64
- candidates.sort((a, b) => b.length - a.length);
65
- return candidates[0];
66
- }
67
- export function handleJsonSchemaRequest(message, connection, config, store) {
68
- const eventName = message.DETAILS?.FEATURE;
69
- const override = eventName ? config.eventSchemas?.[eventName] : undefined;
70
- // A DATASERVER/REQUEST_SERVER resource name (e.g. 'ALL_USERS') resolves
71
- // through its own definition to the backing table/view. The FAST-Element
72
- // showcase's entity-management grids ask by query name, not by table or
73
- // event name, so without this their create/update forms render empty.
74
- const resourceSource = eventName
75
- ? (config.queries?.[eventName]?.source ?? config.requestReplies?.[eventName]?.source)
238
+ function handleLegacyJsonSchemaRequest(message, connection, config, store, feature) {
239
+ const override = feature ? config.eventSchemas?.[feature] : undefined;
240
+ const resourceSource = feature
241
+ ? (config.queries?.[feature]?.source ?? config.requestReplies?.[feature]?.source)
76
242
  : undefined;
77
- const table = override?.table ??
78
- resourceSource ??
79
- inferEventTable(eventName, Object.keys(config.tables ?? {}));
80
- const legacy = isLegacyFidelity(config);
243
+ const table = override?.table ?? resourceSource ?? inferEventTable(feature, Object.keys(config.tables ?? {}));
81
244
  if (!table) {
82
- connection.reply(message, legacy
83
- ? {
84
- MESSAGE_TYPE: message.MESSAGE_TYPE,
85
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${eventName}` }],
86
- }
87
- : resourceNotMappedNack(eventName));
245
+ connection.reply(message, {
246
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
247
+ ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${feature}` }],
248
+ });
88
249
  return;
89
250
  }
90
- const pkOnly = override?.pkOnly ?? DELETE_EVENT_RE.test(eventName);
251
+ const pkOnly = override?.pkOnly ?? isDeleteEventName(feature);
91
252
  const pkFields = store.getPkFields(table);
92
- let fields = deriveFields(store.getMetadataRows(table), override?.fieldTypes);
253
+ let fields = withTypeOverrides(resolveSourceSchema(table, config, store), override?.fieldTypes).fields.map((field) => ({ NAME: field.name, TYPE: field.type }));
93
254
  if (pkOnly)
94
255
  fields = fields.filter((field) => pkFields.includes(field.NAME));
95
- const schema = buildSchema(fields, pkOnly ? pkFields : []);
256
+ const schema = legacySchema(fields, pkOnly ? pkFields : []);
96
257
  connection.reply(message, {
97
- MESSAGE_TYPE: legacy ? message.MESSAGE_TYPE : MESSAGE_TYPE.JSON_SCHEMA_FEATURE_ACK,
258
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
98
259
  INBOUND: schema,
99
260
  OUTBOUND: schema,
100
261
  });