@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
@@ -1,4 +1,4 @@
1
- import type { FieldDef, MockServerConfig, QueryDef, RequestReplyDef, Row, TableDef } from '../types.ts';
1
+ import type { CrudEventVerb, FieldDef, IndexDef, MockServerConfig, QueryDef, RequestReplyDef, Row, TableDef } from '../types.ts';
2
2
  import type { Store } from './store.ts';
3
3
  export interface ResolvedField {
4
4
  name: string;
@@ -18,18 +18,34 @@ export interface ResolvedSchema {
18
18
  fields: ResolvedField[];
19
19
  declared: boolean;
20
20
  }
21
+ export declare function pkFieldsOf(def: TableDef): string[];
21
22
  export declare function generatedFieldsOf(def: TableDef): Set<string>;
23
+ export declare function generatedFieldList(def: TableDef): string[];
24
+ export declare function isDeclared(source: string, config: MockServerConfig, store: Store): boolean;
22
25
  export declare function resolveSourceSchema(source: string, config: MockServerConfig, store: Store): ResolvedSchema;
23
26
  export declare function withTypeOverrides(schema: ResolvedSchema, fieldTypes: Record<string, string> | undefined): ResolvedSchema;
24
27
  export declare function projectSchema(schema: ResolvedSchema, names: string[] | undefined): ResolvedSchema;
25
28
  export declare function querySchema(query: QueryDef, config: MockServerConfig, store: Store): ResolvedSchema;
26
29
  export declare function requestReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
27
30
  export declare const RECORD_IDENTITY_FIELDS: ResolvedField[];
31
+ export declare function requestReplySourceSchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
32
+ export declare function requestReplyReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
33
+ export declare function requestReplyFieldNames(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): string[] | undefined;
28
34
  export declare function requestFieldsOf(requestReply: RequestReplyDef, store: Store): string[];
29
35
  export declare function knownValues(field: ResolvedField): string[] | undefined;
30
36
  export declare function eventDefaultOf(field: ResolvedField): unknown;
31
37
  export declare function queryFieldNames(query: QueryDef | undefined, config: MockServerConfig, store: Store): string[] | undefined;
32
38
  export declare function projectRow(row: Row, names: string[] | undefined): Row;
39
+ export declare function defaultIndexName(table: string, fields: string[]): string;
40
+ export declare function primaryKeyIndexName(table: string, def: TableDef): string;
41
+ export declare function secondaryUniqueIndexes(def: TableDef): IndexDef[];
42
+ export declare const CRUD_EVENT_VERBS: readonly CrudEventVerb[];
43
+ export declare function crudEventName(table: string, verb: CrudEventVerb): string;
44
+ export declare function crudEventOf(name: string | undefined, config: MockServerConfig): {
45
+ table: string;
46
+ verb: CrudEventVerb;
47
+ } | undefined;
48
+ export declare function crudEventNames(config: MockServerConfig): string[];
33
49
  export declare function isDeleteEventName(name: string): boolean;
34
50
  export declare function inferEventTable(eventName: string | undefined, tableNames: string[]): string | undefined;
35
51
  export interface EventTarget {
package/dist/db/schema.js CHANGED
@@ -5,18 +5,29 @@
5
5
  // existed, so a config that declares nothing keeps working.
6
6
  import { isLegacyFidelity } from "../protocol/fidelity.js";
7
7
  import { daoClassOf, titleCase } from "../protocol/fieldTypes.js";
8
+ import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from "./identity.js";
8
9
  import { deriveFields } from "./metadata.js";
9
- function pkFieldsOf(def) {
10
+ export function pkFieldsOf(def) {
10
11
  return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
11
12
  }
12
13
  // The declared generated fields, plus the key of a table that mints its own
13
14
  // keys through sequencePrefix.
14
15
  export function generatedFieldsOf(def) {
15
- const generated = new Set((def.generated ?? []).map((entry) => entry.field));
16
+ return new Set(generatedFieldList(def));
17
+ }
18
+ // The same fields in the order GSF lists a table's generated fields
19
+ // (EventHandlerUtils.kt generatedFields: the id sequences, then the
20
+ // auto-increment fields): the sequencePrefix key first, then the declared
21
+ // SEQUENCE fields, then the AUTO_INCREMENT ones.
22
+ export function generatedFieldList(def) {
16
23
  const pkFields = pkFieldsOf(def);
17
- if (def.sequencePrefix && pkFields.length === 1)
18
- generated.add(pkFields[0]);
19
- return generated;
24
+ const declared = def.generated ?? [];
25
+ const fields = [
26
+ ...(def.sequencePrefix && pkFields.length === 1 ? [pkFields[0]] : []),
27
+ ...declared.filter((entry) => entry.kind === 'SEQUENCE').map((entry) => entry.field),
28
+ ...declared.filter((entry) => entry.kind === 'AUTO_INCREMENT').map((entry) => entry.field),
29
+ ];
30
+ return [...new Set(fields)];
20
31
  }
21
32
  function fromFieldDef(field, table, notNullByDefault, generated) {
22
33
  return {
@@ -105,7 +116,10 @@ function pick(fields, names) {
105
116
  return field ? [field] : [];
106
117
  });
107
118
  }
108
- function isDeclared(source, config, store) {
119
+ // Whether a table (or a view, through its base table) declares its fields.
120
+ // Checked without resolving the schema, so it costs nothing for a big
121
+ // undeclared table.
122
+ export function isDeclared(source, config, store) {
109
123
  const table = config.tables?.[source];
110
124
  if (table)
111
125
  return !!table.fields;
@@ -180,11 +194,59 @@ export function requestReplySchema(requestReply, config, store) {
180
194
  };
181
195
  return withTypeOverrides(schema, requestReply.fieldTypes);
182
196
  }
183
- // The record identity a criteria-only request server adds to every reply row.
197
+ // The record identity every table entity carries after its own columns: what
198
+ // a request server with no reply block sends in each row and lists in
199
+ // REPLY_FIELD, and what a criteria-only one can sort and filter by.
184
200
  export const RECORD_IDENTITY_FIELDS = [
185
- { name: 'RECORD_ID', type: 'LONG', nullable: false, generated: true },
186
- { name: 'TIMESTAMP', type: 'NANO_TIMESTAMP', nullable: false, generated: true },
201
+ { name: RECORD_ID, type: 'LONG', nullable: false, generated: true },
202
+ { name: TIMESTAMP, type: 'NANO_TIMESTAMP', nullable: false, generated: true },
203
+ ];
204
+ const withIdentity = (fields) => [
205
+ ...fields.filter((field) => !RECORD_FIELDS.includes(field.name)),
206
+ ...RECORD_IDENTITY_FIELDS,
187
207
  ];
208
+ // What a request server's database entity has: its source's fields, then
209
+ // RECORD_ID and TIMESTAMP (a resolver's: its metadataRows). A criteria-only
210
+ // server sorts and filters by these (SORTABLE_FIELDS, CRITERIA_FIELDS), and
211
+ // REQUEST may name any of them, whatever the reply block says.
212
+ export function requestReplySourceSchema(requestReply, config, store) {
213
+ const schema = requestReplySchema(requestReply, config, store);
214
+ return requestReply.source && !requestReply.resolver
215
+ ? { ...schema, fields: withIdentity(schema.fields) }
216
+ : schema;
217
+ }
218
+ // What a request server's REP_ rows carry (getReplyFields in
219
+ // RequestReplyServer.kt, GSF v8.15.29): the reply block (`fields`), else the
220
+ // source's fields followed by RECORD_ID and TIMESTAMP — a table entity's own
221
+ // field list, which the real server sends whether or not the server is
222
+ // criteria-only (auth's RIGHT sends no RECORD_ID only because its reply block
223
+ // names CODE and DESCRIPTION). REPLY_FIELD and the JSON schema's REPLY come
224
+ // from here. A resolver's fields are its metadataRows'; under fidelity:
225
+ // 'legacy' everything is the source schema, as up to 15.47.
226
+ export function requestReplyReplySchema(requestReply, config, store) {
227
+ if (isLegacyFidelity(config))
228
+ return requestReplySchema(requestReply, config, store);
229
+ const schema = requestReplySourceSchema(requestReply, config, store);
230
+ if (!requestReply.fields || !requestReply.source || requestReply.resolver)
231
+ return schema;
232
+ return projectSchema(schema, requestReply.fields);
233
+ }
234
+ // The columns a source-backed request server's REP_ rows carry, in order (see
235
+ // requestReplyReplySchema). undefined — rows as stored, which already end
236
+ // with their RECORD_ID and TIMESTAMP — for an undeclared source with no reply
237
+ // block, for a resolver, and under fidelity: 'legacy'.
238
+ export function requestReplyFieldNames(requestReply, config, store) {
239
+ if (isLegacyFidelity(config) || !requestReply.source || requestReply.resolver)
240
+ return undefined;
241
+ const declared = sourceFieldNames(requestReply.source, config, store);
242
+ const names = declared && [
243
+ ...declared.filter((name) => !RECORD_FIELDS.includes(name)),
244
+ ...RECORD_FIELDS,
245
+ ];
246
+ if (!requestReply.fields)
247
+ return names;
248
+ return names ? requestReply.fields.filter((name) => names.includes(name)) : requestReply.fields;
249
+ }
188
250
  // The request block's fields: requestFields, else the source's key.
189
251
  export function requestFieldsOf(requestReply, store) {
190
252
  if (requestReply.requestFields)
@@ -249,7 +311,56 @@ export function projectRow(row, names) {
249
311
  return row;
250
312
  return Object.fromEntries(names.map((name) => [name, row[name] ?? null]));
251
313
  }
314
+ // --- indexes -----------------------------------------------------------------
315
+ // The name the dictionary gives an index declared without one
316
+ // (DslFieldContext.kt generateIndexName, GSF v8.15.29): '<TABLE>_BY_' and the
317
+ // fields, each without a leading '<TABLE>_', joined with '_' — so
318
+ // primaryKey("COUNTERPARTY_ID") is COUNTERPARTY_BY_ID and
319
+ // unique("INSTRUMENT_ID") on POSITION is POSITION_BY_INSTRUMENT_ID.
320
+ export function defaultIndexName(table, fields) {
321
+ const prefix = `${table}_`;
322
+ const names = fields.map((field) => (field.startsWith(prefix) && field.slice(prefix.length)) || field);
323
+ return `${prefix}BY_${names.join('_')}`;
324
+ }
325
+ const sameFields = (a, b) => a.length === b.length && a.every((field, index) => field === b[index]);
326
+ // The primary key's index name: a declared unique index over exactly the key
327
+ // fields names it, else the dictionary default. NACKs name it in FIELD.
328
+ export function primaryKeyIndexName(table, def) {
329
+ const pkFields = pkFieldsOf(def);
330
+ const declared = def.indexes?.find((index) => index.unique && sameFields(index.fields, pkFields));
331
+ return declared?.name ?? defaultIndexName(table, pkFields);
332
+ }
333
+ // The table's other unique indexes, each a constraint an insert or modify
334
+ // must not break.
335
+ export function secondaryUniqueIndexes(def) {
336
+ const pkFields = pkFieldsOf(def);
337
+ return (def.indexes ?? []).filter((index) => index.unique && !sameFields(index.fields, pkFields));
338
+ }
252
339
  // --- events ------------------------------------------------------------------
340
+ export const CRUD_EVENT_VERBS = ['INSERT', 'MODIFY', 'DELETE'];
341
+ // A generic event's name: Create names the handler '<TABLE>_<VERB>'
342
+ // (as GSF's GenericInsertEventHandler.messageType() does too) and the event
343
+ // handler serves it as EVENT_<TABLE>_<VERB>.
344
+ export function crudEventName(table, verb) {
345
+ return `EVENT_${table}_${verb}`;
346
+ }
347
+ // The table and verb of a generic CRUD event a table registers through
348
+ // TableDef.events, or undefined.
349
+ export function crudEventOf(name, config) {
350
+ if (!name?.startsWith('EVENT_'))
351
+ return undefined;
352
+ for (const [table, def] of Object.entries(config.tables ?? {})) {
353
+ for (const verb of def.events ?? []) {
354
+ if (crudEventName(table, verb) === name)
355
+ return { table, verb };
356
+ }
357
+ }
358
+ return undefined;
359
+ }
360
+ // Every generic CRUD event the tables register, in table then verb order.
361
+ export function crudEventNames(config) {
362
+ return Object.entries(config.tables ?? {}).flatMap(([table, def]) => CRUD_EVENT_VERBS.filter((verb) => def.events?.includes(verb)).map((verb) => crudEventName(table, verb)));
363
+ }
253
364
  // Any event named .../_DELETE(_...)? takes only the key fields, which is what
254
365
  // entity-management's delete confirmation copies from the selected row.
255
366
  export function isDeleteEventName(name) {
@@ -278,20 +389,24 @@ export function inferEventTable(eventName, tableNames) {
278
389
  candidates.sort((a, b) => b.length - a.length);
279
390
  return candidates[0];
280
391
  }
281
- // The table an event writes: config.eventSchemas first, then the
282
- // EVENT_<TABLE>_<VERB> convention. Only a registered event (an eventHandlers
283
- // or eventSchemas entry) has metadata, as only a deployed event handler has
284
- // on the real server; anything else is the router's 404. `bareTable` also
285
- // accepts a plain table name, which only JSON_SCHEMA_REQUEST does (see the
286
- // README).
392
+ // The table an event writes: config.eventSchemas first, then the table that
393
+ // registers it as a generic CRUD event, then the EVENT_<TABLE>_<VERB>
394
+ // convention. Only a registered event (an eventHandlers or eventSchemas
395
+ // entry, or a TableDef.events verb) has metadata, as only a deployed event
396
+ // handler has on the real server; anything else is the router's 404.
397
+ // `bareTable` also accepts a plain table name, which only JSON_SCHEMA_REQUEST
398
+ // does (see the README).
287
399
  export function resolveEventTarget(name, config, { bareTable = false } = {}) {
288
400
  if (!name)
289
401
  return undefined;
290
402
  const override = config.eventSchemas?.[name];
291
- if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name])
403
+ const crud = crudEventOf(name, config);
404
+ if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name] && !crud) {
292
405
  return undefined;
406
+ }
293
407
  const tableNames = Object.keys(config.tables ?? {});
294
408
  const table = override?.table ??
409
+ crud?.table ??
295
410
  (bareTable || name.startsWith('EVENT_') ? inferEventTable(name, tableNames) : undefined);
296
411
  const fieldTypes = override?.fieldTypes ? { fieldTypes: override.fieldTypes } : {};
297
412
  if (override?.fields) {
@@ -4,22 +4,28 @@ export declare class Store {
4
4
  private tables;
5
5
  private tableConfig;
6
6
  private sequenceCounters;
7
+ private autoIncrementCounters;
7
8
  private clock;
8
9
  private seedRows;
9
10
  private removedRows;
10
11
  readonly views: Map<string, ViewDef>;
11
- registerTable(tableName: string, { pkField, sequencePrefix, sequenceWidth, sequenceFormat, rows }: TableDef): this;
12
+ registerTable(tableName: string, definition: TableDef): this;
13
+ private counterOf;
14
+ private countersFor;
15
+ private generatedNumber;
12
16
  registerView(viewName: string, definition: ViewDef): this;
13
17
  private stampSeedRow;
14
18
  private stampNewRow;
15
19
  private noteRemoved;
16
- private observeSequenceValue;
20
+ private observeGeneratedValues;
17
21
  nextSequenceValue(sequencePrefix: string, width: number): string;
18
22
  private generateSequenceValue;
23
+ private generateValues;
19
24
  getAllRows(tableName: string): Row[];
20
25
  getRow(tableName: string, pk: Pk): Row | undefined;
21
26
  rowKey(tableName: string, row: Row): Pk;
22
27
  findRow(tableName: string, criteria: Row): Row | undefined;
28
+ withGeneratedValues(tableName: string, record: Row): Row;
23
29
  insertRow(tableName: string, record: Row): Row;
24
30
  updateRow(tableName: string, pk: Pk, patch: Row): Row | undefined;
25
31
  deleteRow(tableName: string, pk: Pk): boolean;
package/dist/db/store.js CHANGED
@@ -1,6 +1,28 @@
1
1
  // In-memory tables + views + sequence generation. No persistence, no external deps.
2
2
  // This is the "database" — disposable and rebuilt from seed data on every server start.
3
3
  import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from "./identity.js";
4
+ // The generators of a table, the key's own sequence first. A SEQUENCE with
5
+ // neither its own prefix nor the table's can't mint anything and is left to
6
+ // the caller.
7
+ function generatorsOf(pkFields, { sequencePrefix, generated = [] }) {
8
+ const generators = [];
9
+ if (sequencePrefix && pkFields.length === 1) {
10
+ generators.push({ field: pkFields[0], kind: 'SEQUENCE', prefix: sequencePrefix });
11
+ }
12
+ for (const entry of generated) {
13
+ if (generators.some((generator) => generator.field === entry.field))
14
+ continue;
15
+ if (entry.kind === 'AUTO_INCREMENT') {
16
+ generators.push({ field: entry.field, kind: 'AUTO_INCREMENT' });
17
+ continue;
18
+ }
19
+ const prefix = entry.prefix ?? sequencePrefix;
20
+ if (prefix)
21
+ generators.push({ field: entry.field, kind: 'SEQUENCE', prefix });
22
+ }
23
+ return generators;
24
+ }
25
+ const isMissing = (value) => value === undefined || value === null;
4
26
  // Composite keys join the pk fields' values with '|' (so values must not
5
27
  // contain '|'). Single-field keys stay the raw value, preserving
6
28
  // getRow('TABLE', 'TR001')-style lookups.
@@ -24,12 +46,11 @@ function escapeRegExp(text) {
24
46
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
47
  }
26
48
  // The counter inside a generated value, or undefined when the value isn't
27
- // one this table's sequence could have produced.
28
- function sequenceNumber(config, value) {
29
- const prefix = config.sequencePrefix;
30
- if (!prefix || typeof value !== 'string')
49
+ // one a sequence with this prefix and format could have produced.
50
+ function sequenceNumber(prefix, format, value) {
51
+ if (typeof value !== 'string')
31
52
  return undefined;
32
- const digits = config.sequenceFormat === 'genesis'
53
+ const digits = format === 'genesis'
33
54
  ? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(value)?.[1]
34
55
  : value.startsWith(prefix)
35
56
  ? value.slice(prefix.length)
@@ -40,15 +61,16 @@ function sequenceNumber(config, value) {
40
61
  return Number.isFinite(n) ? n : undefined;
41
62
  }
42
63
  // Counters are shared by tables using the same prefix in the same format.
43
- function counterKey(config) {
44
- return config.sequenceFormat === 'genesis'
45
- ? `${config.sequencePrefix}\u0000genesis`
46
- : config.sequencePrefix;
64
+ function counterKey(prefix, format) {
65
+ return format === 'genesis' ? `${prefix}\u0000genesis` : prefix;
47
66
  }
67
+ // An AUTO_INCREMENT counter belongs to its table's field.
68
+ const autoIncrementKey = (tableName, field) => `${tableName}\u0000${field}`;
48
69
  export class Store {
49
70
  tables = new Map();
50
71
  tableConfig = new Map();
51
72
  sequenceCounters = new Map();
73
+ autoIncrementCounters = new Map();
52
74
  clock = new RecordClock();
53
75
  // Immutable snapshot of each table's rows as registered, used only for field
54
76
  // metadata derivation (see getMetadataRows) so deleting every live row can't
@@ -61,9 +83,16 @@ export class Store {
61
83
  // for handlers that delete without ever broadcasting.
62
84
  removedRows = new Map();
63
85
  views = new Map();
64
- registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [] }) {
86
+ registerTable(tableName, definition) {
87
+ const { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [], } = definition;
65
88
  const pkFields = normalizePkFields(pkField);
66
- const config = { pkFields, sequencePrefix, sequenceWidth, sequenceFormat };
89
+ const config = {
90
+ pkFields,
91
+ sequencePrefix,
92
+ sequenceWidth,
93
+ sequenceFormat,
94
+ generators: generatorsOf(pkFields, definition),
95
+ };
67
96
  const table = new Map();
68
97
  const seenIds = new Set();
69
98
  let restamped = 0;
@@ -81,18 +110,41 @@ export class Store {
81
110
  this.tableConfig.set(tableName, config);
82
111
  this.removedRows.delete(tableName);
83
112
  this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
84
- // Sequence generation only makes sense for a single-field pk.
85
- if (sequencePrefix && pkFields.length === 1) {
113
+ // Each counter starts after the highest value the seed rows carry (two
114
+ // fields of one table sharing a prefix share the counter). The key's
115
+ // sequence only exists for a single-field pk.
116
+ const starts = new Map();
117
+ for (const generator of config.generators) {
86
118
  let maxN = 0;
87
119
  for (const row of rows) {
88
- const n = sequenceNumber(config, row[pkFields[0]]);
120
+ const n = this.generatedNumber(config, generator, row[generator.field]);
89
121
  if (n !== undefined)
90
122
  maxN = Math.max(maxN, n);
91
123
  }
92
- this.sequenceCounters.set(counterKey(config), maxN + 1);
124
+ const counter = this.counterOf(tableName, config, generator);
125
+ starts.set(counter, Math.max(starts.get(counter) ?? 1, maxN + 1));
126
+ }
127
+ for (const generator of config.generators) {
128
+ const counter = this.counterOf(tableName, config, generator);
129
+ this.countersFor(generator).set(counter, starts.get(counter));
93
130
  }
94
131
  return this;
95
132
  }
133
+ counterOf(tableName, config, generator) {
134
+ return generator.kind === 'SEQUENCE'
135
+ ? counterKey(generator.prefix, config.sequenceFormat)
136
+ : autoIncrementKey(tableName, generator.field);
137
+ }
138
+ countersFor(generator) {
139
+ return generator.kind === 'SEQUENCE' ? this.sequenceCounters : this.autoIncrementCounters;
140
+ }
141
+ // The counter inside a value a generator could have produced.
142
+ generatedNumber(config, generator, value) {
143
+ if (generator.kind === 'SEQUENCE') {
144
+ return sequenceNumber(generator.prefix, config.sequenceFormat, value);
145
+ }
146
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
147
+ }
96
148
  registerView(viewName, definition) {
97
149
  this.views.set(viewName, definition);
98
150
  return this;
@@ -144,35 +196,50 @@ export class Store {
144
196
  if (removed.size > MAX_REMOVED_KEYS)
145
197
  removed.delete(removed.keys().next().value);
146
198
  }
147
- // An explicitly-supplied pk inside the sequence's own range must advance the
148
- // counter too. Without this, inserting e.g. 'TR002' by hand leaves the
149
- // counter at 2, and the next auto-generated insert mints 'TR002' again and
150
- // silently overwrites the hand-keyed row (Map.set, no error, event still
151
- // acks success).
152
- observeSequenceValue(config, row) {
153
- if (!config.sequencePrefix || config.pkFields.length !== 1)
154
- return;
155
- const n = sequenceNumber(config, row[config.pkFields[0]]);
156
- if (n === undefined)
157
- return;
158
- const key = counterKey(config);
159
- const next = this.sequenceCounters.get(key) ?? 1;
160
- if (n + 1 > next)
161
- this.sequenceCounters.set(key, n + 1);
199
+ // A supplied value inside a generator's own range must advance its counter
200
+ // too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
201
+ // 2, and the next generated insert mints 'TR002' again and silently
202
+ // overwrites the hand-keyed row (Map.set, no error, event still acks
203
+ // success).
204
+ observeGeneratedValues(tableName, config, row) {
205
+ for (const generator of config.generators) {
206
+ const n = this.generatedNumber(config, generator, row[generator.field]);
207
+ if (n === undefined)
208
+ continue;
209
+ const counters = this.countersFor(generator);
210
+ const counter = this.counterOf(tableName, config, generator);
211
+ if (n + 1 > (counters.get(counter) ?? 1))
212
+ counters.set(counter, n + 1);
213
+ }
162
214
  }
163
215
  nextSequenceValue(sequencePrefix, width) {
164
216
  const n = this.sequenceCounters.get(sequencePrefix) ?? 1;
165
217
  this.sequenceCounters.set(sequencePrefix, n + 1);
166
218
  return `${sequencePrefix}${String(n).padStart(width, '0')}`;
167
219
  }
168
- generateSequenceValue(config) {
220
+ generateSequenceValue(config, prefix) {
169
221
  if (config.sequenceFormat !== 'genesis') {
170
- return this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
222
+ return this.nextSequenceValue(prefix, config.sequenceWidth);
171
223
  }
172
- const key = counterKey(config);
224
+ const key = counterKey(prefix, 'genesis');
173
225
  const n = this.sequenceCounters.get(key) ?? 1;
174
226
  this.sequenceCounters.set(key, n + 1);
175
- return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${config.sequencePrefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
227
+ return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${prefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
228
+ }
229
+ // Fills every generated field the record leaves out (undefined or null).
230
+ generateValues(tableName, config, row) {
231
+ for (const generator of config.generators) {
232
+ if (!isMissing(row[generator.field]))
233
+ continue;
234
+ if (generator.kind === 'SEQUENCE') {
235
+ row[generator.field] = this.generateSequenceValue(config, generator.prefix);
236
+ continue;
237
+ }
238
+ const counter = autoIncrementKey(tableName, generator.field);
239
+ const n = this.autoIncrementCounters.get(counter) ?? 1;
240
+ this.autoIncrementCounters.set(counter, n + 1);
241
+ row[generator.field] = n;
242
+ }
176
243
  }
177
244
  getAllRows(tableName) {
178
245
  const table = this.tables.get(tableName);
@@ -214,26 +281,35 @@ export class Store {
214
281
  }
215
282
  return rows.find(matchesAllCriteria);
216
283
  }
217
- // Generates a missing (undefined or null) sequence pk and keeps a supplied
218
- // one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already stored under
219
- // the same key is replaced (the real database would reject the insert with
220
- // DUPLICATE_KEY — check first and throw NackError.duplicateKey for that).
284
+ // A copy of the record with its missing generated fields filled in, as the
285
+ // database does before checking its unique indexes. Each value used is
286
+ // gone even if the record is then rejected, as a real sequence's is.
287
+ withGeneratedValues(tableName, record) {
288
+ const config = this.tableConfig.get(tableName);
289
+ if (!config)
290
+ throw new Error(`Unknown table: ${tableName}`);
291
+ const row = { ...record };
292
+ this.generateValues(tableName, config, row);
293
+ return row;
294
+ }
295
+ // Generates every missing (undefined or null) sequence and auto-increment
296
+ // field — the sequencePrefix key and TableDef.generated — and keeps a
297
+ // supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already
298
+ // stored under the same key is replaced (the real database would reject the
299
+ // insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
300
+ // for that, as the generic CRUD events do).
221
301
  insertRow(tableName, record) {
222
302
  const table = this.tables.get(tableName);
223
303
  const config = this.tableConfig.get(tableName);
224
304
  if (!table || !config)
225
305
  throw new Error(`Unknown table: ${tableName}`);
226
306
  const row = { ...record };
227
- if (config.pkFields.length === 1 &&
228
- (row[config.pkFields[0]] === undefined || row[config.pkFields[0]] === null) &&
229
- config.sequencePrefix) {
230
- row[config.pkFields[0]] = this.generateSequenceValue(config);
231
- }
307
+ this.generateValues(tableName, config, row);
232
308
  const missing = config.pkFields.filter((field) => row[field] === undefined);
233
309
  if (missing.length > 0) {
234
310
  throw new Error(`Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`);
235
311
  }
236
- this.observeSequenceValue(config, row);
312
+ this.observeGeneratedValues(tableName, config, row);
237
313
  this.stampNewRow(row);
238
314
  const key = keyFor(config.pkFields, row);
239
315
  const displaced = table.get(key);
@@ -1,18 +1,29 @@
1
- import { nackPayload, warningPayload } from "../protocol/errors.js";
1
+ import { NackError, nackPayload, warningPayload } from "../protocol/errors.js";
2
2
  import { isLegacyFidelity } from "../protocol/fidelity.js";
3
3
  import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
4
  import { resourceNotMappedNack } from "../protocol/msgNack.js";
5
+ import { crudEventHandler } from "./crudEvents.js";
6
+ import { validateEventMessage } from "./eventValidation.js";
7
+ // The handler serving an event: an eventHandlers entry, else the generic CRUD
8
+ // handler a table registers through TableDef.events, else
9
+ // defaultEventHandler.
10
+ function resolveEventHandler(eventName, config) {
11
+ return (config.eventHandlers?.[eventName] ??
12
+ crudEventHandler(eventName, config) ??
13
+ config.defaultEventHandler);
14
+ }
5
15
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
6
16
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
7
- // config.eventHandlers. A handler receives (details, ctx) and either returns
8
- // { generated?: [...] } on success, { warnings: [...] } to NACK with warnings
9
- // only, or throws NackError(errors) on failure.
17
+ // config.eventHandlers, then the tables' generic CRUD events. A handler
18
+ // receives (details, ctx) and either returns { generated?: [...] } on
19
+ // success, { warnings: [...] } to NACK with warnings only, or throws
20
+ // NackError(errors) on failure.
10
21
  export function handleCommitEvent(message, connection, config, store, ctx) {
11
22
  const eventName = message.MESSAGE_TYPE;
12
23
  // defaultEventHandler catches anything unregistered — lets projects with
13
24
  // dozens of commit events adopt incrementally (e.g. `() => ({})` to ack
14
25
  // everything unhandled) instead of enumerating ack-only handlers.
15
- const handler = config.eventHandlers?.[eventName] ?? config.defaultEventHandler;
26
+ const handler = resolveEventHandler(eventName, config);
16
27
  // Replies use the GENERIC EVENT_ACK/EVENT_NACK — correlation is purely via
17
28
  // SOURCE_REF. See the note on MESSAGE_TYPE in protocol/messageTypes.ts:
18
29
  // real consumers (foundation-comms' kv.service, entity-management) match
@@ -34,14 +45,27 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
34
45
  : resourceNotMappedNack(eventName));
35
46
  return;
36
47
  }
48
+ // Before any handler runs, the message is checked against the event's
49
+ // declared schema (eventValidation.ts), as GSF's decoder does; each failure
50
+ // is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
37
51
  // A handler's failure is an EVENT_NACK with GSF's error items (see
38
52
  // protocol/errors.ts): a thrown NackError gives its own, anything else is
39
53
  // StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
40
- // ERROR — the IGNORE_WARNINGS/VALIDATE flow that would let them through is
41
- // not modelled yet.
54
+ // ERROR — the IGNORE_WARNINGS flow that would let them through is not
55
+ // modelled yet. VALIDATE: true reaches the handler as ctx.validate; a
56
+ // handler that honours it acks without writing.
42
57
  const legacy = isLegacyFidelity(config);
43
58
  try {
44
- const result = handler(message.DETAILS ?? {}, { connection, store, message, ...ctx }) ?? {};
59
+ const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
60
+ if (schemaErrors.length > 0)
61
+ throw new NackError(schemaErrors);
62
+ const result = handler(message.DETAILS ?? {}, {
63
+ connection,
64
+ store,
65
+ message,
66
+ ...ctx,
67
+ validate: message.VALIDATE === true,
68
+ }) ?? {};
45
69
  if (result.warnings?.length) {
46
70
  connection.reply(message, {
47
71
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
@@ -0,0 +1,2 @@
1
+ import type { EventHandler, MockServerConfig } from '../types.ts';
2
+ export declare function crudEventHandler(eventName: string, config: MockServerConfig): EventHandler | undefined;