@genesislcap/mock-server 15.51.1 → 15.52.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 (53) hide show
  1. package/README.md +217 -31
  2. package/dist/db/criteria.d.ts +1 -0
  3. package/dist/db/criteria.js +29 -0
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/schema.d.ts +46 -0
  7. package/dist/db/schema.js +375 -0
  8. package/dist/db/store.d.ts +13 -2
  9. package/dist/db/store.js +208 -34
  10. package/dist/handlers/commitEvent.js +20 -6
  11. package/dist/handlers/dataLogon.js +126 -6
  12. package/dist/handlers/jsonSchema.d.ts +2 -1
  13. package/dist/handlers/jsonSchema.js +216 -61
  14. package/dist/handlers/meta.d.ts +6 -1
  15. package/dist/handlers/meta.js +164 -21
  16. package/dist/handlers/requestReply.js +16 -5
  17. package/dist/handlers/resourceAuth.d.ts +1 -0
  18. package/dist/handlers/resourceAuth.js +14 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/protocol/connection.d.ts +2 -0
  22. package/dist/protocol/errors.d.ts +100 -2
  23. package/dist/protocol/errors.js +300 -12
  24. package/dist/protocol/fieldTypes.d.ts +9 -0
  25. package/dist/protocol/fieldTypes.js +101 -0
  26. package/dist/protocol/gsfSchemas.d.ts +9 -0
  27. package/dist/protocol/gsfSchemas.js +423 -0
  28. package/dist/protocol/messageTypes.d.ts +1 -0
  29. package/dist/protocol/messageTypes.js +1 -0
  30. package/dist/protocol/rowUpdate.d.ts +5 -3
  31. package/dist/protocol/rowUpdate.js +33 -15
  32. package/dist/server.js +90 -28
  33. package/dist/types.d.ts +40 -0
  34. package/package.json +1 -1
  35. package/src/db/criteria.ts +26 -0
  36. package/src/db/identity.ts +53 -0
  37. package/src/db/schema.ts +523 -0
  38. package/src/db/store.ts +225 -32
  39. package/src/handlers/commitEvent.ts +20 -7
  40. package/src/handlers/dataLogon.ts +144 -7
  41. package/src/handlers/jsonSchema.ts +290 -69
  42. package/src/handlers/meta.ts +222 -23
  43. package/src/handlers/requestReply.ts +15 -6
  44. package/src/handlers/resourceAuth.ts +18 -0
  45. package/src/index.ts +16 -1
  46. package/src/protocol/connection.ts +10 -2
  47. package/src/protocol/errors.ts +361 -13
  48. package/src/protocol/fieldTypes.ts +108 -0
  49. package/src/protocol/gsfSchemas.ts +505 -0
  50. package/src/protocol/messageTypes.ts +1 -0
  51. package/src/protocol/rowUpdate.ts +40 -20
  52. package/src/server.ts +101 -27
  53. package/src/types.ts +145 -1
package/dist/db/store.js CHANGED
@@ -1,9 +1,17 @@
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";
3
4
  // Composite keys join the pk fields' values with '|' (so values must not
4
5
  // contain '|'). Single-field keys stay the raw value, preserving
5
6
  // getRow('TABLE', 'TR001')-style lookups.
6
7
  const COMPOSITE_KEY_SEPARATOR = '|';
8
+ const MAX_REMOVED_PER_KEY = 16;
9
+ const MAX_REMOVED_KEYS = 10_000;
10
+ // IdStrategy.java: a 15-digit counter, the sequence id, the location
11
+ // (DbConfig's default 'LO') and the system id (always 1).
12
+ const GENESIS_SEQUENCE_DIGITS = 15;
13
+ const GENESIS_LOCATION = 'LO';
14
+ const GENESIS_SYSTEM = '1';
7
15
  function normalizePkFields(pkField) {
8
16
  return Array.isArray(pkField) ? pkField : [pkField];
9
17
  }
@@ -12,36 +20,76 @@ function keyFor(pkFields, row) {
12
20
  return row[pkFields[0]];
13
21
  return pkFields.map((field) => String(row[field])).join(COMPOSITE_KEY_SEPARATOR);
14
22
  }
