@genesislcap/mock-server 15.52.0 → 15.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +210 -27
  2. package/dist/db/criteria.d.ts +12 -0
  3. package/dist/db/criteria.js +39 -29
  4. package/dist/db/ordering.d.ts +8 -0
  5. package/dist/db/ordering.js +49 -0
  6. package/dist/db/schema.d.ts +17 -1
  7. package/dist/db/schema.js +131 -16
  8. package/dist/db/store.d.ts +8 -2
  9. package/dist/db/store.js +120 -44
  10. package/dist/handlers/commitEvent.js +32 -8
  11. package/dist/handlers/crudEvents.d.ts +2 -0
  12. package/dist/handlers/crudEvents.js +138 -0
  13. package/dist/handlers/dataLogon.js +2 -23
  14. package/dist/handlers/eventValidation.d.ts +5 -0
  15. package/dist/handlers/eventValidation.js +29 -0
  16. package/dist/handlers/jsonSchema.d.ts +3 -2
  17. package/dist/handlers/jsonSchema.js +23 -17
  18. package/dist/handlers/meta.js +13 -9
  19. package/dist/handlers/requestReply.js +518 -40
  20. package/dist/handlers/resources.js +3 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/protocol/fieldTypes.d.ts +1 -0
  23. package/dist/protocol/fieldTypes.js +6 -0
  24. package/dist/protocol/msgNack.d.ts +2 -0
  25. package/dist/protocol/msgNack.js +10 -0
  26. package/dist/protocol/schemaValidation.d.ts +11 -0
  27. package/dist/protocol/schemaValidation.js +186 -0
  28. package/dist/server.js +5 -1
  29. package/dist/types.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/db/criteria.ts +52 -27
  32. package/src/db/ordering.ts +56 -0
  33. package/src/db/schema.ts +164 -15
  34. package/src/db/store.ts +146 -46
  35. package/src/handlers/commitEvent.ts +39 -8
  36. package/src/handlers/crudEvents.ts +181 -0
  37. package/src/handlers/dataLogon.ts +2 -19
  38. package/src/handlers/eventValidation.ts +43 -0
  39. package/src/handlers/jsonSchema.ts +31 -19
  40. package/src/handlers/meta.ts +14 -9
  41. package/src/handlers/requestReply.ts +625 -41
  42. package/src/handlers/resources.ts +3 -0
  43. package/src/index.ts +1 -0
  44. package/src/protocol/fieldTypes.ts +7 -0
  45. package/src/protocol/msgNack.ts +12 -0
  46. package/src/protocol/schemaValidation.ts +208 -0
  47. package/src/server.ts +5 -1
  48. package/src/types.ts +56 -18
package/src/db/schema.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  import { isLegacyFidelity } from '../protocol/fidelity.ts';
8
8
  import { daoClassOf, titleCase } from '../protocol/fieldTypes.ts';
9
9
  import type {
10
+ CrudEventVerb,
10
11
  FieldDef,
11
12
  GenesisFieldType,
13
+ IndexDef,
12
14
  MockServerConfig,
13
15
  QueryDef,
14
16
  RequestReplyDef,
@@ -16,6 +18,7 @@ import type {
16
18
  TableDef,
17
19
  ViewDef,
18
20
  } from '../types.ts';
21
+ import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from './identity.ts';
19
22
  import { deriveFields } from './metadata.ts';
20
23
  import type { Store } from './store.ts';
21
24
 
@@ -46,17 +49,29 @@ export interface ResolvedSchema {
46
49
  declared: boolean;
47
50
  }
48
51
 
49
- function pkFieldsOf(def: TableDef): string[] {
52
+ export function pkFieldsOf(def: TableDef): string[] {
50
53
  return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
51
54
  }
52
55
 
53
56
  // The declared generated fields, plus the key of a table that mints its own
54
57
  // keys through sequencePrefix.
55
58
  export function generatedFieldsOf(def: TableDef): Set<string> {
56
- const generated = new Set((def.generated ?? []).map((entry) => entry.field));
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[] {
57
67
  const pkFields = pkFieldsOf(def);
58
- if (def.sequencePrefix && pkFields.length === 1) generated.add(pkFields[0]);
59
- return generated;
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)];
60
75
  }
61
76
 
62
77
  function fromFieldDef(
@@ -184,7 +199,10 @@ function pick(fields: Map<string, ResolvedField>, names: string[]): ResolvedFiel
184
199
  });
