@genesislcap/mock-server 15.56.0 → 15.58.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.
package/README.md CHANGED
@@ -347,9 +347,9 @@ and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:
347
347
  `new NackError([...])` reports several errors at once. Anything else a handler
348
348
  throws is `StandardError INTERNAL_ERROR` with its message. A handler can
349
349
  return `{ warnings: [...] }` for a NACK with an empty `ERROR` and those
350
- warnings, or pass `{ warnings }` to `NackError`; `IGNORE_WARNINGS` is not
351
- modelled yet. `VALIDATE: true` reaches a handler as `ctx.validate` (see
352
- [Generic CRUD events](#generic-crud-events-tabledefevents)). A request server
350
+ warnings, which `IGNORE_WARNINGS: true` turns into a commit, or pass
351
+ `{ warnings }` to `NackError` to send them with its errors (see
352
+ [Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)). A request server
353
353
  that fails answers a service `MSG_NACK` carrying the same items:
354
354
  `{WARNING, ERROR, MESSAGE_TYPE: 'MSG_NACK'}` (genesis-messages `MsgNack`). The
355
355
  router's `MSG_NACK` keeps its own shape (no `@type`,
@@ -399,12 +399,14 @@ key fields names the primary key, else it is `<TABLE>_BY_<fields>` without the
399
399
  the table and on views over it.
400
400
 
401
401
  **`VALIDATE: true`** reaches every handler as `ctx.validate`, and nothing is
402
- written. The generic events have no `onValidate`, so they ack `GENERATED: []`
403
- straight away (recorded for `COUNTERPARTY_INSERT`): no duplicate check, no
402
+ written (except under `fidelity: 'legacy'`). The generic events have no
403
+ `onValidate`, so they ack `GENERATED: []` straight away (recorded for
404
+ `COUNTERPARTY_INSERT`): no duplicate check, no
404
405
  sequence value used, no lookup for a modify or delete. A hand-written handler
405
406
  should run its checks and return before writing when `ctx.validate` is set
406
- (recorded: `TRADE_INSERT`'s `onValidate` NACK still comes back).
407
- `IGNORE_WARNINGS` is not modelled.
407
+ (recorded: `TRADE_INSERT`'s `onValidate` NACK still comes back); the engine
408
+ rolls back anything it writes anyway (see
409
+ [Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)).
408
410
 
409
411
  **An `eventHandlers` entry of the same name wins**, so a table can list all
410
412
  three verbs and still write out the one with business rules.
@@ -446,6 +448,56 @@ field's errors in field order, then missing fields, then unknown ones.
446
448
  on the recorded schemas. A modify therefore needs every required field, as on
447
449
  the real server. `fidelity: 'legacy'` checks nothing.
448
450
 
451
+ ## Commit semantics: `VALIDATE`, warnings and `IGNORE_WARNINGS`
452
+
453
+ A handler stands for a GSF event handler's `onValidate` and `onCommit` at
454
+ once. The engine gives its outcome the semantics of GSF's
455
+ `TransactionalEventHandlerContainer` (genesis-pal-eventhandler, v8.15.29; the
456
+ typed `SyncValidatingEventHandler` is the same): `VALIDATE` runs `onValidate`
457
+ only; otherwise `onCommit` runs when `onValidate` acks, or when it NACKs with
458
+ warnings only (`error.isEmpty()`) and the message has `IGNORE_WARNINGS: true`;
459
+ any other NACK is the reply. So each handler runs in a transaction, and the
460
+ event commits only when it acks:
461
+
462
+ | The handler… | Without `IGNORE_WARNINGS` | With `IGNORE_WARNINGS: true` | Under `VALIDATE: true` |
463
+ |---|---|---|---|
464
+ | Returns `{ generated? }` | `EVENT_ACK`, committed | The same | `EVENT_ACK {GENERATED: []}`, rolled back |
465
+ | Returns `{ warnings, generated? }` | `EVENT_NACK {ERROR: [], WARNING}`, rolled back | `EVENT_ACK {GENERATED}` (no `WARNING`), committed | `EVENT_NACK {ERROR: [], WARNING}`, rolled back |
466
+ | Throws `NackError` (errors, warnings or both), or anything else | `EVENT_NACK`, rolled back | The same | The same |
467
+
468
+ *Rolled back* means every row the handler inserted, changed or deleted is
469
+ put back, and the live pushes it asked for through `ctx.broadcast` /
470
+ `ctx.broadcastTableChange` are dropped. They go out only once the event has
471
+ committed, right after its `EVENT_ACK`, the order the real server's
472
+ recordings show: a client can't count on its grid holding the change when
473
+ the ACK arrives. A push that fails then is logged, and the event stays
474
+ committed. Sequence and auto-increment values a rolled-back insert used stay
475
+ used, as a database sequence's do. A `VALIDATE` ack has `GENERATED: []`, as
476
+ the recorded ones do.
477
+
478
+ So a handler writes, then returns its warnings, and lets the engine decide.
479
+ A handler that returns its warnings *before* writing, as this README advised
480
+ before `IGNORE_WARNINGS` was modelled, now acks an ignored warning without
481
+ having written anything. `ctx.ignoreWarnings` is the message's flag (also
482
+ under `VALIDATE`, where warnings still NACK): read it to skip work, or before
483
+ throwing `NackError.warning(...)`, which always NACKs (the handler stopped
484
+ there, before its writes; GSF exceptions are always errors). Keep
485
+ `ctx.broadcastTableChange` for later (a timer, as the showcase's price ticker
486
+ does) and it pushes straight away once the event is over. The transaction
487
+ covers the store and the `ctx` broadcast functions only, not the server's
488
+ own `broadcast` / `broadcastTableChange` or side effects like timers. It
489
+ saves a table on the event's first write to it, a copy of its rows: cheap for
490
+ a mock's tables, noticeable only with very large ones and many events.
491
+
492
+ Where this differs from GSF: a GSF handler without `transactional = true`
493
+ keeps whatever its `onCommit` wrote before failing, and a transactional one
494
+ commits writes made before it *returns* `nack(...)`; here every NACK rolls
495
+ back. `IGNORE_WARNINGS` was not recorded (the showcase has no handler that
496
+ warns): `test/commitSemantics.test.ts` follows the Kotlin.
497
+ `fidelity: 'legacy'` keeps the old behaviour: no transaction (a handler's
498
+ writes stay whatever it replies), warnings always NACK (in `ERROR`), and
499
+ `ctx.ignoreWarnings` is always `false`.
500
+
449
501
  ## Running several servers in parallel (and one-command dev stacks)
450
502
 
451
503
  Every `createMockServer()` instance is fully isolated — its own store,
@@ -668,6 +720,7 @@ createMockServer({ ...config, fidelity: 'legacy' });
668
720
  | `META_REQUEST` content | The GSF shapes below; event META too | `{NAME, TYPE}` fields, `KEY: []` on dataservers, `REPLY_FIELD` alone on request servers; events are unknown |
669
721
  | `JSON_SCHEMA_REQUEST` content | Per resource type, as below | One minimal `{DETAILS: {properties: {F: {type}}, required}}` as both INBOUND and OUTBOUND; a query or request-server name resolves to its source table |
670
722
  | Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
723
+ | Commit events | Committed only on `EVENT_ACK`; `IGNORE_WARNINGS` lets returned warnings commit ([Commit semantics](#commit-semantics-validate-warnings-and-ignore_warnings)) | A handler's writes stay whatever it replies; warnings always NACK |
671
724
  | A change to a table a view joins | Pushed only through joins with `backwardsJoin: true`, as the view rows it inserts, deletes or changes ([Views](#views-the-join-model)) | Every view row pushed as a MODIFY, whatever the join |
672
725
  | `DATA_LOGON` `ORDER_BY` / `REVERSE` / `FIELDS`, criteria on unexposed fields | Honoured / `LOGON_NACK` | Ignored |
673
726
  | `REQ_` | As in [Request servers](#request-servers-req_): `PARAMETRIC_TYPE`, criteria-only paging and ordering, `REQUEST` wildcards, ranges and arrays, a reply block, `MSG_NACK` failures | Every matching row in insertion order; `REQUEST` from the top level or `DETAILS`, `'*'`/`null`/`''` match anything, an array value any of its values; `CRITERIA_MATCH` fails open; no `RECORD_ID`/`TIMESTAMP`; errors as a `REP_` with `ERROR` |
@@ -1151,7 +1204,8 @@ plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
1151
1204
  delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
1152
1205
  `test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
1153
1206
  fidelity mode; `test/requestReply.test.ts` covers both kinds of request server,
1154
- `test/views.test.ts` the view join model and its live pushes)
1207
+ `test/views.test.ts` the view join model and its live pushes,
1208
+ `test/commitSemantics.test.ts` when an event commits)
1155
1209
  via `node --test`, no build step. Only `test/**/*.test.ts`
1156
1210
  files are run, so shared helpers (`test/support.ts`) can sit beside them.
1157
1211
 
@@ -14,7 +14,13 @@ export declare class Store {
14
14
  private seedRows;
15
15
  private removedRows;
16
16
  private previousRows;
17
+ private savepoint;
17
18
  readonly views: Map<string, ViewDef>;
19
+ begin(): void;
20
+ commit(): void;
21
+ rollback(): void;
22
+ get inTransaction(): boolean;
23
+ private save;
18
24
  registerTable(tableName: string, definition: TableDef): this;
19
25
  private counterOf;
20
26
  private countersFor;
package/dist/db/store.js CHANGED
@@ -67,6 +67,12 @@ function counterKey(prefix, format) {
67
67
  }
68
68
  // An AUTO_INCREMENT counter belongs to its table's field.
69
69
  const autoIncrementKey = (tableName, field) => `${tableName}\u0000${field}`;
70
+ function restoreEntry(maps, tableName, saved) {
71
+ if (saved === undefined)
72
+ maps.delete(tableName);
73
+ else
74
+ maps.set(tableName, saved);
75
+ }
70
76
  export class Store {
71
77
  tables = new Map();
72
78
  tableConfig = new Map();
@@ -87,7 +93,56 @@ export class Store {
87
93
  // it, as it was before the first such modify (by RECORD_ID) — see
88
94
  // takePreviousRecord. Bounded like removedRows.
89
95
  previousRows = new Map();
96
+ // While a transaction is open (see begin), each table it has written to,
97
+ // as it was before.
98
+ savepoint;
90
99
  views = new Map();
100
+ // Opens a transaction: the commit-event path wraps every handler in one
101
+ // (handlers/commitEvent.ts), so an event that NACKs leaves the tables as
102
+ // they were, as a GSF event whose onValidate NACKs never reaches its
103
+ // onCommit. Writes go through as usual; the first write to a table saves
104
+ // it, and rollback() puts back every table the transaction wrote to.
105
+ // Sequence and auto-increment counters and the RECORD_ID clock are not put
106
+ // back: a value used stays used, as a database sequence's does. Not nested,
107
+ // and the engine's own: an event that finds one open answers INTERNAL_ERROR.
108
+ // Saving copies the table's row map, O(rows) per event and table written.
109
+ begin() {
110
+ if (this.savepoint)
111
+ throw new Error('A store transaction is already open');
112
+ this.savepoint = new Map();
113
+ }
114
+ // Keeps every write since begin().
115
+ commit() {
116
+ this.savepoint = undefined;
117
+ }
118
+ // Undoes every write since begin().
119
+ rollback() {
120
+ const savepoint = this.savepoint;
121
+ this.savepoint = undefined;
122
+ for (const [tableName, saved] of savepoint ?? []) {
123
+ this.tables.set(tableName, saved.rows);
124
+ restoreEntry(this.removedRows, tableName, saved.removed);
125
+ restoreEntry(this.previousRows, tableName, saved.previous);
126
+ }
127
+ }
128
+ get inTransaction() {
129
+ return this.savepoint !== undefined;
130
+ }
131
+ // Saves a table before the open transaction's first change to it.
132
+ save(tableName) {
133
+ if (!this.savepoint || this.savepoint.has(tableName))
134
+ return;
135
+ const rows = this.tables.get(tableName);
136
+ if (!rows)
137
+ return;
138
+ const removed = this.removedRows.get(tableName);
139
+ const previous = this.previousRows.get(tableName);
140
+ this.savepoint.set(tableName, {
141
+ rows: new Map(rows),
142
+ removed: removed && new Map(removed),
143
+ previous: previous && new Map(previous),
144
+ });
145
+ }
91
146
  registerTable(tableName, definition) {
92
147
  const { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [], } = definition;
93
148
  const pkFields = normalizePkFields(pkField);
@@ -113,6 +168,8 @@ export class Store {
113
168
  }
114
169
  this.tables.set(tableName, table);
115
170
  this.tableConfig.set(tableName, config);
171
+ // Registering a table again replaces it whole: a rollback leaves it be.
172
+ this.savepoint?.delete(tableName);
116
173
  this.removedRows.delete(tableName);
117
174
  this.previousRows.delete(tableName);
118
175
  this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
@@ -326,6 +383,7 @@ export class Store {
326
383
  const config = this.tableConfig.get(tableName);
327
384
  if (!table || !config)
328
385
  throw new Error(`Unknown table: ${tableName}`);
386
+ this.save(tableName);
329
387
  const row = { ...record };
330
388
  this.generateValues(tableName, config, row);
331
389
  const missing = config.pkFields.filter((field) => row[field] === undefined);
@@ -348,6 +406,7 @@ export class Store {
348
406
  const config = this.tableConfig.get(tableName);
349
407
  if (!table || !config || !table.has(pk))
350
408
  return undefined;
409
+ this.save(tableName);
351
410
  const existing = table.get(pk);
352
411
  this.notePrevious(tableName, existing);
353
412
  const updated = { ...existing, ...patch };
@@ -373,6 +432,7 @@ export class Store {
373
432
  const existing = table?.get(pk);
374
433
  if (!table || !existing)
375
434
  return false;
435
+ this.save(tableName);
376
436
  table.delete(pk);
377
437
  this.noteRemoved(tableName, pk, existing);
378
438
  return true;
@@ -383,6 +443,7 @@ export class Store {
383
443
  // doesn't), including the old record when a key's row was replaced before
384
444
  // one broadcast.
385
445
  takeRemovedRows(tableName, key) {
446
+ this.save(tableName);
386
447
  const removed = this.removedRows.get(tableName);
387
448
  const records = removed?.get(key) ?? [];
388
449
  removed?.delete(key);
@@ -396,6 +457,7 @@ export class Store {
396
457
  const previous = this.previousRows.get(tableName);
397
458
  if (id === undefined || !previous)
398
459
  return undefined;
460
+ this.save(tableName);
399
461
  const record = previous.get(id);
400
462
  previous.delete(id);
401
463
  return record;
@@ -12,12 +12,70 @@ function resolveEventHandler(eventName, config) {
12
12
  crudEventHandler(eventName, config) ??
13
13
  config.defaultEventHandler);
14
14
  }
15
+ function openTransaction(store, ctx) {
16
+ const pending = [];
17
+ let open = true;
18
+ store.begin();
19
+ const hold = (push) => {
20
+ if (open)
21
+ pending.push(push);
22
+ else
23
+ push();
24
+ };
25
+ return {
26
+ broadcast: (resourceName, operation, row) => hold(() => ctx.broadcast(resourceName, operation, row)),
27
+ broadcastTableChange: (tableName, operation, pk) => hold(() => ctx.broadcastTableChange(tableName, operation, pk)),
28
+ commit() {
29
+ open = false;
30
+ store.commit();
31
+ },
32
+ flush() {
33
+ for (const push of pending.splice(0)) {
34
+ try {
35
+ push();
36
+ }
37
+ catch (error) {
38
+ console.error('[mock-server] Error pushing a committed change:', error);
39
+ }
40
+ }
41
+ },
42
+ rollback() {
43
+ if (!open)
44
+ return;
45
+ open = false;
46
+ pending.length = 0;
47
+ store.rollback();
48
+ },
49
+ };
50
+ }
15
51
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
16
52
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
17
53
  // 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.
54
+ // receives (details, ctx) and either returns { generated?, warnings? } or
55
+ // throws NackError(errors) on failure.
56
+ //
57
+ // A handler stands for a GSF event handler's onValidate and onCommit at once,
58
+ // and the engine gives its outcome the commit semantics of GSF's
59
+ // TransactionalEventHandlerContainer (genesis-pal-eventhandler, GSF v8.15.29,
60
+ // transactionalEventFlow/defaultEventFlow; the typed handlers'
61
+ // SyncValidatingEventHandler.process is the same):
62
+ //
63
+ // VALIDATE: true → onValidate only: its reply, nothing committed
64
+ // onValidate ACKs → onCommit runs: its reply
65
+ // onValidate NACKs with warnings only (`result.error.isEmpty()`)
66
+ // and IGNORE_WARNINGS: true → onCommit runs: its reply (an ACK carries
67
+ // no WARNING)
68
+ // any other NACK → that NACK, nothing committed
69
+ //
70
+ // So the handler runs in a transaction (openTransaction), and the event
71
+ // commits only when it acks: returned warnings without IGNORE_WARNINGS, a
72
+ // thrown NackError (errors, with or without warnings), any other exception,
73
+ // and VALIDATE all roll back what the handler wrote, and drop the live pushes
74
+ // it asked for. Returned warnings with IGNORE_WARNINGS commit and ack with
75
+ // the handler's GENERATED. A thrown NackError always NACKs, warnings-only
76
+ // ones included: the handler stopped there, before its writes (a GSF
77
+ // exception is always an error). A VALIDATE ack has GENERATED: [], as the
78
+ // recorded ones do: whatever the handler generated was rolled back.
21
79
  export function handleCommitEvent(message, connection, config, store, ctx) {
22
80
  const eventName = message.MESSAGE_TYPE;
23
81
  // defaultEventHandler catches anything unregistered — lets projects with
@@ -47,15 +105,21 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
47
105
  }
48
106
  // Before any handler runs, the message is checked against the event's
49
107
  // declared schema (eventValidation.ts), as GSF's decoder does; each failure
50
- // is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
51
- // A handler's failure is an EVENT_NACK with GSF's error items (see
52
- // protocol/errors.ts): a thrown NackError gives its own, anything else is
53
- // StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
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.
108
+ // is a FieldError in one EVENT_NACK. A handler's failure is an EVENT_NACK
109
+ // with GSF's error items (see protocol/errors.ts): a thrown NackError gives
110
+ // its own, anything else is StandardError INTERNAL_ERROR.
111
+ // Legacy fidelity keeps what this engine did before: no schema check, no
112
+ // transaction (a handler's writes stay whatever it replies), and warnings
113
+ // always NACK, IGNORE_WARNINGS or not.
57
114
  const legacy = isLegacyFidelity(config);
115
+ const validate = message.VALIDATE === true;
116
+ const ignoreWarnings = !legacy && message.IGNORE_WARNINGS === true;
117
+ let transaction;
58
118
  try {
119
+ // Inside the try: a store transaction someone else left open fails this
120
+ // event (INTERNAL_ERROR) instead of leaving it unanswered.
121
+ if (!legacy)
122
+ transaction = openTransaction(store, ctx);
59
123
  const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
60
124
  if (schemaErrors.length > 0)
61
125
  throw new NackError(schemaErrors);
@@ -63,22 +127,36 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
63
127
  connection,
64
128
  store,
65
129
  message,
66
- ...ctx,
67
- validate: message.VALIDATE === true,
130
+ broadcast: transaction?.broadcast ?? ctx.broadcast,
131
+ broadcastTableChange: transaction?.broadcastTableChange ?? ctx.broadcastTableChange,
132
+ validate,
133
+ ignoreWarnings,
68
134
  }) ?? {};
69
- if (result.warnings?.length) {
135
+ if (result.warnings?.length && (validate || !ignoreWarnings)) {
136
+ transaction?.rollback();
70
137
  connection.reply(message, {
71
138
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
72
139
  ...warningPayload(result.warnings, legacy),
73
140
  });
74
141
  return;
75
142
  }
76
- connection.reply(message, {
77
- MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
78
- GENERATED: result.generated ?? [],
79
- });
143
+ const generated = validate && !legacy ? [] : (result.generated ?? []);
144
+ // A GENERATED that can't go on the wire (a BigInt, a cycle) fails the
145
+ // event here, before anything is committed.
146
+ JSON.stringify(generated);
147
+ if (validate)
148
+ transaction?.rollback();
149
+ else
150
+ transaction?.commit();
151
+ try {
152
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK, GENERATED: generated });
153
+ }
154
+ finally {
155
+ transaction?.flush();
156
+ }
80
157
  }
81
158
  catch (error) {
159
+ transaction?.rollback();
82
160
  connection.reply(message, {
83
161
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
84
162
  ...nackPayload(error, legacy),
@@ -264,7 +264,9 @@ export class NackError extends Error {
264
264
  static nack(text) {
265
265
  return new NackError({ '@type': 'StandardError', CODE: ErrorCode.INTERNAL_ERROR, TEXT: text });
266
266
  }
267
- // A NACK that only warns: ERROR stays empty, WARNING carries these.
267
+ // A NACK that only warns: ERROR stays empty, WARNING carries these. Thrown,
268
+ // it NACKs even under IGNORE_WARNINGS (the handler stopped there); check
269
+ // ctx.ignoreWarnings first, or return { warnings } instead.
268
270
  static warning(warnings) {
269
271
  return new NackError([], { warnings: asList(warnings) });
270
272
  }
package/dist/types.d.ts CHANGED
@@ -164,6 +164,7 @@ export interface EventHandlerCtx {
164
164
  broadcast: BroadcastFn;
165
165
  broadcastTableChange: BroadcastTableChangeFn;
166
166
  validate: boolean;
167
+ ignoreWarnings: boolean;
167
168
  }
168
169
  export type EventHandlerResult = {
169
170
  generated?: Row[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genesislcap/mock-server",
3
- "version": "15.56.0",
3
+ "version": "15.58.0",
4
4
  "description": "Reusable in-memory Genesis WebSocket protocol mock server for client automation/CI, without a JVM or database.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/db/store.ts CHANGED
@@ -116,6 +116,21 @@ function counterKey(prefix: string, format: SequenceFormat): string {
116
116
  // An AUTO_INCREMENT counter belongs to its table's field.
117
117
  const autoIncrementKey = (tableName: string, field: string) => `${tableName}\u0000${field}`;
118
118
 
119
+ // What a transaction keeps of a table from before its first write in it: the
120
+ // rows, and the removed and previous records the write's bookkeeping changes.
121
+ // Row objects are never changed in place once stored, so copying the maps
122
+ // is enough.
123
+ interface TableSavepoint {
124
+ rows: Map<Pk, Row>;
125
+ removed: Map<Pk, Row[]> | undefined;
126
+ previous: Map<string, Row> | undefined;
127
+ }
128
+
129
+ function restoreEntry<V>(maps: Map<string, V>, tableName: string, saved: V | undefined): void {
130
+ if (saved === undefined) maps.delete(tableName);
131
+ else maps.set(tableName, saved);
132
+ }
133
+
119
134
  export class Store {
120
135
  private tables = new Map<string, Map<Pk, Row>>();
121
136
  private tableConfig = new Map<string, TableConfig>();
@@ -136,8 +151,59 @@ export class Store {
136
151
  // it, as it was before the first such modify (by RECORD_ID) — see
137
152
  // takePreviousRecord. Bounded like removedRows.
138
153
  private previousRows = new Map<string, Map<string, Row>>();
154
+ // While a transaction is open (see begin), each table it has written to,
155
+ // as it was before.
156
+ private savepoint: Map<string, TableSavepoint> | undefined;
139
157
  readonly views = new Map<string, ViewDef>();
140
158
 
159
+ // Opens a transaction: the commit-event path wraps every handler in one
160
+ // (handlers/commitEvent.ts), so an event that NACKs leaves the tables as
161
+ // they were, as a GSF event whose onValidate NACKs never reaches its
162
+ // onCommit. Writes go through as usual; the first write to a table saves
163
+ // it, and rollback() puts back every table the transaction wrote to.
164
+ // Sequence and auto-increment counters and the RECORD_ID clock are not put
165
+ // back: a value used stays used, as a database sequence's does. Not nested,
166
+ // and the engine's own: an event that finds one open answers INTERNAL_ERROR.
167
+ // Saving copies the table's row map, O(rows) per event and table written.
168
+ begin(): void {
169
+ if (this.savepoint) throw new Error('A store transaction is already open');
170
+ this.savepoint = new Map();
171
+ }
172
+
173
+ // Keeps every write since begin().
174
+ commit(): void {
175
+ this.savepoint = undefined;
176
+ }
177
+
178
+ // Undoes every write since begin().
179
+ rollback(): void {
180
+ const savepoint = this.savepoint;
181
+ this.savepoint = undefined;
182
+ for (const [tableName, saved] of savepoint ?? []) {
183
+ this.tables.set(tableName, saved.rows);
184
+ restoreEntry(this.removedRows, tableName, saved.removed);
185
+ restoreEntry(this.previousRows, tableName, saved.previous);
186
+ }
187
+ }
188
+
189
+ get inTransaction(): boolean {
190
+ return this.savepoint !== undefined;
191
+ }
192
+
193
+ // Saves a table before the open transaction's first change to it.
194
+ private save(tableName: string): void {
195
+ if (!this.savepoint || this.savepoint.has(tableName)) return;
196
+ const rows = this.tables.get(tableName);
197
+ if (!rows) return;
198
+ const removed = this.removedRows.get(tableName);
199
+ const previous = this.previousRows.get(tableName);
200
+ this.savepoint.set(tableName, {
201
+ rows: new Map(rows),
202
+ removed: removed && new Map(removed),
203
+ previous: previous && new Map(previous),
204
+ });
205
+ }
206
+
141
207
  registerTable(tableName: string, definition: TableDef): this {
142
208
  const {
143
209
  pkField,
@@ -170,6 +236,8 @@ export class Store {
170
236
  }
171
237
  this.tables.set(tableName, table);
172
238
  this.tableConfig.set(tableName, config);
239
+ // Registering a table again replaces it whole: a rollback leaves it be.
240
+ this.savepoint?.delete(tableName);
173
241
  this.removedRows.delete(tableName);
174
242
  this.previousRows.delete(tableName);
175
243
  this.seedRows.set(
@@ -398,6 +466,7 @@ export class Store {
398
466
  const config = this.tableConfig.get(tableName);
399
467
  if (!table || !config) throw new Error(`Unknown table: ${tableName}`);
400
468
 
469
+ this.save(tableName);
401
470
  const row = { ...record };
402
471
  this.generateValues(tableName, config, row);
403
472
  const missing = config.pkFields.filter((field) => row[field] === undefined);
@@ -421,6 +490,7 @@ export class Store {
421
490
  const table = this.tables.get(tableName);
422
491
  const config = this.tableConfig.get(tableName);
423
492
  if (!table || !config || !table.has(pk)) return undefined;
493
+ this.save(tableName);
424
494
  const existing = table.get(pk)!;
425
495
  this.notePrevious(tableName, existing);
426
496
  const updated = { ...existing, ...patch };
@@ -445,6 +515,7 @@ export class Store {
445
515
  const table = this.tables.get(tableName);
446
516
  const existing = table?.get(pk);
447
517
  if (!table || !existing) return false;
518
+ this.save(tableName);
448
519
  table.delete(pk);
449
520
  this.noteRemoved(tableName, pk, existing);
450
521
  return true;
@@ -456,6 +527,7 @@ export class Store {
456
527
  // doesn't), including the old record when a key's row was replaced before
457
528
  // one broadcast.
458
529
  takeRemovedRows(tableName: string, key: Pk): Row[] {
530
+ this.save(tableName);
459
531
  const removed = this.removedRows.get(tableName);
460
532
  const records = removed?.get(key) ?? [];
461
533
  removed?.delete(key);
@@ -469,6 +541,7 @@ export class Store {
469
541
  const id = recordKey(row);
470
542
  const previous = this.previousRows.get(tableName);
471
543
  if (id === undefined || !previous) return undefined;
544
+ this.save(tableName);
472
545
  const record = previous.get(id);
473
546
  previous.delete(id);
474
547
  return record;
@@ -33,12 +33,90 @@ function resolveEventHandler(
33
33
  );
34
34
  }
35
35
 
36
+ // One event's transaction: the store's (Store.begin), plus the live pushes
37
+ // the handler asks for, held back until the event has committed and acked —
38
+ // a client must never see a change that is then rolled back, and on the real
39
+ // server the dataserver's update reaches the client after the EVENT_ACK (the
40
+ // recordings' order), so a client can't count on its grid holding the change
41
+ // when the ACK arrives. Once the transaction is over, the broadcast
42
+ // functions push straight away again, so a handler can keep them for later
43
+ // (the showcase's price ticker does).
44
+ interface EventTransaction {
45
+ broadcast: BroadcastFn;
46
+ broadcastTableChange: BroadcastTableChangeFn;
47
+ // Keeps the writes; the held pushes wait for flush().
48
+ commit(): void;
49
+ // Sends the held pushes. The event is committed whatever a push does: a
50
+ // failing one is logged, and the rest still go out.
51
+ flush(): void;
52
+ // Undoes the writes and drops the held pushes. Does nothing once the
53
+ // transaction is over.
54
+ rollback(): void;
55
+ }
56
+
57
+ function openTransaction(store: Store, ctx: CommitEventCtx): EventTransaction {
58
+ const pending: Array<() => void> = [];
59
+ let open = true;
60
+ store.begin();
61
+ const hold = (push: () => void) => {
62
+ if (open) pending.push(push);
63
+ else push();
64
+ };
65
+ return {
66
+ broadcast: (resourceName, operation, row) =>
67
+ hold(() => ctx.broadcast(resourceName, operation, row)),
68
+ broadcastTableChange: (tableName, operation, pk) =>
69
+ hold(() => ctx.broadcastTableChange(tableName, operation, pk)),
70
+ commit() {
71
+ open = false;
72
+ store.commit();
73
+ },
74
+ flush() {
75
+ for (const push of pending.splice(0)) {
76
+ try {
77
+ push();
78
+ } catch (error) {
79
+ console.error('[mock-server] Error pushing a committed change:', error);
80
+ }
81
+ }
82
+ },
83
+ rollback() {
84
+ if (!open) return;
85
+ open = false;
86
+ pending.length = 0;
87
+ store.rollback();
88
+ },
89
+ };
90
+ }
91
+
36
92
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
37
93
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
38
94
  // 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.
95
+ // receives (details, ctx) and either returns { generated?, warnings? } or
96
+ // throws NackError(errors) on failure.
97
+ //
98
+ // A handler stands for a GSF event handler's onValidate and onCommit at once,
99
+ // and the engine gives its outcome the commit semantics of GSF's
100
+ // TransactionalEventHandlerContainer (genesis-pal-eventhandler, GSF v8.15.29,
101
+ // transactionalEventFlow/defaultEventFlow; the typed handlers'
102
+ // SyncValidatingEventHandler.process is the same):
103
+ //
104
+ // VALIDATE: true → onValidate only: its reply, nothing committed
105
+ // onValidate ACKs → onCommit runs: its reply
106
+ // onValidate NACKs with warnings only (`result.error.isEmpty()`)
107
+ // and IGNORE_WARNINGS: true → onCommit runs: its reply (an ACK carries
108
+ // no WARNING)
109
+ // any other NACK → that NACK, nothing committed
110
+ //
111
+ // So the handler runs in a transaction (openTransaction), and the event
112
+ // commits only when it acks: returned warnings without IGNORE_WARNINGS, a
113
+ // thrown NackError (errors, with or without warnings), any other exception,
114
+ // and VALIDATE all roll back what the handler wrote, and drop the live pushes
115
+ // it asked for. Returned warnings with IGNORE_WARNINGS commit and ack with
116
+ // the handler's GENERATED. A thrown NackError always NACKs, warnings-only
117
+ // ones included: the handler stopped there, before its writes (a GSF
118
+ // exception is always an error). A VALIDATE ack has GENERATED: [], as the
119
+ // recorded ones do: whatever the handler generated was rolled back.
42
120
  export function handleCommitEvent(
43
121
  message: GenesisMessage,
44
122
  connection: Connection,
@@ -79,15 +157,20 @@ export function handleCommitEvent(
79
157
 
80
158
  // Before any handler runs, the message is checked against the event's
81
159
  // declared schema (eventValidation.ts), as GSF's decoder does; each failure
82
- // is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
83
- // A handler's failure is an EVENT_NACK with GSF's error items (see
84
- // protocol/errors.ts): a thrown NackError gives its own, anything else is
85
- // StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
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.
160
+ // is a FieldError in one EVENT_NACK. A handler's failure is an EVENT_NACK
161
+ // with GSF's error items (see protocol/errors.ts): a thrown NackError gives
162
+ // its own, anything else is StandardError INTERNAL_ERROR.
163
+ // Legacy fidelity keeps what this engine did before: no schema check, no
164
+ // transaction (a handler's writes stay whatever it replies), and warnings
165
+ // always NACK, IGNORE_WARNINGS or not.
89
166
  const legacy = isLegacyFidelity(config);
167
+ const validate = message.VALIDATE === true;
168
+ const ignoreWarnings = !legacy && message.IGNORE_WARNINGS === true;
169
+ let transaction: EventTransaction | undefined;
90
170
  try {
171
+ // Inside the try: a store transaction someone else left open fails this
172
+ // event (INTERNAL_ERROR) instead of leaving it unanswered.
173
+ if (!legacy) transaction = openTransaction(store, ctx);
91
174
  const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
92
175
  if (schemaErrors.length > 0) throw new NackError(schemaErrors);
93
176
  const result =
@@ -95,21 +178,32 @@ export function handleCommitEvent(
95
178
  connection,
96
179
  store,
97
180
  message,
98
- ...ctx,
99
- validate: message.VALIDATE === true,
181
+ broadcast: transaction?.broadcast ?? ctx.broadcast,
182
+ broadcastTableChange: transaction?.broadcastTableChange ?? ctx.broadcastTableChange,
183
+ validate,
184
+ ignoreWarnings,
100
185
  }) ?? {};
101
- if (result.warnings?.length) {
186
+ if (result.warnings?.length && (validate || !ignoreWarnings)) {
187
+ transaction?.rollback();
102
188
  connection.reply(message, {
103
189
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
104
190
  ...warningPayload(result.warnings, legacy),
105
191
  });
106
192
  return;
107
193
  }
108
- connection.reply(message, {
109
- MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
110
- GENERATED: result.generated ?? [],
111
- });
194
+ const generated = validate && !legacy ? [] : (result.generated ?? []);
195
+ // A GENERATED that can't go on the wire (a BigInt, a cycle) fails the
196
+ // event here, before anything is committed.
197
+ JSON.stringify(generated);
198
+ if (validate) transaction?.rollback();
199
+ else transaction?.commit();
200
+ try {
201
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK, GENERATED: generated });
202
+ } finally {
203
+ transaction?.flush();
204
+ }
112
205
  } catch (error) {
206
+ transaction?.rollback();
113
207
  connection.reply(message, {
114
208
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
115
209
  ...nackPayload(error, legacy),
@@ -245,8 +245,9 @@ function asList(inputs: string | NackErrorInput | NackErrorInput[] | undefined):
245
245
  }
246
246
 
247
247
  export interface NackErrorOptions {
248
- // Sent in the NACK's WARNING list. (Acting on IGNORE_WARNINGS is a later
249
- // item; for now a warning always fails the event.)
248
+ // Sent in the NACK's WARNING list. A thrown NackError always fails the
249
+ // event, IGNORE_WARNINGS or not: return warnings from the handler instead
250
+ // ({ warnings }) for ones that should give way to it.
250
251
  warnings?: NackErrorInput | NackErrorInput[];
251
252
  }
252
253
 
@@ -334,7 +335,9 @@ export class NackError extends Error {
334
335
  return new NackError({ '@type': 'StandardError', CODE: ErrorCode.INTERNAL_ERROR, TEXT: text });
335
336
  }
336
337
 
337
- // A NACK that only warns: ERROR stays empty, WARNING carries these.
338
+ // A NACK that only warns: ERROR stays empty, WARNING carries these. Thrown,
339
+ // it NACKs even under IGNORE_WARNINGS (the handler stopped there); check
340
+ // ctx.ignoreWarnings first, or return { warnings } instead.
338
341
  static warning(warnings: string | NackErrorInput | NackErrorInput[]): NackError {
339
342
  return new NackError([], { warnings: asList(warnings) });
340
343
  }
package/src/types.ts CHANGED
@@ -426,14 +426,29 @@ export interface EventHandlerCtx {
426
426
  // succeed. GSF then runs only the handler's onValidate and answers its NACK,
427
427
  // or an EVENT_ACK with GENERATED: [] — nothing is written. A handler that
428
428
  // writes should run its checks and return before writing when this is set;
429
- // the generic CRUD events do.
429
+ // the generic CRUD events do. The engine rolls back whatever it writes
430
+ // anyway, and acks with GENERATED: [] whatever the handler returns (see
431
+ // handlers/commitEvent.ts; not under fidelity: 'legacy').
430
432
  validate: boolean;
433
+ // `IGNORE_WARNINGS: true` on the message: warnings the handler returns
434
+ // don't stop the event. The engine applies it: outside VALIDATE, returned
435
+ // warnings commit the handler's writes and ACK when this is set, and roll
436
+ // them back and NACK when it isn't. So write, then return the warnings:
437
+ // a handler that returns them before writing (as advised before this
438
+ // flag was modelled) now ACKs an ignored warning without writing. Read it
439
+ // to skip work that only matters when the event will commit, or before
440
+ // throwing NackError.warning(...), which always NACKs. Always false under
441
+ // fidelity: 'legacy', which doesn't model it.
442
+ ignoreWarnings: boolean;
431
443
  }
432
444
 
433
- // `warnings` turns the reply into an EVENT_NACK carrying them in WARNING
434
- // (with an empty ERROR), as a GSF handler's warning-only EventNack does.
435
- // Return them from a handler that has not written anything yet: the mock has
436
- // no transaction to roll back, and doesn't honour IGNORE_WARNINGS yet.
445
+ // `warnings` (a non-empty list) is a GSF onValidate's warning-only
446
+ // EventNack: without IGNORE_WARNINGS, or under VALIDATE, the reply is an
447
+ // EVENT_NACK carrying them in WARNING with an empty ERROR, and nothing the
448
+ // handler wrote is kept; with IGNORE_WARNINGS it is an EVENT_ACK with
449
+ // `generated` (no WARNING), and the writes are kept. Errors are thrown
450
+ // (NackError), never returned: a thrown NackError NACKs with its errors and
451
+ // any warnings it carries, and rolls back too.
437
452
  export type EventHandlerResult = { generated?: Row[]; warnings?: NackErrorInput[] } | void;
438
453
 
439
454
  export type EventHandler = (details: Row, ctx: EventHandlerCtx) => EventHandlerResult;