23
+ function escapeRegExp(text) {
24
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+ // The counter inside a generated value, or undefined when the value isn't
27
+ // one this table's sequence could have produced.
28
+ function sequenceNumber(config, value) {
29
+ const prefix = config.sequencePrefix;
30
+ if (!prefix || typeof value !== 'string')
31
+ return undefined;
32
+ const digits = config.sequenceFormat === 'genesis'
33
+ ? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(value)?.[1]
34
+ : value.startsWith(prefix)
35
+ ? value.slice(prefix.length)
36
+ : undefined;
37
+ if (digits === undefined)
38
+ return undefined;
39
+ const n = parseInt(digits, 10);
40
+ return Number.isFinite(n) ? n : undefined;
41
+ }
42
+ // Counters are shared by tables using the same prefix in the same format.
43
+ function counterKey(config) {
44
+ return config.sequenceFormat === 'genesis'
45
+ ? `${config.sequencePrefix}\u0000genesis`
46
+ : config.sequencePrefix;
47
+ }
15
48
  export class Store {
16
49
  tables = new Map();
17
50
  tableConfig = new Map();
18
51
  sequenceCounters = new Map();
52
+ clock = new RecordClock();
19
53
  // Immutable snapshot of each table's rows as registered, used only for field
20
54
  // metadata derivation (see getMetadataRows) so deleting every live row can't
21
55
  // collapse a resource's schema to zero fields.
22
56
  seedRows = new Map();
57
+ // Per table and key, every record the key lost (deleted, or displaced by
58
+ // an insert or a key change landing on it) and no broadcast has announced
59
+ // yet, oldest first — see takeRemovedRows. Bounded: a key keeps its last
60
+ // MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
61
+ // for handlers that delete without ever broadcasting.
62
+ removedRows = new Map();
23
63
  views = new Map();
24
- registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, rows = [] }) {
64
+ registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [] }) {
25
65
  const pkFields = normalizePkFields(pkField);
66
+ const config = { pkFields, sequencePrefix, sequenceWidth, sequenceFormat };
26
67
  const table = new Map();
68
+ const seenIds = new Set();
69
+ let restamped = 0;
27
70
  for (const row of rows) {
28
- table.set(keyFor(pkFields, row), { ...row });
71
+ const stamped = this.stampSeedRow({ ...row }, seenIds);
72
+ if (stamped.restamped)
73
+ restamped += 1;
74
+ table.set(keyFor(pkFields, row), stamped.row);
75
+ }
76
+ if (restamped > 0) {
77
+ console.warn(`[mock-server] ${tableName}: re-stamped ${restamped} seed row(s) whose RECORD_ID was ` +
78
+ 'not a safe integer or a string, or repeated another row, so every ROW_REF stays unique');
29
79
  }
30
80
  this.tables.set(tableName, table);
31
- this.tableConfig.set(tableName, { pkFields, sequencePrefix, sequenceWidth });
81
+ this.tableConfig.set(tableName, config);
82
+ this.removedRows.delete(tableName);
32
83
  this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
33
84
  // Sequence generation only makes sense for a single-field pk.
34
85
  if (sequencePrefix && pkFields.length === 1) {
35
86
  let maxN = 0;
36
87
  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
- }
88
+ const n = sequenceNumber(config, row[pkFields[0]]);
89
+ if (n !== undefined)
90
+ maxN = Math.max(maxN, n);
43
91
  }
44
- this.sequenceCounters.set(sequencePrefix, maxN + 1);
92
+ this.sequenceCounters.set(counterKey(config), maxN + 1);
45
93
  }
46
94
  return this;
47
95
  }
@@ -49,6 +97,53 @@ export class Store {
49
97
  this.views.set(viewName, definition);
50
98
  return this;
51
99
  }
