@genesislcap/mock-server 15.52.0 → 15.53.0-GENC-1662.1

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 (48) hide show
  1. package/README.md +210 -27
  2. package/dist/db/criteria.d.ts +12 -0
  3. package/dist/db/criteria.js +39 -29
  4. package/dist/db/ordering.d.ts +8 -0
  5. package/dist/db/ordering.js +49 -0
  6. package/dist/db/schema.d.ts +17 -1
  7. package/dist/db/schema.js +131 -16
  8. package/dist/db/store.d.ts +8 -2
  9. package/dist/db/store.js +120 -44
  10. package/dist/handlers/commitEvent.js +32 -8
  11. package/dist/handlers/crudEvents.d.ts +2 -0
  12. package/dist/handlers/crudEvents.js +138 -0
  13. package/dist/handlers/dataLogon.js +2 -23
  14. package/dist/handlers/eventValidation.d.ts +5 -0
  15. package/dist/handlers/eventValidation.js +29 -0
  16. package/dist/handlers/jsonSchema.d.ts +3 -2
  17. package/dist/handlers/jsonSchema.js +23 -17
  18. package/dist/handlers/meta.js +13 -9
  19. package/dist/handlers/requestReply.js +518 -40
  20. package/dist/handlers/resources.js +3 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/protocol/fieldTypes.d.ts +1 -0
  23. package/dist/protocol/fieldTypes.js +6 -0
  24. package/dist/protocol/msgNack.d.ts +2 -0
  25. package/dist/protocol/msgNack.js +10 -0
  26. package/dist/protocol/schemaValidation.d.ts +11 -0
  27. package/dist/protocol/schemaValidation.js +186 -0
  28. package/dist/server.js +5 -1
  29. package/dist/types.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/db/criteria.ts +52 -27
  32. package/src/db/ordering.ts +56 -0
  33. package/src/db/schema.ts +164 -15
  34. package/src/db/store.ts +146 -46
  35. package/src/handlers/commitEvent.ts +39 -8
  36. package/src/handlers/crudEvents.ts +181 -0
  37. package/src/handlers/dataLogon.ts +2 -19
  38. package/src/handlers/eventValidation.ts +43 -0
  39. package/src/handlers/jsonSchema.ts +31 -19
  40. package/src/handlers/meta.ts +14 -9
  41. package/src/handlers/requestReply.ts +625 -41
  42. package/src/handlers/resources.ts +3 -0
  43. package/src/index.ts +1 -0
  44. package/src/protocol/fieldTypes.ts +7 -0
  45. package/src/protocol/msgNack.ts +12 -0
  46. package/src/protocol/schemaValidation.ts +208 -0
  47. package/src/server.ts +5 -1
  48. package/src/types.ts +56 -18
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: {
@@ -178,13 +182,12 @@ row does (genesis-db `InsertOperation.kt`):
178
182
  - **Dataserver rows** never carry `RECORD_ID`/`TIMESTAMP` (the real dataserver
179
183
  identifies rows by `DETAILS.ROW_REF` only), unless the query's `fields` block
180
184
  names them. Field metadata (`META_REQUEST`, `JSON_SCHEMA_REQUEST`) leaves them out.
181
- - **`REP_` rows** from a `source` include them. **Deviation:** GSF sends them
182
- only from criteria-only request servers (`criteriaOnly: true`); here every
183
- source-backed request server does, because grid-pro's client-side
184
- REQUEST_SERVER datasource keys rows by `RECORD_ID` and gives up without it,
185
- and the showcase's servers are not marked `criteriaOnly` until `OFFSET`
186
- paging lands (plan item 1.9), which makes this exact. A `resolver`'s rows
187
- are sent as returned.
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.
188
191
  - **`DETAILS.ROW_REF`** is the record's `RECORD_ID` as a string
189
192
  (`GenesisSetMaskingJsonSerializer`: `value.toString()`); a view row's is its
190
193
  base record's. Rows no longer carry a top-level `ROW_REF` — clients copy it
@@ -207,6 +210,11 @@ row does (genesis-db `InsertOperation.kt`):
207
210
  `IdStrategy.java`'s format — 15-digit counter, sequence id, `LO`, `1`:
208
211
  `000000000000001TRLO1`. Seeds in the table's format advance the counter. A
209
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.
210
218
 
211
219
  `fidelity: 'legacy'` restores the pk-based `ROW_REF` (the pk value, or the
212
220
  composite key), the top-level `ROW_REF`, pk-stub DELETE rows and `REP_` rows
@@ -214,7 +222,7 @@ without `RECORD_ID`/`TIMESTAMP`.
214
222
 
215
223
  ## NACK vocabulary
216
224
 
217
- Service NACKs (`EVENT_NACK`, `REP_*` errors) follow GSF's `GenesisError` family
225
+ Service NACKs (`EVENT_NACK`, and the `MSG_NACK` a request server fails with) follow GSF's `GenesisError` family
218
226
  (`src/protocol/errors.ts`, pinned against recorded frames in
219
227
  `test/nack.test.ts`): every `ERROR` item has `@type`, a string `CODE`, `TEXT`
220
228
  and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:
@@ -231,12 +239,105 @@ and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:
231
239
  `new NackError([...])` reports several errors at once. Anything else a handler
232
240
  throws is `StandardError INTERNAL_ERROR` with its message. A handler can
233
241
  return `{ warnings: [...] }` for a NACK with an empty `ERROR` and those
234
- warnings, or pass `{ warnings }` to `NackError`; `VALIDATE`/`IGNORE_WARNINGS`
235
- are not modelled yet. The router's `MSG_NACK` keeps its own shape (no `@type`,
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`,
236
248
  an integer `CODE`, `DETAILS {ERROR, STATUS_CODE}`). `fidelity: 'legacy'` sends
237
249
  the old `{CODE, TEXT, FIELD?}` items (`VALIDATION_ERROR` by default) and no
238
250
  `WARNING`: warnings go in `ERROR` there.
239
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.
340
+
240
341
  ## Running several servers in parallel (and one-command dev stacks)
241
342
 
242
343
  Every `createMockServer()` instance is fully isolated — its own store,
@@ -330,7 +431,8 @@ having to monkey-patch the engine:
330
431
  unhandled) instead of enumerating ack-only handlers. Unregistered events
331
432
  get the router's `MSG_NACK` 404 when unset (see
332
433
  [Protocol coverage](#protocol-coverage)). Note `RESOURCES_REQUEST` only
333
- lists *named* events (`eventHandlers` + `eventSchemas` keys) — if the
434
+ lists *named* events (`eventHandlers` and `eventSchemas` keys, and the
435
+ tables' `events`) — if the
334
436
  client's resource gate blocks an event served only by the default handler,
335
437
  name it in `config.eventSchemas` or `config.resources`.
336
438
 
@@ -398,8 +500,8 @@ the envelopes this engine sent up to 15.47 — see
398
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) |
399
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) |
400
502
  | `RESOURCES_REQUEST` | `RESOURCES_REQUEST_ACK` | |
401
- | `REQ_<NAME>` | `REP_<NAME> {REPLY}` | Incl. keyed `REQUEST` field lookups (`'*'` = any) and `CRITERIA_MATCH` |
402
- | `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) |
403
505
  | Anything naming an unknown resource | `MSG_NACK` 404 | See below |
