@genesislcap/mock-server 15.52.0 → 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.
- package/README.md +210 -27
- package/dist/db/criteria.d.ts +12 -0
- package/dist/db/criteria.js +39 -29
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +17 -1
- package/dist/db/schema.js +131 -16
- package/dist/db/store.d.ts +8 -2
- package/dist/db/store.js +120 -44
- package/dist/handlers/commitEvent.js +32 -8
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +2 -23
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +3 -2
- package/dist/handlers/jsonSchema.js +23 -17
- package/dist/handlers/meta.js +13 -9
- package/dist/handlers/requestReply.js +518 -40
- package/dist/handlers/resources.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/protocol/fieldTypes.d.ts +1 -0
- package/dist/protocol/fieldTypes.js +6 -0
- package/dist/protocol/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +5 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +52 -27
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +164 -15
- package/src/db/store.ts +146 -46
- package/src/handlers/commitEvent.ts +39 -8
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +2 -19
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +31 -19
- package/src/handlers/meta.ts +14 -9
- package/src/handlers/requestReply.ts +625 -41
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +1 -0
- package/src/protocol/fieldTypes.ts +7 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +5 -1
- 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
|
-
|
|
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
|
|
92
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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`, `
|
|
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`; `
|
|
235
|
-
|
|
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`
|
|
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}` |
|
|
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' }], //
|
|
492
|
-
indexes: [{ name: 'TRADE_BY_SIDE', fields: ['SIDE'] }], //
|
|
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}`
|
|
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).
|
|
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
|
|
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...".
|
|
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_`
|
|
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
|
|
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
|
package/dist/db/criteria.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/criteria.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|