100
+ // Seed rows keep a RECORD_ID they bring (e.g. rows copied from a real
101
+ // server) when it is usable: a safe integer, or a string, not used by an
102
+ // earlier seed row. Anything else — a missing id, a number above 2^53 that
103
+ // JSON.parse already rounded onto its neighbour's, a repeat — gets a fresh
104
+ // stamp, in seed order, so no two rows share a ROW_REF. A missing or
105
+ // unusable TIMESTAMP becomes the RECORD_ID.
106
+ stampSeedRow(row, seenIds) {
107
+ const given = row[RECORD_ID];
108
+ const usable = (value) => (typeof value === 'number' && Number.isSafeInteger(value)) ||
109
+ (typeof value === 'string' && value !== '');
110
+ const missing = given === undefined || given === null;
111
+ const keep = !missing && usable(given) && !seenIds.has(String(given));
112
+ if (keep) {
113
+ this.clock.observe(given);
114
+ }
115
+ else {
116
+ row[RECORD_ID] = this.clock.next();
117
+ }
118
+ seenIds.add(String(row[RECORD_ID]));
119
+ if (!usable(row[TIMESTAMP]))
120
+ row[TIMESTAMP] = row[RECORD_ID];
121
+ this.clock.observe(row[TIMESTAMP]);
122
+ return { row, restamped: !missing && !keep };
123
+ }
124
+ // A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
125
+ // it was given — as on the real server — appended after its own fields.
126
+ stampNewRow(row) {
127
+ delete row[RECORD_ID];
128
+ delete row[TIMESTAMP];
129
+ const stamp = this.clock.next();
130
+ row[RECORD_ID] = stamp;
131
+ row[TIMESTAMP] = stamp;
132
+ return row;
133
+ }
134
+ noteRemoved(tableName, key, row) {
135
+ let removed = this.removedRows.get(tableName);
136
+ if (!removed) {
137
+ removed = new Map();
138
+ this.removedRows.set(tableName, removed);
139
+ }
140
+ const records = removed.get(key) ?? [];
141
+ // Re-set so the key moves to the end: the oldest keys are dropped first.
142
+ removed.delete(key);
143
+ removed.set(key, [...records, row].slice(-MAX_REMOVED_PER_KEY));
144
+ if (removed.size > MAX_REMOVED_KEYS)
145
+ removed.delete(removed.keys().next().value);
146
+ }
52
147
  // An explicitly-supplied pk inside the sequence's own range must advance the
53
148
  // counter too. Without this, inserting e.g. 'TR002' by hand leaves the
54
149
  // counter at 2, and the next auto-generated insert mints 'TR002' again and
@@ -57,21 +152,28 @@ export class Store {
57
152
  observeSequenceValue(config, row) {
58
153
  if (!config.sequencePrefix || config.pkFields.length !== 1)
59
154
  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))
155
+ const n = sequenceNumber(config, row[config.pkFields[0]]);
156
+ if (n === undefined)
65
157
  return;
66
- const next = this.sequenceCounters.get(config.sequencePrefix) ?? 1;
158
+ const key = counterKey(config);
159
+ const next = this.sequenceCounters.get(key) ?? 1;
67
160
  if (n + 1 > next)
68
- this.sequenceCounters.set(config.sequencePrefix, n + 1);
161
+ this.sequenceCounters.set(key, n + 1);
69
162
  }
70
163
  nextSequenceValue(sequencePrefix, width) {
71
164
  const n = this.sequenceCounters.get(sequencePrefix) ?? 1;
72
165
  this.sequenceCounters.set(sequencePrefix, n + 1);
73
166
  return `${sequencePrefix}${String(n).padStart(width, '0')}`;
74
167
  }
