@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,208 @@
1
+ // Inbound schema validation, as GSF runs it on every event before any handler
2
+ // sees it: GenesisMessageDecoder validates the whole message against the
3
+ // event's JSON schema (the INBOUND that JSON_SCHEMA_REQUEST serves) and
4
+ // answers each failure with a FieldError VALIDATION_ERROR. GSF 8.15.29 uses
5
+ // networknt json-schema-validator 2.0.1, configured by
6
+ // GenesisJsonSchemaFactory (draft 2019-09, PathType.LEGACY,
7
+ // losslessNarrowing). This is the subset of that validator the engine's own
8
+ // event schemas need: type, const, enum, oneOf, properties, required,
9
+ // additionalProperties, minLength / maxLength, minimum / maximum and pattern.
10
+ // Texts, locations and their order follow networknt's output, checked by
11
+ // running the real validator on the recorded showcase schemas
12
+ // (test/schemaValidation.test.ts pins the cases).
13
+ //
14
+ // Order: at each schema, `type` and then `properties` are checked first and
15
+ // every other keyword after them in the schema's key order; `properties` in
16
+ // their key order; unknown properties in the instance's order. A failed
17
+ // `type` doesn't stop the rest (an enum still reports its own error).
18
+ // `oneOf` with no valid branch reports itself, then every branch's errors.
19
+
20
+ import type { NackErrorInput } from './errors.ts';
21
+
22
+ type Schema = Record<string, any>;
23
+
24
+ export interface SchemaError {
25
+ keyword: string;
26
+ // PathType.LEGACY: '$', '$.DETAILS', '$.DETAILS.NAME'.
27
+ instanceLocation: string;
28
+ // The missing property, for `required`; the unknown one, for
29
+ // `additionalProperties`.
30
+ property?: string;
31
+ message: string;
32
+ }
33
+
34
+ // networknt's JsonType names. With losslessNarrowing a number with no
35
+ // fraction is an integer (1.0 included, which JSON.parse makes 1 anyway).
36
+ function jsonTypeOf(value: unknown): string {
37
+ if (value === null) return 'null';
38
+ if (Array.isArray(value)) return 'array';
39
+ if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
40
+ return typeof value;
41
+ }
42
+
43
+ function matchesType(value: unknown, type: string): boolean {
44
+ const actual = jsonTypeOf(value);
45
+ return actual === type || (type === 'number' && actual === 'integer');
46
+ }
47
+
48
+ const isObject = (value: unknown): value is Record<string, unknown> =>
49
+ typeof value === 'object' && value !== null && !Array.isArray(value);
50
+
51
+ function deepEqual(a: unknown, b: unknown): boolean {
52
+ if (a === b) return true;
53
+ if (Array.isArray(a) && Array.isArray(b)) {
54
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
55
+ }
56
+ if (isObject(a) && isObject(b)) {
57
+ const keys = Object.keys(a);
58
+ return (
59
+ keys.length === Object.keys(b).length &&
60
+ keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
61
+ );
62
+ }
63
+ return false;
64
+ }
65
+
66
+ // networknt evaluates these before a schema's other keywords, whatever their
67
+ // position; the rest go in key order.
68
+ const FIRST_KEYWORDS = ['type', 'properties'];
69
+
70
+ // String lengths count code points, as Java's codePointCount does.
71
+ const codePoints = (text: string) => [...text].length;
72
+
73
+ interface KeywordCheck {
74
+ arg: any;
75
+ schema: Schema;
76
+ value: unknown;
77
+ location: string;
78
+ errors: SchemaError[];
79
+ fail(message: string, property?: string): void;
80
+ }
81
+
82
+ const NOT_DEFINED =
83
+ 'is not defined in the schema and the schema does not allow additional properties';
84
+
85
+ // One check per keyword, each a no-op on values it doesn't apply to (string
86
+ // keywords on non-strings, object keywords on non-objects, ...).
87
+ const KEYWORDS: Record<string, (check: KeywordCheck) => void> = {
88
+ type({ arg, value, fail }) {
89
+ if (typeof arg === 'string' && !matchesType(value, arg)) {
90
+ fail(`${jsonTypeOf(value)} found, ${arg} expected`);
91
+ }
92
+ },
93
+ const({ arg, value, fail }) {
94
+ if (!deepEqual(value, arg)) fail(`must be the constant value '${String(arg)}'`);
95
+ },
96
+ enum({ arg, value, fail }) {
97
+ if (Array.isArray(arg) && !arg.some((candidate) => deepEqual(value, candidate))) {
98
+ const values = arg.map((item) => JSON.stringify(item)).join(', ');
99
+ fail(`does not have a value in the enumeration [${values}]`);
100
+ }
101
+ },
102
+ minLength({ arg, value, fail }) {
103
+ if (typeof value === 'string' && codePoints(value) < arg) {
104
+ fail(`must be at least ${arg} characters long`);
105
+ }
106
+ },
107
+ maxLength({ arg, value, fail }) {
108
+ if (typeof value === 'string' && codePoints(value) > arg) {
109
+ fail(`must be at most ${arg} characters long`);
110
+ }
111
+ },
112
+ minimum({ arg, value, fail }) {
113
+ if (typeof value === 'number' && value < arg) fail(`must have a minimum value of ${arg}`);
114
+ },
115
+ maximum({ arg, value, fail }) {
116
+ if (typeof value === 'number' && value > arg) fail(`must have a maximum value of ${arg}`);
117
+ },
118
+ // JSON Schema patterns are unanchored: '[0-9]+' accepts 'x1y'.
119
+ pattern({ arg, value, fail }) {
120
+ if (typeof value === 'string' && !new RegExp(arg, 'u').test(value)) {
121
+ fail(`does not match the regex pattern ${arg}`);
122
+ }
123
+ },
124
+ oneOf({ arg, value, location, errors, fail }) {
125
+ if (!Array.isArray(arg)) return;
126
+ const results = arg.map((branch: Schema) => validateSchema(branch, value, location));
127
+ const valid = results.flatMap((result, index) => (result.length === 0 ? [index] : []));
128
+ if (valid.length === 1) return;
129
+ if (valid.length > 1) {
130
+ fail(
131
+ `must be valid to one and only one schema, but ${valid.length} are valid with indexes '${valid.join(', ')}'`,
132
+ );
133
+ return;
134
+ }
135
+ fail('must be valid to one and only one schema, but 0 are valid');
136
+ errors.push(...results.flat());
137
+ },
138
+ properties({ arg, value, location, errors }) {
139
+ if (!isObject(value) || !isObject(arg)) return;
140
+ for (const [name, propertySchema] of Object.entries(arg)) {
141
+ if (!Object.hasOwn(value, name)) continue;
142
+ errors.push(...validateSchema(propertySchema as Schema, value[name], `${location}.${name}`));
143
+ }
144
+ },
145
+ required({ arg, value, fail }) {
146
+ if (!isObject(value) || !Array.isArray(arg)) return;
147
+ for (const name of arg) {
148
+ if (!Object.hasOwn(value, name)) fail(`required property '${name}' not found`, name);
149
+ }
150
+ },
151
+ additionalProperties({ arg, schema, value, location, errors, fail }) {
152
+ if (!isObject(value) || (arg !== false && !isObject(arg))) return;
153
+ const known = isObject(schema.properties) ? schema.properties : {};
154
+ for (const name of Object.keys(value)) {
155
+ if (Object.hasOwn(known, name)) continue;
156
+ if (arg === false) fail(`property '${name}' ${NOT_DEFINED}`, name);
157
+ else errors.push(...validateSchema(arg, value[name], `${location}.${name}`));
158
+ }
159
+ },
160
+ // Anything else is an annotation (description, title, default, readOnly,
161
+ // genesisType, $schema, $defs, ...) and validates nothing; networknt
162
+ // doesn't enforce readOnly unless asked to.
163
+ };
164
+
165
+ export function validateSchema(schema: Schema, value: unknown, location = '$'): SchemaError[] {
166
+ const errors: SchemaError[] = [];
167
+ if (!isObject(schema)) return errors;
168
+ const keywords = Object.keys(schema);
169
+ const ordered = [
170
+ ...FIRST_KEYWORDS.filter((keyword) => keywords.includes(keyword)),
171
+ ...keywords.filter((keyword) => !FIRST_KEYWORDS.includes(keyword)),
172
+ ];
173
+ for (const keyword of ordered) {
174
+ if (!Object.hasOwn(KEYWORDS, keyword)) continue;
175
+ KEYWORDS[keyword]({
176
+ arg: schema[keyword],
177
+ schema,
178
+ value,
179
+ location,
180
+ errors,
181
+ fail: (message, property) =>
182
+ errors.push({
183
+ keyword,
184
+ instanceLocation: location,
185
+ ...(property !== undefined ? { property } : {}),
186
+ message,
187
+ }),
188
+ });
189
+ }
190
+ return errors;
191
+ }
192
+
193
+ // GenesisMessageDecoder's FieldError for each failure: TEXT is the error's
194
+ // toString() ("<location>: <message>"), FIELD the missing property for
195
+ // `required` and otherwise the last segment of the location (so an unknown
196
+ // DETAILS property names 'DETAILS'), PATH the location.
197
+ export function schemaFieldErrors(errors: SchemaError[]): NackErrorInput[] {
198
+ return errors.map((error) => ({
199
+ '@type': 'FieldError',
200
+ CODE: 'VALIDATION_ERROR',
201
+ TEXT: `${error.instanceLocation}: ${error.message}`,
202
+ FIELD:
203
+ error.keyword === 'required'
204
+ ? error.property
205
+ : error.instanceLocation.slice(error.instanceLocation.lastIndexOf('.') + 1),
206
+ PATH: error.instanceLocation,
207
+ }));
208
+ }
package/src/server.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import http from 'node:http';
2
2
  import { WebSocketServer, type WebSocket } from 'ws';
