@genesislcap/mock-server 15.51.1 → 15.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +408 -39
  2. package/dist/db/criteria.d.ts +13 -0
  3. package/dist/db/criteria.js +60 -21
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/ordering.d.ts +8 -0
  7. package/dist/db/ordering.js +49 -0
  8. package/dist/db/schema.d.ts +62 -0
  9. package/dist/db/schema.js +490 -0
  10. package/dist/db/store.d.ts +20 -3
  11. package/dist/db/store.js +300 -50
  12. package/dist/handlers/commitEvent.js +47 -9
  13. package/dist/handlers/crudEvents.d.ts +2 -0
  14. package/dist/handlers/crudEvents.js +138 -0
  15. package/dist/handlers/dataLogon.js +105 -6
  16. package/dist/handlers/eventValidation.d.ts +5 -0
  17. package/dist/handlers/eventValidation.js +29 -0
  18. package/dist/handlers/jsonSchema.d.ts +4 -2
  19. package/dist/handlers/jsonSchema.js +222 -61
  20. package/dist/handlers/meta.d.ts +6 -1
  21. package/dist/handlers/meta.js +168 -21
  22. package/dist/handlers/requestReply.js +519 -30
  23. package/dist/handlers/resourceAuth.d.ts +1 -0
  24. package/dist/handlers/resourceAuth.js +14 -0
  25. package/dist/handlers/resources.js +3 -0
  26. package/dist/index.d.ts +3 -2
  27. package/dist/index.js +2 -1
  28. package/dist/protocol/connection.d.ts +2 -0
  29. package/dist/protocol/errors.d.ts +100 -2
  30. package/dist/protocol/errors.js +300 -12
  31. package/dist/protocol/fieldTypes.d.ts +10 -0
  32. package/dist/protocol/fieldTypes.js +107 -0
  33. package/dist/protocol/gsfSchemas.d.ts +9 -0
  34. package/dist/protocol/gsfSchemas.js +423 -0
  35. package/dist/protocol/messageTypes.d.ts +1 -0
  36. package/dist/protocol/messageTypes.js +1 -0
  37. package/dist/protocol/msgNack.d.ts +2 -0
  38. package/dist/protocol/msgNack.js +10 -0
  39. package/dist/protocol/rowUpdate.d.ts +5 -3
  40. package/dist/protocol/rowUpdate.js +33 -15
  41. package/dist/protocol/schemaValidation.d.ts +11 -0
  42. package/dist/protocol/schemaValidation.js +186 -0
  43. package/dist/server.js +95 -29
  44. package/dist/types.d.ts +45 -0
  45. package/package.json +1 -1
  46. package/src/db/criteria.ts +72 -21
  47. package/src/db/identity.ts +53 -0
  48. package/src/db/ordering.ts +56 -0
  49. package/src/db/schema.ts +672 -0
  50. package/src/db/store.ts +343 -50
  51. package/src/handlers/commitEvent.ts +54 -10
  52. package/src/handlers/crudEvents.ts +181 -0
  53. package/src/handlers/dataLogon.ts +127 -7
  54. package/src/handlers/eventValidation.ts +43 -0
  55. package/src/handlers/jsonSchema.ts +302 -69
  56. package/src/handlers/meta.ts +227 -23
  57. package/src/handlers/requestReply.ts +626 -33
  58. package/src/handlers/resourceAuth.ts +18 -0
  59. package/src/handlers/resources.ts +3 -0
  60. package/src/index.ts +17 -1
  61. package/src/protocol/connection.ts +10 -2
  62. package/src/protocol/errors.ts +361 -13
  63. package/src/protocol/fieldTypes.ts +115 -0
  64. package/src/protocol/gsfSchemas.ts +505 -0
  65. package/src/protocol/messageTypes.ts +1 -0
  66. package/src/protocol/msgNack.ts +12 -0
  67. package/src/protocol/rowUpdate.ts +40 -20
  68. package/src/protocol/schemaValidation.ts +208 -0
  69. package/src/server.ts +106 -28
  70. package/src/types.ts +187 -5
package/dist/db/store.js CHANGED
@@ -1,9 +1,39 @@
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
+ 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;
3
26
  // Composite keys join the pk fields' values with '|' (so values must not