168
+ generateSequenceValue(config) {
169
+ if (config.sequenceFormat !== 'genesis') {
170
+ return this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
171
+ }
172
+ const key = counterKey(config);
173
+ const n = this.sequenceCounters.get(key) ?? 1;
174
+ this.sequenceCounters.set(key, n + 1);
175
+ return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${config.sequencePrefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
176
+ }
75
177
  getAllRows(tableName) {
76
178
  const table = this.tables.get(tableName);
77
179
  if (!table)
@@ -112,6 +214,10 @@ export class Store {
112
214
  }
113
215
  return rows.find(matchesAllCriteria);
114
216
  }
217
+ // Generates a missing (undefined or null) sequence pk and keeps a supplied
218
+ // one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already stored under
219
+ // the same key is replaced (the real database would reject the insert with
220
+ // DUPLICATE_KEY — check first and throw NackError.duplicateKey for that).
115
221
  insertRow(tableName, record) {
116
222
  const table = this.tables.get(tableName);
117
223
  const config = this.tableConfig.get(tableName);
@@ -119,49 +225,85 @@ export class Store {
119
225
  throw new Error(`Unknown table: ${tableName}`);
120
226
  const row = { ...record };
121
227
  if (config.pkFields.length === 1 &&
122
- row[config.pkFields[0]] === undefined &&
228
+ (row[config.pkFields[0]] === undefined || row[config.pkFields[0]] === null) &&
123
229
  config.sequencePrefix) {
124
- row[config.pkFields[0]] = this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
230
+ row[config.pkFields[0]] = this.generateSequenceValue(config);
125
231
  }
126
232
  const missing = config.pkFields.filter((field) => row[field] === undefined);
127
233
  if (missing.length > 0) {
128
234
  throw new Error(`Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`);
129
235
  }
130
236
  this.observeSequenceValue(config, row);
131
- table.set(keyFor(config.pkFields, row), row);
237
+ this.stampNewRow(row);
238
+ const key = keyFor(config.pkFields, row);
239
+ const displaced = table.get(key);
240
+ if (displaced)
241
+ this.noteRemoved(tableName, key, displaced);
242
+ table.set(key, row);
132
243
  return row;
133
244
  }
245
+ // Merges the patch, moves TIMESTAMP on and keeps RECORD_ID (a patch can't
246
+ // change a row's identity).
134
247
  updateRow(tableName, pk, patch) {
135
248
  const table = this.tables.get(tableName);
136
249
  const config = this.tableConfig.get(tableName);
137
250
  if (!table || !config || !table.has(pk))
138
251
  return undefined;
139
- const updated = { ...table.get(pk), ...patch };
252
+ const existing = table.get(pk);
253
+ const updated = { ...existing, ...patch };
254
+ updated[RECORD_ID] = existing[RECORD_ID];
255
+ updated[TIMESTAMP] = this.clock.next();
140
256
  // A patch that changes a pk field (e.g. an entity-management form posting
141
257
  // its full DETAILS after the user edited the key column) must move the row
142
258
  // to its new key. Leaving it under the old one makes the index disagree
143
259
  // with the row itself: getRow(newKey) misses, while rowKey()/getRowRef()
144
260
  // report the new key, so the matching broadcast finds nothing to push.
145
261
  const nextKey = keyFor(config.pkFields, updated);
146
- if (nextKey !== pk)
262
+ if (nextKey !== pk) {
147
263
  table.delete(pk);
264
+ const displaced = table.get(nextKey);
265
+ if (displaced)
266
+ this.noteRemoved(tableName, nextKey, displaced);
267
+ }
148
268
  table.set(nextKey, updated);
149
269
  return updated;
150
270
  }
151
271
  deleteRow(tableName, pk) {
152
272
  const table = this.tables.get(tableName);
153
- if (!table)
273
+ const existing = table?.get(pk);
274
+ if (!table || !existing)
154
275
  return false;
155
- return table.delete(pk);
276
+ table.delete(pk);
277
+ this.noteRemoved(tableName, pk, existing);
278
+ return true;
279
+ }
280
+ // Reads and clears every record a key lost that no broadcast announced
281
+ // yet, oldest first: what a DELETE broadcast names (their RECORD_IDs are
282
+ // the ROW_REFs a client may hold — held-rows tracking drops the ones it
283
+ // doesn't), including the old record when a key's row was replaced before
284
+ // one broadcast.
285
+ takeRemovedRows(tableName, key) {
286
+ const removed = this.removedRows.get(tableName);
287
+ const records = removed?.get(key) ?? [];
288
+ removed?.delete(key);
289
+ return records;
290
+ }
291
+ // The same, for a row of a table or view given by its (base) pk fields —
292
+ // what a public broadcast(resource, 'DELETE', pkStub) names.
293
+ takeRemovedRecordsFor(resourceName, row) {
294
+ const located = this.locateRecord(resourceName, row);
295
+ return located ? this.takeRemovedRows(located.tableName, located.key) : [];
156
296
  }
157
297
  // Materializes a view: base table left-joined to related tables (optionally
158
298
  // narrowed by per-join `where` predicates), minus anti-joined rows, plus
159
- // derived fields.
160
- getViewRows(viewName) {
299
+ // derived fields. `tables` substitutes the rows of any table the view reads
300
+ // (its base or a joined one) — e.g. one base row, to build that row's view
301
+ // row, or a table's earlier state, to rebuild the view as it was.
302
+ getViewRows(viewName, tables = {}) {
161
303
  const view = this.views.get(viewName);
162
304
  if (!view)
163
305
  throw new Error(`Unknown view: ${viewName}`);
164
- return this.materializeView(view, (tableName) => this.getAllRows(tableName));
306
+ return this.materializeView(view, (tableName) => Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName));
165
307
  }
166
308
  materializeView(view, getRows) {
167
309
  let rows = getRows(view.base).map((row) => ({ ...row }));
@@ -214,14 +356,17 @@ export class Store {
214
356
  // deleting a table's last row would make the next metadata reply say the
215
357
  // resource has zero fields — which entity-management renders as a form with
216
358
  // no inputs and a delete button that can't build a payload, silently.
359
+ // RECORD_ID and TIMESTAMP are left out: they are not fields a dataserver
360
+ // row or an event's DETAILS carry.
217
361
  getMetadataRows(resourceName) {
218
362
  const live = this.getResourceRows(resourceName);
219
363
  if (live.length > 0)
220
- return live;
364
+ return live.map(withoutRecordFields);
221
365
  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) ?? [];
366
+ const seed = view
367
+ ? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
368
+ : (this.seedRows.get(resourceName) ?? []);
369
+ return seed.map(withoutRecordFields);
225
370
  }
226
371
  // Row-identity fields for a table or view; empty when unknown.
227
372
  getPkFields(resourceName) {
@@ -233,17 +378,46 @@ export class Store {
233
378
  }
234
379
  return this.tableConfig.get(resourceName)?.pkFields ?? [];
235
380
  }
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.
381
+ // The pk-based row reference: the pk field's value, or the composite key.
382
+ // This was DETAILS.ROW_REF up to 15.47 and still is under
383
+ // fidelity: 'legacy'; by default ROW_REF is getRecordRef's.
239
384
  getRowRef(resourceName, row) {
240
385
  const pkFields = this.getPkFields(resourceName);
241
386
  if (pkFields.length === 0)
242
387
  return undefined;
243
388
  return keyFor(pkFields, row);
244
389
  }
390
+ // The DETAILS.ROW_REF GSF stamps on dataserver rows: the row's RECORD_ID as
391
+ // a string (GenesisSetMaskingJsonSerializer: value.toString()). A view row
392
+ // carries its base row's RECORD_ID. A row passed without one (a hand-built
393
+ // row, or a pk-only stub) is matched to the stored record by its pk — a
394
+ // just-deleted record included — and only when that fails too does this
395
+ // fall back to the pk-based reference.
396
+ getRecordRef(resourceName, row) {
397
+ const id = recordKey(row) ?? recordKey(this.findStoredRecord(resourceName, row));
398
+ return id ?? this.getRowRef(resourceName, row);
399
+ }
400
+ locateRecord(resourceName, row) {
401
+ const view = this.views.get(resourceName);
402
+ const tableName = view ? view.base : resourceName;
403
+ const pkFields = this.tableConfig.get(tableName)?.pkFields ?? [];
404
+ if (pkFields.length === 0 || pkFields.some((field) => row[field] === undefined)) {
405
+ return undefined;
406
+ }
407
+ return { tableName, key: keyFor(pkFields, row) };
408
+ }
409
+ // The stored record a pk-only row names: the live one, else the newest
410
+ // record removed from that key and not yet broadcast.
411
+ findStoredRecord(resourceName, row) {
412
+ const located = this.locateRecord(resourceName, row);
413
+ if (!located)
414
+ return undefined;
415
+ const { tableName, key } = located;
416
+ return (this.tables.get(tableName)?.get(key) ?? this.removedRows.get(tableName)?.get(key)?.at(-1));
417
+ }
245
418
  // Rebuilds a DELETE stub row carrying the pk field(s) from a stored key, so
246
419
  // subscribers can be told which row to drop after it's already gone.
420
+ // (The DELETE body under fidelity: 'legacy'; GSF sends DETAILS only.)
247
421
  rowStubFromKey(resourceName, key) {
248
422
  const pkFields = this.getPkFields(resourceName);
249
423
  if (pkFields.length <= 1)
@@ -1,11 +1,12 @@
1
- import { NackError } from "../protocol/errors.js";
1
+ import { 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
5
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
6
6
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
7
7
  // config.eventHandlers. A handler receives (details, ctx) and either returns
8
- // { generated?: [...] } on success, or throws NackError(errors) on failure.
8
+ // { generated?: [...] } on success, { warnings: [...] } to NACK with warnings
9
+ // only, or throws NackError(errors) on failure.
9
10
  export function handleCommitEvent(message, connection, config, store, ctx) {
10
11
  const eventName = message.MESSAGE_TYPE;
11
12
  // defaultEventHandler catches anything unregistered — lets projects with
@@ -33,17 +34,30 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
33
34
  : resourceNotMappedNack(eventName));
34
35
  return;
35
36
  }
37
+ // A handler's failure is an EVENT_NACK with GSF's error items (see
38
+ // protocol/errors.ts): a thrown NackError gives its own, anything else is
39
+ // StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
40
+ // ERROR — the IGNORE_WARNINGS/VALIDATE flow that would let them through is
41
+ // not modelled yet.
42
+ const legacy = isLegacyFidelity(config);
36
43
  try {
37
44
  const result = handler(message.DETAILS ?? {}, { connection, store, message, ...ctx }) ?? {};
45
+ if (result.warnings?.length) {
46
+ connection.reply(message, {
47
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
48
+ ...warningPayload(result.warnings, legacy),
49
+ });
50
+ return;
51
+ }
38
52
  connection.reply(message, {
39
53
  MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
40
54
  GENERATED: result.generated ?? [],
41
55
  });
42
56
  }
43
57
  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 });
58
+ connection.reply(message, {
59
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
60
+ ...nackPayload(error, legacy),
61
+ });
48
62
  }
49
63
  }
