@genesislcap/mock-server 15.51.1 → 15.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +217 -31
  2. package/dist/db/criteria.d.ts +1 -0
  3. package/dist/db/criteria.js +29 -0
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/schema.d.ts +46 -0
  7. package/dist/db/schema.js +375 -0
  8. package/dist/db/store.d.ts +13 -2
  9. package/dist/db/store.js +208 -34
  10. package/dist/handlers/commitEvent.js +20 -6
  11. package/dist/handlers/dataLogon.js +126 -6
  12. package/dist/handlers/jsonSchema.d.ts +2 -1
  13. package/dist/handlers/jsonSchema.js +216 -61
  14. package/dist/handlers/meta.d.ts +6 -1
  15. package/dist/handlers/meta.js +164 -21
  16. package/dist/handlers/requestReply.js +16 -5
  17. package/dist/handlers/resourceAuth.d.ts +1 -0
  18. package/dist/handlers/resourceAuth.js +14 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/protocol/connection.d.ts +2 -0
  22. package/dist/protocol/errors.d.ts +100 -2
  23. package/dist/protocol/errors.js +300 -12
  24. package/dist/protocol/fieldTypes.d.ts +9 -0
  25. package/dist/protocol/fieldTypes.js +101 -0
  26. package/dist/protocol/gsfSchemas.d.ts +9 -0
  27. package/dist/protocol/gsfSchemas.js +423 -0
  28. package/dist/protocol/messageTypes.d.ts +1 -0
  29. package/dist/protocol/messageTypes.js +1 -0
  30. package/dist/protocol/rowUpdate.d.ts +5 -3
  31. package/dist/protocol/rowUpdate.js +33 -15
  32. package/dist/server.js +90 -28
  33. package/dist/types.d.ts +40 -0
  34. package/package.json +1 -1
  35. package/src/db/criteria.ts +26 -0
  36. package/src/db/identity.ts +53 -0
  37. package/src/db/schema.ts +523 -0
  38. package/src/db/store.ts +225 -32
  39. package/src/handlers/commitEvent.ts +20 -7
  40. package/src/handlers/dataLogon.ts +144 -7
  41. package/src/handlers/jsonSchema.ts +290 -69
  42. package/src/handlers/meta.ts +222 -23
  43. package/src/handlers/requestReply.ts +15 -6
  44. package/src/handlers/resourceAuth.ts +18 -0
  45. package/src/index.ts +16 -1
  46. package/src/protocol/connection.ts +10 -2
  47. package/src/protocol/errors.ts +361 -13
  48. package/src/protocol/fieldTypes.ts +108 -0
  49. package/src/protocol/gsfSchemas.ts +505 -0
  50. package/src/protocol/messageTypes.ts +1 -0
  51. package/src/protocol/rowUpdate.ts +40 -20
  52. package/src/server.ts +101 -27
  53. package/src/types.ts +145 -1
package/README.md CHANGED
@@ -152,12 +152,90 @@ covers the server-side behaviors real projects lean on:
152
152
  ```
153
153
 
154
154
  - **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`.