4
27
  // contain '|'). Single-field keys stay the raw value, preserving
5
28
  // getRow('TABLE', 'TR001')-style lookups.
6
29
  const COMPOSITE_KEY_SEPARATOR = '|';
30
+ const MAX_REMOVED_PER_KEY = 16;
31
+ const MAX_REMOVED_KEYS = 10_000;
32
+ // IdStrategy.java: a 15-digit counter, the sequence id, the location
33
+ // (DbConfig's default 'LO') and the system id (always 1).
34
+ const GENESIS_SEQUENCE_DIGITS = 15;
35
+ const GENESIS_LOCATION = 'LO';
36
+ const GENESIS_SYSTEM = '1';
7
37
  function normalizePkFields(pkField) {
8
38
  return Array.isArray(pkField) ? pkField : [pkField];
9
39
  }
@@ -12,66 +42,205 @@ function keyFor(pkFields, row) {
12
42
  return row[pkFields[0]];
13
43
  return pkFields.map((field) => String(row[field])).join(COMPOSITE_KEY_SEPARATOR);
14
44
  }
45
+ function escapeRegExp(text) {
46
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ }
48
+ // The counter inside a generated value, or undefined when the value isn't
49
+ // one a sequence with this prefix and format could have produced.
50
+ function sequenceNumber(prefix, format, value) {
51
+ if (typeof value !== 'string')
52
+ return undefined;
53
+ const digits = format === 'genesis'
54
+ ? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(value)?.[1]
55
+ : value.startsWith(prefix)
56
+ ? value.slice(prefix.length)
57
+ : undefined;
58
+ if (digits === undefined)
59
+ return undefined;
60
+ const n = parseInt(digits, 10);
61
+ return Number.isFinite(n) ? n : undefined;
62
+ }
63
+ // Counters are shared by tables using the same prefix in the same format.
64
+ function counterKey(prefix, format) {
65
+ return format === 'genesis' ? `${prefix}\u0000genesis` : prefix;
66
+ }
67
+ // An AUTO_INCREMENT counter belongs to its table's field.
68
+ const autoIncrementKey = (tableName, field) => `${tableName}\u0000${field}`;
15
69
  export class Store {
16
70
  tables = new Map();
17
71
  tableConfig = new Map();
18
72
  sequenceCounters = new Map();
73
+ autoIncrementCounters = new Map();
74
+ clock = new RecordClock();
19
75
  // Immutable snapshot of each table's rows as registered, used only for field
20
76
  // metadata derivation (see getMetadataRows) so deleting every live row can't
21
77
  // collapse a resource's schema to zero fields.
22
78
  seedRows = new Map();
79
+ // Per table and key, every record the key lost (deleted, or displaced by
80
+ // an insert or a key change landing on it) and no broadcast has announced
81
+ // yet, oldest first — see takeRemovedRows. Bounded: a key keeps its last
82
+ // MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
83
+ // for handlers that delete without ever broadcasting.
84
+ removedRows = new Map();
23
85
  views = new Map();
24
- registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, rows = [] }) {
86
+ registerTable(tableName, definition) {
87
+ const { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [], } = definition;
25
88
  const pkFields = normalizePkFields(pkField);
89
+ const config = {
90
+ pkFields,
91
+ sequencePrefix,
92
+ sequenceWidth,
93
+ sequenceFormat,
94
+ generators: generatorsOf(pkFields, definition),
95
+ };
26
96
  const table = new Map();
97
+ const seenIds = new Set();
98
+ let restamped = 0;
27
99
  for (const row of rows) {
28
- table.set(keyFor(pkFields, row), { ...row });
100
+ const stamped = this.stampSeedRow({ ...row }, seenIds);
101
+ if (stamped.restamped)
102
+ restamped += 1;
103
+ table.set(keyFor(pkFields, row), stamped.row);
104
+ }
105
+ if (restamped > 0) {
106
+ console.warn(`[mock-server] ${tableName}: re-stamped ${restamped} seed row(s) whose RECORD_ID was ` +
107
+ 'not a safe integer or a string, or repeated another row, so every ROW_REF stays unique');
29
108
  }
30
109
  this.tables.set(tableName, table);
31
- this.tableConfig.set(tableName, { pkFields, sequencePrefix, sequenceWidth });
110
+ this.tableConfig.set(tableName, config);
111
+ this.removedRows.delete(tableName);
32
112
  this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
33
- // Sequence generation only makes sense for a single-field pk.
34
- 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) {
35
118
  let maxN = 0;
36
119
  for (const row of rows) {
37
- const pk = String(row[pkFields[0]] ?? '');
38
- if (pk.startsWith(sequencePrefix)) {
39
- const n = parseInt(pk.slice(sequencePrefix.length), 10);
40
- if (Number.isFinite(n))
41
- maxN = Math.max(maxN, n);
42
- }
120
+ const n = this.generatedNumber(config, generator, row[generator.field]);
121
+ if (n !== undefined)
122
+ maxN = Math.max(maxN, n);
43
123
  }
44
- this.sequenceCounters.set(sequencePrefix, 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));
45
130
  }