@@ -1,9 +1,11 @@
1
- import { compileCriteria, filterByCriteria } from "../db/criteria.js";
1
+ import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
2
+ import { RECORD_ID } from "../db/identity.js";
3
+ import { queryFieldNames, querySchema } from "../db/schema.js";
2
4
  import { isLegacyFidelity } from "../protocol/fidelity.js";
3
5
  import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
4
6
  import { nonExistentSubscriptionNack, resourceNotMappedNack, subscriptionAlreadyExistsNack, } from "../protocol/msgNack.js";
5
- import { stampRow } from "../protocol/rowUpdate.js";
6
- import { applyResourceAuth, requestingUser } from "./resourceAuth.js";
7
+ import { dataserverRow, rowRefFor, stampRow } from "../protocol/rowUpdate.js";
8
+ import { permittedQueryRow, requestingUser } from "./resourceAuth.js";
7
9
  // The real dataserver's defaults (genesis-pal-dataserver ClientOptions,
8
10
  // GSF 8.15.x) — also what foundation-comms' DatasourceDefaults send.
9
11
  const DEFAULT_MAX_ROWS = 250;
@@ -57,6 +59,55 @@ function takePage(subscription) {
57
59
  }
58
60
  return page;
59
61
  }
62
+ // The dataserver's own refusal of a logon (genesis-pal-dataserver): a
63
+ // LOGON_NACK with a StandardError, sent instead of LOGON_ACK. No subscription
64
+ // is opened, so a later DATA_LOGOFF on it gets the router's 404.
65
+ function logonNack(code, text) {
66
+ return {
67
+ MESSAGE_TYPE: MESSAGE_TYPE.LOGON_NACK,
68
+ ERROR: [{ '@type': 'StandardError', CODE: code, TEXT: text, STATUS_CODE: '400 Bad Request' }],
69
+ WARNING: [],
70
+ };
71
+ }
72
+ // DETAILS.FIELDS: a space-separated projection of the query's columns, in the
73
+ // query's order. Names it doesn't expose are dropped, as the real dataserver
74
+ // drops them.
75
+ function requestedFieldNames(details, query, exposed, config, store) {
76
+ const requested = typeof details.FIELDS === 'string' && details.FIELDS.trim() !== ''
77
+ ? details.FIELDS.trim().split(/\s+/)
78
+ : undefined;
79
+ if (!requested)
80
+ return exposed;
81
+ const available = exposed ?? querySchema(query, config, store).fields.map((field) => field.name);
82
+ return available.filter((name) => requested.includes(name));
83
+ }
84
+ // Ascending by the index's fields, nulls first. Ties are broken by RECORD_ID,
85
+ // as the real index breaks them, so REVERSE, which flips the whole order,
86
+ // puts the newest of equal rows first.
87
+ function indexComparator(index) {
88
+ const compareValues = (a, b) => {
89
+ if (a === b)
90
+ return 0;
91
+ if (a === undefined || a === null)
92
+ return -1;
93
+ if (b === undefined || b === null)
94
+ return 1;
95
+ if (typeof a === 'number' && typeof b === 'number')
96
+ return a - b;
97
+ if (typeof a === 'boolean' && typeof b === 'boolean')
98
+ return a ? 1 : -1;
99
+ const [left, right] = [String(a), String(b)];
100
+ return left < right ? -1 : left > right ? 1 : 0;
101
+ };
102
+ return (a, b) => {
103
+ for (const field of index.fields) {
104
+ const result = compareValues(a[field], b[field]);
105
+ if (result !== 0)
106
+ return result;
107
+ }
108
+ return compareValues(a[RECORD_ID], b[RECORD_ID]);
109
+ };
110
+ }
60
111
  // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