155
+ (`pkField: ['RFQ_ID', 'SIDE']`). Rows are stored under the fields' values
156
+ joined with `'|'` (so values must not contain `'|'`) — no synthetic key
157
+ column, nothing extra in grids. Event handlers on composite tables pass
158
+ `store.rowKey(table, row)` to `broadcastTableChange`; sequence generation
159
+ still requires a single `pkField`.
160
+
161
+ ## Record identity, ROW_REF and sequences
162
+
163
+ Every stored row carries `RECORD_ID` and `TIMESTAMP`, as every Genesis table
164
+ row does (genesis-db `InsertOperation.kt`):
165
+
166
+ - Seeding and `insertRow` stamp `RECORD_ID === TIMESTAMP` from one counter per
167
+ server that only goes up (`src/db/identity.ts`). A seed row's own
168
+ `RECORD_ID` is kept when it is a safe integer or a string not used by an
169
+ earlier seed row; an unsafe one (above 2^53, already rounded by
170
+ `JSON.parse`) or a repeat is re-stamped, with one warning per table, and a
171
+ missing `TIMESTAMP` becomes the `RECORD_ID`. An insert always gets fresh ones. `updateRow` moves `TIMESTAMP` on and
172
+ keeps `RECORD_ID`, even if the patch names either field.
173
+ - **Deviation:** GSF's values are about 7.5e18 (epoch millis shifted left 22
174
+ bits, plus a counter). That is above 2^53, so `JSON.parse` rounds
175
+ neighbouring rows onto one number. The mock's stamps are epoch millis, or the
176
+ last stamp plus one: exact, strictly increasing safe integers, in the same
177
+ order, that still read as a write time.
178
+ - **Dataserver rows** never carry `RECORD_ID`/`TIMESTAMP` (the real dataserver
179
+ identifies rows by `DETAILS.ROW_REF` only), unless the query's `fields` block
180
+ 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.
188
+ - **`DETAILS.ROW_REF`** is the record's `RECORD_ID` as a string
189
+ (`GenesisSetMaskingJsonSerializer`: `value.toString()`); a view row's is its
190
+ base record's. Rows no longer carry a top-level `ROW_REF` — clients copy it
191
+ from `DETAILS` themselves (`row.DETAILS[rowId] ?? row[rowId]`). Lookups stay
192
+ by pk: handlers still pass the pk (or `store.rowKey`) to
193
+ `broadcastTableChange`, and a pk-only row passed to `broadcast()` is matched
194
+ to its record, a just-deleted one included. A view row is rebuilt from the
195
+ changed base row, so a view's own `pkField` doesn't matter, and a MODIFY that
196
+ takes the row out of the view (an anti-join) is pushed as a DELETE.
197
+ - **Live DELETE** is `ROW: [{DETAILS: {OPERATION: 'DELETE', ROW_REF}}]` and
198
+ nothing else. The store remembers every record a key lost until a broadcast
199
+ announces it (`broadcastTableChange`, or `broadcast()` with a DELETE), so a
200
+ DELETE names each of them — a client gets the ones it holds — and a record
201
+ replaced under the same key before one broadcast goes out as a DELETE of the
202
+ old `ROW_REF` followed by the new INSERT. Deletes nobody broadcasts are kept
203
+ for the next one (bounded: 16 per key, 10,000 keys per table).
204
+ - **Sequences:** `TableDef.sequenceFormat` is `'prefix'` by default
205
+ (`sequencePrefix` + a counter padded to `sequenceWidth`: `TR001`), so
206
+ existing seeds, tests and snapshots keep their ids. `'genesis'` mints
207
+ `IdStrategy.java`'s format — 15-digit counter, sequence id, `LO`, `1`:
208
+ `000000000000001TRLO1`. Seeds in the table's format advance the counter. A
209
+ supplied value is kept; only a missing (`undefined`/`null`) one is generated.
210
+
211
+ `fidelity: 'legacy'` restores the pk-based `ROW_REF` (the pk value, or the
212
+ composite key), the top-level `ROW_REF`, pk-stub DELETE rows and `REP_` rows
213
+ without `RECORD_ID`/`TIMESTAMP`.
214
+
215
+ ## NACK vocabulary
216
+
217
+ Service NACKs (`EVENT_NACK`, `REP_*` errors) follow GSF's `GenesisError` family
218
+ (`src/protocol/errors.ts`, pinned against recorded frames in
219
+ `test/nack.test.ts`): every `ERROR` item has `@type`, a string `CODE`, `TEXT`
220
+ and a `"<code> <reason>"` `STATUS_CODE`, and the envelope carries `WARNING`:
221
+
222
+ | `@type` | Extra fields | Built by |
223
+ |---|---|---|
224
+ | `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?)` |
225
+ | `FieldError` | `FIELD`, `PATH` (`null` for a handler's own check) | `NackError.field(field, text, path?)`, or any input with `FIELD` (`VALIDATION_ERROR`) |
226
+ | `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) |
227
+ | `LoginError` | `DETAILS: null` | `{'@type': 'LoginError', ...}` |
228
+
229
+ `ErrorCode` mirrors GSF's `ErrorCode.kt` (names, default texts, statuses;
230
+ `statusCodeFor(code)`); unknown codes get `500 Internal Server Error`.
231
+ `new NackError([...])` reports several errors at once. Anything else a handler
232
+ throws is `StandardError INTERNAL_ERROR` with its message. A handler can
233
+ 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`,
236
+ an integer `CODE`, `DETAILS {ERROR, STATUS_CODE}`). `fidelity: 'legacy'` sends
237
+ the old `{CODE, TEXT, FIELD?}` items (`VALIDATION_ERROR` by default) and no
238
+ `WARNING`: warnings go in `ERROR` there.
161
239
 