185
200
  }
186
201
 
187
- function isDeclared(source: string, config: MockServerConfig, store: Store): boolean {
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 {
188
206
  const table = config.tables?.[source];
189
207
  if (table) return !!table.fields;
190
208
  const view = viewOf(source, config, store);
@@ -285,12 +303,72 @@ export function requestReplySchema(
285
303
  return withTypeOverrides(schema, requestReply.fieldTypes);
286
304
  }
287
305
 
288
- // The record identity a criteria-only request server adds to every reply row.
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.
289
309
  export const RECORD_IDENTITY_FIELDS: ResolvedField[] = [
290
- { name: 'RECORD_ID', type: 'LONG', nullable: false, generated: true },
291
- { name: 'TIMESTAMP', type: 'NANO_TIMESTAMP', nullable: false, generated: true },
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,
292
317
  ];
293
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
+
294
372
  // The request block's fields: requestFields, else the source's key.
295
373
  export function requestFieldsOf(requestReply: RequestReplyDef, store: Store): string[] {
296
374
  if (requestReply.requestFields) return requestReply.requestFields;
@@ -359,8 +437,74 @@ export function projectRow(row: Row, names: string[] | undefined): Row {
359
437
  return Object.fromEntries(names.map((name) => [name, row[name] ?? null]));
360
438
  }
361
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
+
362
473
  // --- events ------------------------------------------------------------------
363
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
+
364
508
  // Any event named .../_DELETE(_...)? takes only the key fields, which is what
365
509
  // entity-management's delete confirmation copies from the selected row.
366
510
  export function isDeleteEventName(name: string): boolean {
@@ -409,12 +553,13 @@ export interface EventTarget {
409
553
  fields?: FieldDef[];
410
554
  }
411
555
 
412
- // The table an event writes: config.eventSchemas first, then the
413
- // EVENT_<TABLE>_<VERB> convention. Only a registered event (an eventHandlers
414
- // or eventSchemas entry) has metadata, as only a deployed event handler has
415
- // on the real server; anything else is the router's 404. `bareTable` also
416
- // accepts a plain table name, which only JSON_SCHEMA_REQUEST does (see the
417
- // README).
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).
418
563
  export function resolveEventTarget(
419
564
  name: string | undefined,
420
565
  config: MockServerConfig,
@@ -422,10 +567,14 @@ export function resolveEventTarget(
422
567
  ): EventTarget | undefined {
423
568
  if (!name) return undefined;
424
569
  const override = config.eventSchemas?.[name];
425
- if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name]) return undefined;
570
+ const crud = crudEventOf(name, config);
571
+ if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name] && !crud) {
572
+ return undefined;
573
+ }
426
574
  const tableNames = Object.keys(config.tables ?? {});
427
575
  const table =
428
576
  override?.table ??
577
+ crud?.table ??
429
578
  (bareTable || name.startsWith('EVENT_') ? inferEventTable(name, tableNames) : undefined);
430
579
  const fieldTypes = override?.fieldTypes ? { fieldTypes: override.fieldTypes } : {};
431
580
  if (override?.fields) {
package/src/db/store.ts CHANGED
@@ -6,13 +6,45 @@ import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } fro
6
6
 
7
7
  type Pk = string;
8
8
 
9
+ // A field the store fills on insert when the record leaves it out: the
10
+ // sequencePrefix key, and every TableDef.generated entry it can mint.
11
+ type Generator =
12
+ | { field: string; kind: 'SEQUENCE'; prefix: string }
13
+ | { field: string; kind: 'AUTO_INCREMENT' };
14
+
9
15
  interface TableConfig {
10
16
  pkFields: string[];
11
17
  sequencePrefix?: string;
12
18
  sequenceWidth: number;
13
19
  sequenceFormat: SequenceFormat;
20
+ generators: Generator[];
14
21
  }
15
22
 
23
+ // The generators of a table, the key's own sequence first. A SEQUENCE with
24
+ // neither its own prefix nor the table's can't mint anything and is left to
25
+ // the caller.
26
+ function generatorsOf(
27
+ pkFields: string[],
28
+ { sequencePrefix, generated = [] }: TableDef,
29
+ ): Generator[] {
30
+ const generators: Generator[] = [];
31
+ if (sequencePrefix && pkFields.length === 1) {
32
+ generators.push({ field: pkFields[0], kind: 'SEQUENCE', prefix: sequencePrefix });
33
+ }
34
+ for (const entry of generated) {
35
+ if (generators.some((generator) => generator.field === entry.field)) continue;
36
+ if (entry.kind === 'AUTO_INCREMENT') {
37
+ generators.push({ field: entry.field, kind: 'AUTO_INCREMENT' });
38
+ continue;
39
+ }
40
+ const prefix = entry.prefix ?? sequencePrefix;
41
+ if (prefix) generators.push({ field: entry.field, kind: 'SEQUENCE', prefix });
42
+ }
43
+ return generators;
44
+ }
45
+
46
+ const isMissing = (value: unknown) => value === undefined || value === null;
47
+
16
48
  // Composite keys join the pk fields' values with '|' (so values must not
17
49
  // contain '|'). Single-field keys stay the raw value, preserving
18
50
  // getRow('TABLE', 'TR001')-style lookups.
@@ -41,12 +73,15 @@ function escapeRegExp(text: string): string {
41
73
  }
42
74
 
43
75
  // The counter inside a generated value, or undefined when the value isn't
44
- // one this table's sequence could have produced.
45
- function sequenceNumber(config: TableConfig, value: unknown): number | undefined {
46
- const prefix = config.sequencePrefix;
47
- if (!prefix || typeof value !== 'string') return undefined;
76
+ // one a sequence with this prefix and format could have produced.
77
+ function sequenceNumber(
78
+ prefix: string,
79
+ format: SequenceFormat,
80
+ value: unknown,
81
+ ): number | undefined {
82
+ if (typeof value !== 'string') return undefined;
48
83
  const digits =
49
- config.sequenceFormat === 'genesis'
84
+ format === 'genesis'
50
85
  ? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(
51
86
  value,
52
87
  )?.[1]
@@ -59,16 +94,18 @@ function sequenceNumber(config: TableConfig, value: unknown): number | undefined
59
94
  }
60
95
 
61
96
  // Counters are shared by tables using the same prefix in the same format.
62
- function counterKey(config: TableConfig): string {
63
- return config.sequenceFormat === 'genesis'
64
- ? `${config.sequencePrefix}\u0000genesis`
65
- : config.sequencePrefix!;
97
+ function counterKey(prefix: string, format: SequenceFormat): string {
98
+ return format === 'genesis' ? `${prefix}\u0000genesis` : prefix;
66
99
  }
67
100
 
101
+ // An AUTO_INCREMENT counter belongs to its table's field.
102
+ const autoIncrementKey = (tableName: string, field: string) => `${tableName}\u0000${field}`;
103
+
68
104
  export class Store {
69
105
  private tables = new Map<string, Map<Pk, Row>>();
70
106
  private tableConfig = new Map<string, TableConfig>();
71
107
  private sequenceCounters = new Map<string, number>();
108
+ private autoIncrementCounters = new Map<string, number>();
72
109
  private clock = new RecordClock();
73
110
  // Immutable snapshot of each table's rows as registered, used only for field
74
111
  // metadata derivation (see getMetadataRows) so deleting every live row can't
@@ -82,12 +119,22 @@ export class Store {
82
119
  private removedRows = new Map<string, Map<Pk, Row[]>>();
83
120
  readonly views = new Map<string, ViewDef>();
84
121
 
85
- registerTable(
86
- tableName: string,
87
- { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [] }: TableDef,
88
- ): this {
122
+ registerTable(tableName: string, definition: TableDef): this {
123
+ const {
124
+ pkField,
125
+ sequencePrefix,
126
+ sequenceWidth = 3,
127
+ sequenceFormat = 'prefix',
128
+ rows = [],
129
+ } = definition;
89
130
  const pkFields = normalizePkFields(pkField);
90
- const config: TableConfig = { pkFields, sequencePrefix, sequenceWidth, sequenceFormat };
131
+ const config: TableConfig = {
132
+ pkFields,
133
+ sequencePrefix,
134
+ sequenceWidth,
135
+ sequenceFormat,
136
+ generators: generatorsOf(pkFields, definition),
137
+ };
91
138
  const table = new Map<Pk, Row>();
92
139
  const seenIds = new Set<string>();
93
140
  let restamped = 0;
@@ -110,18 +157,48 @@ export class Store {
110
157
  rows.map((row) => ({ ...row })),
111
158
  );
112
159
 
113
- // Sequence generation only makes sense for a single-field pk.
114
- if (sequencePrefix && pkFields.length === 1) {
160
+ // Each counter starts after the highest value the seed rows carry (two
161
+ // fields of one table sharing a prefix share the counter). The key's
162
+ // sequence only exists for a single-field pk.
163
+ const starts = new Map<string, number>();
164
+ for (const generator of config.generators) {
115
165
  let maxN = 0;
116
166
  for (const row of rows) {
117
- const n = sequenceNumber(config, row[pkFields[0]]);
167
+ const n = this.generatedNumber(config, generator, row[generator.field]);
118
168
  if (n !== undefined) maxN = Math.max(maxN, n);
119
169
  }
120
- this.sequenceCounters.set(counterKey(config), maxN + 1);
170
+ const counter = this.counterOf(tableName, config, generator);
171
+ starts.set(counter, Math.max(starts.get(counter) ?? 1, maxN + 1));
172
+ }
173
+ for (const generator of config.generators) {
174
+ const counter = this.counterOf(tableName, config, generator);
175
+ this.countersFor(generator).set(counter, starts.get(counter)!);
121
176
  }
122
177
  return this;
123
178
  }
124
179
 
180
+ private counterOf(tableName: string, config: TableConfig, generator: Generator): string {
181
+ return generator.kind === 'SEQUENCE'
182
+ ? counterKey(generator.prefix, config.sequenceFormat)
183
+ : autoIncrementKey(tableName, generator.field);
184
+ }
185
+
186
+ private countersFor(generator: Generator): Map<string, number> {
187
+ return generator.kind === 'SEQUENCE' ? this.sequenceCounters : this.autoIncrementCounters;
188
+ }
189
+
190
+ // The counter inside a value a generator could have produced.
191
+ private generatedNumber(
192
+ config: TableConfig,
193
+ generator: Generator,
194
+ value: unknown,
195
+ ): number | undefined {
196
+ if (generator.kind === 'SEQUENCE') {
197
+ return sequenceNumber(generator.prefix, config.sequenceFormat, value);
198
+ }
199
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
200
+ }
201
+
125
202
  registerView(viewName: string, definition: ViewDef): this {
126
203
  this.views.set(viewName, definition);
127
204
  return this;
@@ -175,18 +252,19 @@ export class Store {
175
252
  if (removed.size > MAX_REMOVED_KEYS) removed.delete(removed.keys().next().value!);
176
253
  }
177
254
 
178
- // An explicitly-supplied pk inside the sequence's own range must advance the
179
- // counter too. Without this, inserting e.g. 'TR002' by hand leaves the
180
- // counter at 2, and the next auto-generated insert mints 'TR002' again and
181
- // silently overwrites the hand-keyed row (Map.set, no error, event still
182
- // acks success).
183
- private observeSequenceValue(config: TableConfig, row: Row): void {
184
- if (!config.sequencePrefix || config.pkFields.length !== 1) return;
185
- const n = sequenceNumber(config, row[config.pkFields[0]]);
186
- if (n === undefined) return;
187
- const key = counterKey(config);
188
- const next = this.sequenceCounters.get(key) ?? 1;
189
- if (n + 1 > next) this.sequenceCounters.set(key, n + 1);
255
+ // A supplied value inside a generator's own range must advance its counter
256
+ // too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
257
+ // 2, and the next generated insert mints 'TR002' again and silently
258
+ // overwrites the hand-keyed row (Map.set, no error, event still acks
259
+ // success).
260
+ private observeGeneratedValues(tableName: string, config: TableConfig, row: Row): void {
261
+ for (const generator of config.generators) {
262
+ const n = this.generatedNumber(config, generator, row[generator.field]);
263
+ if (n === undefined) continue;
264
+ const counters = this.countersFor(generator);
265
+ const counter = this.counterOf(tableName, config, generator);
266
+ if (n + 1 > (counters.get(counter) ?? 1)) counters.set(counter, n + 1);
267
+ }
190
268
  }
191
269
 
192
270
  nextSequenceValue(sequencePrefix: string, width: number): string {
@@ -195,14 +273,29 @@ export class Store {
195
273
  return `${sequencePrefix}${String(n).padStart(width, '0')}`;
196
274
  }
197
275
 
198
- private generateSequenceValue(config: TableConfig): string {
276
+ private generateSequenceValue(config: TableConfig, prefix: string): string {
199
277
  if (config.sequenceFormat !== 'genesis') {
200
- return this.nextSequenceValue(config.sequencePrefix!, config.sequenceWidth);
278
+ return this.nextSequenceValue(prefix, config.sequenceWidth);
201
279
  }
202
- const key = counterKey(config);
280
+ const key = counterKey(prefix, 'genesis');
203
281
  const n = this.sequenceCounters.get(key) ?? 1;
204
282
  this.sequenceCounters.set(key, n + 1);
205
- return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${config.sequencePrefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
283
+ return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${prefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
284
+ }
285
+
286
+ // Fills every generated field the record leaves out (undefined or null).
287
+ private generateValues(tableName: string, config: TableConfig, row: Row): void {
288
+ for (const generator of config.generators) {
289
+ if (!isMissing(row[generator.field])) continue;
290
+ if (generator.kind === 'SEQUENCE') {
291
+ row[generator.field] = this.generateSequenceValue(config, generator.prefix);
292
+ continue;
293
+ }
294
+ const counter = autoIncrementKey(tableName, generator.field);
295
+ const n = this.autoIncrementCounters.get(counter) ?? 1;
296
+ this.autoIncrementCounters.set(counter, n + 1);
297
+ row[generator.field] = n;
298
+ }
206
299
  }
207
300
 
208
301
  getAllRows(tableName: string): Row[] {
@@ -248,30 +341,37 @@ export class Store {
248
341
  return rows.find(matchesAllCriteria);
249
342
  }
250
343
 
251
- // Generates a missing (undefined or null) sequence pk and keeps a supplied
252
- // one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already stored under
253
- // the same key is replaced (the real database would reject the insert with
254
- // DUPLICATE_KEY — check first and throw NackError.duplicateKey for that).
344
+ // A copy of the record with its missing generated fields filled in, as the
345
+ // database does before checking its unique indexes. Each value used is
346
+ // gone even if the record is then rejected, as a real sequence's is.
347
+ withGeneratedValues(tableName: string, record: Row): Row {
348
+ const config = this.tableConfig.get(tableName);
349
+ if (!config) throw new Error(`Unknown table: ${tableName}`);
350
+ const row = { ...record };
351
+ this.generateValues(tableName, config, row);
352
+ return row;
353
+ }
354
+
355
+ // Generates every missing (undefined or null) sequence and auto-increment
356
+ // field — the sequencePrefix key and TableDef.generated — and keeps a
357
+ // supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already
358
+ // stored under the same key is replaced (the real database would reject the
359
+ // insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
360
+ // for that, as the generic CRUD events do).
255
361
  insertRow(tableName: string, record: Row): Row {
256
362
  const table = this.tables.get(tableName);
257
363
  const config = this.tableConfig.get(tableName);
258
364
  if (!table || !config) throw new Error(`Unknown table: ${tableName}`);
259
365
 
260
366
  const row = { ...record };
261
- if (
262
- config.pkFields.length === 1 &&
263
- (row[config.pkFields[0]] === undefined || row[config.pkFields[0]] === null) &&
264
- config.sequencePrefix
265
- ) {
266
- row[config.pkFields[0]] = this.generateSequenceValue(config);
267
- }
367
+ this.generateValues(tableName, config, row);
268
368
  const missing = config.pkFields.filter((field) => row[field] === undefined);
269
369
  if (missing.length > 0) {
270
370
  throw new Error(
271
371
  `Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`,
272
372
  );
273
373
  }
274
- this.observeSequenceValue(config, row);
374
+ this.observeGeneratedValues(tableName, config, row);
275
375
  this.stampNewRow(row);
276
376
  const key = keyFor(config.pkFields, row);
277
377
  const displaced = table.get(key);
@@ -1,26 +1,44 @@
1
1
  import type { Store } from '../db/store.ts';
2
2
  import type { Connection } from '../protocol/connection.ts';
3
- import { nackPayload, warningPayload } from '../protocol/errors.ts';
3
+ import { NackError, nackPayload, warningPayload } from '../protocol/errors.ts';
4
4
  import { isLegacyFidelity } from '../protocol/fidelity.ts';
5
5
  import { ERROR_CODE, MESSAGE_TYPE } from '../protocol/messageTypes.ts';
6
6
  import { resourceNotMappedNack } from '../protocol/msgNack.ts';
7
7
  import type {
8
8
  BroadcastFn,
9
9
  BroadcastTableChangeFn,
10
+ EventHandler,
10
11
  GenesisMessage,
11
12
  MockServerConfig,
12
13
  } from '../types.ts';
14
+ import { crudEventHandler } from './crudEvents.ts';
15
+ import { validateEventMessage } from './eventValidation.ts';
13
16
 
14
17
  export interface CommitEventCtx {
15
18
  broadcast: BroadcastFn;
16
19
  broadcastTableChange: BroadcastTableChangeFn;
17
20
  }
18
21
 
22
+ // The handler serving an event: an eventHandlers entry, else the generic CRUD
23
+ // handler a table registers through TableDef.events, else
24
+ // defaultEventHandler.
25
+ function resolveEventHandler(
26
+ eventName: string,
27
+ config: MockServerConfig,
28
+ ): EventHandler | undefined {
29
+ return (
30
+ config.eventHandlers?.[eventName] ??
31
+ crudEventHandler(eventName, config) ??
32
+ config.defaultEventHandler
33
+ );
34
+ }
35
+
19
36
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
20
37
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
21
- // config.eventHandlers. A handler receives (details, ctx) and either returns
22
- // { generated?: [...] } on success, { warnings: [...] } to NACK with warnings
23
- // only, or throws NackError(errors) on failure.
38
+ // config.eventHandlers, then the tables' generic CRUD events. A handler
39
+ // receives (details, ctx) and either returns { generated?: [...] } on
40
+ // success, { warnings: [...] } to NACK with warnings only, or throws
41
+ // NackError(errors) on failure.
24
42
  export function handleCommitEvent(
25
43
  message: GenesisMessage,
26
44
  connection: Connection,
@@ -32,7 +50,7 @@ export function handleCommitEvent(
32
50
  // defaultEventHandler catches anything unregistered — lets projects with
33
51
  // dozens of commit events adopt incrementally (e.g. `() => ({})` to ack
34
52
  // everything unhandled) instead of enumerating ack-only handlers.
35
- const handler = config.eventHandlers?.[eventName] ?? config.defaultEventHandler;
53
+ const handler = resolveEventHandler(eventName, config);
36
54
 
37
55
  // Replies use the GENERIC EVENT_ACK/EVENT_NACK — correlation is purely via
38
56
  // SOURCE_REF. See the note on MESSAGE_TYPE in protocol/messageTypes.ts:
@@ -59,14 +77,27 @@ export function handleCommitEvent(
59
77
  return;
60
78
  }
61
79
 
80
+ // Before any handler runs, the message is checked against the event's
81
+ // declared schema (eventValidation.ts), as GSF's decoder does; each failure
82
+ // is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
62
83
  // A handler's failure is an EVENT_NACK with GSF's error items (see
63
84
  // protocol/errors.ts): a thrown NackError gives its own, anything else is
64
85
  // StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
65
- // ERROR — the IGNORE_WARNINGS/VALIDATE flow that would let them through is
66
- // not modelled yet.
86
+ // ERROR — the IGNORE_WARNINGS flow that would let them through is not
87
+ // modelled yet. VALIDATE: true reaches the handler as ctx.validate; a
88
+ // handler that honours it acks without writing.
67
89
  const legacy = isLegacyFidelity(config);
68
90
  try {
69
- const result = handler(message.DETAILS ?? {}, { connection, store, message, ...ctx }) ?? {};
91
+ const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
92
+ if (schemaErrors.length > 0) throw new NackError(schemaErrors);
93
+ const result =
94
+ handler(message.DETAILS ?? {}, {
95
+ connection,
96
+ store,
97
+ message,
98
+ ...ctx,
99
+ validate: message.VALIDATE === true,
100
+ }) ?? {};
70
101
  if (result.warnings?.length) {
71
102
  connection.reply(message, {
72
103
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,