61
112
  // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
62
113
  // registered so MORE_ROWS can page the rest and later inserts/amends/deletes
@@ -87,6 +138,32 @@ export function handleDataLogon(message, connection, config, store) {
87
138
  }
88
139
  const criteria = details.CRITERIA_MATCH;
89
140
  const user = requestingUser(connection);
141
+ // The query's fields block (or the source's declared columns) is all a
142
+ // criteria may name and all a row carries; DETAILS.FIELDS narrows the rows.
143
+ // Legacy subscriptions ignore all of it, and ORDER_BY, as 15.47 did.
144
+ const exposed = queryFieldNames(query, config, store);
145
+ let compare;
146
+ let reverse = false;
147
+ if (!legacy) {
148
+ // The real dataserver lets a criteria read only the query's own fields
149
+ // (QueryClientFactory.kt). Unknown columns are only checked when the
150
+ // query's fields are known; an inferred query fails open as before.
151
+ const outside = exposed && criteriaFields(criteria)?.find((field) => !exposed.includes(field));
152
+ if (outside) {
153
+ connection.reply(message, logonNack('INVALID_CRITERIA', `GenericCriteriaValidationException : Criteria validation failed: Variable ${outside} is not allowed in criteria expression`));
154
+ return;
155
+ }
156
+ // ORDER_BY names one of the query's own indexes (QueryDef.indexes).
157
+ if (details.ORDER_BY) {
158
+ const index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
159
+ if (!index) {
160
+ connection.reply(message, logonNack('INVALID_INDEX', `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`));
161
+ return;
162
+ }
163
+ compare = indexComparator(index);
164
+ reverse = details.REVERSE === true;
165
+ }
166
+ }
90
167
  // Order matters: the query's own filter (the where-clause equivalent) and
