@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/src/db/store.ts CHANGED
@@ -1,21 +1,64 @@
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
 
4
- import type { Row, TableDef, ViewDef } from '../types.ts';
4
+ import type { Row, SequenceFormat, TableDef, ViewDef } from '../types.ts';
5
+ import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from './identity.ts';
5
6
 
6
7
  type Pk = string;
7
8
 
9
+ // A field the store fills on insert when the record leaves it out: the
10
+ // sequencePrefix key, and every TableDef.generated entry it can mint.
11
+ type Generator =
12
+ | { field: string; kind: 'SEQUENCE'; prefix: string }
13
+ | { field: string; kind: 'AUTO_INCREMENT' };
14
+
8
15
  interface TableConfig {
9
16
  pkFields: string[];
10
17
  sequencePrefix?: string;
11
18
  sequenceWidth: number;
19
+ sequenceFormat: SequenceFormat;
20
+ generators: Generator[];
12
21
  }
13
22
 
23
+ // The generators of a table, the key's own sequence first. A SEQUENCE with
24
+ // neither its own prefix nor the table's can't mint anything and is left to
25
+ // the caller.
26
+ function generatorsOf(
27
+ pkFields: string[],
28
+ { sequencePrefix, generated = [] }: TableDef,
29
+ ): Generator[] {
30
+ const generators: Generator[] = [];
31
+ if (sequencePrefix && pkFields.length === 1) {
32
+ generators.push({ field: pkFields[0], kind: 'SEQUENCE', prefix: sequencePrefix });
33
+ }
34
+ for (const entry of generated) {
35
+ if (generators.some((generator) => generator.field === entry.field)) continue;
36
+ if (entry.kind === 'AUTO_INCREMENT') {
37
+ generators.push({ field: entry.field, kind: 'AUTO_INCREMENT' });
38
+ continue;
39
+ }
40
+ const prefix = entry.prefix ?? sequencePrefix;
41
+ if (prefix) generators.push({ field: entry.field, kind: 'SEQUENCE', prefix });
42
+ }
43
+ return generators;
44
+ }
45
+
46
+ const isMissing = (value: unknown) => value === undefined || value === null;
47
+
14
48
  // Composite keys join the pk fields' values with '|' (so values must not
15
49
  // contain '|'). Single-field keys stay the raw value, preserving
16
50
  // getRow('TABLE', 'TR001')-style lookups.
17
51
  const COMPOSITE_KEY_SEPARATOR = '|';
18
52
 
53
+ const MAX_REMOVED_PER_KEY = 16;
54
+ const MAX_REMOVED_KEYS = 10_000;
55
+
56
+ // IdStrategy.java: a 15-digit counter, the sequence id, the location
57
+ // (DbConfig's default 'LO') and the system id (always 1).
58
+ const GENESIS_SEQUENCE_DIGITS = 15;
59
+ const GENESIS_LOCATION = 'LO';
60
+ const GENESIS_SYSTEM = '1';
61
+
19
62
  function normalizePkFields(pkField: string | string[]): string[] {
20
63
  return Array.isArray(pkField) ? pkField : [pkField];
21
64
  }
@@ -25,65 +68,203 @@ function keyFor(pkFields: string[], row: Row): Pk {
25
68
  return pkFields.map((field) => String(row[field])).join(COMPOSITE_KEY_SEPARATOR);
26
69
  }
27
70
 