162
240
  ## Running several servers in parallel (and one-command dev stacks)
163
241
 
@@ -317,8 +395,8 @@ the envelopes this engine sent up to 15.47 — see
317
395
  | (live push) | `QUERY_UPDATE {ROW, SEQUENCE_ID}` | One row per push; `DETAILS.OPERATION` is `INSERT`/`MODIFY`/`DELETE`. No `MORE_ROWS`, no `ROWS_COUNT` |
318
396
  | `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
397
  | `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 |
398
+ | `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
+ | `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
400
  | `RESOURCES_REQUEST` | `RESOURCES_REQUEST_ACK` | |
323
401
  | `REQ_<NAME>` | `REP_<NAME> {REPLY}` | Incl. keyed `REQUEST` field lookups (`'*'` = any) and `CRITERIA_MATCH` |
324
402
  | `EVENT_<ENTITY>_<VERB>` commit events | `EVENT_ACK` / `EVENT_NACK` | Via `config.eventHandlers` or `defaultEventHandler` |
@@ -377,12 +455,109 @@ createMockServer({ ...config, fidelity: 'legacy' });
377
455
  | `MORE_ROWS` | The next page, plus `MORE_ROWS_ACK` | Unanswered (logged as unhandled) |
378
456
  | `DATA_LOGOFF` | `LOGOFF_ACK`, or `MSG_NACK` 404 | Unanswered |
379
457
  | `META_REQUEST` / `JSON_SCHEMA_REQUEST` success | `FEATURE_ACK` / `JSON_SCHEMA_FEATURE_ACK` | Echoes the request's `MESSAGE_TYPE` |
458
+ | `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 |
459
+ | `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
+ | Declared schemas (`TableDef.fields`, query and view `fields`, `derivedTypes`) | Used for metadata and rows | Ignored: metadata is inferred, rows go out as stored |
461
+ | `DATA_LOGON` `ORDER_BY` / `REVERSE` / `FIELDS`, criteria on unexposed fields | Honoured / `LOGON_NACK` | Ignored |
380
462
  | Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
381
463
 
464
+ `test/legacyParity.test.ts` pins `'legacy'` to replies recorded from the 15.47.0 engine
465
+ itself, for a config with no declarations and for the showcase config.
466
+
382
467
  `'legacy'` is a migration aid, not a second supported protocol. Plan to remove
383
468
  it from your config; it will then be dropped from the engine. It is read on
384
469
  every message, so changing it on a running server takes effect straight away.
385
470
 