3
3
  import { Store } from './db/store.ts';
4
+ import { crudEventHandler } from './handlers/crudEvents.ts';
4
5
  import { pendingKey } from './handlers/dataLogon.ts';
5
6
  import { createHttpRequestListener } from './handlers/http.ts';
6
- import { applyResourceAuth, requestingUser } from './handlers/resourceAuth.ts';
7
+ import { permittedQueryRow, requestingUser } from './handlers/resourceAuth.ts';
7
8
  import { Connection, type Subscription } from './protocol/connection.ts';
8
9
  import { isLegacyFidelity } from './protocol/fidelity.ts';
9
10
  import { routeMessage } from './protocol/router.ts';
10
- import { stampRow, type RowOperation } from './protocol/rowUpdate.ts';
11
+ import { dataserverRow, stampRow, type RowOperation } from './protocol/rowUpdate.ts';
11
12
  import type {
12
13
  BroadcastFn,
13
14
  BroadcastTableChangeFn,
@@ -141,9 +142,10 @@ export function createMockServer(config: MockServerConfig): MockServer {
141
142
  const permitted =
142
143
  operation === 'DELETE'
143
144
  ? undefined
144
- : applyResourceAuth([row], query?.auth, requestingUser(connection))[0];
145
+ : permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
145
146
  if (pending.has(key)) {
146
- if (permitted) pending.set(key, stampRow(permitted, rowRef, 'INSERT'));
147
+ if (permitted)
148
+ pending.set(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
147
149
  else pending.delete(key);
148
150
  return null;
149
151
  }
@@ -153,11 +155,22 @@ export function createMockServer(config: MockServerConfig): MockServer {
153
155
  return 'DELETE';
154
156
  }
155
157
  if (!permitted) return null;
158
+ // Under ORDER_BY, a new row that sorts among the waiting ones waits too,
159
+ // so MORE_ROWS pages stay in order.
160
+ if (
161
+ subscription.queueInsert?.(
162
+ key,
163
+ stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'),
164
+ row,
165
+ )
166
+ )
167
+ return null;
168
+
156
169
  held.add(key);
157
170
  return 'INSERT';
158
171
  }
159
172
 
160
- const broadcast: BroadcastFn = (resourceName, operation, row) => {
173
+ function pushChange(resourceName: string, operation: RowOperation, row: Row): void {
161
174
  const query = config.queries?.[resourceName];
162
175
  // The query's own filter (where-clause equivalent) applies to live pushes
163
176
  // too — a row the snapshot would exclude must not leak in via broadcast.
@@ -188,19 +201,27 @@ export function createMockServer(config: MockServerConfig): MockServer {
188
201
  ? trackClientView(subscription, connection, query, resolved, row, rowRef)
189
202
  : resolved;
190
203
  if (rowOperation === null) continue;
204
+ const legacy = isLegacyFidelity(config);
191
205
  let outRow = row;
192
206
  if (rowOperation === 'DELETE') {
193
- // A change turned into a DELETE: send the row identity only — it is
194
- // all the client needs to drop the row, and the full row may carry
195
- // columns this user's hideFields strips. (A real DELETE's row is a
196
- // stub already.)
197
- if (operation !== 'DELETE') {
207
+ // GSF's DELETE row is DETAILS {OPERATION, ROW_REF} and nothing else.
208
+ // Legacy mode sent the pk fields: the stub it was given for a real
209
+ // DELETE, or one rebuilt from the key for a change that left the
210
+ // subscription (the full row may carry columns hideFields strips).
211
+ if (!legacy) outRow = {};
212
+ else if (operation !== 'DELETE') {
198
213
  outRow = query ? store.rowStubFromKey(query.source, rowRef as string) : row;
199
214
  }
200
- } else if (query?.auth) {
201
- const [permitted] = applyResourceAuth([row], query.auth, requestingUser(connection));
215
+ } else {
216
+ // Only the subscription's columns, minus the ones this user can't see.
217
+ const permitted = permittedQueryRow(
218
+ row,
219
+ query?.auth,
220
+ requestingUser(connection),
221
+ subscription.fieldNames,
222
+ );
202
223
  if (!permitted) continue;
203
- outRow = permitted;
224
+ outRow = dataserverRow(permitted, row, query?.fields);
204
225
  }
205
226
  subscription.sequenceId += 1;
206
227
  // A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
@@ -208,12 +229,33 @@ export function createMockServer(config: MockServerConfig): MockServer {
208
229
  connection.send({
209
230
  MESSAGE_TYPE: 'QUERY_UPDATE',
210
231
  SOURCE_REF: sourceRef,
211
- ROW: [stampRow(outRow, rowRef, rowOperation)],
212
- ...(isLegacyFidelity(config) ? { ROWS_COUNT: 1 } : {}),
232
+ ROW: [stampRow(outRow, rowRef, rowOperation, legacy)],
233
+ ...(legacy ? { ROWS_COUNT: 1 } : {}),
213
234
  SEQUENCE_ID: subscription.sequenceId,
214
235
  });
215
236
  }
216
237
  }
238
+ }
239
+
240
+ // Pushes an unsolicited row update to every subscription on the resource
241
+ // (see pushChange). A DELETE given as a pk-only row is resolved to the
242
+ // record(s) removed from that key that no broadcast has announced yet —
243
+ // their RECORD_IDs are the ROW_REFs a client may hold — and those are
244
+ // consumed, so a later delete of a re-inserted key names its own record.
245
+ const broadcast: BroadcastFn = (resourceName, operation, row) => {
246
+ const source = config.queries?.[resourceName]?.source;
247
+ if (operation !== 'DELETE' || !source) {
248
+ pushChange(resourceName, operation, row);
249
+ return;
250
+ }
251
+ const removed = store.takeRemovedRecordsFor(source, row);
252
+ if (isLegacyFidelity(config)) {
253
+ pushChange(resourceName, operation, row);
254
+ return;
255
+ }
256
+ const records = row.RECORD_ID === undefined || row.RECORD_ID === null ? removed : [row];
257
+ if (records.length === 0) records.push(row);
258
+ for (const record of records) pushChange(resourceName, 'DELETE', record);
217
259
  };
218
260
 
219
261
  // Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
@@ -221,8 +263,26 @@ export function createMockServer(config: MockServerConfig): MockServer {
221
263
  // query resource backed by that table — directly or via a view — and pushes
222
264
  // the (re-joined, for views) row to each one's subscribers. `pk` is the
223
265
  // store key: the single pk field's value, or store.rowKey(...) for
224
- // composite-keyed tables.
266
+ // composite-keyed tables. ROW_REF is the record's RECORD_ID, so a DELETE is
267
+ // broadcast with every record the key lost that no broadcast announced yet
268
+ // (Store.takeRemovedRows; held-rows tracking drops the ones a client
269
+ // doesn't hold), and a record replaced under the same key (deleted, then
270
+ // re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
271
+ // the new one goes out. A view row is built from the changed base row
272
+ // alone, so it is found whatever the view's own pkField; a MODIFY that takes
273
+ // it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
274
+ // are the key itself, so there a DELETE is the pk stub and a replacement is
275
+ // the one INSERT/MODIFY, as before.
225
276
  const broadcastTableChange: BroadcastTableChangeFn = (tableName, operation, pk) => {
277
+ const legacy = isLegacyFidelity(config);
278
+ const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
279
+ let removed = store
280
+ .takeRemovedRows(tableName, pk)
281
+ .filter((record) => record.RECORD_ID !== current?.RECORD_ID);
282
+ // A DELETE broadcast before the row is removed still names it.
283
+ const live = store.getRow(tableName, pk);
284
+ if (operation === 'DELETE' && removed.length === 0 && live) removed = [live];
285
+ if (legacy) removed = [];
226
286
  for (const [queryName, query] of Object.entries(config.queries ?? {})) {
227
287
  const isDirect = query.source === tableName;
228
288
  const view = store.views.get(query.source);
@@ -242,23 +302,38 @@ export function createMockServer(config: MockServerConfig): MockServer {
242
302
 
243
303
  if (isViewJoiningTable) {
244
304
  for (const viewRow of store.getViewRows(query.source)) {
245
- broadcast(queryName, 'MODIFY', viewRow);
305
+ pushChange(queryName, 'MODIFY', viewRow);
246
306
  }
247
307
  continue;
248
308
  }
249
309
 
250
- let row: Row | undefined;
310
+ // The removed base records carry the RECORD_ID a view row's ROW_REF
311
+ // comes from too; the DELETE body is DETAILS only either way.
312
+ for (const record of removed) pushChange(queryName, 'DELETE', record);
251
313
  if (operation === 'DELETE') {
252
- const stub = store.rowStubFromKey(query.source, pk);
253
- row = Object.keys(stub).length > 0 ? stub : undefined;
254
- } else if (isDirect) {
255
- row = store.getRow(tableName, pk);
256
- } else {
257
- row = store
258
- .getViewRows(query.source)
259
- .find((r) => String(store.getRowRef(query.source, r)) === String(pk));
314
+ if (legacy) {
315
+ const stub = store.rowStubFromKey(query.source, pk);
316
+ if (Object.keys(stub).length > 0) pushChange(queryName, 'DELETE', stub);
317
+ }
318
+ continue;
319
+ }
320
+ if (!current) continue;
321
+ if (isDirect) {
322
+ pushChange(queryName, operation, current);
323
+ continue;
324
+ }
325
+ const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
326
+ if (viewRow) {
327
+ pushChange(queryName, operation, viewRow);
328
+ } else if (operation === 'MODIFY') {
329
+ pushChange(
330
+ queryName,
331
+ 'DELETE',
332
+ legacy
333
+ ? store.rowStubFromKey(query.source, store.getRowRef(query.source, current) as string)
334
+ : current,
335
+ );
260
336
  }
261
- if (row) broadcast(queryName, operation, row);
262
337
  }
263
338
  };
264
339
 
@@ -310,8 +385,11 @@ export function createMockServer(config: MockServerConfig): MockServer {
310
385
  return config.requestReplies?.[resourceName];
311
386
  }
312
387
 
388
+ // An eventHandlers entry, else the generic CRUD handler a table registers
389
+ // (TableDef.events) — so extendEventHandler can wrap a generic event; the
390
+ // wrapper is then an explicit handler, which wins.
313
391
  function getEventHandler(eventName: string): EventHandler | undefined {
314
- return config.eventHandlers?.[eventName];
392
+ return config.eventHandlers?.[eventName] ?? crudEventHandler(eventName, config);
315
393
  }
316
394
 
317
395
  // augment(current) => replacement. `current` is undefined if nothing was