71
+ function escapeRegExp(text: string): string {
72
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ }
74
+
75
+ // The counter inside a generated value, or undefined when the value isn't
76
+ // one a sequence with this prefix and format could have produced.
77
+ function sequenceNumber(
78
+ prefix: string,
79
+ format: SequenceFormat,
80
+ value: unknown,
81
+ ): number | undefined {
82
+ if (typeof value !== 'string') return undefined;
83
+ const digits =
84
+ format === 'genesis'
85
+ ? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(
86
+ value,
87
+ )?.[1]
88
+ : value.startsWith(prefix)
89
+ ? value.slice(prefix.length)
90
+ : undefined;
91
+ if (digits === undefined) return undefined;
92
+ const n = parseInt(digits, 10);
93
+ return Number.isFinite(n) ? n : undefined;
94
+ }
95
+
96
+ // Counters are shared by tables using the same prefix in the same format.
97
+ function counterKey(prefix: string, format: SequenceFormat): string {
98
+ return format === 'genesis' ? `${prefix}\u0000genesis` : prefix;
99
+ }
100
+
101
+ // An AUTO_INCREMENT counter belongs to its table's field.
102
+ const autoIncrementKey = (tableName: string, field: string) => `${tableName}\u0000${field}`;
103
+
28
104
  export class Store {
29
105
  private tables = new Map<string, Map<Pk, Row>>();
30
106
  private tableConfig = new Map<string, TableConfig>();
31
107
  private sequenceCounters = new Map<string, number>();
108
+ private autoIncrementCounters = new Map<string, number>();
109
+ private clock = new RecordClock();
32
110
  // Immutable snapshot of each table's rows as registered, used only for field
33
111
  // metadata derivation (see getMetadataRows) so deleting every live row can't
34
112
  // collapse a resource's schema to zero fields.
35
113
  private seedRows = new Map<string, Row[]>();
114
+ // Per table and key, every record the key lost (deleted, or displaced by
115
+ // an insert or a key change landing on it) and no broadcast has announced
116
+ // yet, oldest first — see takeRemovedRows. Bounded: a key keeps its last
117
+ // MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
118
+ // for handlers that delete without ever broadcasting.
119
+ private removedRows = new Map<string, Map<Pk, Row[]>>();
36
120
  readonly views = new Map<string, ViewDef>();
37
121
 
38
- registerTable(
39
- tableName: string,
40
- { pkField, sequencePrefix, sequenceWidth = 3, rows = [] }: TableDef,
41
- ): this {
122
+ registerTable(tableName: string, definition: TableDef): this {
123
+ const {
124
+ pkField,
125
+ sequencePrefix,
126
+ sequenceWidth = 3,
127
+ sequenceFormat = 'prefix',
128
+ rows = [],
129
+ } = definition;
42
130
  const pkFields = normalizePkFields(pkField);
131
+ const config: TableConfig = {
132
+ pkFields,
133
+ sequencePrefix,
134
+ sequenceWidth,
135
+ sequenceFormat,
136
+ generators: generatorsOf(pkFields, definition),
137
+ };
43
138
  const table = new Map<Pk, Row>();
139
+ const seenIds = new Set<string>();
140
+ let restamped = 0;
44
141
  for (const row of rows) {
45
- table.set(keyFor(pkFields, row), { ...row });
142
+ const stamped = this.stampSeedRow({ ...row }, seenIds);
143
+ if (stamped.restamped) restamped += 1;
144
+ table.set(keyFor(pkFields, row), stamped.row);
145
+ }
146
+ if (restamped > 0) {
147
+ console.warn(
148
+ `[mock-server] ${tableName}: re-stamped ${restamped} seed row(s) whose RECORD_ID was ` +
149
+ 'not a safe integer or a string, or repeated another row, so every ROW_REF stays unique',
150
+ );
46
151
  }
47
152
  this.tables.set(tableName, table);
48
- this.tableConfig.set(tableName, { pkFields, sequencePrefix, sequenceWidth });
153
+ this.tableConfig.set(tableName, config);
154
+ this.removedRows.delete(tableName);
49
155
  this.seedRows.set(
50
156
  tableName,
51
157
  rows.map((row) => ({ ...row })),
52
158
  );
53
159
 
54
- // Sequence generation only makes sense for a single-field pk.
55
- if (sequencePrefix && pkFields.length === 1) {
160
+ // Each counter starts after the highest value the seed rows carry (two
161
+ // fields of one table sharing a prefix share the counter). The key's
162
+ // sequence only exists for a single-field pk.
163
+ const starts = new Map<string, number>();
164
+ for (const generator of config.generators) {
56
165
  let maxN = 0;
57
166
  for (const row of rows) {
58
- const pk = String(row[pkFields[0]] ?? '');
59
- if (pk.startsWith(sequencePrefix)) {
60
- const n = parseInt(pk.slice(sequencePrefix.length), 10);
61
- if (Number.isFinite(n)) maxN = Math.max(maxN, n);
62
- }
167
+ const n = this.generatedNumber(config, generator, row[generator.field]);
168
+ if (n !== undefined) maxN = Math.max(maxN, n);
63
169
  }
64
- this.sequenceCounters.set(sequencePrefix, maxN + 1);
170
+ const counter = this.counterOf(tableName, config, generator);
171
+ starts.set(counter, Math.max(starts.get(counter) ?? 1, maxN + 1));
172
+ }
173
+ for (const generator of config.generators) {
174
+ const counter = this.counterOf(tableName, config, generator);
175
+ this.countersFor(generator).set(counter, starts.get(counter)!);
65
176
  }
66
177
  return this;
67
178
  }
68
179
 
180
+ private counterOf(tableName: string, config: TableConfig, generator: Generator): string {
181
+ return generator.kind === 'SEQUENCE'
182
+ ? counterKey(generator.prefix, config.sequenceFormat)
183
+ : autoIncrementKey(tableName, generator.field);
184
+ }
185
+
186
+ private countersFor(generator: Generator): Map<string, number> {
187
+ return generator.kind === 'SEQUENCE' ? this.sequenceCounters : this.autoIncrementCounters;
188
+ }
189
+
190
+ // The counter inside a value a generator could have produced.
191
+ private generatedNumber(
192
+ config: TableConfig,
193
+ generator: Generator,
194
+ value: unknown,
195
+ ): number | undefined {
196
+ if (generator.kind === 'SEQUENCE') {
197
+ return sequenceNumber(generator.prefix, config.sequenceFormat, value);
198
+ }
199
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
200
+ }
201
+
69
202
  registerView(viewName: string, definition: ViewDef): this {
70
203
  this.views.set(viewName, definition);
71
204
  return this;
72
205
  }
73
206
 
74
- // An explicitly-supplied pk inside the sequence's own range must advance the
75
- // counter too. Without this, inserting e.g. 'TR002' by hand leaves the
76
- // counter at 2, and the next auto-generated insert mints 'TR002' again and
77
- // silently overwrites the hand-keyed row (Map.set, no error, event still
78
- // acks success).
79
- private observeSequenceValue(config: TableConfig, row: Row): void {
80
- if (!config.sequencePrefix || config.pkFields.length !== 1) return;
81
- const pk = String(row[config.pkFields[0]] ?? '');
82
- if (!pk.startsWith(config.sequencePrefix)) return;
83
- const n = parseInt(pk.slice(config.sequencePrefix.length), 10);
84
- if (!Number.isFinite(n)) return;
85
- const next = this.sequenceCounters.get(config.sequencePrefix) ?? 1;
86
- if (n + 1 > next) this.sequenceCounters.set(config.sequencePrefix, n + 1);
207
+ // Seed rows keep a RECORD_ID they bring (e.g. rows copied from a real
208
+ // server) when it is usable: a safe integer, or a string, not used by an
209
+ // earlier seed row. Anything else — a missing id, a number above 2^53 that
210
+ // JSON.parse already rounded onto its neighbour's, a repeat — gets a fresh
211
+ // stamp, in seed order, so no two rows share a ROW_REF. A missing or
212
+ // unusable TIMESTAMP becomes the RECORD_ID.
213
+ private stampSeedRow(row: Row, seenIds: Set<string>): { row: Row; restamped: boolean } {
214
+ const given = row[RECORD_ID];
215
+ const usable = (value: unknown) =>
216
+ (typeof value === 'number' && Number.isSafeInteger(value)) ||
217
+ (typeof value === 'string' && value !== '');
218
+ const missing = given === undefined || given === null;
219
+ const keep = !missing && usable(given) && !seenIds.has(String(given));
220
+ if (keep) {
221
+ this.clock.observe(given);
222
+ } else {
223
+ row[RECORD_ID] = this.clock.next();
224
+ }
225
+ seenIds.add(String(row[RECORD_ID]));
226
+ if (!usable(row[TIMESTAMP])) row[TIMESTAMP] = row[RECORD_ID];
227
+ this.clock.observe(row[TIMESTAMP]);
228
+ return { row, restamped: !missing && !keep };
229
+ }
230
+
231
+ // A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
232
+ // it was given — as on the real server — appended after its own fields.
233
+ private stampNewRow(row: Row): Row {
234
+ delete row[RECORD_ID];
235
+ delete row[TIMESTAMP];
236
+ const stamp = this.clock.next();
237
+ row[RECORD_ID] = stamp;
238
+ row[TIMESTAMP] = stamp;
239
+ return row;
240
+ }
241
+
242
+ private noteRemoved(tableName: string, key: Pk, row: Row): void {
243
+ let removed = this.removedRows.get(tableName);
244
+ if (!removed) {
245
+ removed = new Map();
246
+ this.removedRows.set(tableName, removed);
247
+ }
248
+ const records = removed.get(key) ?? [];
249
+ // Re-set so the key moves to the end: the oldest keys are dropped first.
250
+ removed.delete(key);
251
+ removed.set(key, [...records, row].slice(-MAX_REMOVED_PER_KEY));
252
+ if (removed.size > MAX_REMOVED_KEYS) removed.delete(removed.keys().next().value!);
253
+ }
254
+
255
+ // A supplied value inside a generator's own range must advance its counter
256
+ // too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
257
+ // 2, and the next generated insert mints 'TR002' again and silently
258
+ // overwrites the hand-keyed row (Map.set, no error, event still acks
259
+ // success).
260
+ private observeGeneratedValues(tableName: string, config: TableConfig, row: Row): void {
261
+ for (const generator of config.generators) {
262
+ const n = this.generatedNumber(config, generator, row[generator.field]);
263
+ if (n === undefined) continue;
264
+ const counters = this.countersFor(generator);
265
+ const counter = this.counterOf(tableName, config, generator);
266
+ if (n + 1 > (counters.get(counter) ?? 1)) counters.set(counter, n + 1);
267
+ }
87
268
  }
88
269
 
89
270
  nextSequenceValue(sequencePrefix: string, width: number): string {
@@ -92,6 +273,31 @@ export class Store {
92
273
  return `${sequencePrefix}${String(n).padStart(width, '0')}`;
93
274
  }
94
275
 
276
+ private generateSequenceValue(config: TableConfig, prefix: string): string {
277
+ if (config.sequenceFormat !== 'genesis') {
278
+ return this.nextSequenceValue(prefix, config.sequenceWidth);
279
+ }
280
+ const key = counterKey(prefix, 'genesis');
281
+ const n = this.sequenceCounters.get(key) ?? 1;
282
+ this.sequenceCounters.set(key, n + 1);
283
+ return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${prefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
284
+ }
285
+
286
+ // Fills every generated field the record leaves out (undefined or null).
287
+ private generateValues(tableName: string, config: TableConfig, row: Row): void {
288
+ for (const generator of config.generators) {
289
+ if (!isMissing(row[generator.field])) continue;
290
+ if (generator.kind === 'SEQUENCE') {
291
+ row[generator.field] = this.generateSequenceValue(config, generator.prefix);
292
+ continue;
293
+ }
294
+ const counter = autoIncrementKey(tableName, generator.field);
295
+ const n = this.autoIncrementCounters.get(counter) ?? 1;
296
+ this.autoIncrementCounters.set(counter, n + 1);
297
+ row[generator.field] = n;
298
+ }
299
+ }
300
+
95
301
  getAllRows(tableName: string): Row[] {
96
302
  const table = this.tables.get(tableName);
97
303
  if (!table) return [];
@@ -135,59 +341,109 @@ export class Store {
135
341
  return rows.find(matchesAllCriteria);
136
342
  }
137
343
 
344
+ // A copy of the record with its missing generated fields filled in, as the
345
+ // database does before checking its unique indexes. Each value used is
346
+ // gone even if the record is then rejected, as a real sequence's is.
347
+ withGeneratedValues(tableName: string, record: Row): Row {
348
+ const config = this.tableConfig.get(tableName);
349
+ if (!config) throw new Error(`Unknown table: ${tableName}`);
350
+ const row = { ...record };
351
+ this.generateValues(tableName, config, row);
352
+ return row;
353
+ }
354
+
355
+ // Generates every missing (undefined or null) sequence and auto-increment
356
+ // field — the sequencePrefix key and TableDef.generated — and keeps a
357
+ // supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already
358
+ // stored under the same key is replaced (the real database would reject the
359
+ // insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
360
+ // for that, as the generic CRUD events do).
138
361
  insertRow(tableName: string, record: Row): Row {
139
362
  const table = this.tables.get(tableName);
140
363
  const config = this.tableConfig.get(tableName);
141
364
  if (!table || !config) throw new Error(`Unknown table: ${tableName}`);
142
365
 
143
366
  const row = { ...record };
144
- if (
145
- config.pkFields.length === 1 &&
146
- row[config.pkFields[0]] === undefined &&
147
- config.sequencePrefix
148
- ) {
149
- row[config.pkFields[0]] = this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
150
- }
367
+ this.generateValues(tableName, config, row);
151
368
  const missing = config.pkFields.filter((field) => row[field] === undefined);
152
369
  if (missing.length > 0) {
153
370
  throw new Error(
154
371
  `Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`,
155
372
  );
156
373
  }
157
- this.observeSequenceValue(config, row);
158
- table.set(keyFor(config.pkFields, row), row);
374
+ this.observeGeneratedValues(tableName, config, row);
375
+ this.stampNewRow(row);
376
+ const key = keyFor(config.pkFields, row);
377
+ const displaced = table.get(key);
378
+ if (displaced) this.noteRemoved(tableName, key, displaced);
379
+ table.set(key, row);
159
380
  return row;
160
381
  }
161
382
 
383
+ // Merges the patch, moves TIMESTAMP on and keeps RECORD_ID (a patch can't
384
+ // change a row's identity).
162
385
  updateRow(tableName: string, pk: Pk, patch: Row): Row | undefined {
163
386
  const table = this.tables.get(tableName);
164
387
  const config = this.tableConfig.get(tableName);
165
388
  if (!table || !config || !table.has(pk)) return undefined;
166
- const updated = { ...table.get(pk), ...patch };
389
+ const existing = table.get(pk)!;
390
+ const updated = { ...existing, ...patch };
391
+ updated[RECORD_ID] = existing[RECORD_ID];
392
+ updated[TIMESTAMP] = this.clock.next();
167
393
  // A patch that changes a pk field (e.g. an entity-management form posting
168
394
  // its full DETAILS after the user edited the key column) must move the row
169
395
  // to its new key. Leaving it under the old one makes the index disagree
170
396
  // with the row itself: getRow(newKey) misses, while rowKey()/getRowRef()
171
397
  // report the new key, so the matching broadcast finds nothing to push.
172
398
  const nextKey = keyFor(config.pkFields, updated);
173
- if (nextKey !== pk) table.delete(pk);
399
+ if (nextKey !== pk) {
400
+ table.delete(pk);
401
+ const displaced = table.get(nextKey);
402
+ if (displaced) this.noteRemoved(tableName, nextKey, displaced);
403
+ }
174
404
  table.set(nextKey, updated);
175
405
  return updated;
176
406
  }
177
407
 
178
408
  deleteRow(tableName: string, pk: Pk): boolean {
179
409
  const table = this.tables.get(tableName);
180
- if (!table) return false;
181
- return table.delete(pk);
410
+ const existing = table?.get(pk);
411
+ if (!table || !existing) return false;
412
+ table.delete(pk);
413
+ this.noteRemoved(tableName, pk, existing);
414
+ return true;
415
+ }
416
+
417
+ // Reads and clears every record a key lost that no broadcast announced
418
+ // yet, oldest first: what a DELETE broadcast names (their RECORD_IDs are
419
+ // the ROW_REFs a client may hold — held-rows tracking drops the ones it
420
+ // doesn't), including the old record when a key's row was replaced before
421
+ // one broadcast.
422
+ takeRemovedRows(tableName: string, key: Pk): Row[] {
423
+ const removed = this.removedRows.get(tableName);
424
+ const records = removed?.get(key) ?? [];
425
+ removed?.delete(key);
426
+ return records;
427
+ }
428
+
429
+ // The same, for a row of a table or view given by its (base) pk fields —
430
+ // what a public broadcast(resource, 'DELETE', pkStub) names.
431
+ takeRemovedRecordsFor(resourceName: string, row: Row): Row[] {
432
+ const located = this.locateRecord(resourceName, row);
433
+ return located ? this.takeRemovedRows(located.tableName, located.key) : [];
182
434
  }
183
435
 
184
436
  // Materializes a view: base table left-joined to related tables (optionally
185
437
  // narrowed by per-join `where` predicates), minus anti-joined rows, plus
186
- // derived fields.
187
- getViewRows(viewName: string): Row[] {
438
+ // derived fields. `tables` substitutes the rows of any table the view reads
439
+ // (its base or a joined one) — e.g. one base row, to build that row's view
440
+ // row, or a table's earlier state, to rebuild the view as it was.
441
+ getViewRows(viewName: string, tables: Record<string, Row[]> = {}): Row[] {
188
442
  const view = this.views.get(viewName);
189
443
  if (!view) throw new Error(`Unknown view: ${viewName}`);
190
- return this.materializeView(view, (tableName) => this.getAllRows(tableName));
444
+ return this.materializeView(view, (tableName) =>
445
+ Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName),
446
+ );
191
447
  }
192
448
 
193
449
  private materializeView(view: ViewDef, getRows: (tableName: string) => Row[]): Row[] {
@@ -254,12 +510,16 @@ export class Store {
254
510
  // deleting a table's last row would make the next metadata reply say the
255
511
  // resource has zero fields — which entity-management renders as a form with
256
512
  // no inputs and a delete button that can't build a payload, silently.
513
+ // RECORD_ID and TIMESTAMP are left out: they are not fields a dataserver
514
+ // row or an event's DETAILS carry.
257
515
  getMetadataRows(resourceName: string): Row[] {
258
516
  const live = this.getResourceRows(resourceName);
259
- if (live.length > 0) return live;
517
+ if (live.length > 0) return live.map(withoutRecordFields);
260
518
  const view = this.views.get(resourceName);
261
- if (view) return this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? []);
262
- return this.seedRows.get(resourceName) ?? [];
519
+ const seed = view
520
+ ? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
521
+ : (this.seedRows.get(resourceName) ?? []);
522
+ return seed.map(withoutRecordFields);
263
523
  }
264
524
 
265
525
  // Row-identity fields for a table or view; empty when unknown.
@@ -272,17 +532,50 @@ export class Store {
272
532
  return this.tableConfig.get(resourceName)?.pkFields ?? [];
273
533
  }
274
534
 
275
- // The DETAILS.ROW_REF value stamped on QUERY_UPDATE rows (see
276
- // protocol/rowUpdate.ts): the pk field's value, or the composite key. The
277
- // client's dataServerResultFilter keys inserts/amends/deletes off this.
535
+ // The pk-based row reference: the pk field's value, or the composite key.
536
+ // This was DETAILS.ROW_REF up to 15.47 and still is under
537
+ // fidelity: 'legacy'; by default ROW_REF is getRecordRef's.
278
538
  getRowRef(resourceName: string, row: Row): unknown {
279
539
  const pkFields = this.getPkFields(resourceName);
280
540
  if (pkFields.length === 0) return undefined;
281
541
  return keyFor(pkFields, row);
282
542
  }
283
543
 
544
+ // The DETAILS.ROW_REF GSF stamps on dataserver rows: the row's RECORD_ID as
545
+ // a string (GenesisSetMaskingJsonSerializer: value.toString()). A view row
546
+ // carries its base row's RECORD_ID. A row passed without one (a hand-built
547
+ // row, or a pk-only stub) is matched to the stored record by its pk — a
548
+ // just-deleted record included — and only when that fails too does this
549
+ // fall back to the pk-based reference.
550
+ getRecordRef(resourceName: string, row: Row): unknown {
551
+ const id = recordKey(row) ?? recordKey(this.findStoredRecord(resourceName, row));
552
+ return id ?? this.getRowRef(resourceName, row);
553
+ }
554
+
555
+ private locateRecord(resourceName: string, row: Row): { tableName: string; key: Pk } | undefined {
556
+ const view = this.views.get(resourceName);
557
+ const tableName = view ? view.base : resourceName;
558
+ const pkFields = this.tableConfig.get(tableName)?.pkFields ?? [];
559
+ if (pkFields.length === 0 || pkFields.some((field) => row[field] === undefined)) {
560
+ return undefined;
561
+ }
562
+ return { tableName, key: keyFor(pkFields, row) };
563
+ }
564
+
565
+ // The stored record a pk-only row names: the live one, else the newest
566
+ // record removed from that key and not yet broadcast.
567
+ private findStoredRecord(resourceName: string, row: Row): Row | undefined {
568
+ const located = this.locateRecord(resourceName, row);
569
+ if (!located) return undefined;
570
+ const { tableName, key } = located;
571
+ return (
572
+ this.tables.get(tableName)?.get(key) ?? this.removedRows.get(tableName)?.get(key)?.at(-1)
573
+ );
574
+ }
575
+
284
576
  // Rebuilds a DELETE stub row carrying the pk field(s) from a stored key, so
285
577
  // subscribers can be told which row to drop after it's already gone.
578
+ // (The DELETE body under fidelity: 'legacy'; GSF sends DETAILS only.)
286
579
  rowStubFromKey(resourceName: string, key: Pk): Row {
287
580
  const pkFields = this.getPkFields(resourceName);
288
581
  if (pkFields.length <= 1) return pkFields.length ? { [pkFields[0]]: key } : {};
@@ -1,25 +1,44 @@
1
1
  import type { Store } from '../db/store.ts';
2
2
  import type { Connection } from '../protocol/connection.ts';
3
- import { NackError } from '../protocol/errors.ts';
3
+ import { NackError, nackPayload, warningPayload } from '../protocol/errors.ts';
4
4
  import { isLegacyFidelity } from '../protocol/fidelity.ts';
5
5
  import { ERROR_CODE, MESSAGE_TYPE } from '../protocol/messageTypes.ts';
6
6
  import { resourceNotMappedNack } from '../protocol/msgNack.ts';
7
7
  import type {
8
8
  BroadcastFn,
9
9
  BroadcastTableChangeFn,
10
+ EventHandler,
10
11
  GenesisMessage,
11
12
  MockServerConfig,
12
13
  } from '../types.ts';
14
+ import { crudEventHandler } from './crudEvents.ts';
15
+ import { validateEventMessage } from './eventValidation.ts';
13
16
 
14
17
  export interface CommitEventCtx {
15
18
  broadcast: BroadcastFn;
16
19
  broadcastTableChange: BroadcastTableChangeFn;
17
20
  }
18
21
 
22
+ // The handler serving an event: an eventHandlers entry, else the generic CRUD
23
+ // handler a table registers through TableDef.events, else
24
+ // defaultEventHandler.
25
+ function resolveEventHandler(
26
+ eventName: string,
27
+ config: MockServerConfig,
28
+ ): EventHandler | undefined {
29
+ return (
30
+ config.eventHandlers?.[eventName] ??
31
+ crudEventHandler(eventName, config) ??
32
+ config.defaultEventHandler
33
+ );
34
+ }
35
+
19
36
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
20
37
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
21
- // config.eventHandlers. A handler receives (details, ctx) and either returns
22
- // { generated?: [...] } on success, or throws NackError(errors) on failure.
38
+ // config.eventHandlers, then the tables' generic CRUD events. A handler
39
+ // receives (details, ctx) and either returns { generated?: [...] } on
40
+ // success, { warnings: [...] } to NACK with warnings only, or throws
41
+ // NackError(errors) on failure.
23
42
  export function handleCommitEvent(
24
43
  message: GenesisMessage,
25
44
  connection: Connection,
@@ -31,7 +50,7 @@ export function handleCommitEvent(
31
50
  // defaultEventHandler catches anything unregistered — lets projects with
32
51
  // dozens of commit events adopt incrementally (e.g. `() => ({})` to ack
33
52
  // everything unhandled) instead of enumerating ack-only handlers.
34
- const handler = config.eventHandlers?.[eventName] ?? config.defaultEventHandler;
53
+ const handler = resolveEventHandler(eventName, config);
35
54
 
36
55
  // Replies use the GENERIC EVENT_ACK/EVENT_NACK — correlation is purely via
37
56
  // SOURCE_REF. See the note on MESSAGE_TYPE in protocol/messageTypes.ts:
@@ -58,17 +77,42 @@ export function handleCommitEvent(
58
77
  return;
59
78
  }
60
79
 
80
+ // Before any handler runs, the message is checked against the event's
81
+ // declared schema (eventValidation.ts), as GSF's decoder does; each failure
82
+ // is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
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.
89
+ const legacy = isLegacyFidelity(config);
61
90
  try {
62
- const result = handler(message.DETAILS ?? {}, { connection, store, message, ...ctx }) ?? {};
91
+ const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
92
+ if (schemaErrors.length > 0) throw new NackError(schemaErrors);
93
+ const result =
94
+ handler(message.DETAILS ?? {}, {
95
+ connection,
96
+ store,
97
+ message,
98
+ ...ctx,
99
+ validate: message.VALIDATE === true,
100
+ }) ?? {};
101
+ if (result.warnings?.length) {
102
+ connection.reply(message, {
103
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
104
+ ...warningPayload(result.warnings, legacy),
105
+ });
106
+ return;
107
+ }
63
108
  connection.reply(message, {
64
109
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
65
110
  GENERATED: result.generated ?? [],
66
111
  });
67
112
  } catch (error) {
68
- const errors =
69
- error instanceof NackError
70
- ? error.errors
71
- : [{ CODE: ERROR_CODE.VALIDATION_ERROR, TEXT: (error as Error).message }];
72
- connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK, ERROR: errors });
113
+ connection.reply(message, {
114
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
115
+ ...nackPayload(error, legacy),
116
+ });
73
117
  }
74
118
  }