471
+ ## Declared schema (`META_REQUEST` and `JSON_SCHEMA_REQUEST`)
472
+
473
+ Metadata comes from the tables dictionary when a table declares one, and is
474
+ inferred from the rows (`src/db/metadata.ts`) when it doesn't. **Declared
475
+ wins.** A config that declares nothing keeps working, but its types are
476
+ guesses (`LONG`/`DOUBLE`/`BOOLEAN`/`STRING`), it has no enum values,
477
+ defaults or not-null, and a table with no rows has no fields.
478
+
479
+ ```ts
480
+ tables: {
481
+ TRADE: {
482
+ pkField: 'TRADE_ID',
483
+ sequencePrefix: 'TR', // the key is a SEQUENCE: NULLABLE false, OPTIONAL true
484
+ fields: [
485
+ { name: 'TRADE_ID', type: 'STRING' },
486
+ { name: 'QUANTITY', type: 'INT', nullable: false },
487
+ { name: 'SIDE', type: 'ENUM', values: ['SELL', 'BUY'], default: 'BUY', nullable: false },
488
+ { name: 'COMMISSION', type: 'BIGDECIMAL' },
489
+ { name: 'NAME', type: 'STRING', maxSize: 100, minLength: 0, title: 'Name' },
490
+ ],
491
+ generated: [{ field: 'TRADE_NO', kind: 'AUTO_INCREMENT' }], // shape only
492
+ indexes: [{ name: 'TRADE_BY_SIDE', fields: ['SIDE'] }], // for later items
493
+ },
494
+ },
495
+ views: { TRADE_VIEW: { base: 'TRADE', /* joins, derived */ derivedTypes: { TOTAL: 'DOUBLE' } } },
496
+ queries: { ALL_TRADES: { source: 'TRADE_VIEW', fields: ['TRADE_ID', 'SIDE', 'TOTAL'],
497
+ indexes: [{ name: 'BY_SIDE', fields: ['SIDE'] }] } },
498
+ requestReplies: { TRADE: { source: 'TRADE', criteriaOnly: true } },
499
+ eventSchemas: { EVENT_TRADE_BOOK: { className: 'com.acme.BookInput', fields: [/* FieldDef */] } },
500
+ appName: 'showcase', // enum class names: global.genesis.gen.dao.enums.<appName>.<table>.<Field>
501
+ ```
502
+
503
+ - **`FieldDef`** `{name, type, nullable?, values?, default?, maxSize?, minLength?, description?, title?, optional?}`.
504
+ `type` is a dictionary type (`STRING ENUM INT SHORT LONG DOUBLE BIGDECIMAL BOOLEAN DATE
505
+ DATETIME NANO_TIMESTAMP RAW`). Fields are nullable unless declared otherwise; key and
506
+ generated fields default to not null. `description` replaces the JVM class name the real
507
+ server puts there (leave it unset on dates: foundation-forms spots a date by
508
+ `org.joda.time.DateTime`). `optional` decides whether an event payload may leave the field
509
+ out (see events below); an ENUM with no `values` sends no `VALID_VALUES` and no `enum`.
510
+ - **Views** take their base table's fields, each join's `select` columns typed from the joined
511
+ table (nullable), and `derivedTypes` for derived fields (inferred from rows when missing).
512
+ `ViewDef.fields` is the view's `fields { }` block: the view then exposes only those, in that
513
+ order (keep the key in it).
514
+ - **`QueryDef.fields`** is the query's `fields { }` block: rows **and** metadata carry only those
515
+ columns, in that order. A declared source's rows always carry every exposed column, `null`
516
+ when unset, as the real dataserver sends them. `rowFilter` still sees the whole row.
517
+ - `fieldTypes` (queries, request servers, `eventSchemas`) still overrides a TYPE either way.
518
+
519
+ What is sent (GSF 8.15.29; `test/recordedSchemas.test.ts` pins every shape to recorded frames,
520
+ ignoring only `SOURCE_REF`):
521
+
522
+ | Resource | `META_REQUEST` `DETAILS` | `JSON_SCHEMA_REQUEST` |
523
+ |---|---|---|
524
+ | 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]`) |
526
+ | 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
+
528
+ Events resolve as `JSON_SCHEMA_REQUEST` always did: `config.eventSchemas`, then
529
+ `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
531
+ the real server. `OPTIONAL` is true only when the class property has a default
532
+ (PALMetadataUtils.kt): a table's generated DAO defaults every nullable and generated column, so
533
+ those are optional; a custom DTO field is optional only with a `default` or `optional: true`
534
+ (a nullable DTO field with no default must still be sent). An optional ENUM with no default
535
+ reports its first value as `DEFAULT`. Three classes, as the real server reports them:
536
+
537
+ - **Table events** (a generated DAO): every field sorted by Kotlin property name (`DATE_FIELD`
538
+ before `DATETIME_FIELD`), with titles, lengths, ranges and `GENESIS_TYPE`. `TYPE` is the JVM
539
+ one: a DATE column is `TYPE: 'DATETIME', GENESIS_TYPE: 'DATE'`.
540
+ - **Deletes** (`.../_DELETE`, or `pkOnly`): `<Table>.ById`, the key fields only, all mandatory,
541
+ no annotations (`GENESIS_TYPE: null`).
542
+ - **Custom DTOs** (`eventSchemas[name].fields`): the declared fields, no annotations,
543
+ `className` as DESCRIPTION.
544
+
545
+ The AI assistant (`ai-assistant/src/genesis/event-fields.ts`) builds its write tools from event
546
+ META: `NAME`, `GENESIS_TYPE`/`TYPE`, `JSON_TYPE`, `OPTIONAL`, `READ_ONLY`, `TITLE`,
547
+ `VALID_VALUES`.
548
+
549
+ **Behaviour changes for existing configs** (none under `fidelity: 'legacy'`):
550
+ `JSON_SCHEMA_REQUEST` by a query or request-server name now returns that resource's own
551
+ schema (the `DATA_LOGON` message, or `REQUEST` / `REPLY`), as GSF does, not its source table's
552
+ event-shaped one. Event names sort their fields, and `EVENT_*` names with no handler are a 404.
553
+ A declared source's rows carry exactly its exposed columns, `null` when unset.
554
+
555
+ Deviations: a bare table name with no request server of that name still gets the table's
556
+ 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.
560
+
386
561
  ## Dataserver paging