404
506
 
405
507
  Each `SEQUENCE_ID` on a subscription is the previous one plus 1. The snapshot
@@ -459,6 +561,7 @@ createMockServer({ ...config, fidelity: 'legacy' });
459
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 |
460
562
  | Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
461
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` |
462
565
  | Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
463
566
 
464
567
  `test/legacyParity.test.ts` pins `'legacy'` to replies recorded from the 15.47.0 engine
@@ -488,8 +591,9 @@ tables: {
488
591
  { name: 'COMMISSION', type: 'BIGDECIMAL' },
489
592
  { name: 'NAME', type: 'STRING', maxSize: 100, minLength: 0, title: 'Name' },
490
593
  ],
491
- generated: [{ field: 'TRADE_NO', kind: 'AUTO_INCREMENT' }], // shape only
492
- indexes: [{ name: 'TRADE_BY_SIDE', fields: ['SIDE'] }], // for later items
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
493
597
  },
494
598
  },
495
599
  views: { TRADE_VIEW: { base: 'TRADE', /* joins, derived */ derivedTypes: { TOTAL: 'DOUBLE' } } },
@@ -522,12 +626,12 @@ ignoring only `SOURCE_REF`):
522
626
  | Resource | `META_REQUEST` `DETAILS` | `JSON_SCHEMA_REQUEST` |
523
627
  |---|---|---|
524
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`) |
525
- | 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}` with `RECORD_ID: LONG` and `TIMESTAMP: NANO_TIMESTAMP` added | INBOUND: top-level `REQUEST` (object or array of the request fields) required, `DETAILS {VIEW_NUMBER, MAX_ROWS, CRITERIA_MATCH}`. OUTBOUND: `properties.REPLY.items` (nullable fields as `oneOf [null, type]`) |
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]`) |
526
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` |
527
631
 
528
632
  Events resolve as `JSON_SCHEMA_REQUEST` always did: `config.eventSchemas`, then
529
633
  `EVENT_<TABLE>_<VERB>` (longest table name wins) — but only for a registered event (an
530
- `eventHandlers` or `eventSchemas` entry); any other `EVENT_*` name gets the router's 404, as on
634
+ `eventHandlers` or `eventSchemas` entry, or a table's `events`); any other `EVENT_*` name gets the router's 404, as on
531
635
  the real server. `OPTIONAL` is true only when the class property has a default
532
636
  (PALMetadataUtils.kt): a table's generated DAO defaults every nullable and generated column, so
533
637
  those are optional; a custom DTO field is optional only with a `default` or `optional: true`
@@ -554,9 +658,7 @@ A declared source's rows carry exactly its exposed columns, `null` when unset.
554
658
 
555
659
  Deviations: a bare table name with no request server of that name still gets the table's
556
660
  insert-shaped event schema from `JSON_SCHEMA_REQUEST` (this engine's fallback; the real server
557
- answers 404). `criteriaOnly` changes metadata only — `REQ_` doesn't page by `OFFSET` yet, so the
558
- showcase mock leaves it off (grid-pro's server-side and infinite models page a criteria-only
559
- resource by `OFFSET`). Dataserver rows still carry neither RECORD_ID nor TIMESTAMP.
661
+ answers 404). Dataserver rows still carry neither RECORD_ID nor TIMESTAMP.
560
662
 
561
663
  ## Dataserver paging
562
664
 
@@ -633,6 +735,82 @@ whatever a view's `pkField` says.
633
735
  `VIEW_NUMBER` is accepted and ignored. It selects the real server's paginated
634
736
  mode, where each page replaces the last; here paging is always sequential.
635
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
+
636
814
  ## Entity-management (`@genesislcap/foundation-entity-management`) support
637
815
 
638
816
  `<entity-management>`'s create/update forms and delete confirmation don't use
@@ -742,7 +920,7 @@ extracted from for how each was actually found:
742
920
  (`src/protocol/connection.ts`) does this automatically — never build a raw
743
921
  response object yourself without going through it. A response missing
744
922
  `SOURCE_REF` leaves the client's pending promise unresolved forever.
745
- 2. **`EVENT_NACK`/`REP_*` errors are always an `ERROR` array of items** —
923
+ 2. **`EVENT_NACK` and request-server `MSG_NACK` errors are always an `ERROR` array of items** —
746
924
  uppercase keys (see [NACK vocabulary](#nack-vocabulary)). Throw `NackError` from an event handler or
747
925
  request-reply resolver rather than hand-rolling this shape; a lowercase
748
926
  `{error, details}` object crashes the real client's `.forEach`. (The
@@ -802,7 +980,10 @@ extracted from for how each was actually found:
802
980
  warning. Any criteria this grammar still doesn't cover fails open instead
803
981
  of crashing the handler: `compileCriteria()` logs a warning and returns a
804
982
  match-all predicate, so `DATA_LOGON` still responds (unfiltered) rather
805
- 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
806
987
  `EXPR_FUNCTIONS` in `criteria.ts` if you hit one that isn't covered yet.
807
988
  7. **Composite-key AMEND/DELETE matching needs a fallback beyond the PK.**
808
989
  `Store.findRow()` (`src/db/store.ts`) tries the configured PK field, then
@@ -841,7 +1022,8 @@ Where this engine still differs from the real server:
841
1022
  - A `CRITERIA_MATCH` naming a field the query doesn't expose is `LOGON_NACK