46
131
  return this;
47
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
+ }
48
148
  registerView(viewName, definition) {
49
149
  this.views.set(viewName, definition);
50
150
  return this;
51
151
  }
52
- // An explicitly-supplied pk inside the sequence's own range must advance the
53
- // counter too. Without this, inserting e.g. 'TR002' by hand leaves the
54
- // counter at 2, and the next auto-generated insert mints 'TR002' again and
55
- // silently overwrites the hand-keyed row (Map.set, no error, event still
56
- // acks success).
57
- observeSequenceValue(config, row) {
58
- if (!config.sequencePrefix || config.pkFields.length !== 1)
59
- return;
60
- const pk = String(row[config.pkFields[0]] ?? '');
61
- if (!pk.startsWith(config.sequencePrefix))
62
- return;
63
- const n = parseInt(pk.slice(config.sequencePrefix.length), 10);
64
- if (!Number.isFinite(n))
65
- return;
66
- const next = this.sequenceCounters.get(config.sequencePrefix) ?? 1;
67
- if (n + 1 > next)
68
- this.sequenceCounters.set(config.sequencePrefix, n + 1);
152
+ // Seed rows keep a RECORD_ID they bring (e.g. rows copied from a real
153
+ // server) when it is usable: a safe integer, or a string, not used by an
154
+ // earlier seed row. Anything else — a missing id, a number above 2^53 that
155
+ // JSON.parse already rounded onto its neighbour's, a repeat — gets a fresh
156
+ // stamp, in seed order, so no two rows share a ROW_REF. A missing or
157
+ // unusable TIMESTAMP becomes the RECORD_ID.
158
+ stampSeedRow(row, seenIds) {
159
+ const given = row[RECORD_ID];
160
+ const usable = (value) => (typeof value === 'number' && Number.isSafeInteger(value)) ||
161
+ (typeof value === 'string' && value !== '');
162
+ const missing = given === undefined || given === null;
163
+ const keep = !missing && usable(given) && !seenIds.has(String(given));
164
+ if (keep) {
165
+ this.clock.observe(given);
166
+ }
167
+ else {
168
+ row[RECORD_ID] = this.clock.next();
169
+ }
170
+ seenIds.add(String(row[RECORD_ID]));
171
+ if (!usable(row[TIMESTAMP]))
172
+ row[TIMESTAMP] = row[RECORD_ID];
173
+ this.clock.observe(row[TIMESTAMP]);
174
+ return { row, restamped: !missing && !keep };
175
+ }
176
+ // A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
177
+ // it was given — as on the real server — appended after its own fields.
178
+ stampNewRow(row) {
179
+ delete row[RECORD_ID];
180
+ delete row[TIMESTAMP];
181
+ const stamp = this.clock.next();
182
+ row[RECORD_ID] = stamp;
183
+ row[TIMESTAMP] = stamp;
184
+ return row;
185
+ }
186
+ noteRemoved(tableName, key, row) {
187
+ let removed = this.removedRows.get(tableName);
188
+ if (!removed) {
189
+ removed = new Map();
190
+ this.removedRows.set(tableName, removed);
191
+ }
192
+ const records = removed.get(key) ?? [];
193
+ // Re-set so the key moves to the end: the oldest keys are dropped first.
194
+ removed.delete(key);
195
+ removed.set(key, [...records, row].slice(-MAX_REMOVED_PER_KEY));
196
+ if (removed.size > MAX_REMOVED_KEYS)
197
+ removed.delete(removed.keys().next().value);
198
+ }
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
+ }
69
214
  }
