@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,672 @@
1
+ // The field schema of a table, view, query, request server or event: what
2
+ // META_REQUEST and JSON_SCHEMA_REQUEST describe, and which columns a query's
3
+ // rows carry. Declared fields (TableDef.fields) win; without them the fields
4
+ // are inferred from the rows (metadata.ts), as before declared schemas
5
+ // existed, so a config that declares nothing keeps working.
6
+
7
+ import { isLegacyFidelity } from '../protocol/fidelity.ts';
8
+ import { daoClassOf, titleCase } from '../protocol/fieldTypes.ts';
9
+ import type {
10
+ CrudEventVerb,
11
+ FieldDef,
12
+ GenesisFieldType,
13
+ IndexDef,
14
+ MockServerConfig,
15
+ QueryDef,
16
+ RequestReplyDef,
17
+ Row,
18
+ TableDef,
19
+ ViewDef,
20
+ } from '../types.ts';
21
+ import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './identity.ts';
22
+ import { deriveFields } from './metadata.ts';
23
+ import type { Store } from './store.ts';
24
+
25
+ export interface ResolvedField {
26
+ name: string;
27
+ // A GenesisFieldType, or whatever a fieldTypes override names.
28
+ type: string;
29
+ nullable: boolean;
30
+ // Filled in by the database on insert (sequence / auto-increment).
31
+ generated: boolean;
32
+ values?: string[];
33
+ // undefined: no default.
34
+ default?: unknown;
35
+ maxSize?: number;
36
+ minLength?: number;
37
+ description?: string;
38
+ title?: string;
39
+ // FieldDef.optional; for an event field, resolved by eventFields.
40
+ optional?: boolean;
41
+ // The table the column comes from (for a view, the joined table for a
42
+ // join's column), for generated enum class names.
43
+ table?: string;
44
+ }
45
+
46
+ export interface ResolvedSchema {
47
+ fields: ResolvedField[];
48
+ // True when the fields come from TableDef.fields rather than from rows.
49
+ declared: boolean;
50
+ }
51
+
52
+ export function pkFieldsOf(def: TableDef): string[] {
53
+ return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
54
+ }
55
+
56
+ // The declared generated fields, plus the key of a table that mints its own
57
+ // keys through sequencePrefix.
58
+ export function generatedFieldsOf(def: TableDef): Set<string> {
59
+ return new Set(generatedFieldList(def));
60
+ }
61
+
62
+ // The same fields in the order GSF lists a table's generated fields
63
+ // (EventHandlerUtils.kt generatedFields: the id sequences, then the
64
+ // auto-increment fields): the sequencePrefix key first, then the declared
65
+ // SEQUENCE fields, then the AUTO_INCREMENT ones.
66
+ export function generatedFieldList(def: TableDef): string[] {
67
+ const pkFields = pkFieldsOf(def);
68
+ const declared = def.generated ?? [];
69
+ const fields = [
70
+ ...(def.sequencePrefix && pkFields.length === 1 ? [pkFields[0]] : []),
71
+ ...declared.filter((entry) => entry.kind === 'SEQUENCE').map((entry) => entry.field),
72
+ ...declared.filter((entry) => entry.kind === 'AUTO_INCREMENT').map((entry) => entry.field),
73
+ ];
74
+ return [...new Set(fields)];
75
+ }
76
+
77
+ function fromFieldDef(
78
+ field: FieldDef | (Omit<FieldDef, 'name'> & { name: string }),
79
+ table: string | undefined,
80
+ notNullByDefault: boolean,
81
+ generated: boolean,
82
+ ): ResolvedField {
83
+ return {
84
+ name: field.name,
85
+ type: field.type,
86
+ nullable: field.nullable ?? !notNullByDefault,
87
+ generated,
88
+ ...(field.values ? { values: field.values } : {}),
89
+ ...(field.default !== undefined ? { default: field.default } : {}),
90
+ ...(field.maxSize !== undefined ? { maxSize: field.maxSize } : {}),
91
+ ...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
92
+ ...(field.description !== undefined ? { description: field.description } : {}),
93
+ ...(field.title !== undefined ? { title: field.title } : {}),
94
+ ...(field.optional !== undefined ? { optional: field.optional } : {}),
95
+ ...(table ? { table } : {}),
96
+ };
97
+ }
98
+
99
+ function declaredTableFields(tableName: string, def: TableDef): ResolvedField[] {
100
+ const pkFields = pkFieldsOf(def);
101
+ const generated = generatedFieldsOf(def);
102
+ return (def.fields ?? []).map((field) =>
103
+ fromFieldDef(
104
+ field,
105
+ tableName,
106
+ pkFields.includes(field.name) || generated.has(field.name),
107
+ generated.has(field.name),
108
+ ),
109
+ );
110
+ }
111
+
112
+ // Inference from rows: a type per column (metadata.ts), nothing nullable but
113
+ // the key, no defaults or enum values (rows can't say).
114
+ function inferredFields(
115
+ rows: Row[],
116
+ pkFields: string[],
117
+ generated: Set<string>,
118
+ table: string | undefined,
119
+ ): ResolvedField[] {
120
+ return deriveFields(rows).map(({ NAME, TYPE }) => ({
121
+ name: NAME,
122
+ type: TYPE,
123
+ nullable: !(pkFields.includes(NAME) || generated.has(NAME)),
124
+ generated: generated.has(NAME),
125
+ ...(table ? { table } : {}),
126
+ }));
127
+ }
128
+
129
+ function derivedTypeDef(
130
+ name: string,
131
+ entry: NonNullable<ViewDef['derivedTypes']>[string],
132
+ ): Omit<FieldDef, 'name'> & { name: string } {
133
+ return typeof entry === 'string' ? { name, type: entry } : { ...entry, name };
134
+ }
135
+
136
+ function viewOf(name: string, config: MockServerConfig, store: Store): ViewDef | undefined {
137
+ return store.views.get(name) ?? config.views?.[name];
138
+ }
139
+
140
+ // A view over a declared base table: the base table's columns, each join's
141
+ // selected columns typed from the joined table (nullable, since a base row
142
+ // may have no match), and the derived fields typed from derivedTypes (or
143
+ // inferred from the view's rows when not given) — narrowed to the view's
144
+ // fields block when it has one.
145
+ function declaredViewFields(
146
+ viewName: string,
147
+ view: ViewDef,
148
+ config: MockServerConfig,
149
+ store: Store,
150
+ ): ResolvedField[] {
151
+ const fields = new Map<string, ResolvedField>();
152
+ for (const field of resolveSourceSchema(view.base, config, store).fields) {
153
+ fields.set(field.name, field);
154
+ }
155
+ for (const join of view.joins ?? []) {
156
+ const joined = resolveSourceSchema(join.table, config, store).fields;
157
+ for (const [alias, sourceField] of Object.entries(join.select ?? {})) {
158
+ const source = joined.find((field) => field.name === sourceField);
159
+ const { title: _title, ...rest } = source ?? {
160
+ name: alias,
161
+ type: 'STRING',
162
+ nullable: true,
163
+ generated: false,
164
+ };
165
+ fields.set(alias, { ...rest, name: alias, nullable: true, generated: false });
166
+ }
167
+ }
168
+ const derivedNames = Object.keys(view.derived ?? {});
169
+ const untyped = derivedNames.filter((name) => !view.derivedTypes?.[name]);
170
+ const inferred =
171
+ untyped.length > 0
172
+ ? deriveFields(
173
+ store
174
+ .getMetadataRows(viewName)
175
+ .map((row) => Object.fromEntries(untyped.map((name) => [name, row[name]]))),
176
+ )
177
+ : [];
178
+ for (const name of derivedNames) {
179
+ const typed = view.derivedTypes?.[name];
180
+ fields.set(
181
+ name,
182
+ typed
183
+ ? fromFieldDef(derivedTypeDef(name, typed), undefined, false, false)
184
+ : {
185
+ name,
186
+ type: inferred.find((field) => field.NAME === name)?.TYPE ?? 'STRING',
187
+ nullable: true,
188
+ generated: false,
189
+ },
190
+ );
191
+ }
192
+ return view.fields ? pick(fields, view.fields) : [...fields.values()];
193
+ }
194
+
195
+ function pick(fields: Map<string, ResolvedField>, names: string[]): ResolvedField[] {
196
+ return names.flatMap((name) => {
197
+ const field = fields.get(name);
198
+ return field ? [field] : [];
199
+ });
200
+ }
201
+
202
+ // Whether a table (or a view, through its base table) declares its fields.
203
+ // Checked without resolving the schema, so it costs nothing for a big
204
+ // undeclared table.
205
+ export function isDeclared(source: string, config: MockServerConfig, store: Store): boolean {
206
+ const table = config.tables?.[source];
207
+ if (table) return !!table.fields;
208
+ const view = viewOf(source, config, store);
209
+ return view ? isDeclared(view.base, config, store) : false;
210
+ }
211
+
212
+ // The fields of a table or view. Unknown names resolve to no fields. Under
213
+ // fidelity: 'legacy' everything is inferred, as up to 15.47: declarations,
214
+ // fields blocks and derivedTypes are ignored.
215
+ export function resolveSourceSchema(
216
+ source: string,
217
+ config: MockServerConfig,
218
+ store: Store,
219
+ ): ResolvedSchema {
220
+ const legacy = isLegacyFidelity(config);
221
+ const table = config.tables?.[source];
222
+ if (!legacy && table?.fields) {
223
+ return { fields: declaredTableFields(source, table), declared: true };
224
+ }
225
+ const view = viewOf(source, config, store);
226
+ if (!legacy && view && isDeclared(view.base, config, store)) {
227
+ return { fields: declaredViewFields(source, view, config, store), declared: true };
228
+ }
229
+ const generated = table ? generatedFieldsOf(table) : new Set<string>();
230
+ let fields = inferredFields(
231
+ store.getMetadataRows(source),
232
+ store.getPkFields(source),
233
+ generated,
234
+ table ? source : view?.base,
235
+ );
236
+ if (legacy) return { fields, declared: false };
237
+ // derivedTypes and the fields block describe a view even when the rest of
238
+ // it is inferred.
239
+ if (view?.derivedTypes) {
240
+ fields = fields.map((field) => {
241
+ const typed = view.derivedTypes?.[field.name];
242
+ return typed
243
+ ? fromFieldDef(derivedTypeDef(field.name, typed), undefined, false, false)
244
+ : field;
245
+ });
246
+ }
247
+ if (view?.fields) fields = pick(new Map(fields.map((field) => [field.name, field])), view.fields);
248
+ return { fields, declared: false };
249
+ }
250
+
251
+ // fieldTypes overrides the TYPE of the named fields, declared or inferred.
252
+ export function withTypeOverrides(
253
+ schema: ResolvedSchema,
254
+ fieldTypes: Record<string, string> | undefined,
255
+ ): ResolvedSchema {
256
+ if (!fieldTypes) return schema;
257
+ return {
258
+ ...schema,
259
+ fields: schema.fields.map((field) =>
260
+ fieldTypes[field.name] ? { ...field, type: fieldTypes[field.name] } : field,
261
+ ),
262
+ };
263
+ }
264
+
265
+ // A query's fields block: those fields only, in its order. Names the source
266
+ // doesn't have are dropped, as the real dataserver drops them.
267
+ export function projectSchema(schema: ResolvedSchema, names: string[] | undefined): ResolvedSchema {
268
+ if (!names) return schema;
269
+ const byName = new Map(schema.fields.map((field) => [field.name, field]));
270
+ return {
271
+ ...schema,
272
+ fields: names.flatMap((name) => {
273
+ const field = byName.get(name);
274
+ return field ? [field] : [];
275
+ }),
276
+ };
277
+ }
278
+
279
+ export function querySchema(
280
+ query: QueryDef,
281
+ config: MockServerConfig,
282
+ store: Store,
283
+ ): ResolvedSchema {
284
+ return projectSchema(
285
+ withTypeOverrides(resolveSourceSchema(query.source, config, store), query.fieldTypes),
286
+ isLegacyFidelity(config) ? undefined : query.fields,
287
+ );
288
+ }
289
+
290
+ // A resolver-only request server has no table: its fields come from the
291
+ // def's metadataRows sample.
292
+ export function requestReplySchema(
293
+ requestReply: RequestReplyDef,
294
+ config: MockServerConfig,
295
+ store: Store,
296
+ ): ResolvedSchema {
297
+ const schema = requestReply.source
298
+ ? resolveSourceSchema(requestReply.source, config, store)
299
+ : {
300
+ fields: inferredFields(requestReply.metadataRows ?? [], [], new Set(), undefined),
301
+ declared: false,
302
+ };
303
+ return withTypeOverrides(schema, requestReply.fieldTypes);
304
+ }
305
+
306
+ // The record identity every table entity carries after its own columns: what
307
+ // a request server with no reply block sends in each row and lists in
308
+ // REPLY_FIELD, and what a criteria-only one can sort and filter by.
309
+ export const RECORD_IDENTITY_FIELDS: ResolvedField[] = [
310
+ { name: RECORD_ID, type: 'LONG', nullable: false, generated: true },
311
+ { name: TIMESTAMP, type: 'NANO_TIMESTAMP', nullable: false, generated: true },
312
+ ];
313
+
314
+ const withIdentity = (fields: ResolvedField[]): ResolvedField[] => [
315
+ ...fields.filter((field) => !RECORD_FIELDS.includes(field.name)),
316
+ ...RECORD_IDENTITY_FIELDS,
317
+ ];
318
+
319
+ // What a request server's database entity has: its source's fields, then
320
+ // RECORD_ID and TIMESTAMP (a resolver's: its metadataRows). A criteria-only
321
+ // server sorts and filters by these (SORTABLE_FIELDS, CRITERIA_FIELDS), and
322
+ // REQUEST may name any of them, whatever the reply block says.
323
+ export function requestReplySourceSchema(
324
+ requestReply: RequestReplyDef,
325
+ config: MockServerConfig,
326
+ store: Store,
327
+ ): ResolvedSchema {
328
+ const schema = requestReplySchema(requestReply, config, store);
329
+ return requestReply.source && !requestReply.resolver
330
+ ? { ...schema, fields: withIdentity(schema.fields) }
331
+ : schema;
332
+ }
333
+
334
+ // What a request server's REP_ rows carry (getReplyFields in
335
+ // RequestReplyServer.kt, GSF v8.15.29): the reply block (`fields`), else the
336
+ // source's fields followed by RECORD_ID and TIMESTAMP — a table entity's own
337
+ // field list, which the real server sends whether or not the server is
338
+ // criteria-only (auth's RIGHT sends no RECORD_ID only because its reply block
339
+ // names CODE and DESCRIPTION). REPLY_FIELD and the JSON schema's REPLY come
340
+ // from here. A resolver's fields are its metadataRows'; under fidelity:
341
+ // 'legacy' everything is the source schema, as up to 15.47.
342
+ export function requestReplyReplySchema(
343
+ requestReply: RequestReplyDef,
344
+ config: MockServerConfig,
345
+ store: Store,
346
+ ): ResolvedSchema {
347
+ if (isLegacyFidelity(config)) return requestReplySchema(requestReply, config, store);
348
+ const schema = requestReplySourceSchema(requestReply, config, store);
349
+ if (!requestReply.fields || !requestReply.source || requestReply.resolver) return schema;
350
+ return projectSchema(schema, requestReply.fields);
351
+ }
352
+
353
+ // The columns a source-backed request server's REP_ rows carry, in order (see
354
+ // requestReplyReplySchema). undefined — rows as stored, which already end
355
+ // with their RECORD_ID and TIMESTAMP — for an undeclared source with no reply
356
+ // block, for a resolver, and under fidelity: 'legacy'.
357
+ export function requestReplyFieldNames(
358
+ requestReply: RequestReplyDef,
359
+ config: MockServerConfig,
360
+ store: Store,
361
+ ): string[] | undefined {
362
+ if (isLegacyFidelity(config) || !requestReply.source || requestReply.resolver) return undefined;
363
+ const declared = sourceFieldNames(requestReply.source, config, store);
364
+ const names = declared && [
365
+ ...declared.filter((name) => !RECORD_FIELDS.includes(name)),
366
+ ...RECORD_FIELDS,
367
+ ];
368
+ if (!requestReply.fields) return names;
369
+ return names ? requestReply.fields.filter((name) => names.includes(name)) : requestReply.fields;
370
+ }
371
+
372
+ // The request block's fields: requestFields, else the source's key.
373
+ export function requestFieldsOf(requestReply: RequestReplyDef, store: Store): string[] {
374
+ if (requestReply.requestFields) return requestReply.requestFields;
375
+ return requestReply.source ? store.getPkFields(requestReply.source) : [];
376
+ }
377
+
378
+ // An ENUM's values, or undefined when none are known: a client must then be
379
+ // told nothing rather than an empty list (grid-pro builds an empty select from
380
+ // VALID_VALUES: [], and a JSON schema `enum: []` accepts no value at all).
381
+ export function knownValues(field: ResolvedField): string[] | undefined {
382
+ return field.values?.length ? field.values : undefined;
383
+ }
384
+
385
+ // The DEFAULT an event field reports (META DEFAULT, JSON schema `default`).
386
+ // Only an optional property has one, and it is its type's default
387
+ // (PALMetadataUtils.kt:159, getPropertyDefaultValue, GSF v8.15.29): the
388
+ // dictionary default, or for an ENUM with none the enum's first value
389
+ // (PALMetadataUtils.kt:205, EnumType defaultValue = ... ?: jsonValues.firstOrNull()).
390
+ export function eventDefaultOf(field: ResolvedField): unknown {
391
+ if (!field.optional) return undefined;
392
+ if (field.default !== undefined) return field.default;
393
+ return field.type === 'ENUM' ? knownValues(field)?.[0] : undefined;
394
+ }
395
+
396
+ // --- query rows --------------------------------------------------------------
397
+
398
+ function sourceFieldNames(
399
+ source: string,
400
+ config: MockServerConfig,
401
+ store: Store,
402
+ ): string[] | undefined {
403
+ const table = config.tables?.[source];
404
+ if (table) return table.fields?.map((field) => field.name);
405
+ const view = viewOf(source, config, store);
406
+ if (!view) return undefined;
407
+ const base = sourceFieldNames(view.base, config, store);
408
+ // An undeclared base: the view's own fields block is all that is known.
409
+ if (!base) return view.fields;
410
+ const names = new Set(base);
411
+ for (const join of view.joins ?? []) {
412
+ for (const alias of Object.keys(join.select ?? {})) names.add(alias);
413
+ }
414
+ for (const name of Object.keys(view.derived ?? {})) names.add(name);
415
+ return view.fields ? view.fields.filter((name) => names.has(name)) : [...names];
416
+ }
417
+
418
+ // The columns a query's rows carry, in order: its fields block, else every
419
+ // declared column of its source (a view's own fields block included).
420
+ // undefined when neither is known, and always under fidelity: 'legacy': rows
421
+ // go out as stored.
422
+ export function queryFieldNames(
423
+ query: QueryDef | undefined,
424
+ config: MockServerConfig,
425
+ store: Store,
426
+ ): string[] | undefined {
427
+ if (!query || isLegacyFidelity(config)) return undefined;
428
+ const names = sourceFieldNames(query.source, config, store);
429
+ if (!query.fields) return names;
430
+ return names ? query.fields.filter((name) => names.includes(name)) : query.fields;
431
+ }
432
+
433
+ // A row with exactly these columns. The real dataserver sends every column it
434
+ // exposes, null when unset, so a missing value goes out as null.
435
+ export function projectRow(row: Row, names: string[] | undefined): Row {
436
+ if (!names) return row;
437
+ return Object.fromEntries(names.map((name) => [name, row[name] ?? null]));
438
+ }
439
+
440
+ // --- indexes -----------------------------------------------------------------
441
+
442
+ // The name the dictionary gives an index declared without one
443
+ // (DslFieldContext.kt generateIndexName, GSF v8.15.29): '<TABLE>_BY_' and the
444
+ // fields, each without a leading '<TABLE>_', joined with '_' — so
445
+ // primaryKey("COUNTERPARTY_ID") is COUNTERPARTY_BY_ID and
446
+ // unique("INSTRUMENT_ID") on POSITION is POSITION_BY_INSTRUMENT_ID.
447
+ export function defaultIndexName(table: string, fields: string[]): string {
448
+ const prefix = `${table}_`;
449
+ const names = fields.map(
450
+ (field) => (field.startsWith(prefix) && field.slice(prefix.length)) || field,
451
+ );
452
+ return `${prefix}BY_${names.join('_')}`;
453
+ }
454
+
455
+ const sameFields = (a: string[], b: string[]) =>
456
+ a.length === b.length && a.every((field, index) => field === b[index]);
457
+
458
+ // The primary key's index name: a declared unique index over exactly the key
459
+ // fields names it, else the dictionary default. NACKs name it in FIELD.
460
+ export function primaryKeyIndexName(table: string, def: TableDef): string {
461
+ const pkFields = pkFieldsOf(def);
462
+ const declared = def.indexes?.find((index) => index.unique && sameFields(index.fields, pkFields));
463
+ return declared?.name ?? defaultIndexName(table, pkFields);
464
+ }
465
+
466
+ // The table's other unique indexes, each a constraint an insert or modify
467
+ // must not break.
468
+ export function secondaryUniqueIndexes(def: TableDef): IndexDef[] {
469
+ const pkFields = pkFieldsOf(def);
470
+ return (def.indexes ?? []).filter((index) => index.unique && !sameFields(index.fields, pkFields));
471
+ }
472
+
473
+ // --- events ------------------------------------------------------------------
474
+
475
+ export const CRUD_EVENT_VERBS: readonly CrudEventVerb[] = ['INSERT', 'MODIFY', 'DELETE'];
476
+
477
+ // A generic event's name: Create names the handler '<TABLE>_<VERB>'
478
+ // (as GSF's GenericInsertEventHandler.messageType() does too) and the event
479
+ // handler serves it as EVENT_<TABLE>_<VERB>.
480
+ export function crudEventName(table: string, verb: CrudEventVerb): string {
481
+ return `EVENT_${table}_${verb}`;
482
+ }
483
+
484
+ // The table and verb of a generic CRUD event a table registers through
485
+ // TableDef.events, or undefined.
486
+ export function crudEventOf(
487
+ name: string | undefined,
488
+ config: MockServerConfig,
489
+ ): { table: string; verb: CrudEventVerb } | undefined {
490
+ if (!name?.startsWith('EVENT_')) return undefined;
491
+ for (const [table, def] of Object.entries(config.tables ?? {})) {
492
+ for (const verb of def.events ?? []) {
493
+ if (crudEventName(table, verb) === name) return { table, verb };
494
+ }
495
+ }
496
+ return undefined;
497
+ }
498
+
499
+ // Every generic CRUD event the tables register, in table then verb order.
500
+ export function crudEventNames(config: MockServerConfig): string[] {
501
+ return Object.entries(config.tables ?? {}).flatMap(([table, def]) =>
502
+ CRUD_EVENT_VERBS.filter((verb) => def.events?.includes(verb)).map((verb) =>
503
+ crudEventName(table, verb),
504
+ ),
505
+ );
506
+ }
507
+
508
+ // Any event named .../_DELETE(_...)? takes only the key fields, which is what
509
+ // entity-management's delete confirmation copies from the selected row.
510
+ export function isDeleteEventName(name: string): boolean {
511
+ return /_DELETE(_|$)/.test(name);
512
+ }
513
+
514
+ // Zero-config default: EVENT_<TABLE>_<VERB...> maps to <TABLE> if it's a
515
+ // registered table name (longest match wins, so e.g. a hypothetical
516
+ // COUNTERPARTY_AUTH table can't shadow COUNTERPARTY). Covers every event
517
+ // naming pattern in this codebase (INSERT, MODIFY, DELETE, plus suffixed
518
+ // variants like INSERT_WITH_APPROVAL, DEMO_INSERT, MODIFY_PRICE, INSERT_FAIL)
519
+ // without hand-maintaining a table per event. Override via
520
+ // config.eventSchemas for names that don't follow this convention.
521
+ export function inferEventTable(
522
+ eventName: string | undefined,
523
+ tableNames: string[],
524
+ ): string | undefined {
525
+ if (!eventName) return undefined;
526
+ // Not every caller asks by event name: entity-management resolves a grid's
527
+ // schema from the bare resource/table name (FEATURE: 'TRADE'), so accept an
528
+ // exact table match before requiring the EVENT_ prefix. Without this the
529
+ // request errors with UNKNOWN_RESOURCE and the form renders no fields.
530
+ if (tableNames.includes(eventName)) return eventName;
531
+ if (!eventName.startsWith('EVENT_')) return undefined;
532
+ const rest = eventName.slice('EVENT_'.length);
533
+ const candidates = tableNames.filter((name) => rest === name || rest.startsWith(`${name}_`));
534
+ candidates.sort((a, b) => b.length - a.length);
535
+ return candidates[0];
536
+ }
537
+
538
+ export interface EventTarget {
539
+ // How the real server describes the DETAILS class: 'table', a generated
540
+ // DAO (titles, lengths, ranges, genesis types; fields sorted by name);
541
+ // 'byId', a delete's <Table>.ById (the key fields only, all mandatory, no
542
+ // annotations); 'dto', a custom class (EventSchemaOverride.fields, no
543
+ // annotations).
544
+ kind: 'table' | 'byId' | 'dto';
545
+ // The table (or view) the DETAILS take. For a 'dto', where its ENUM
546
+ // columns come from, if anywhere.
547
+ table?: string;
548
+ // DESCRIPTION: the DETAILS class name.
549
+ className: string;
550
+ // TITLE, on 'table' events only.
551
+ title?: string;
552
+ fieldTypes?: Record<string, string>;
553
+ fields?: FieldDef[];
554
+ }
555
+
556
+ // The table an event writes: config.eventSchemas first, then the table that
557
+ // registers it as a generic CRUD event, then the EVENT_<TABLE>_<VERB>
558
+ // convention. Only a registered event (an eventHandlers or eventSchemas
559
+ // entry, or a TableDef.events verb) has metadata, as only a deployed event
560
+ // handler has on the real server; anything else is the router's 404.
561
+ // `bareTable` also accepts a plain table name, which only JSON_SCHEMA_REQUEST
562
+ // does (see the README).
563
+ export function resolveEventTarget(
564
+ name: string | undefined,
565
+ config: MockServerConfig,
566
+ { bareTable = false }: { bareTable?: boolean } = {},
567
+ ): EventTarget | undefined {
568
+ if (!name) return undefined;
569
+ const override = config.eventSchemas?.[name];
570
+ const crud = crudEventOf(name, config);
571
+ if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name] && !crud) {
572
+ return undefined;
573
+ }
574
+ const tableNames = Object.keys(config.tables ?? {});
575
+ const table =
576
+ override?.table ??
577
+ crud?.table ??
578
+ (bareTable || name.startsWith('EVENT_') ? inferEventTable(name, tableNames) : undefined);
579
+ const fieldTypes = override?.fieldTypes ? { fieldTypes: override.fieldTypes } : {};
580
+ if (override?.fields) {
581
+ return {
582
+ kind: 'dto',
583
+ ...(table ? { table } : {}),
584
+ className: override.className ?? daoClassOf(table ?? name.replace(/^EVENT_/, '')),
585
+ fields: override.fields,
586
+ ...fieldTypes,
587
+ };
588
+ }
589
+ if (!table) return undefined;
590
+ const byId = override?.pkOnly ?? isDeleteEventName(name);
591
+ return byId
592
+ ? {
593
+ kind: 'byId',
594
+ table,
595
+ className: override?.className ?? `${daoClassOf(table)}.ById`,
596
+ ...fieldTypes,
597
+ }
598
+ : {
599
+ kind: 'table',
600
+ table,
601
+ className: override?.className ?? daoClassOf(table),
602
+ title: titleCase(table),
603
+ ...fieldTypes,
604
+ };
605
+ }
606
+
607
+ // Kotlin reflection lists a class's properties sorted by their (camelCase)
608
+ // names, and the real server only then turns them into UPPER_UNDERSCORE —
609
+ // so DATE_FIELD (dateField) sorts before DATETIME_FIELD (datetimeField),
610
+ // which a plain sort of the field names would reverse.
611
+ function propertyName(field: ResolvedField): string {
612
+ return field.name
613
+ .toLowerCase()
614
+ .replace(/_+([a-z0-9])/g, (_match, char: string) => char.toUpperCase());
615
+ }
616
+
617
+ const byName = (a: ResolvedField, b: ResolvedField) => {
618
+ const [left, right] = [propertyName(a), propertyName(b)];
619
+ return left < right ? -1 : left > right ? 1 : 0;
620
+ };
621
+
622
+ // The fields an event's DETAILS take, sorted by name as the real server
623
+ // lists a class's properties — except a delete's, which are the key fields
624
+ // in key order, all mandatory. A key field the schema doesn't know (an
625
+ // inferred table with no rows) is still listed, as a STRING. `optional` is
626
+ // always resolved: FieldDef.optional when given, else whether the class
627
+ // property has a default (PALMetadataUtils.kt mapMemberProperty: optional =
628
+ // propertyType != Mandatory). A generated DAO gives every nullable and every
629
+ // generated column a default; a custom DTO's defaults are invisible, so a
630
+ // DTO field is optional only when it declares a default or `optional: true`.
631
+ export function eventFields(
632
+ target: EventTarget,
633
+ config: MockServerConfig,
634
+ store: Store,
635
+ ): ResolvedField[] {
636
+ if (target.kind === 'dto') {
637
+ const resolved = (target.fields ?? []).map((field) =>
638
+ fromFieldDef(field, target.table, false, false),
639
+ );
640
+ return withTypeOverrides({ fields: resolved, declared: true }, target.fieldTypes)
641
+ .fields.map((field) => ({
642
+ ...field,
643
+ optional: field.optional ?? field.default !== undefined,
644
+ }))
645
+ .sort(byName);
646
+ }
647
+ const table = target.table!;
648
+ const { fields } = withTypeOverrides(
649
+ resolveSourceSchema(table, config, store),
650
+ target.fieldTypes,
651
+ );
652
+ if (target.kind === 'byId') {
653
+ return store.getPkFields(table).map((name) => {
654
+ const { default: _default, ...field } = fields.find(
655
+ (candidate) => candidate.name === name,
656
+ ) ?? {
657
+ name,
658
+ type: 'STRING' satisfies GenesisFieldType,
659
+ nullable: false,
660
+ generated: false,
661
+ };
662
+ return { ...field, nullable: false, generated: false, optional: false };
663
+ });
664
+ }
665
+ return fields
666
+ .map((field) => ({
667
+ ...field,
668
+ optional:
669
+ field.optional ?? (field.generated || field.nullable || field.default !== undefined),
670
+ }))
671
+ .sort(byName);
672
+ }