387
562
 
388
563
  `DATA_LOGON` pages the way the real dataserver does (genesis-pal-dataserver's
@@ -451,11 +626,9 @@ real server would also evict the oldest row in the same frame (the moving
451
626
  view, a later item), so here the view can grow past `MAX_VIEW`; the next
452
627
  `MORE_ROWS` then delivers nothing until DELETEs bring it back under the cap.
453
628
 
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.
629
+ Every row's `ROW_REF` is its own record's `RECORD_ID` (a view row's, its base
630
+ record's), so live changes are matched to exactly the row the client holds,
631
+ whatever a view's `pkField` says.
459
632
 
460
633
  `VIEW_NUMBER` is accepted and ignored. It selects the real server's paginated
461
634
  mode, where each page replaces the last; here paging is always sequential.
@@ -479,21 +652,22 @@ zero config for the common case: `EVENT_<TABLE>_<VERB...>` is matched against
479
652
  `config.tables` (longest match wins), and any event name matching
480
653
  `.../_DELETE(/...)?/` gets a PK-only schema (matching what
481
654
  `EntityManagement.confirmDelete` actually needs) while everything else gets
482
- every field on that table. This covers naming variants like
655
+ every field on that table (see [Declared schema](#declared-schema-meta_request-and-json_schema_request)
656
+ for what each property carries). This covers naming variants like
483
657
  `EVENT_COUNTERPARTY_INSERT_WITH_APPROVAL`, `EVENT_TRADE_MODIFY_PRICE`, or
484
658
  `EVENT_TRADE_INSERT_FAIL` without per-event config. Override via
485
659
  `config.eventSchemas['<eventName>'] = { table, pkOnly, fieldTypes }` for
486
660
  names that don't follow the convention.
487
661
 
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.
662
+ The `FEATURE` doesn't have to be an event name. It resolves, in order: an
663
+ entry in `config.eventSchemas`, a **dataserver** name (`ALL_TRADES`: the
664
+ `DATA_LOGON` schema, its rows in `OUTBOUND.$defs[...QueryRow]` — what
665
+ `<foundation-filters>` reads), a **request server** name (`TRADE`, without
666
+ `REQ_`: its rows in `OUTBOUND.properties.REPLY.items`), an
667
+ `EVENT_<TABLE>_<VERB>` event, and finally a bare **table name** (the table's
668
+ insert-shaped schema — a fallback the real server doesn't have). Under
669
+ `fidelity: 'legacy'` a query or request-server name resolves to its source
670
+ table's fields instead, as up to 15.47.
497
671
 
498
672
  Everything else `<entity-management>` needs — resource discovery
499
673
  (`RESOURCES_REQUEST`), grid metadata (`META_REQUEST`), row data (`DATA_LOGON`
@@ -568,8 +742,8 @@ extracted from for how each was actually found:
568
742
  (`src/protocol/connection.ts`) does this automatically — never build a raw
569
743
  response object yourself without going through it. A response missing
570
744
  `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
745
+ 2. **`EVENT_NACK`/`REP_*` errors are always an `ERROR` array of items** —
746
+ uppercase keys (see [NACK vocabulary](#nack-vocabulary)). Throw `NackError` from an event handler or
573
747
  request-reply resolver rather than hand-rolling this shape; a lowercase
574
748
  `{error, details}` object crashes the real client's `.forEach`. (The
575
749
  router's `MSG_NACK` uses the same array, with an integer `CODE` and a
@@ -593,7 +767,9 @@ extracted from for how each was actually found:
593
767
  directly.
594
768
  5. **Datasource `init()` needs `TYPE: 'DATASERVER'` plus a non-empty `FIELD`
595
769
  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
770
+ Declare the table's `fields` and it never is (see
771
+ [Declared schema](#declared-schema-meta_request-and-json_schema_request)).
772
+ Without them, `deriveFields()` (`src/db/metadata.ts`) infers this from the rows' own JS
597
773
  types — falling back to the registration-time seed rows when every live
598
774
  row has been deleted, so a runtime delete can't collapse a resource's
599
775
  schema to zero fields. Numeric inference scans every row (a column is
@@ -643,8 +819,7 @@ in is pushed as an **INSERT** (`resolveSubscriptionOperation()` and
643
819
  `trackClientView()` in `src/server.ts`; see
644
820
  [Dataserver paging](#dataserver-paging)). If the predicate throws, the push
645
821
  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
822
+ the criteria predicate; they reach every client that holds the row. (Under
648
823
  `fidelity: 'legacy'`, every DELETE reaches every subscription, and a row
649
824
  entering the criteria is pushed as a MODIFY.)
650
825
 
@@ -656,9 +831,20 @@ Where this engine still differs from the real server:
656
831
  only the rows whose values changed.
657
832
  - There is no **moving view**. Once the view holds `MAX_VIEW` rows, live
658
833
  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`.
834
+ - Without `ORDER_BY`, snapshots are in **insertion order** (with or without
835
+ `REVERSE`), where the real default is newest first by `RECORD_ID`. `ORDER_BY` names one of
836
+ the query's `indexes` and sorts ascending by its fields, ties by `RECORD_ID`; `REVERSE`
837
+
838
+ flips that; an unknown index is `LOGON_NACK INVALID_INDEX`. Under `ORDER_BY`, a live INSERT
839
+ that sorts among the rows still waiting for `MORE_ROWS` waits with them, in order (a MODIFY
840
+ of a waiting row keeps its place). `DETAILS.FIELDS` narrows the rows to those columns.
841
+ - A `CRITERIA_MATCH` naming a field the query doesn't expose is `LOGON_NACK
842
+ INVALID_CRITERIA`, as on GSF — when the query's fields are known (declared, or a `fields`
843
+ 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: []`).
845
+ - `RECORD_ID`/`TIMESTAMP` are safe integers, not GSF's ~7.5e18 values (see
846
+ [Record identity](#record-identity-row_ref-and-sequences)).
847
+
662
848
  - The opt-in auth gate answers with typed `NOT_LOGGED_ON` replies, not the
663
849
  router's `MSG_NACK` 401.
664
850
 
@@ -2,4 +2,5 @@ import type { Row } from '../types.ts';
2
2
  export type Predicate = (record: Row) => boolean;
3
3
  /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
4
4
  export declare function compileCriteria(criteria?: string | null): Predicate;
5
+ export declare function criteriaFields(criteria?: string | null): string[] | undefined;
5
6
  export declare function filterByCriteria(rows: Row[], criteria?: string | null): Row[];
@@ -154,6 +154,9 @@ function compareByTime(record, args, resolve, cmp) {
154
154
  class Parser {
155
155
  tokens;
156
156
  pos = 0;
157
+ // Every field the expression reads: bare identifiers in comparisons, and
158
+ // function-call arguments and receivers.
159
+ fields = new Set();
157
160
  constructor(tokens) {
158
161
  this.tokens = tokens;
159
162
  }
@@ -227,6 +230,8 @@ class Parser {
227
230
  if (rightToken === undefined) {
228
231
  throw new Error('Expected operand on the right side of comparison');
229
232
  }
233
+ this.noteField(leftToken);
234
+ this.noteField(rightToken);
230
235
  const resolveLeft = (record) => this.resolveValue(leftToken, record);
231
236
  const resolveRight = (record) => this.resolveValue(rightToken, record);
232
237
  return (record) => {
@@ -252,6 +257,10 @@ class Parser {
252
257
  }
253
258
  };
254
259
  }
260
+ noteField(token) {
261
+ if (parseLiteral(token) === undefined)
262
+ this.fields.add(token);
263
+ }
255
264
  resolveValue(token, record) {
256
265
  const literal = parseLiteral(token);
257
266
  if (literal !== undefined)
@@ -286,6 +295,8 @@ class Parser {
286
295
  if (!handler)
287
296
  throw new Error(`Unsupported criteria function: ${name}`);
288
297
  const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
298
+ for (const arg of allArgs)
299
+ this.noteField(arg);
289
300
  const resolve = (token, record) => this.resolveValue(token, record);
290
301
  return (record) => handler(record, allArgs, resolve);
291
302
  }
@@ -314,6 +325,24 @@ export function compileCriteria(criteria) {
314
325
  return () => true;
315
326
  }
316
327
  }
328
+ // The fields a CRITERIA_MATCH reads, or undefined when it doesn't parse
329
+ // (compileCriteria then fails open). The real dataserver refuses a criteria
330
+ // naming a field its query doesn't expose (INVALID_CRITERIA).
331
+ export function criteriaFields(criteria) {
332
+ if (!criteria || typeof criteria !== 'string' || criteria.trim() === '')
333
+ return [];
334
+ 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];
341
+ }
342
+ catch {
343
+ return undefined;
344
+ }
345
+ }
317
346
  export function filterByCriteria(rows, criteria) {
318
347
  const predicate = compileCriteria(criteria);
319
348
  return rows.filter((row) => {
@@ -0,0 +1,11 @@
1
+ import type { Row } from '../types.ts';
2
+ export declare const RECORD_ID = "RECORD_ID";
3
+ export declare const TIMESTAMP = "TIMESTAMP";
4
+ export declare const RECORD_FIELDS: readonly string[];
5
+ export declare class RecordClock {
6
+ private last;
7
+ next(): number;
8
+ observe(value: unknown): void;
9
+ }
10
+ export declare function withoutRecordFields(row: Row): Row;
11
+ export declare function recordKey(row: Row | undefined | null): string | undefined;
@@ -0,0 +1,46 @@
1
+ // Record identity: the RECORD_ID and TIMESTAMP every Genesis table row carries.
2
+ //
3
+ // GSF sets TIMESTAMP from a high-resolution clock on every write and
4
+ // RECORD_ID = TIMESTAMP on insert (genesis-db InsertOperation.kt); a modify
5
+ // moves TIMESTAMP on and keeps RECORD_ID. The real values are around 7.5e18:
6
+ // epoch millis shifted left 22 bits, plus a counter. That is far above 2^53,
7
+ // so a JavaScript client's JSON.parse rounds neighbouring rows onto the same
8
+ // number. The mock deliberately stays below that: its stamps are a counter
9
+ // seeded from epoch millis that only goes up, so they stay exact, strictly
10
+ // increasing safe integers, and a client that reads TIMESTAMP as a date (the
11
+ // AI assistant shows a NANO_TIMESTAMP as epoch millis) still gets roughly the
12
+ // write time. The same order, a different magnitude.
13
+ export const RECORD_ID = 'RECORD_ID';
14
+ export const TIMESTAMP = 'TIMESTAMP';
15
+ export const RECORD_FIELDS = [RECORD_ID, TIMESTAMP];
16
+ // Hands out RECORD_ID/TIMESTAMP values: the current epoch millis, or one more
17
+ // than the last value when that is not ahead of it (seeding a thousand rows
18
+ // runs the counter a second or so ahead of the clock).
19
+ export class RecordClock {
20
+ last = 0;
21
+ next() {
22
+ this.last = Math.max(Date.now(), this.last + 1);
23
+ return this.last;
24
+ }
25
+ // Keeps later stamps above a value a seed row brought with it.
26
+ observe(value) {
27
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > this.last) {
28
+ this.last = value;
29
+ }
30
+ }
31
+ }
32
+ // A row without its record identity — what a dataserver sends (the real
33
+ // dataserver never puts RECORD_ID or TIMESTAMP in a row, only
34
+ // DETAILS.ROW_REF) and what field metadata is derived from.
35
+ export function withoutRecordFields(row) {
36
+ if (!(RECORD_ID in row) && !(TIMESTAMP in row))
37
+ return row;
38
+ const { [RECORD_ID]: _recordId, [TIMESTAMP]: _timestamp, ...rest } = row;
39
+ return rest;
40
+ }
41
+ // The Map key a record is tracked under. RECORD_IDs are numbers here, but a
42
+ // seed may bring a recorded string, so they are compared as strings.
43
+ export function recordKey(row) {
44
+ const id = row?.[RECORD_ID];
45
+ return id === undefined || id === null ? undefined : String(id);
46
+ }
@@ -0,0 +1,46 @@
1
+ import type { FieldDef, MockServerConfig, QueryDef, RequestReplyDef, Row, TableDef } from '../types.ts';
2
+ import type { Store } from './store.ts';
3
+ export interface ResolvedField {
4
+ name: string;
5
+ type: string;
6
+ nullable: boolean;
7
+ generated: boolean;
8
+ values?: string[];
9
+ default?: unknown;
10
+ maxSize?: number;
11
+ minLength?: number;
12
+ description?: string;
13
+ title?: string;
14
+ optional?: boolean;
15
+ table?: string;
16
+ }
17
+ export interface ResolvedSchema {
18
+ fields: ResolvedField[];
19
+ declared: boolean;
20
+ }
21
+ export declare function generatedFieldsOf(def: TableDef): Set<string>;
22
+ export declare function resolveSourceSchema(source: string, config: MockServerConfig, store: Store): ResolvedSchema;
23
+ export declare function withTypeOverrides(schema: ResolvedSchema, fieldTypes: Record<string, string> | undefined): ResolvedSchema;
24
+ export declare function projectSchema(schema: ResolvedSchema, names: string[] | undefined): ResolvedSchema;
25
+ export declare function querySchema(query: QueryDef, config: MockServerConfig, store: Store): ResolvedSchema;
26
+ export declare function requestReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
27
+ export declare const RECORD_IDENTITY_FIELDS: ResolvedField[];
28
+ export declare function requestFieldsOf(requestReply: RequestReplyDef, store: Store): string[];
29
+ export declare function knownValues(field: ResolvedField): string[] | undefined;
30
+ export declare function eventDefaultOf(field: ResolvedField): unknown;
31
+ export declare function queryFieldNames(query: QueryDef | undefined, config: MockServerConfig, store: Store): string[] | undefined;
32
+ export declare function projectRow(row: Row, names: string[] | undefined): Row;
33
+ export declare function isDeleteEventName(name: string): boolean;
34
+ export declare function inferEventTable(eventName: string | undefined, tableNames: string[]): string | undefined;
35
+ export interface EventTarget {
36
+ kind: 'table' | 'byId' | 'dto';
37
+ table?: string;
38
+ className: string;
39
+ title?: string;
40
+ fieldTypes?: Record<string, string>;
41
+ fields?: FieldDef[];
42
+ }
43
+ export declare function resolveEventTarget(name: string | undefined, config: MockServerConfig, { bareTable }?: {
44
+ bareTable?: boolean;
45
+ }): EventTarget | undefined;
46
+ export declare function eventFields(target: EventTarget, config: MockServerConfig, store: Store): ResolvedField[];