91
168
  // the user's row permissioning narrow the set BEFORE the client's
92
169
  // CRITERIA_MATCH; hidden fields are stripped last, off the final rows.
@@ -101,20 +178,38 @@ export function handleDataLogon(message, connection, config, store) {
101
178
  // the count is taken after it — an assumption, as no recording covers it.
102
179
  const rowsCount = rows.length;
103
180
  rows = filterByCriteria(rows, criteria);
181
+ if (compare) {
182
+ rows = [...rows].sort(compare);
183
+ if (reverse)
184
+ rows.reverse();
185
+ }
104
186
  // Permissioning is applied per row so the ROW_REF can still be computed from
105
187
  // the UNSTRIPPED row — pk fields may themselves be hidden from this user,
106
188
  // and a snapshot row with no ROW_REF can never be matched to the later
107
189
  // INSERT/MODIFY/DELETE pushes (which stamp the real ref, see server.ts).
190
+ // RECORD_ID (or, in legacy mode, the pk fields) is never sent unless the
191
+ // query's fields block names it.
192
+ const getRowRef = (row) => rowRefFor(config, store, query.source, row);
193
+ const fieldNames = legacy
194
+ ? undefined
195
+ : requestedFieldNames(details, query, exposed, config, store);
108
196
  const stampedRows = [];
197
+ // Each sent-or-waiting row as stored, by pending key, for ORDER_BY's live
198
+ // INSERT placement below.
199
+ const rawRows = new Map();
109
200
  for (const row of rows) {
110
- const [permitted] = applyResourceAuth([row], query.auth, user);
201
+ const permitted = permittedQueryRow(row, query.auth, user, fieldNames);
111
202
  if (!permitted)
112
203
  continue;
113
- stampedRows.push(stampRow(permitted, store.getRowRef(query.source, row), 'INSERT'));
204
+ const rowRef = getRowRef(row);
205
+ const key = pendingKey(rowRef);
206
+ if (key !== undefined)
207
+ rawRows.set(key, row);
208
+ stampedRows.push(stampRow(dataserverRow(permitted, row, query.fields), rowRef, 'INSERT', legacy));
114
209
  }
115
210
  const subscription = {
116
211
  resourceName,
117
- getRowRef: (row) => store.getRowRef(query.source, row),
212
+ getRowRef,
118
213
  predicate: compileCriteria(criteria),
119
214
  sequenceId: 1,
120
215
  };
@@ -140,7 +235,32 @@ export function handleDataLogon(message, connection, config, store) {
140
235
  held: new Set(),
141
236
  maxRows,
142
237
  maxView,
238
+ fieldNames,
143
239
  };
240
+ // Under ORDER_BY a live INSERT that sorts among the rows still waiting for
241
+ // MORE_ROWS joins them in order rather than being pushed out of order; one
242
+ // that sorts before them lands in what the client has, and is pushed. (A
243
+ // MODIFY of a waiting row keeps its place.)
244
+ if (compare) {
245
+ const ascending = compare;
246
+ const sortCompare = reverse ? (a, b) => ascending(b, a) : ascending;
247
+ tracked.queueInsert = (key, stamped, row) => {
248
+ const waiting = [...tracked.pending];
249
+ const first = waiting[0] && rawRows.get(waiting[0][0]);
250
+ if (!first || sortCompare(row, first) < 0)
251
+ return false;
252
+ const at = waiting.findIndex(([waitingKey]) => {
253
+ const other = rawRows.get(waitingKey);
254
+ return other !== undefined && sortCompare(row, other) < 0;
255
+ });
256
+ waiting.splice(at < 0 ? waiting.length : at, 0, [key, stamped]);
257
+ tracked.pending.clear();
258
+ for (const [waitingKey, waitingRow] of waiting)
259
+ tracked.pending.set(waitingKey, waitingRow);
260
+ rawRows.set(key, row);
261
+ return true;
262
+ };
263
+ }
144
264
  const firstPage = takePage(tracked);
145
265
  const pending = tracked.pending;
146
266
  connection.subscribe(sourceRef, tracked);
@@ -1,5 +1,6 @@
1
+ import { inferEventTable } from '../db/schema.ts';
1
2
  import type { Store } from '../db/store.ts';
2
3
  import type { Connection } from '../protocol/connection.ts';
3
4
  import type { GenesisMessage, MockServerConfig } from '../types.ts';
4
- export declare function inferEventTable(eventName: string | undefined, tableNames: string[]): string | undefined;
5
+ export { inferEventTable };
5
6
  export declare function handleJsonSchemaRequest(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;