842
1023
  INVALID_CRITERIA`, as on GSF — when the query's fields are known (declared, or a `fields`
843
1024
  block). An inferred query can't tell, and evaluates the criteria on the stored row.
844
- `REQ_` criteria aren't checked (the real server answers an unknown field with `REPLY: []`).
1025
+ (`REQ_` answers an unknown field with `REPLY: []`, as GSF does — see
1026
+ [Request servers](#request-servers-req_).)
845
1027
  - `RECORD_ID`/`TIMESTAMP` are safe integers, not GSF's ~7.5e18 values (see
846
1028
  [Record identity](#record-identity-row_ref-and-sequences)).
847
1029
 
@@ -855,7 +1037,8 @@ the criteria grammar, store, metadata inference, and event-table inference
855
1037
  plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
856
1038
  delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
857
1039
  `test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
858
- 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`
859
1042
  files are run, so shared helpers (`test/support.ts`) can sit beside them.
860
1043
 
861
1044
  ## What's per-project vs. generic
@@ -1,5 +1,17 @@
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;
5
17
  export declare function criteriaFields(criteria?: string | null): string[] | undefined;
@@ -15,6 +15,17 @@
15
15
  // Deliberately NOT eval()/new Function() — this parses a small typed grammar
16
16
  // so a malformed or hostile criteria string can only fail to parse, never
17
17
  // execute arbitrary JS.
18
+ // A criteria this grammar can't parse: unbalanced parentheses, a missing
19
+ // operand, text it can't tokenize. GSF fails to compile those too.
20
+ export class CriteriaSyntaxError extends Error {
21
+ name = 'CriteriaSyntaxError';
22
+ }
23
+ // A well-formed call to a function EXPR_FUNCTIONS doesn't have. It may well be
24
+ // valid Groovy, so callers that refuse malformed criteria still fail open on
25
+ // this one.
26
+ export class UnsupportedCriteriaError extends Error {
27
+ name = 'UnsupportedCriteriaError';
28
+ }
18
29
  const TOKEN_RE = /\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
19
30
  function tokenize(input) {
20
31
  const tokens = [];
@@ -23,13 +34,13 @@ function tokenize(input) {
23
34
  TOKEN_RE.lastIndex = 0;
24
35
  while ((match = TOKEN_RE.exec(input))) {
25
36
  if (match.index !== lastIndex) {
26
- throw new Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
37
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
27
38
  }
28
39
  tokens.push(match[1]);
29
40
  lastIndex = TOKEN_RE.lastIndex;
30
41
  }
31
42
  if (lastIndex !== input.length) {
32
- throw new Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
43
+ throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
33
44
  }
34
45
  return tokens;
35
46
  }
@@ -53,7 +64,7 @@ const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
53
64
  // them makes every Expr.dateTimeIs* comparison silently false (date-filtered
54
65
  // grids render zero rows with no warning). Row values may already be epoch
55
66
  // millis (number) or an ISO string. Returns null if unparseable.
56
- function parseGenesisDateTime(value) {
67
+ export function parseGenesisDateTime(value) {
57
68
  if (typeof value === 'number')
58
69
  return value;
59
70
  if (typeof value !== 'string')
@@ -208,12 +219,12 @@ class Parser {
208
219
  this.next();
209
220
  const inner = this.parseExpression();
210
221
  if (this.next() !== ')')
211
- throw new Error('Missing closing parenthesis in criteria');
222
+ throw new CriteriaSyntaxError('Missing closing parenthesis in criteria');
212
223
  return inner;
213
224
  }
214
225
  const leftToken = this.next();
215
226
  if (leftToken === undefined) {
216
- throw new Error('Expected operand on the left side of comparison');
227
+ throw new CriteriaSyntaxError('Expected operand on the left side of comparison');
217
228
  }
218
229
  // Both function-call dialects arrive as one dotted token followed by '(':
219
230
  // 'Expr.containsIgnoreCase' (foundation-criteria) or
@@ -223,12 +234,12 @@ class Parser {
223
234
  }
224
235
  const op = this.peek();
225
236
  if (!op || !COMPARATORS.has(op)) {
226
- throw new Error(`Expected comparator, got: ${op}`);
237
+ throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`);
227
238
  }