70
215
  nextSequenceValue(sequencePrefix, width) {
71
216
  const n = this.sequenceCounters.get(sequencePrefix) ?? 1;
72
217
  this.sequenceCounters.set(sequencePrefix, n + 1);
73
218
  return `${sequencePrefix}${String(n).padStart(width, '0')}`;
74
219
  }
220
+ generateSequenceValue(config, prefix) {
221
+ if (config.sequenceFormat !== 'genesis') {
222
+ return this.nextSequenceValue(prefix, config.sequenceWidth);
223
+ }
224
+ const key = counterKey(prefix, 'genesis');
225
+ const n = this.sequenceCounters.get(key) ?? 1;
226
+ this.sequenceCounters.set(key, n + 1);
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
+ }
243
+ }
75
244
  getAllRows(tableName) {
76
245
  const table = this.tables.get(tableName);
77
246
  if (!table)
@@ -112,56 +281,105 @@ export class Store {
112
281
  }
113
282
  return rows.find(matchesAllCriteria);
114
283
  }
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).
115
301
  insertRow(tableName, record) {
116
302
  const table = this.tables.get(tableName);
117
303
  const config = this.tableConfig.get(tableName);
118
304
  if (!table || !config)
119
305
  throw new Error(`Unknown table: ${tableName}`);
120
306
  const row = { ...record };
121
- if (config.pkFields.length === 1 &&
122
- row[config.pkFields[0]] === undefined &&
123
- config.sequencePrefix) {
124
- row[config.pkFields[0]] = this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
125
- }
307
+ this.generateValues(tableName, config, row);
126
308
  const missing = config.pkFields.filter((field) => row[field] === undefined);
127
309
  if (missing.length > 0) {
128
310
  throw new Error(`Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`);
129
311
  }
130
- this.observeSequenceValue(config, row);
131
- table.set(keyFor(config.pkFields, row), row);
312
+ this.observeGeneratedValues(tableName, config, row);
313
+ this.stampNewRow(row);
314
+ const key = keyFor(config.pkFields, row);
315
+ const displaced = table.get(key);
316
+ if (displaced)
317
+ this.noteRemoved(tableName, key, displaced);
318
+ table.set(key, row);
132
319
  return row;
133
320
  }
321
+ // Merges the patch, moves TIMESTAMP on and keeps RECORD_ID (a patch can't
322
+ // change a row's identity).
134
323
  updateRow(tableName, pk, patch) {
135
324
  const table = this.tables.get(tableName);
136
325
  const config = this.tableConfig.get(tableName);
137
326
  if (!table || !config || !table.has(pk))
138
327
  return undefined;
139
- const updated = { ...table.get(pk), ...patch };
328
+ const existing = table.get(pk);
329
+ const updated = { ...existing, ...patch };
330
+ updated[RECORD_ID] = existing[RECORD_ID];
331
+ updated[TIMESTAMP] = this.clock.next();
140
332
  // A patch that changes a pk field (e.g. an entity-management form posting
141
333
  // its full DETAILS after the user edited the key column) must move the row
142
334
  // to its new key. Leaving it under the old one makes the index disagree
143
335
  // with the row itself: getRow(newKey) misses, while rowKey()/getRowRef()
144
336
  // report the new key, so the matching broadcast finds nothing to push.
145
337
  const nextKey = keyFor(config.pkFields, updated);
146
- if (nextKey !== pk)
338
+ if (nextKey !== pk) {
147
339
  table.delete(pk);
340
+ const displaced = table.get(nextKey);
341
+ if (displaced)
342
+ this.noteRemoved(tableName, nextKey, displaced);
343
+ }
148
344
  table.set(nextKey, updated);
149
345
  return updated;
150
346
  }
