@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/README.md CHANGED
@@ -42,7 +42,9 @@ import { createMockServer, NackError, type MockServerConfig } from '@genesislcap
42
42
 
43
43
  const config: MockServerConfig = {
44
44
  tables: {
45
- TRADE: { pkField: 'TRADE_ID', sequencePrefix: 'TR', rows: [...] },
45
+ // events: generic EVENT_TRADE_MODIFY / _DELETE handlers
46
+ // (see "Generic CRUD events"); EVENT_TRADE_INSERT is written out below.
47
+ TRADE: { pkField: 'TRADE_ID', sequencePrefix: 'TR', rows: [...], events: ['MODIFY', 'DELETE'] },
46
48
  },
47
49
  views: {
48
50
  TRADE_VIEW: {
@@ -88,8 +90,10 @@ covers the server-side behaviors real projects lean on:
88
90
  of a `source`. It receives `{ request, message, user, store }` (the keyed
89
91
  `REQUEST` fields, the raw message, the requesting user, the live store) and
90
92
  returns the `REPLY` rows verbatim; throw `NackError` for a
91
- protocol-correct error. Set `metadataRows` (a representative sample reply)
92
- so `META_REQUEST` can still derive `REPLY_FIELD`:
93
+ protocol-correct error (a `MSG_NACK`; see
94
+ [Request servers](#request-servers-req_)). Set `metadataRows` (a
95
+ representative sample reply) so `META_REQUEST` can still derive
96
+ `REPLY_FIELD`:
93
97
 
94
98
  ```ts
95
99
  requestReplies: {
@@ -152,12 +156,187 @@ covers the server-side behaviors real projects lean on:
152
156
  ```
153
157
 
154
158
  - **Composite primary keys** — `pkField` accepts an array
155
- (`pkField: ['RFQ_ID', 'SIDE']`). Rows are identified by the fields' values
156
- joined with `'|'` (so values must not contain `'|'`), which becomes the
157
- `ROW_REF` — no synthetic key column, nothing extra in grids. Event handlers
158
- on composite tables pass `store.rowKey(table, row)` to
159
- `broadcastTableChange`; sequence generation still requires a single
160
- `pkField`.
159
+ (`pkField: ['RFQ_ID', 'SIDE']`). Rows are stored under the fields' values
160
+ joined with `'|'` (so values must not contain `'|'`) — no synthetic key
161
+ column, nothing extra in grids. Event handlers on composite tables pass
162
+ `store.rowKey(table, row)` to `broadcastTableChange`; sequence generation
163
+ still requires a single `pkField`.
164
+
165
+ ## Record identity, ROW_REF and sequences
166
+
167
+ Every stored row carries `RECORD_ID` and `TIMESTAMP`, as every Genesis table
168
+ row does (genesis-db `InsertOperation.kt`):
169
+
170
+ - Seeding and `insertRow` stamp `RECORD_ID === TIMESTAMP` from one counter per
171
+ server that only goes up (`src/db/identity.ts`). A seed row's own
172
+ `RECORD_ID` is kept when it is a safe integer or a string not used by an
173
+ earlier seed row; an unsafe one (above 2^53, already rounded by
174
+ `JSON.parse`) or a repeat is re-stamped, with one warning per table, and a
175
+ missing `TIMESTAMP` becomes the `RECORD_ID`. An insert always gets fresh ones. `updateRow` moves `TIMESTAMP` on and
176
+ keeps `RECORD_ID`, even if the patch names either field.
177
+ - **Deviation:** GSF's values are about 7.5e18 (epoch millis shifted left 22
178
+ bits, plus a counter). That is above 2^53, so `JSON.parse` rounds
179
+ neighbouring rows onto one number. The mock's stamps are epoch millis, or the
180
+ last stamp plus one: exact, strictly increasing safe integers, in the same
181
+ order, that still read as a write time.
182
+ - **Dataserver rows** never carry `RECORD_ID`/`TIMESTAMP` (the real dataserver
183
+ identifies rows by `DETAILS.ROW_REF` only), unless the query's `fields` block
184
+ names them. Field metadata (`META_REQUEST`, `JSON_SCHEMA_REQUEST`) leaves them out.
185
+ - **`REP_` rows** from a `source` include them, last, unless the request
186
+ server's reply block (`RequestReplyDef.fields`) leaves them out — as GSF
187
+ sends a table entity, criteria-only or not. Auth's `RIGHT` sends none only
188
+ because its `reply { CODE DESCRIPTION }` block names neither (see
189
+ [Request servers](#request-servers-req_)). A `resolver`'s rows are sent as
190
+ returned.
191
+ - **`DETAILS.ROW_REF`** is the record's `RECORD_ID` as a string
192
+ (`GenesisSetMaskingJsonSerializer`: `value.toString()`); a view row's is its
193
+ base record's. Rows no longer carry a top-level `ROW_REF` — clients copy it
194
+ from `DETAILS` themselves (`row.DETAILS[rowId] ?? row[rowId]`). Lookups stay
195
+ by pk: handlers still pass the pk (or `store.rowKey`) to
196
+ `broadcastTableChange`, and a pk-only row passed to `broadcast()` is matched
197
+ to its record, a just-deleted one included. A view row is rebuilt from the
198
+ changed base row, so a view's own `pkField` doesn't matter, and a MODIFY that
199
+ takes the row out of the view (an anti-join) is pushed as a DELETE.
200
+ - **Live DELETE** is `ROW: [{DETAILS: {OPERATION: 'DELETE', ROW_REF}}]` and
201
+ nothing else. The store remembers every record a key lost until a broadcast
202
+ announces it (`broadcastTableChange`, or `broadcast()` with a DELETE), so a
203
+ DELETE names each of them — a client gets the ones it holds — and a record
204
+ replaced under the same key before one broadcast goes out as a DELETE of the
205
+ old `ROW_REF` followed by the new INSERT. Deletes nobody broadcasts are kept
206
+ for the next one (bounded: 16 per key, 10,000 keys per table).
207
+ - **Sequences:** `TableDef.sequenceFormat` is `'prefix'` by default
208
+ (`sequencePrefix` + a counter padded to `sequenceWidth`: `TR001`), so
209
+ existing seeds, tests and snapshots keep their ids. `'genesis'` mints
210
+ `IdStrategy.java`'s format — 15-digit counter, sequence id, `LO`, `1`:
211
+ `000000000000001TRLO1`. Seeds in the table's format advance the counter. A
212
+ supplied value is kept; only a missing (`undefined`/`null`) one is generated.
213
+ - **Generated fields:** `insertRow` also fills the `TableDef.generated` fields
214
+ a record leaves out: a `SEQUENCE` in the same format as the key's (its own
215
+ `prefix`, else the table's `sequencePrefix`; with neither it stays shape
216
+ only), an `AUTO_INCREMENT` from the highest seeded value plus one (1 with no
217
+ seeds). Supplied values are kept and move the counters on.
218
+
219
+ `fidelity: 'legacy'` restores the pk-based `ROW_REF` (the pk value, or the
220
+ composite key), the top-level `ROW_REF`, pk-stub DELETE rows and `REP_` rows
221
+ without `RECORD_ID`/`TIMESTAMP`.
222
+
223
+ ## NACK vocabulary
224
+
225
+ Service NACKs (`EVENT_NACK`, and the `MSG_NACK` a request server fails with) follow GSF's `GenesisError` family
226
+ (`src/protocol/errors.ts`, pinned against recorded frames in
227
+ `test/nack.test.ts`): every `ERROR` item has `@type`, a string `CODE`, `TEXT`
228
+ and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:
229
+
230
+ | `@type` | Extra fields | Built by |
231
+ |---|---|---|
232
+ | `StandardError` | — | `new NackError('text')` (`REQUEST_FAILED`, 400: a failed GPAL `require()`), `NackError.nack(text)` (`INTERNAL_ERROR`, 500: GPAL `nack("text")`, which GSF wraps in an Exception), `NackError.standard(code, text?)` |
233
+ | `FieldError` | `FIELD`, `PATH` (`null` for a handler's own check) | `NackError.field(field, text, path?)`, or any input with `FIELD` (`VALIDATION_ERROR`) |
234
+ | `TableStandardError` | `TABLE`, `FIELD` | `NackError.duplicateKey(table, index, value)` (`DUPLICATE_KEY`, 500, `FIELD: '<INDEX> -> <value>'`), `NackError.recordNotFound(table, index?)` (404; the index for a modify, `''` for a delete) |
235
+ | `LoginError` | `DETAILS: null` | `{'@type': 'LoginError', ...}` |
236
+
237
+ `ErrorCode` mirrors GSF's `ErrorCode.kt` (names, default texts, statuses;
238
+ `statusCodeFor(code)`); unknown codes get `500 Internal Server Error`.
239
+ `new NackError([...])` reports several errors at once. Anything else a handler
240
+ throws is `StandardError INTERNAL_ERROR` with its message. A handler can
241
+ return `{ warnings: [...] }` for a NACK with an empty `ERROR` and those
242
+ warnings, or pass `{ warnings }` to `NackError`; `IGNORE_WARNINGS` is not
243
+ modelled yet. `VALIDATE: true` reaches a handler as `ctx.validate` (see
244
+ [Generic CRUD events](#generic-crud-events-tabledefevents)). A request server
245
+ that fails answers a service `MSG_NACK` carrying the same items:
246
+ `{WARNING, ERROR, MESSAGE_TYPE: 'MSG_NACK'}` (genesis-messages `MsgNack`). The
247
+ router's `MSG_NACK` keeps its own shape (no `@type`,
248
+ an integer `CODE`, `DETAILS {ERROR, STATUS_CODE}`). `fidelity: 'legacy'` sends
249
+ the old `{CODE, TEXT, FIELD?}` items (`VALIDATION_ERROR` by default) and no
250
+ `WARNING`: warnings go in `ERROR` there.
251
+
252
+ ## Generic CRUD events (`TableDef.events`)
253
+
254
+ A table that only needs plain insert, modify and delete gets them without a
255
+ hand-written handler:
256
+
257
+ ```ts
258
+ tables: {
259
+ COUNTERPARTY: {
260
+ pkField: 'COUNTERPARTY_ID',
261
+ sequencePrefix: 'CP',
262
+ fields: [/* FieldDef */],
263
+ indexes: [{ name: 'COUNTERPARTY_BY_LEI', fields: ['LEI'], unique: true }],
264
+ events: ['INSERT', 'MODIFY', 'DELETE'],
265
+ },
266
+ }
267
+ ```
268
+
269
+ Each verb registers `EVENT_<TABLE>_<VERB>` as the handler Genesis Create
270
+ generates for a table's `crud` event handlers (codegen-service
271
+ `JsonToKtsEventHandlersGenerator`): `eventHandler<Table>("<TABLE>_INSERT")`
272
+ and `_MODIFY` calling `entityDb.insert` / `modify`, and
273
+ `eventHandler<Table.ById>("<TABLE>_DELETE")` calling `entityDb.delete`, with no
274
+ `onValidate`. Those are what the showcase server runs — the recordings come
275
+ from them — and what Create's real tier deploys. The events are listed in
276
+ `RESOURCES_REQUEST` and described by `META_REQUEST` and `JSON_SCHEMA_REQUEST`
277
+ like any registered event (the table's DAO; a delete's `<Table>.ById`). Verbs a
278
+ table doesn't list are the router's 404.
279
+
280
+ | Event | Does | Replies |
281
+ |---|---|---|
282
+ | `INSERT` | Inserts DETAILS as the table's record: fields left out take their declared `default`, else `null`; sequence and auto-increment fields left out are generated | `EVENT_ACK` with `GENERATED: [{…}]`: every generated field, sorted by name, with its stored value — supplied or generated (`ack(listOf(mapOf("TRADE_ID" to insertedRow.record.tradeId)))`) — or `[]` for a table with no generated fields. A taken primary key or other unique index: `DUPLICATE_KEY` |
283
+ | `MODIFY` | Finds the record by primary key and writes DETAILS over it. DETAILS are the **whole record**: a field left out is reset to its default or `null`, as the deserialized DAO overwrites the stored row (recorded: a TRADE modify without `CREATED_AT` nulls it). Generated fields left out keep their values; `RECORD_ID` stays, `TIMESTAMP` moves on | `EVENT_ACK GENERATED: []`. No record: `RECORD_NOT_FOUND`, `FIELD` the key's index. A unique value another record holds: `DUPLICATE_KEY` |
284
+ | `DELETE` | Deletes by primary key | `EVENT_ACK GENERATED: []`. No record: `RECORD_NOT_FOUND`, `FIELD: ''` |
285
+
286
+ Index names follow the dictionary: a unique `indexes` entry over exactly the
287
+ key fields names the primary key, else it is `<TABLE>_BY_<fields>` without the
288
+ `<TABLE>_` prefix (`COUNTERPARTY_ID` → `COUNTERPARTY_BY_ID`).
289
+ `DUPLICATE_KEY`'s `FIELD` is `'<INDEX> -> <values>'`, values joined with
290
+ `' | '`. Every change is pushed live (`broadcastTableChange`), to queries on
291
+ the table and on views over it.
292
+
293
+ **`VALIDATE: true`** reaches every handler as `ctx.validate`, and nothing is
294
+ written. The generic events have no `onValidate`, so they ack `GENERATED: []`
295
+ straight away (recorded for `COUNTERPARTY_INSERT`): no duplicate check, no
296
+ sequence value used, no lookup for a modify or delete. A hand-written handler
297
+ should run its checks and return before writing when `ctx.validate` is set
298
+ (recorded: `TRADE_INSERT`'s `onValidate` NACK still comes back).
299
+ `IGNORE_WARNINGS` is not modelled.
300
+
301
+ **An `eventHandlers` entry of the same name wins**, so a table can list all
302
+ three verbs and still write out the one with business rules.
303
+ `getEventHandler(name)` returns the generic handler when there is no entry, so
304
+ `extendEventHandler` can wrap it.
305
+
306
+ **Not genesis-eventhandler's typed handlers.** GSF also ships
307
+ `GenericInsertEventHandler`, `GenericModifyEventHandler` and
308
+ `GenericDeleteEventHandler` (`EventHandlerBuilder.registerCUDEvents`), which
309
+ Create doesn't use. They differ in three places: their insert's `GENERATED`
310
+ lists only the values that differ from DETAILS (`[{}]` when all were sent),
311
+ their modify and delete look the record up under `VALIDATE`, and their modify
312
+ makes the key fields mandatory in metadata.
313
+
314
+ ### Inbound schema check
315
+
316
+ Before any handler runs, the real server checks the whole message against the
317
+ event's `INBOUND` JSON schema (`GenesisMessageDecoder`), and so does this
318
+ engine — for the generic events and hand-written ones alike — whenever the
319
+ DETAILS class is declared: a table event on a table with `fields`, or an
320
+ `eventSchemas` entry with `fields`. An inferred schema is a guess from the
321
+ rows and is never checked against. Every failure is one `FieldError
322
+ VALIDATION_ERROR` in a single `EVENT_NACK`, worded as GSF's validator (networknt
323
+ 2.0.1) words it:
324
+
325
+ | Failure | `TEXT` | `FIELD` | `PATH` |
326
+ |---|---|---|---|
327
+ | Missing required field | `$.DETAILS: required property 'NAME' not found` | `NAME` | `$.DETAILS` |
328
+ | Unknown field (`additionalProperties: false`) | `$.DETAILS: property 'X' is not defined in the schema and the schema does not allow additional properties` | `DETAILS` | `$.DETAILS` |
329
+ | Wrong JSON type | `$.DETAILS.ENABLED: string found, boolean expected` | `ENABLED` | `$.DETAILS.ENABLED` |
330
+ | ENUM value not allowed | `$.DETAILS.TIER: does not have a value in the enumeration ["STANDARD", "PREMIUM", "TRIAL"]` | `TIER` | `$.DETAILS.TIER` |
331
+ | Nullable field invalid | `…: must be valid to one and only one schema, but 0 are valid`, then each branch's error | the field | its path |
332
+
333
+ Lengths (`maxLength`, in code points), ranges (`minimum`/`maximum`: an INT
334
+ must fit 32 bits) and BigDecimal's pattern are checked too, and so is the
335
+ envelope (`VALIDATE` must be a boolean). The order is the validator's: each
336
+ field's errors in field order, then missing fields, then unknown ones.
337
+ `test/schemaValidation.test.ts` pins all of it to the real validator's output
338
+ on the recorded schemas. A modify therefore needs every required field, as on
339
+ the real server. `fidelity: 'legacy'` checks nothing.
161
340
 
162
341
  ## Running several servers in parallel (and one-command dev stacks)
163
342
 
@@ -252,7 +431,8 @@ having to monkey-patch the engine:
252
431
  unhandled) instead of enumerating ack-only handlers. Unregistered events
253
432
  get the router's `MSG_NACK` 404 when unset (see
254
433
  [Protocol coverage](#protocol-coverage)). Note `RESOURCES_REQUEST` only
255
- lists *named* events (`eventHandlers` + `eventSchemas` keys) — if the
434
+ lists *named* events (`eventHandlers` and `eventSchemas` keys, and the
435
+ tables' `events`) — if the
256
436
  client's resource gate blocks an event served only by the default handler,
257
437
  name it in `config.eventSchemas` or `config.resources`.
258
438
 
@@ -317,11 +497,11 @@ the envelopes this engine sent up to 15.47 — see
317
497
  | (live push) | `QUERY_UPDATE {ROW, SEQUENCE_ID}` | One row per push; `DETAILS.OPERATION` is `INSERT`/`MODIFY`/`DELETE`. No `MORE_ROWS`, no `ROWS_COUNT` |
318
498
  | `MORE_ROWS` | `QUERY_UPDATE {ROW?, MORE_ROWS, SEQUENCE_ID}` on the logon's `SOURCE_REF`, plus `MORE_ROWS_ACK` | See [Dataserver paging](#dataserver-paging) |
319
499
  | `DATA_LOGOFF` | `LOGOFF_ACK` | `MSG_NACK` 404 when the connection has no such subscription |
320
- | `META_REQUEST` | `FEATURE_ACK {DETAILS}` | Auto-derived from seed data |
321
- | `JSON_SCHEMA_REQUEST` | `JSON_SCHEMA_FEATURE_ACK {INBOUND, OUTBOUND}` | Auto-derived per event — see below |
500
+ | `META_REQUEST` | `FEATURE_ACK {DETAILS}` | Dataservers, request servers (by name, without `REQ_`) and events — see [Declared schema](#declared-schema-meta_request-and-json_schema_request) |
501
+ | `JSON_SCHEMA_REQUEST` | `JSON_SCHEMA_FEATURE_ACK {INBOUND, OUTBOUND}` | Events, dataservers and request servers — see [Declared schema](#declared-schema-meta_request-and-json_schema_request) |
322
502
  | `RESOURCES_REQUEST` | `RESOURCES_REQUEST_ACK` | |
323
- | `REQ_<NAME>` | `REP_<NAME> {REPLY}` | Incl. keyed `REQUEST` field lookups (`'*'` = any) and `CRITERIA_MATCH` |
324
- | `EVENT_<ENTITY>_<VERB>` commit events | `EVENT_ACK` / `EVENT_NACK` | Via `config.eventHandlers` or `defaultEventHandler` |
503
+ | `REQ_<NAME>` | `REP_<NAME> {REPLY, PARAMETRIC_TYPE}`, plus `MORE_ROWS` / `NEXT_OFFSET` / `NEXT_VIEW` on criteria-only servers; a failure is a service `MSG_NACK` | Plain servers select by `REQUEST` fields (exact, `*` wildcards, ranges, arrays) and keyed lookups (`'*'` = any); criteria-only ones page by `OFFSET` and sort by `ORDER_BY` — see [Request servers](#request-servers-req_) |
504
+ | `EVENT_<ENTITY>_<VERB>` commit events | `EVENT_ACK` / `EVENT_NACK` | Via `config.eventHandlers`, a table's generic `events`, or `defaultEventHandler`; checked against a declared schema first — see [Generic CRUD events](#generic-crud-events-tabledefevents) |
325
505
  | Anything naming an unknown resource | `MSG_NACK` 404 | See below |
326
506
 
327
507
  Each `SEQUENCE_ID` on a subscription is the previous one plus 1. The snapshot
@@ -377,12 +557,109 @@ createMockServer({ ...config, fidelity: 'legacy' });
377
557
  | `MORE_ROWS` | The next page, plus `MORE_ROWS_ACK` | Unanswered (logged as unhandled) |
378
558
  | `DATA_LOGOFF` | `LOGOFF_ACK`, or `MSG_NACK` 404 | Unanswered |
379
559
  | `META_REQUEST` / `JSON_SCHEMA_REQUEST` success | `FEATURE_ACK` / `JSON_SCHEMA_FEATURE_ACK` | Echoes the request's `MESSAGE_TYPE` |
560
+ | `META_REQUEST` content | The GSF shapes below; event META too | `{NAME, TYPE}` fields, `KEY: []` on dataservers, `REPLY_FIELD` alone on request servers; events are unknown |
561
+ | `JSON_SCHEMA_REQUEST` content | Per resource type, as below | One minimal `{DETAILS: {properties: {F: {type}}, required}}` as both INBOUND and OUTBOUND; a query or request-server name resolves to its source table |
562
+ | Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
563
+ | `DATA_LOGON` `ORDER_BY` / `REVERSE` / `FIELDS`, criteria on unexposed fields | Honoured / `LOGON_NACK` | Ignored |
564
+ | `REQ_` | As in [Request servers](#request-servers-req_): `PARAMETRIC_TYPE`, criteria-only paging and ordering, `REQUEST` wildcards, ranges and arrays, a reply block, `MSG_NACK` failures | Every matching row in insertion order; `REQUEST` from the top level or `DETAILS`, `'*'`/`null`/`''` match anything, an array value any of its values; `CRITERIA_MATCH` fails open; no `RECORD_ID`/`TIMESTAMP`; errors as a `REP_` with `ERROR` |
380
565
  | Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
381
566
 
567
+ `test/legacyParity.test.ts` pins `'legacy'` to replies recorded from the 15.47.0 engine
568
+ itself, for a config with no declarations and for the showcase config.
569
+
382
570
  `'legacy'` is a migration aid, not a second supported protocol. Plan to remove
383
571
  it from your config; it will then be dropped from the engine. It is read on
384
572
  every message, so changing it on a running server takes effect straight away.
385
573
 
574
+ ## Declared schema (`META_REQUEST` and `JSON_SCHEMA_REQUEST`)
575
+
576
+ Metadata comes from the tables dictionary when a table declares one, and is
577
+ inferred from the rows (`src/db/metadata.ts`) when it doesn't. **Declared
578
+ wins.** A config that declares nothing keeps working, but its types are
579
+ guesses (`LONG`/`DOUBLE`/`BOOLEAN`/`STRING`), it has no enum values,
580
+ defaults or not-null, and a table with no rows has no fields.
581
+
582
+ ```ts
583
+ tables: {
584
+ TRADE: {
585
+ pkField: 'TRADE_ID',
586
+ sequencePrefix: 'TR', // the key is a SEQUENCE: NULLABLE false, OPTIONAL true
587
+ fields: [
588
+ { name: 'TRADE_ID', type: 'STRING' },
589
+ { name: 'QUANTITY', type: 'INT', nullable: false },
590
+ { name: 'SIDE', type: 'ENUM', values: ['SELL', 'BUY'], default: 'BUY', nullable: false },
591
+ { name: 'COMMISSION', type: 'BIGDECIMAL' },
592
+ { name: 'NAME', type: 'STRING', maxSize: 100, minLength: 0, title: 'Name' },
593
+ ],
594
+ generated: [{ field: 'TRADE_NO', kind: 'AUTO_INCREMENT' }], // filled in on insert
595
+ indexes: [{ name: 'TRADE_BY_SIDE', fields: ['SIDE'] }], // unique ones: DUPLICATE_KEY
596
+ events: ['INSERT', 'MODIFY', 'DELETE'], // see Generic CRUD events
597
+ },
598
+ },
599
+ views: { TRADE_VIEW: { base: 'TRADE', /* joins, derived */ derivedTypes: { TOTAL: 'DOUBLE' } } },
600
+ queries: { ALL_TRADES: { source: 'TRADE_VIEW', fields: ['TRADE_ID', 'SIDE', 'TOTAL'],
601
+ indexes: [{ name: 'BY_SIDE', fields: ['SIDE'] }] } },
602
+ requestReplies: { TRADE: { source: 'TRADE', criteriaOnly: true } },
603
+ eventSchemas: { EVENT_TRADE_BOOK: { className: 'com.acme.BookInput', fields: [/* FieldDef */] } },
604
+ appName: 'showcase', // enum class names: global.genesis.gen.dao.enums.<appName>.<table>.<Field>
605
+ ```
606
+
607
+ - **`FieldDef`** `{name, type, nullable?, values?, default?, maxSize?, minLength?, description?, title?, optional?}`.
608
+ `type` is a dictionary type (`STRING ENUM INT SHORT LONG DOUBLE BIGDECIMAL BOOLEAN DATE
609
+ DATETIME NANO_TIMESTAMP RAW`). Fields are nullable unless declared otherwise; key and
610
+ generated fields default to not null. `description` replaces the JVM class name the real
611
+ server puts there (leave it unset on dates: foundation-forms spots a date by
612
+ `org.joda.time.DateTime`). `optional` decides whether an event payload may leave the field
613
+ out (see events below); an ENUM with no `values` sends no `VALID_VALUES` and no `enum`.
614
+ - **Views** take their base table's fields, each join's `select` columns typed from the joined
615
+ table (nullable), and `derivedTypes` for derived fields (inferred from rows when missing).
616
+ `ViewDef.fields` is the view's `fields { }` block: the view then exposes only those, in that
617
+ order (keep the key in it).
618
+ - **`QueryDef.fields`** is the query's `fields { }` block: rows **and** metadata carry only those
619
+ columns, in that order. A declared source's rows always carry every exposed column, `null`
620
+ when unset, as the real dataserver sends them. `rowFilter` still sees the whole row.
621
+ - `fieldTypes` (queries, request servers, `eventSchemas`) still overrides a TYPE either way.
622
+
623
+ What is sent (GSF 8.15.29; `test/recordedSchemas.test.ts` pins every shape to recorded frames,
624
+ ignoring only `SOURCE_REF`):
625
+
626
+ | Resource | `META_REQUEST` `DETAILS` | `JSON_SCHEMA_REQUEST` |
627
+ |---|---|---|
628
+ | Dataserver | `{TYPE: 'DATASERVER', NAME, FIELD: [{NAME, TYPE, VALID_VALUES?}], INDEXES: [{NAME, FIELDS: 'A B'}]}`. `VALID_VALUES` on ENUMs only; no indexes → `[{NAME: null, FIELDS: 'RECORD_ID'}]` | INBOUND: the `DATA_LOGON` message. OUTBOUND: the `DataLogonReply` `oneOf`, the fields in `$defs['global.genesis.message.core.dataserver.QueryRow']` (all `oneOf [null, type]`, `genesisType`) |
629
+ | Request server | Plain: `{REQUEST_FIELD, INDICES: [], CRITERIA_ONLY_REQUEST: false, REPLY_FIELD}`; `REQUEST_FIELD` is `requestFields`, else the key. `criteriaOnly`: `{REQUEST_FIELD: [], CRITERIA_ONLY_REQUEST: true, SORTABLE_FIELDS, CRITERIA_FIELDS, REPLY_FIELD}`, the sortable and criteria fields being the source's fields then `RECORD_ID` and `TIMESTAMP`. `REPLY_FIELD` is the reply block (`fields`), else the source's fields then `RECORD_ID: LONG` and `TIMESTAMP: NANO_TIMESTAMP`, for either kind | INBOUND: top-level `REQUEST` (object or array of the request fields) required, `DETAILS {VIEW_NUMBER, MAX_ROWS, CRITERIA_MATCH}`. OUTBOUND: `properties.REPLY.items` (as `REPLY_FIELD`; nullable fields as `oneOf [null, type]`) |
630
+ | Event | `{NAME, TITLE?, DESCRIPTION, FIELD, DEFINITIONS: {}, REPLY, REQUIRES_APPROVAL: false, SCHEDULABLE: false, TYPE: 'EVENT_HANDLER'}`. FIELD items: `NAME, TITLE?, DESCRIPTION (JVM class), NULLABLE, TYPE, JSON_TYPE, OPTIONAL, READ_ONLY, GENESIS_TYPE` plus `MIN/MAX`, `MIN_LENGTH/MAX_LENGTH`, `VALID_VALUES`, `DEFAULT_VALUE`, `ENUM_CLASS`, `DEFAULT` where they apply. `REPLY` describes EventAck/EventApprovableAck/EventNack, copied from the recording | INBOUND: the event envelope, `DETAILS` with `additionalProperties: false` and `required` = the fields that aren't `OPTIONAL`. Nullable fields are `oneOf [schema, null]`; dates are `integer`; BigDecimal is a `string` with a pattern and no `genesisType`. OUTBOUND: the `EventReply` `oneOf` |
631
+
632
+ Events resolve as `JSON_SCHEMA_REQUEST` always did: `config.eventSchemas`, then
633
+ `EVENT_<TABLE>_<VERB>` (longest table name wins) — but only for a registered event (an
634
+ `eventHandlers` or `eventSchemas` entry, or a table's `events`); any other `EVENT_*` name gets the router's 404, as on
635
+ the real server. `OPTIONAL` is true only when the class property has a default
636
+ (PALMetadataUtils.kt): a table's generated DAO defaults every nullable and generated column, so
637
+ those are optional; a custom DTO field is optional only with a `default` or `optional: true`
638
+ (a nullable DTO field with no default must still be sent). An optional ENUM with no default
639
+ reports its first value as `DEFAULT`. Three classes, as the real server reports them:
640
+
641
+ - **Table events** (a generated DAO): every field sorted by Kotlin property name (`DATE_FIELD`
642
+ before `DATETIME_FIELD`), with titles, lengths, ranges and `GENESIS_TYPE`. `TYPE` is the JVM
643
+ one: a DATE column is `TYPE: 'DATETIME', GENESIS_TYPE: 'DATE'`.
644
+ - **Deletes** (`.../_DELETE`, or `pkOnly`): `<Table>.ById`, the key fields only, all mandatory,
645
+ no annotations (`GENESIS_TYPE: null`).
646
+ - **Custom DTOs** (`eventSchemas[name].fields`): the declared fields, no annotations,
647
+ `className` as DESCRIPTION.
648
+
649
+ The AI assistant (`ai-assistant/src/genesis/event-fields.ts`) builds its write tools from event
650
+ META: `NAME`, `GENESIS_TYPE`/`TYPE`, `JSON_TYPE`, `OPTIONAL`, `READ_ONLY`, `TITLE`,
651
+ `VALID_VALUES`.
652
+
653
+ **Behaviour changes for existing configs** (none under `fidelity: 'legacy'`):
654
+ `JSON_SCHEMA_REQUEST` by a query or request-server name now returns that resource's own
655
+ schema (the `DATA_LOGON` message, or `REQUEST` / `REPLY`), as GSF does, not its source table's
656
+ event-shaped one. Event names sort their fields, and `EVENT_*` names with no handler are a 404.
657
+ A declared source's rows carry exactly its exposed columns, `null` when unset.
658
+
659
+ Deviations: a bare table name with no request server of that name still gets the table's
660
+ insert-shaped event schema from `JSON_SCHEMA_REQUEST` (this engine's fallback; the real server
661
+ answers 404). Dataserver rows still carry neither RECORD_ID nor TIMESTAMP.
662
+
386
663
  ## Dataserver paging
387
664
 
388
665
  `DATA_LOGON` pages the way the real dataserver does (genesis-pal-dataserver's
@@ -451,15 +728,89 @@ real server would also evict the oldest row in the same frame (the moving
451
728
  view, a later item), so here the view can grow past `MAX_VIEW`; the next
452
729
  `MORE_ROWS` then delivers nothing until DELETEs bring it back under the cap.
453
730
 
454
- Rows with no `ROW_REF`, or one another row already has (a view `pkField` that
455
- isn't unique), are still delivered by the snapshot and `MORE_ROWS`. Live
456
- changes can only be matched by `ROW_REF`, as on the client: a change to a row
457
- without one is never pushed, and rows sharing one are a single row to live
458
- changes.
731
+ Every row's `ROW_REF` is its own record's `RECORD_ID` (a view row's, its base
732
+ record's), so live changes are matched to exactly the row the client holds,
733
+ whatever a view's `pkField` says.
459
734
 
460
735
  `VIEW_NUMBER` is accepted and ignored. It selects the real server's paginated
461
736
  mode, where each page replaces the last; here paging is always sequential.
462
737
 
738
+ ## Request servers (`REQ_`)
739
+
740
+ A request server is one of two kinds on GSF 8.15.29, and they read a request
741
+ differently. `RequestReplyDef.criteriaOnly` picks the kind, as
742
+ `criteriaOnlyRequest = true` does in the GPAL config (genesis-pal-requestserver:
743
+ `EntityDbRequestReply.kt` for criteria-only, `RequestReply.kt` otherwise).
744
+ `test/requestReply.test.ts` pins the rules below, and the `reqrep-*` conformance
745
+ recordings replay against the showcase mock.
746
+
747
+ ```ts
748
+ requestReplies: {
749
+ // showcase-reqrep.kts: config { criteriaOnlyRequest = true }
750
+ COUNTERPARTY: { source: 'COUNTERPARTY', criteriaOnly: true },
751
+ // auth-reqrep.kts: request { CODE } reply { CODE DESCRIPTION }
752
+ RIGHT: { source: 'RIGHT', fields: ['CODE', 'DESCRIPTION'] },
753
+ // config { maxRows = 500 }
754
+ BIG: { source: 'BIG', criteriaOnly: true, rowLimit: 500 },
755
+ }
756
+ ```
757
+
758
+ | | Criteria-only (`criteriaOnly: true`) | Plain |
759
+ |---|---|---|
760
+ | `REQUEST` | Must be a top-level object (missing, `null`, an array or inside `DETAILS`: `MSG_NACK INTERNAL_ERROR`, as recorded); its fields are ignored | Top level only (`DETAILS.REQUEST` is never read); missing means every row. Each field selects rows: an exact value (converted to the field's type; `null` matches nulls, and nothing on a non-null field), a string with `*` (a wildcard, below), or `FIELD_FROM` + `FIELD_TO` (an inclusive range; one alone matches nothing). A field the source doesn't have matches nothing unless its value is `null`. An array runs each object and concatenates the results (`[]`: no rows) |
761
+ | `CRITERIA_MATCH` | Filters the rows. Malformed: `MSG_NACK INTERNAL_ERROR` (recorded). An unknown field: `REPLY: []` (recorded), and `REQUEST_FAILED` "Paging not supported on lazy criteria evaluation" with an `OFFSET` past 0 | Filters the rows. Malformed: `MSG_NACK REQUEST_FAILED` with the parse error. An unknown field matches nothing |
762
+ | `MAX_ROWS` | The page size: `DETAILS.MAX_ROWS`, else `rowLimit`, else 10000. Below 1: `REQUEST_FAILED` "maxRows should be greater than 0" | The most rows each `REQUEST` object returns, same defaults. No signal that more exist |
763
+ | `OFFSET` / `VIEW_NUMBER` | `OFFSET` skips that many rows. `VIEW_NUMBER` n (from 1) is the page at `MAX_ROWS × (n − 1)`, with `NEXT_VIEW: n + 1` while the page has rows. Both, or either negative: `REQUEST_FAILED` with GSF's text | Ignored |
764
+ | `ORDER_BY` | `'FIELD [ASC\|DESC], …'` (grid-pro sends `'NAME DESC'`, `'RECORD_ID ASC'`); an index name or `PK` stands for its fields; ties go by `RECORD_ID` ascending, nulls sort low. Malformed, or a field the source doesn't have: `REQUEST_FAILED` with GSF's text (`Order fields [Asc(field=X)] not found on table T`). Without it, a first page comes in the table's order (oldest first, recorded) and a later page by primary key | Ignored: rows come in primary-key order (recorded: `RIGHT` in `CODE` order) |
765
+ | Reply | `{REPLY, PARAMETRIC_TYPE, MESSAGE_TYPE, NEXT_VIEW?, NEXT_OFFSET?, MORE_ROWS}`: `MORE_ROWS` always, `NEXT_OFFSET` only while it is `true`; past the end `REPLY: []` (recorded: `reqrep-paging`, `reqrep-max-rows`) | `{MESSAGE_TYPE, SOURCE_REF, PARAMETRIC_TYPE, REPLY}` |
766
+
767
+ - **Wildcards** (`WildcardFilter.kt`): any string value containing `*`. With `*`
768
+ at both ends it is "contains", at the start "ends with", at the end "starts
769
+ with", after trimming every leading and trailing `*`; `*` alone matches
770
+ everything. Case-sensitive. A `*` only in the middle matches nothing. The
771
+ row value is compared as Kotlin's `toString()`: `null` reads `"null"`, a
772
+ whole `DOUBLE` `"2.0"`, a date its ISO-8601 form.
773
+ - **`PARAMETRIC_TYPE`** is the source's generated class:
774
+ `global.genesis.gen.dao.<Table>` (`global.genesis.gen.dao.Counterparty`), or
775
+ `global.genesis.gen.view.entity.<View>` for a view.
776
+ - **Rows** carry the reply block (`fields`) in its order, `null` where unset;
777
+ without one, the source's columns then `RECORD_ID` and `TIMESTAMP`.
778
+ `REPLY_FIELD` and the JSON schema's `REPLY` say the same. An undeclared
779
+ source's rows go out as stored.
780
+ - **`filter` and `auth`** are applied to the rows the read returns, so a
781
+ criteria-only server reads on until one row past the page has passed them,
782
+ and `NEXT_OFFSET` is the request's `OFFSET` plus the rows that took, minus
783
+ one — not `OFFSET + MAX_ROWS`. A `VIEW_NUMBER` page read this way keeps
784
+ what passes of one page and sends no `MORE_ROWS` (GSF can't tell).
785
+ - **Failures** are a service `MSG_NACK`
786
+ (`{WARNING: [], ERROR: [{'@type': 'StandardError', CODE, TEXT, STATUS_CODE}], MESSAGE_TYPE: 'MSG_NACK'}`),
787
+ which foundation-comms rejects the request's promise with. A `resolver`'s
788
+ `NackError` keeps its items; anything else it throws is `GENERIC_ERROR`
789
+ with the message (`AbstractCustomReqRep.kt`).
790
+ - A `CRITERIA_MATCH` calling a function this engine's grammar doesn't model
791
+ (it may be valid Groovy) still fails open, logged, as on the dataserver.
792
+
793
+ What clients see:
794
+
795
+ - **grid-pro's server-side and infinite row models** need a criteria-only
796
+ server (they refuse a resource whose `META_REQUEST` says
797
+ `CRITERIA_ONLY_REQUEST: false`), and page it by `OFFSET` with
798
+ `ORDER_BY: '<rowId> ASC'` by default. The showcase mock's request servers
799
+ are criteria-only, as the real showcase's are.
800
+ - **grid-pro's client-side datasource** keys `REP_` rows by `RECORD_ID`
801
+ unless `row-id` is set. A server whose reply block leaves `RECORD_ID` out
802
+ (like `RIGHT`) can't feed it without a `row-id`: that is GSF's behaviour too.
803
+ - **The AI assistant** reads with `REQUEST: {}` and
804
+ `DETAILS: {MAX_ROWS, CRITERIA_MATCH}`, and takes `MORE_ROWS` when sent,
805
+ else a full page, as "there may be more".
806
+
807
+ Deviations: `VIEW_NUMBER` and `OFFSET` are as GSF's SQL engine answers them
808
+ (non-SQL engines send no `MORE_ROWS` or `NEXT_OFFSET`); request field aliases,
809
+ `criteriaTemplate` and timeouts are not modelled; the plain kind always reads
810
+ by primary key, where GSF reads through the index a `REQUEST` names (the same
811
+ rows, possibly in that index's order); a resolver's rows are not filtered by
812
+ `CRITERIA_MATCH` or capped by `MAX_ROWS`, which a custom request server's are.
813
+
463
814
  ## Entity-management (`@genesislcap/foundation-entity-management`) support
464
815
 
465
816
  `<entity-management>`'s create/update forms and delete confirmation don't use
@@ -479,21 +830,22 @@ zero config for the common case: `EVENT_<TABLE>_<VERB...>` is matched against
479
830
  `config.tables` (longest match wins), and any event name matching
480
831
  `.../_DELETE(/...)?/` gets a PK-only schema (matching what
481
832
  `EntityManagement.confirmDelete` actually needs) while everything else gets
482
- every field on that table. This covers naming variants like
833
+ every field on that table (see [Declared schema](#declared-schema-meta_request-and-json_schema_request)
834
+ for what each property carries). This covers naming variants like
483
835
  `EVENT_COUNTERPARTY_INSERT_WITH_APPROVAL`, `EVENT_TRADE_MODIFY_PRICE`, or
484
836
  `EVENT_TRADE_INSERT_FAIL` without per-event config. Override via
485
837
  `config.eventSchemas['<eventName>'] = { table, pkOnly, fieldTypes }` for
486
838
  names that don't follow the convention.
487
839
 
488
- The `FEATURE` doesn't have to be an event name. It also resolves, in order:
489
- an entry in `config.eventSchemas`, a **resource name** (a `config.queries` or
490
- `config.requestReplies` key, via its `source` — `'ALL_TRADES'` →
491
- `TRADE_VIEW`), and a bare **table name** (`'TRADE'`). Both matter in practice:
492
- `<entity-management resourceName="COUNTERPARTY">` asks by table and the
493
- showcase's react-client Admin page does that, while the FAST-Element client's
494
- grids ask by query name (`ALL_USERS`, `ALL_PROFILES`, `ALL_TRADES`). Resolve
495
- only event names and those requests are rejected with `MSG_NACK` 404 and the form
496
- renders no fields — the same dead button as above, from the other direction.
840
+ The `FEATURE` doesn't have to be an event name. It resolves, in order: an
841
+ entry in `config.eventSchemas`, a **dataserver** name (`ALL_TRADES`: the
842
+ `DATA_LOGON` schema, its rows in `OUTBOUND.$defs[...QueryRow]` — what
843
+ `<foundation-filters>` reads), a **request server** name (`TRADE`, without
844
+ `REQ_`: its rows in `OUTBOUND.properties.REPLY.items`), an
845
+ `EVENT_<TABLE>_<VERB>` event, and finally a bare **table name** (the table's
846
+ insert-shaped schema — a fallback the real server doesn't have). Under
847
+ `fidelity: 'legacy'` a query or request-server name resolves to its source
848
+ table's fields instead, as up to 15.47.
497
849
 
498
850
  Everything else `<entity-management>` needs — resource discovery
499
851
  (`RESOURCES_REQUEST`), grid metadata (`META_REQUEST`), row data (`DATA_LOGON`
@@ -568,8 +920,8 @@ extracted from for how each was actually found:
568
920
  (`src/protocol/connection.ts`) does this automatically — never build a raw
569
921
  response object yourself without going through it. A response missing
570
922
  `SOURCE_REF` leaves the client's pending promise unresolved forever.
571
- 2. **`EVENT_NACK`/`REP_*` errors are always `ERROR: [{CODE, TEXT}]`** — an
572
- array, uppercase keys. Throw `NackError` from an event handler or
923
+ 2. **`EVENT_NACK` and request-server `MSG_NACK` errors are always an `ERROR` array of items** —
924
+ uppercase keys (see [NACK vocabulary](#nack-vocabulary)). Throw `NackError` from an event handler or
573
925
  request-reply resolver rather than hand-rolling this shape; a lowercase
574
926
  `{error, details}` object crashes the real client's `.forEach`. (The
575
927
  router's `MSG_NACK` uses the same array, with an integer `CODE` and a
@@ -593,7 +945,9 @@ extracted from for how each was actually found:
593
945
  directly.
594
946
  5. **Datasource `init()` needs `TYPE: 'DATASERVER'` plus a non-empty `FIELD`
595
947
  array** or the client silently reports "no fields returned" and gives up.
596
- `deriveFields()` (`src/db/metadata.ts`) infers this from the rows' own JS
948
+ Declare the table's `fields` and it never is (see
949
+ [Declared schema](#declared-schema-meta_request-and-json_schema_request)).
950
+ Without them, `deriveFields()` (`src/db/metadata.ts`) infers this from the rows' own JS
597
951
  types — falling back to the registration-time seed rows when every live
598
952
  row has been deleted, so a runtime delete can't collapse a resource's
599
953
  schema to zero fields. Numeric inference scans every row (a column is
@@ -626,7 +980,10 @@ extracted from for how each was actually found:
626
980
  warning. Any criteria this grammar still doesn't cover fails open instead
627
981
  of crashing the handler: `compileCriteria()` logs a warning and returns a
628
982
  match-all predicate, so `DATA_LOGON` still responds (unfiltered) rather
629
- than leaving the client's grid stuck on "Loading...". Add new entries to
983
+ than leaving the client's grid stuck on "Loading...". (A request server
984
+ answers a malformed criteria with a `MSG_NACK`, as GSF does, and fails open
985
+ only on a function it doesn't model; see
986
+ [Request servers](#request-servers-req_).) Add new entries to
630
987
  `EXPR_FUNCTIONS` in `criteria.ts` if you hit one that isn't covered yet.
631
988
  7. **Composite-key AMEND/DELETE matching needs a fallback beyond the PK.**
632
989
  `Store.findRow()` (`src/db/store.ts`) tries the configured PK field, then
@@ -643,8 +1000,7 @@ in is pushed as an **INSERT** (`resolveSubscriptionOperation()` and
643
1000
  `trackClientView()` in `src/server.ts`; see
644
1001
  [Dataserver paging](#dataserver-paging)). If the predicate throws, the push
645
1002
  is skipped instead: a broken filter must never evict rows. **Deletes** bypass
646
- the criteria predicate, because the pushed row is a PK-only stub that couldn't
647
- satisfy one; they reach every client that holds the row. (Under
1003
+ the criteria predicate; they reach every client that holds the row. (Under
648
1004
  `fidelity: 'legacy'`, every DELETE reaches every subscription, and a row
649
1005
  entering the criteria is pushed as a MODIFY.)
650
1006
 
@@ -656,9 +1012,21 @@ Where this engine still differs from the real server:
656
1012
  only the rows whose values changed.
657
1013
  - There is no **moving view**. Once the view holds `MAX_VIEW` rows, live
658
1014
  INSERTs are still pushed instead of also evicting the oldest row.
659
- - Snapshots are in **insertion order**, where the real default is newest first
660
- by `RECORD_ID`. `ORDER_BY`, `REVERSE` and `FIELDS` are ignored.
661
- - Rows carry `ROW_REF` at the top level as well as in `DETAILS.ROW_REF`.
1015
+ - Without `ORDER_BY`, snapshots are in **insertion order** (with or without
1016
+ `REVERSE`), where the real default is newest first by `RECORD_ID`. `ORDER_BY` names one of
1017
+ the query's `indexes` and sorts ascending by its fields, ties by `RECORD_ID`; `REVERSE`
1018
+
1019
+ flips that; an unknown index is `LOGON_NACK INVALID_INDEX`. Under `ORDER_BY`, a live INSERT
1020
+ that sorts among the rows still waiting for `MORE_ROWS` waits with them, in order (a MODIFY
1021
+ of a waiting row keeps its place). `DETAILS.FIELDS` narrows the rows to those columns.
1022
+ - A `CRITERIA_MATCH` naming a field the query doesn't expose is `LOGON_NACK
1023
+ INVALID_CRITERIA`, as on GSF — when the query's fields are known (declared, or a `fields`
1024
+ block). An inferred query can't tell, and evaluates the criteria on the stored row.
1025
+ (`REQ_` answers an unknown field with `REPLY: []`, as GSF does — see
1026
+ [Request servers](#request-servers-req_).)
1027
+ - `RECORD_ID`/`TIMESTAMP` are safe integers, not GSF's ~7.5e18 values (see
1028
+ [Record identity](#record-identity-row_ref-and-sequences)).
1029
+
662
1030
  - The opt-in auth gate answers with typed `NOT_LOGGED_ON` replies, not the
663
1031
  router's `MSG_NACK` 401.
664
1032
 
@@ -669,7 +1037,8 @@ the criteria grammar, store, metadata inference, and event-table inference
669
1037
  plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
670
1038
  delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
671
1039
  `test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
672
- fidelity mode) via `node --test`, no build step. Only `test/**/*.test.ts`
1040
+ fidelity mode; `test/requestReply.test.ts` covers both kinds of request server)
1041
+ via `node --test`, no build step. Only `test/**/*.test.ts`
673
1042
  files are run, so shared helpers (`test/support.ts`) can sit beside them.
674
1043
 
675
1044
  ## What's per-project vs. generic
@@ -1,5 +1,18 @@
1
1
  import type { Row } from '../types.ts';
2
2
  export type Predicate = (record: Row) => boolean;
3
+ export declare class CriteriaSyntaxError extends Error {
4
+ name: string;
5
+ }
6
+ export declare class UnsupportedCriteriaError extends Error {
7
+ name: string;
8
+ }
9
+ export declare function parseGenesisDateTime(value: unknown): number | null;
10
+ export interface ParsedCriteria {
11
+ predicate: Predicate;
12
+ fields: string[];
13
+ }
14
+ export declare function parseCriteria(criteria?: string | null): ParsedCriteria;
3
15
  /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
4
16
  export declare function compileCriteria(criteria?: string | null): Predicate;
17
+ export declare function criteriaFields(criteria?: string | null): string[] | undefined;
5
18
  export declare function filterByCriteria(rows: Row[], criteria?: string | null): Row[];