228
239
  this.next();
229
240
  const rightToken = this.next();
230
241
  if (rightToken === undefined) {
231
- throw new Error('Expected operand on the right side of comparison');
242
+ throw new CriteriaSyntaxError('Expected operand on the right side of comparison');
232
243
  }
233
244
  this.noteField(leftToken);
234
245
  this.noteField(rightToken);
@@ -273,18 +284,18 @@ class Parser {
273
284
  if (this.peek() !== ')') {
274
285
  const arg = this.next();
275
286
  if (arg === undefined)
276
- throw new Error(`Unterminated argument list in ${name}(...)`);
287
+ throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
277
288
  args.push(arg);
278
289
  while (this.peek() === ',') {
279
290
  this.next();
280
291
  const nextArg = this.next();
281
292
  if (nextArg === undefined)
282
- throw new Error(`Unterminated argument list in ${name}(...)`);
293
+ throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
283
294
  args.push(nextArg);
284
295
  }
285
296
  }
286
297
  if (this.next() !== ')')
287
- throw new Error(`Missing closing parenthesis in ${name}(...)`);
298
+ throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`);
288
299
  // 'Expr.<fn>(field, value)' passes args through as-is; the method-call
289
300
  // form '<FIELD>.<fn>(value)' makes the receiver field the implicit first
290
301
  // argument, so both dialects share EXPR_FUNCTIONS.
@@ -293,7 +304,7 @@ class Parser {
293
304
  const fnName = name.slice(dotIndex + 1);
294
305
  const handler = EXPR_FUNCTIONS[fnName];
295
306
  if (!handler)
296
- throw new Error(`Unsupported criteria function: ${name}`);
307
+ throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
297
308
  const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
298
309
  for (const arg of allArgs)
299
310
  this.noteField(arg);
@@ -301,24 +312,30 @@ class Parser {
301
312
  return (record) => handler(record, allArgs, resolve);
302
313
  }
303
314
  }
304
- /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
305
- export function compileCriteria(criteria) {
315
+ // Parses a CRITERIA_MATCH without failing open: throws CriteriaSyntaxError for
316
+ // a malformed one and UnsupportedCriteriaError for a call to a function the
317
+ // grammar doesn't have. A blank criteria matches everything.
318
+ export function parseCriteria(criteria) {
306
319
  if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') {
307
- return () => true;
320
+ return { predicate: () => true, fields: [] };
308
321
  }
322
+ const tokens = tokenize(criteria.trim());
323
+ const parser = new Parser(tokens);
324
+ const predicate = parser.parseExpression();
325
+ if (parser.position !== tokens.length) {
326
+ throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
327
+ }
328
+ return { predicate, fields: [...parser.fields] };
329
+ }
330
+ /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
331
+ export function compileCriteria(criteria) {
309
332
  // Real Genesis clients can emit CRITERIA_MATCH syntax this grammar doesn't
310
333
  // cover yet (e.g. function-call criteria like Expr.dateTimeIsGreaterEqual(...)
311
334
  // from date-range filters). Failing open — matching everything and logging —
312
335
  // keeps DATA_LOGON/broadcast working (unfiltered) instead of crashing the
313
336
  // handler and leaving the client's grid stuck on "Loading...".
314
337
  try {
315
- const tokens = tokenize(criteria.trim());
316
- const parser = new Parser(tokens);
317
- const predicate = parser.parseExpression();
318
- if (parser.position !== tokens.length) {
319
- throw new Error(`Unexpected trailing tokens in criteria: ${criteria}`);
320
- }
321
- return predicate;
338
+ return parseCriteria(criteria).predicate;
322
339
  }
323
340
  catch (error) {
324
341
  console.warn(`[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`, error);
@@ -329,15 +346,8 @@ export function compileCriteria(criteria) {
329
346
  // (compileCriteria then fails open). The real dataserver refuses a criteria
330
347
  // naming a field its query doesn't expose (INVALID_CRITERIA).
331
348
  export function criteriaFields(criteria) {
332
- if (!criteria || typeof criteria !== 'string' || criteria.trim() === '')
333
- return [];
334
349
  try {
335
- const tokens = tokenize(criteria.trim());
336
- const parser = new Parser(tokens);
337
- parser.parseExpression();
338
- if (parser.position !== tokens.length)
339
- return undefined;
340
- return [...parser.fields];
350
+ return parseCriteria(criteria).fields;
341
351
  }
342
352
  catch {
343
353
  return undefined;
@@ -0,0 +1,8 @@
1
+ import type { Row } from '../types.ts';
2
+ export declare function compareValues(a: unknown, b: unknown): number;
3
+ export interface OrderedField {
4
+ field: string;
5
+ descending: boolean;
6
+ }
7
+ export declare function rowComparator(fields: OrderedField[]): (a: Row, b: Row) => number;
8
+ export declare function parseOrderBy(orderBy: string): OrderedField[];
@@ -0,0 +1,49 @@
1
+ // Row ordering shared by the dataserver (DATA_LOGON ORDER_BY, an index name)
2
+ // and request servers (REQ_ DETAILS.ORDER_BY, fields with a direction).
3
+ import { RECORD_ID } from "./identity.js";
4
+ // Nulls sort low (first ascending, last descending), as H2's default null
5
+ // ordering does; numbers numerically, booleans false before true, anything
6
+ // else by its string form (UTF-16 order, as Java's String.compareTo).
7
+ export function compareValues(a, b) {
8
+ if (a === b)
9
+ return 0;
10
+ if (a === undefined || a === null)
11
+ return -1;
12
+ if (b === undefined || b === null)
13
+ return 1;
14
+ if (typeof a === 'number' && typeof b === 'number')
15
+ return a - b;
16
+ if (typeof a === 'boolean' && typeof b === 'boolean')
17
+ return a ? 1 : -1;
18
+ const [left, right] = [String(a), String(b)];
19
+ return left < right ? -1 : left > right ? 1 : 0;
20
+ }
21
+ // Sorts by the fields in turn, then by RECORD_ID ascending: the tie-breaker
22
+ // column GSF's SQL ordering always appends (GetByCriteriaOperation.kt,
23
+ // tieBreakerColumn = "RECORD_ID", v8.15.29), whatever the direction.
24
+ export function rowComparator(fields) {
25
+ return (a, b) => {
26
+ for (const { field, descending } of fields) {
27
+ const result = compareValues(a[field], b[field]);
28
+ if (result !== 0)
29
+ return descending ? -result : result;
30
+ }
31
+ return compareValues(a[RECORD_ID], b[RECORD_ID]);
32
+ };
33
+ }
34
+ const ORDER_SEPARATOR = /\s*,\s*/;
35
+ const ORDER_FIELD = /^([A-Za-z]\w+)(?:\s+(ASC|DESC))?$/i;
36
+ // DETAILS.ORDER_BY as a request server reads it (OrderSpec.kt OrderedString,
37
+ // GSF v8.15.29): comma-separated fields, each with an optional ASC or DESC in
38
+ // any case. Throws GSF's IllegalArgumentException text for anything else,
39
+ // which the request server answers as REQUEST_FAILED.
40
+ export function parseOrderBy(orderBy) {
41
+ return orderBy.split(ORDER_SEPARATOR).map((part) => {
42
+ const match = ORDER_FIELD.exec(part);
43
+ if (!match) {
44
+ throw new Error(`${orderBy} is not a valid ORDER string, Fields should be comma separated, with followed ` +
45
+ "by an optional ASC or DESC. E.g. 'TRADER, TRADE_DATE DESC'");
46
+ }
47
+ return { field: match[1], descending: match[2]?.toUpperCase() === 'DESC' };
48
+ });
49
+ }