151
347
  deleteRow(tableName, pk) {
152
348
  const table = this.tables.get(tableName);
153
- if (!table)
349
+ const existing = table?.get(pk);
350
+ if (!table || !existing)
154
351
  return false;
155
- return table.delete(pk);
352
+ table.delete(pk);
353
+ this.noteRemoved(tableName, pk, existing);
354
+ return true;
355
+ }
356
+ // Reads and clears every record a key lost that no broadcast announced
357
+ // yet, oldest first: what a DELETE broadcast names (their RECORD_IDs are
358
+ // the ROW_REFs a client may hold — held-rows tracking drops the ones it
359
+ // doesn't), including the old record when a key's row was replaced before
360
+ // one broadcast.
361
+ takeRemovedRows(tableName, key) {
362
+ const removed = this.removedRows.get(tableName);
363
+ const records = removed?.get(key) ?? [];
364
+ removed?.delete(key);
365
+ return records;
366
+ }
367
+ // The same, for a row of a table or view given by its (base) pk fields —
368
+ // what a public broadcast(resource, 'DELETE', pkStub) names.
369
+ takeRemovedRecordsFor(resourceName, row) {
370
+ const located = this.locateRecord(resourceName, row);
371
+ return located ? this.takeRemovedRows(located.tableName, located.key) : [];
156
372
  }
157
373
  // Materializes a view: base table left-joined to related tables (optionally
158
374
  // narrowed by per-join `where` predicates), minus anti-joined rows, plus
159
- // derived fields.
160
- getViewRows(viewName) {
375
+ // derived fields. `tables` substitutes the rows of any table the view reads
376
+ // (its base or a joined one) — e.g. one base row, to build that row's view
377
+ // row, or a table's earlier state, to rebuild the view as it was.
378
+ getViewRows(viewName, tables = {}) {
161
379
  const view = this.views.get(viewName);
162
380
  if (!view)
163
381
  throw new Error(`Unknown view: ${viewName}`);
164
- return this.materializeView(view, (tableName) => this.getAllRows(tableName));
382
+ return this.materializeView(view, (tableName) => Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName));
165
383
  }
166
384
  materializeView(view, getRows) {
167
385
  let rows = getRows(view.base).map((row) => ({ ...row }));
@@ -214,14 +432,17 @@ export class Store {
214
432
  // deleting a table's last row would make the next metadata reply say the
215
433
  // resource has zero fields — which entity-management renders as a form with
216
434
  // no inputs and a delete button that can't build a payload, silently.
435
+ // RECORD_ID and TIMESTAMP are left out: they are not fields a dataserver
436
+ // row or an event's DETAILS carry.
217
437
  getMetadataRows(resourceName) {
218
438
  const live = this.getResourceRows(resourceName);
219
439
  if (live.length > 0)
220
- return live;
440
+ return live.map(withoutRecordFields);
221
441
  const view = this.views.get(resourceName);
222
- if (view)
223
- return this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? []);
224
- return this.seedRows.get(resourceName) ?? [];
442
+ const seed = view
443
+ ? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
444
+ : (this.seedRows.get(resourceName) ?? []);
445
+ return seed.map(withoutRecordFields);
225
446
  }
226
447
  // Row-identity fields for a table or view; empty when unknown.
227
448
  getPkFields(resourceName) {
@@ -233,17 +454,46 @@ export class Store {
233
454
  }
234
455
  return this.tableConfig.get(resourceName)?.pkFields ?? [];
235
456
  }
236
- // The DETAILS.ROW_REF value stamped on QUERY_UPDATE rows (see
237
- // protocol/rowUpdate.ts): the pk field's value, or the composite key. The
238
- // client's dataServerResultFilter keys inserts/amends/deletes off this.
457
+ // The pk-based row reference: the pk field's value, or the composite key.
458
+ // This was DETAILS.ROW_REF up to 15.47 and still is under
459
+ // fidelity: 'legacy'; by default ROW_REF is getRecordRef's.
239
460
  getRowRef(resourceName, row) {
240
461
  const pkFields = this.getPkFields(resourceName);
241
462
  if (pkFields.length === 0)
242
463
  return undefined;
243
464
  return keyFor(pkFields, row);
244
465
  }
466
+ // The DETAILS.ROW_REF GSF stamps on dataserver rows: the row's RECORD_ID as
467
+ // a string (GenesisSetMaskingJsonSerializer: value.toString()). A view row
468
+ // carries its base row's RECORD_ID. A row passed without one (a hand-built
469
+ // row, or a pk-only stub) is matched to the stored record by its pk — a
470
+ // just-deleted record included — and only when that fails too does this
471
+ // fall back to the pk-based reference.
472
+ getRecordRef(resourceName, row) {
473
+ const id = recordKey(row) ?? recordKey(this.findStoredRecord(resourceName, row));
474
+ return id ?? this.getRowRef(resourceName, row);
475
+ }
476
+ locateRecord(resourceName, row) {
477
+ const view = this.views.get(resourceName);
478
+ const tableName = view ? view.base : resourceName;
479
+ const pkFields = this.tableConfig.get(tableName)?.pkFields ?? [];
480
+ if (pkFields.length === 0 || pkFields.some((field) => row[field] === undefined)) {
481
+ return undefined;
482
+ }
483
+ return { tableName, key: keyFor(pkFields, row) };
484
+ }
485
+ // The stored record a pk-only row names: the live one, else the newest
486
+ // record removed from that key and not yet broadcast.
487
+ findStoredRecord(resourceName, row) {
488
+ const located = this.locateRecord(resourceName, row);
489
+ if (!located)
490
+ return undefined;
491
+ const { tableName, key } = located;
492
+ return (this.tables.get(tableName)?.get(key) ?? this.removedRows.get(tableName)?.get(key)?.at(-1));
493
+ }
245
494
  // Rebuilds a DELETE stub row carrying the pk field(s) from a stored key, so
246
495
  // subscribers can be told which row to drop after it's already gone.
496
+ // (The DELETE body under fidelity: 'legacy'; GSF sends DETAILS only.)
247
497
  rowStubFromKey(resourceName, key) {
248
498
  const pkFields = this.getPkFields(resourceName);
249
499
  if (pkFields.length <= 1)
@@ -1,17 +1,29 @@
1
- import { NackError } 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, 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.
9
21
  export function handleCommitEvent(message, connection, config, store, ctx) {
10
22
  const eventName = message.MESSAGE_TYPE;
11
23
  // defaultEventHandler catches anything unregistered — lets projects with
12
24
  // dozens of commit events adopt incrementally (e.g. `() => ({})` to ack
13
25
  // everything unhandled) instead of enumerating ack-only handlers.
14
- const handler = config.eventHandlers?.[eventName] ?? config.defaultEventHandler;
26
+ const handler = resolveEventHandler(eventName, config);
15
27
  // Replies use the GENERIC EVENT_ACK/EVENT_NACK — correlation is purely via
16
28
  // SOURCE_REF. See the note on MESSAGE_TYPE in protocol/messageTypes.ts:
17
29
  // real consumers (foundation-comms' kv.service, entity-management) match
@@ -33,17 +45,43 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
33
45
  : resourceNotMappedNack(eventName));
34
46
  return;
35
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.
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.
57
+ const legacy = isLegacyFidelity(config);
36
58
  try {
37
- 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
+ }) ?? {};
69
+ if (result.warnings?.length) {
70
+ connection.reply(message, {
71
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
72
+ ...warningPayload(result.warnings, legacy),
73
+ });
74
+ return;
75
+ }
38
76
  connection.reply(message, {
39
77
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
40
78
  GENERATED: result.generated ?? [],
41
79
  });
42
80
  }
43
81
  catch (error) {
44
- const errors = error instanceof NackError
45
- ? error.errors
46
- : [{ CODE: ERROR_CODE.VALIDATION_ERROR, TEXT: error.message }];
47
- connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK, ERROR: errors });
82
+ connection.reply(message, {
83
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
84
+ ...nackPayload(error, legacy),
85
+ });
48
86
  }
49
87
  }
@@ -0,0 +1,2 @@
1
+ import type { EventHandler, MockServerConfig } from '../types.ts';
2
+ export declare function crudEventHandler(eventName: string, config: MockServerConfig): EventHandler | undefined;