@genesislcap/mock-server 15.48.0 → 15.50.0-BACE-0.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.
package/README.md CHANGED
@@ -250,7 +250,8 @@ having to monkey-patch the engine:
250
250
  registered handler, so a project with dozens of events can adopt
251
251
  incrementally (`defaultEventHandler: () => ({})` acks everything
252
252
  unhandled) instead of enumerating ack-only handlers. Unregistered events
253
- NACK with `UNKNOWN_RESOURCE` when unset. Note `RESOURCES_REQUEST` only
253
+ get the router's `MSG_NACK` 404 when unset (see
254
+ [Protocol coverage](#protocol-coverage)). Note `RESOURCES_REQUEST` only
254
255
  lists *named* events (`eventHandlers` + `eventSchemas` keys) — if the
255
256
  client's resource gate blocks an event served only by the default handler,
256
257
  name it in `config.eventSchemas` or `config.resources`.
@@ -301,18 +302,163 @@ a client opens. Enable it only for clients that authenticate over WS.
301
302
 
302
303
  ## Protocol coverage
303
304
 
304
- | MESSAGE_TYPE | Handled |
305
- |---|---|
306
- | `EVENT_LOGIN_AUTH` / `_ACK` / `_NACK` (WS) | ✅ |
307
- | `gwf/event-login-auth`, `gwf/event-login-details` (HTTP) | ✅ |
308
- | `EVENT_LOGOUT` / `LOGOUT_ACK` | ✅ |
309
- | `HEARTBEAT_PING` / `_PONG` | ✅ |
310
- | `DATA_LOGON` / `DATA_LOGOFF` / `QUERY_UPDATE` | ✅ snapshot + live push |
311
- | `META_REQUEST` | ✅ auto-derived from seed data |
312
- | `JSON_SCHEMA_REQUEST` | ✅ auto-derived per event — see below |
313
- | `RESOURCES_REQUEST` | ✅ |
314
- | `REQ_<NAME>` / `REP_<NAME>` | ✅ incl. keyed `REQUEST` field lookups (`'*'` = any) and `CRITERIA_MATCH` |
315
- | `EVENT_<ENTITY>_<VERB>` commit events | ✅ via `config.eventHandlers` |
305
+ Envelopes follow the real GSF 8.15.x server (router, dataserver and event
306
+ handler Kotlin, checked against tag `v8.15.14`). `fidelity: 'legacy'` restores
307
+ the envelopes this engine sent up to 15.47 — see
308
+ [Wire fidelity](#wire-fidelity-and-the-legacy-escape-hatch).
309
+
310
+ | Inbound | Reply | Notes |
311
+ |---|---|---|
312
+ | `EVENT_LOGIN_AUTH` (WS) | `EVENT_LOGIN_AUTH_ACK` / `_NACK` | |
313
+ | `gwf/event-login-auth`, `gwf/event-login-details` (HTTP) | `EVENT_LOGIN_AUTH_ACK` / `EVENT_LOGIN_DETAILS_ACK` | |
314
+ | `EVENT_LOGOUT` | `LOGOUT_ACK` | |
315
+ | `HEARTBEAT_PING` | `HEARTBEAT_PONG` | |
316
+ | `DATA_LOGON` | `LOGON_ACK {SEQUENCE_ID: 0}`, then `QUERY_UPDATE {ROW, MORE_ROWS, ROWS_COUNT, SEQUENCE_ID: 1}` | The first page only — see [Dataserver paging](#dataserver-paging). No matching rows: `{QUERY_UPDATE, MORE_ROWS: false, SEQUENCE_ID: 1}`, with no `ROW` and no `ROWS_COUNT`. A second logon on a live `SOURCE_REF` gets `MSG_NACK` 409 |
317
+ | (live push) | `QUERY_UPDATE {ROW, SEQUENCE_ID}` | One row per push; `DETAILS.OPERATION` is `INSERT`/`MODIFY`/`DELETE`. No `MORE_ROWS`, no `ROWS_COUNT` |
318
+ | `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
+ | `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 |
322
+ | `RESOURCES_REQUEST` | `RESOURCES_REQUEST_ACK` | |
323
+ | `REQ_<NAME>` | `REP_<NAME> {REPLY}` | Incl. keyed `REQUEST` field lookups (`'*'` = any) and `CRITERIA_MATCH` |
324
+ | `EVENT_<ENTITY>_<VERB>` commit events | `EVENT_ACK` / `EVENT_NACK` | Via `config.eventHandlers` or `defaultEventHandler` |
325
+ | Anything naming an unknown resource | `MSG_NACK` 404 | See below |
326
+
327
+ Each `SEQUENCE_ID` on a subscription is the previous one plus 1. The snapshot
328
+ is 1, and each later `MORE_ROWS` page or live push takes the next number.
329
+ `LOGON_ACK` is 0, and `MORE_ROWS_ACK` and `LOGOFF_ACK` carry none.
330
+
331
+ **Unknown resources** are rejected the way the real router rejects them,
332
+ before any handler runs:
333
+
334
+ ```json
335
+ {
336
+ "MESSAGE_TYPE": "MSG_NACK",
337
+ "SOURCE_REF": "<the request's>",
338
+ "ERROR": [{ "CODE": 404, "TEXT": "Resource X is not mapped to a service", "STATUS_CODE": "404 Not Found" }],
339
+ "DETAILS": { "ERROR": "Resource X is not mapped to a service", "STATUS_CODE": "404 Not Found" }
340
+ }
341
+ ```
342
+
343
+ `CODE` is the integer status. The resource name `X` is `DETAILS.DATASOURCE_NAME`
344
+ for `DATA_LOGON`, the name after `REQ_` for request servers, `DETAILS.FEATURE`
345
+ for `META_REQUEST` and `JSON_SCHEMA_REQUEST`, and the `MESSAGE_TYPE` for a
346
+ commit event with no handler (and no `defaultEventHandler`). A missing name is
347
+ the empty string. The same frame answers two subscription errors:
348
+
349
+ - `MORE_ROWS` or `DATA_LOGOFF` for a subscription the connection doesn't
350
+ have gets `TEXT` "Dataserver subscription does not exist".
351
+ - A repeated `DATA_LOGON` gets `CODE` 409 and `"409 Conflict"`.
352
+
353
+ This is the change consumers notice most. foundation-comms' socket
354
+ **rejects** the pending promise on `MSG_NACK`
355
+ (`foundation-comms/src/connect/socket.ts`). The old typed replies resolved it:
356
+ `QUERY_UPDATE`, `REP_*`, `EVENT_NACK` or an echoed `META_REQUEST`, each carrying
357
+ `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]`.
358
+
359
+ ## Wire fidelity and the legacy escape hatch
360
+
361
+ Behaviour that matches the real server is the default. `config.fidelity` is one
362
+ switch for the whole server. Its type is `FidelityMode` (`'gsf' | 'legacy'`)
363
+ and it defaults to `'gsf'`. `'legacy'` restores what this engine sent up to
364
+ 15.47, for a consumer built against those envelopes that can't move yet:
365
+
366
+ ```ts
367
+ createMockServer({ ...config, fidelity: 'legacy' });
368
+ ```
369
+
370
+ | | `'gsf'` (default) | `'legacy'` |
371
+ |---|---|---|
372
+ | `DATA_LOGON` | `LOGON_ACK`, then the first `MAX_ROWS` rows | Every row in one `QUERY_UPDATE`, no `LOGON_ACK`; `MAX_ROWS` and `MAX_VIEW` are ignored |
373
+ | No matching rows | No `ROW`, no `ROWS_COUNT` | `ROW: []`, `ROWS_COUNT: 0` |
374
+ | Repeated `DATA_LOGON` on a live `SOURCE_REF` | `MSG_NACK` 409; the first subscription keeps running | Replaces the subscription and sends a new snapshot |
375
+ | `ROWS_COUNT` | Rows after the query `filter`, before `CRITERIA_MATCH` and the `rowFilter` | Rows after all three |
376
+ | Live push | No `ROWS_COUNT`; only for rows the client holds, or as an `INSERT` for a row that is new to it | `ROWS_COUNT: 1`; to every subscription whose criteria match (every DELETE goes to all of them) |
377
+ | `MORE_ROWS` | The next page, plus `MORE_ROWS_ACK` | Unanswered (logged as unhandled) |
378
+ | `DATA_LOGOFF` | `LOGOFF_ACK`, or `MSG_NACK` 404 | Unanswered |
379
+ | `META_REQUEST` / `JSON_SCHEMA_REQUEST` success | `FEATURE_ACK` / `JSON_SCHEMA_FEATURE_ACK` | Echoes the request's `MESSAGE_TYPE` |
380
+ | Unknown resource | `MSG_NACK` 404 (the client's promise rejects) | A typed reply with `ERROR: [{CODE: 'UNKNOWN_RESOURCE'}]` (the promise resolves) |
381
+
382
+ `'legacy'` is a migration aid, not a second supported protocol. Plan to remove
383
+ it from your config; it will then be dropped from the engine. It is read on
384
+ every message, so changing it on a running server takes effect straight away.
385
+
386
+ ## Dataserver paging
387
+
388
+ `DATA_LOGON` pages the way the real dataserver does (genesis-pal-dataserver's
389
+ `ClientOptions` and `QueryClient`):
390
+
391
+ - **`DETAILS.MAX_ROWS`** (default 250) is the page size. **`DETAILS.MAX_VIEW`**
392
+ (default 1000) is the most rows the view holds across all pages. `MAX_ROWS`
393
+ is clamped to `MAX_VIEW`, and `0` means "no limit" for either. These are
394
+ also foundation-comms' `DatasourceDefaults`, so a grid that sets neither gets
395
+ 250 rows and then pages.
396
+ - The snapshot is the first page: at most `MAX_ROWS` rows, and `MORE_ROWS: true`
397
+ when more are waiting. **`ROWS_COUNT`** is on the first page only, and counts
398
+ like the real dataserver's index count: it **ignores `CRITERIA_MATCH` and the
399
+ user's `rowFilter`** (recorded: `ROWS_COUNT: 10` with 6 rows matching), so it
400
+ can be larger than what the client can ever page to. grid-pro and the AI
401
+ assistant already treat it that way. It is taken after the query's own
402
+ `filter`, the where clause the real index is built over; no recording covers
403
+ that case. With no matching rows it is left out, as GSF does.
404
+ - The other rows wait in the subscription. **`MORE_ROWS`** sends the next
405
+ `MAX_ROWS` of them as a `QUERY_UPDATE` on the logon's `SOURCE_REF`, with
406
+ `ROW` and `MORE_ROWS` and without `ROWS_COUNT`. The request names the
407
+ subscription in `DETAILS.SOURCE_REF`, or is sent on the logon's own
408
+ `SOURCE_REF`. foundation-comms' `createMoreRowsMessage` sends
409
+ `{MESSAGE_TYPE: 'MORE_ROWS', SOURCE_REF: <new>, DETAILS: {SOURCE_REF: <stream>, VIEW_NUMBER}}`
410
+ and doesn't wait for a reply.
411
+ - When `DETAILS.SOURCE_REF` is given, `MORE_ROWS_ACK` also answers the
412
+ request's own `SOURCE_REF`. It is sent before the page: the real router
413
+ acks while its dataserver is still reading.
414
+ - After the last page, `MORE_ROWS` still gets an answer:
415
+ `{QUERY_UPDATE, MORE_ROWS: false}` with no `ROW`. The real server sends the
416
+ same when a read finds nothing.
417
+ - **A full view is not the end of the data.** Once the client holds `MAX_VIEW`
418
+ rows, matching rows that don't fit keep `MORE_ROWS: true` — on the page that
419
+ filled the view, and on the empty `{QUERY_UPDATE, MORE_ROWS: true}` page that
420
+ answers every later `MORE_ROWS` (recorded: `MAX_ROWS` 10, `MAX_VIEW` 3 and 10
421
+ rows give 3 rows, then empty pages, all `MORE_ROWS: true`). A client that
422
+ keeps asking while `MORE_ROWS` is true loops. When a live `DELETE` frees a
423
+ slot, the next `MORE_ROWS` fills it with the next row waiting, as the real
424
+ server's read does.
425
+ - Subscriptions belong to their connection. Another socket's `MORE_ROWS` or
426
+ `DATA_LOGOFF` for the same `SOURCE_REF` gets `MSG_NACK` 404 and no rows, and
427
+ the owner's next page is untouched. Two sockets may use the same
428
+ `SOURCE_REF`; closing one leaves the other's subscription alone.
429
+
430
+ **Live changes follow what each client holds.** Like the real dataserver's
431
+ client index, every subscription knows which rows it has sent (`held`) and
432
+ which matched rows are still waiting (`pending`: past the current page, or
433
+ past `MAX_VIEW`). A row is in scope while it passes the query `filter`,
434
+ `CRITERIA_MATCH` and the user's `rowFilter`. For each live change:
435
+
436
+ | The row is… | In scope after the change | Out of scope, or deleted |
437
+ |---|---|---|
438
+ | Held by the client | `MODIFY` pushed | `DELETE` pushed (row identity only) |
439
+ | Waiting for `MORE_ROWS` | Waiting copy updated in place, nothing pushed | Dropped from the waiting rows, nothing pushed |
440
+ | Not known to the client | `INSERT` pushed with the full row; now held | Nothing pushed |
441
+
442
+ So a new row arrives as an `INSERT`, a row a MODIFY brings back into the
443
+ criteria arrives as an `INSERT` (recorded: `DELETE` on leaving, `INSERT` on
444
+ re-entering, same `ROW_REF`), and rows the client never got — including rows
445
+ cut off by `MAX_VIEW`, and the hundreds of view rows a change to a joined table
446
+ re-materializes — get no MODIFYs or DELETEs at all. A waiting row's page later
447
+ delivers its current values. Nothing absorbed uses a `SEQUENCE_ID`.
448
+
449
+ An `INSERT` is pushed even when the view already holds `MAX_VIEW` rows. The
450
+ real server would also evict the oldest row in the same frame (the moving
451
+ view, a later item), so here the view can grow past `MAX_VIEW`; the next
452
+ `MORE_ROWS` then delivers nothing until DELETEs bring it back under the cap.
453
+
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.
459
+
460
+ `VIEW_NUMBER` is accepted and ignored. It selects the real server's paginated
461
+ mode, where each page replaces the last; here paging is always sequential.
316
462
 
317
463
  ## Entity-management (`@genesislcap/foundation-entity-management`) support
318
464
 
@@ -346,7 +492,7 @@ an entry in `config.eventSchemas`, a **resource name** (a `config.queries` or
346
492
  `<entity-management resourceName="COUNTERPARTY">` asks by table and the
347
493
  showcase's react-client Admin page does that, while the FAST-Element client's
348
494
  grids ask by query name (`ALL_USERS`, `ALL_PROFILES`, `ALL_TRADES`). Resolve
349
- only event names and those requests error with `UNKNOWN_RESOURCE` and the form
495
+ only event names and those requests are rejected with `MSG_NACK` 404 and the form
350
496
  renders no fields — the same dead button as above, from the other direction.
351
497
 
352
498
  Everything else `<entity-management>` needs — resource discovery
@@ -425,7 +571,9 @@ extracted from for how each was actually found:
425
571
  2. **`EVENT_NACK`/`REP_*` errors are always `ERROR: [{CODE, TEXT}]`** — an
426
572
  array, uppercase keys. Throw `NackError` from an event handler or
427
573
  request-reply resolver rather than hand-rolling this shape; a lowercase
428
- `{error, details}` object crashes the real client's `.forEach`.
574
+ `{error, details}` object crashes the real client's `.forEach`. (The
575
+ router's `MSG_NACK` uses the same array, with an integer `CODE` and a
576
+ `STATUS_CODE` — `src/protocol/msgNack.ts` builds it.)
429
577
  3. **Commit-event acks are the GENERIC `EVENT_ACK`/`EVENT_NACK`** — never
430
578
  `<EVENT_NAME>_ACK`/`_NACK`. The reply is correlated purely by
431
579
  `SOURCE_REF`; real consumers match the MESSAGE_TYPE string exactly
@@ -434,9 +582,10 @@ extracted from for how each was actually found:
434
582
  `=== 'EVENT_NACK'`). Get this wrong and every successful commit renders
435
583
  as a failure toast while every NACK passes as silent success.
436
584
  `handleCommitEvent()` (`src/handlers/commitEvent.ts`) does this for you.
437
- 4. **`DATA_LOGON` snapshot and later live pushes use the identical envelope**
438
- (`MESSAGE_TYPE: 'QUERY_UPDATE'`, same `SOURCE_REF`) — the client
439
- distinguishes insert/amend/delete purely via each row's
585
+ 4. **`DATA_LOGON` snapshot, `MORE_ROWS` pages and later live pushes share one
586
+ envelope** (`MESSAGE_TYPE: 'QUERY_UPDATE'`, the logon's `SOURCE_REF`; only
587
+ the snapshot carries `ROWS_COUNT`, and live pushes carry no `MORE_ROWS`) —
588
+ the client distinguishes insert/amend/delete purely via each row's
440
589
  `DETAILS.OPERATION` (`'INSERT' | 'MODIFY' | 'DELETE'`, no other vocabulary
441
590
  is recognized) plus `DETAILS.ROW_REF` for row identity. `stampRow()`
442
591
  (`src/protocol/rowUpdate.ts`) does this for you — always route pushes
@@ -485,30 +634,43 @@ extracted from for how each was actually found:
485
634
  combination of provided fields — needed for tables where no single field
486
635
  is unique on its own.
487
636
 
488
- ## Known, accepted limitation
489
-
490
- If a live **update** (MODIFY) makes a row fall out of a subscription's
491
- `CRITERIA_MATCH`, the correct behavior is to push it as a `DELETE` so the
492
- client drops the now out-of-scope row. This engine does not do that
493
- automatically — `broadcast()` only checks the *new* row against the
494
- predicate, so a row that stops matching just stops being pushed rather than
495
- being explicitly removed from the client's local state. Fix this in your own
496
- `eventHandlers` if a project leans harder on live filtering than the
497
- showcase example does (e.g. re-check membership yourself and call
498
- `broadcastTableChange(table, 'DELETE', pk)` when a row exits the criteria).
499
-
500
- Actual **deletes** are not affected: DELETE pushes bypass the criteria
501
- predicate entirely (the pushed row is a PK-only stub that couldn't satisfy
502
- one anyway), so every subscriber is told to drop the row — a client that
503
- never held that `ROW_REF` ignores the message.
637
+ ## Live criteria membership, and known deviations
638
+
639
+ A live **MODIFY** that moves a row out of a subscription's `CRITERIA_MATCH`, or
640
+ out of the query's `filter`, is pushed as a **DELETE** carrying only the row's
641
+ identity to a client that holds the row, and a MODIFY that brings a row back
642
+ in is pushed as an **INSERT** (`resolveSubscriptionOperation()` and
643
+ `trackClientView()` in `src/server.ts`; see
644
+ [Dataserver paging](#dataserver-paging)). If the predicate throws, the push
645
+ 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
648
+ `fidelity: 'legacy'`, every DELETE reaches every subscription, and a row
649
+ entering the criteria is pushed as a MODIFY.)
650
+
651
+ Where this engine still differs from the real server:
652
+
653
+ - Live MODIFYs carry the **full row**. The real server sends only the changed
654
+ fields; a full row is a superset, which clients tolerate.
655
+ - A change to a **joined table** pushes a MODIFY for every held view row, not
656
+ only the rows whose values changed.
657
+ - There is no **moving view**. Once the view holds `MAX_VIEW` rows, live
658
+ 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`.
662
+ - The opt-in auth gate answers with typed `NOT_LOGGED_ON` replies, not the
663
+ router's `MSG_NACK` 401.
504
664
 
505
665
  ## Tests
506
666
 
507
667
  `pnpm --filter @genesislcap/mock-server test` runs the unit tests for
508
668
  the criteria grammar, store, metadata inference, and event-table inference
509
669
  plus end-to-end WS/HTTP tests (auth gating, filtered-subscription DELETE
510
- delivery, keyed `REQ_*` lookups, session reuse, clean shutdown) via
511
- `node --test`, no build step.
670
+ delivery, keyed `REQ_*` lookups, session reuse, clean shutdown — and, in
671
+ `test/dataserver.test.ts`, the GSF envelopes, paging and the `'legacy'`
672
+ fidelity mode) via `node --test`, no build step. Only `test/**/*.test.ts`
673
+ files are run, so shared helpers (`test/support.ts`) can sit beside them.
512
674
 
513
675
  ## What's per-project vs. generic
514
676
 
@@ -1,5 +1,7 @@
1
1
  import { NackError } from "../protocol/errors.js";
2
+ import { isLegacyFidelity } from "../protocol/fidelity.js";
2
3
  import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
+ import { resourceNotMappedNack } from "../protocol/msgNack.js";
3
5
  // Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
4
6
  // convention your project uses) are looked up by exact MESSAGE_TYPE against
5
7
  // config.eventHandlers. A handler receives (details, ctx) and either returns
@@ -15,13 +17,20 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
15
17
  // real consumers (foundation-comms' kv.service, entity-management) match
16
18
  // these names exactly, so a name-suffixed <EVENT>_ACK reads as failure and
17
19
  // a <EVENT>_NACK passes as silent success.
20
+ // An event nothing handles never reaches an event handler on the real
21
+ // server: the router rejects it by name with MSG_NACK 404.
18
22
  if (!handler) {
19
- connection.reply(message, {
20
- MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
21
- ERROR: [
22
- { CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `No event handler registered for ${eventName}` },
23
- ],
24
- });
23
+ connection.reply(message, isLegacyFidelity(config)
24
+ ? {
25
+ MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
26
+ ERROR: [
27
+ {
28
+ CODE: ERROR_CODE.UNKNOWN_RESOURCE,
29
+ TEXT: `No event handler registered for ${eventName}`,
30
+ },
31
+ ],
32
+ }
33
+ : resourceNotMappedNack(eventName));
25
34
  return;
26
35
  }
27
36
  try {
@@ -1,5 +1,11 @@
1
1
  import type { Store } from '../db/store.ts';
2
2
  import type { Connection } from '../protocol/connection.ts';
3
- import type { GenesisMessage, MockServerConfig } from '../types.ts';
3
+ import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
4
+ export declare function pagingOptions(details: Row): {
5
+ maxRows: number;
6
+ maxView: number;
7
+ };
8
+ export declare function pendingKey(rowRef: unknown): string | undefined;
4
9
  export declare function handleDataLogon(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;
5
- export declare function handleDataLogoff(message: GenesisMessage, connection: Connection): void;
10
+ export declare function handleMoreRows(message: GenesisMessage, connection: Connection, config: MockServerConfig): void;
11
+ export declare function handleDataLogoff(message: GenesisMessage, connection: Connection, config: MockServerConfig): void;
@@ -1,20 +1,88 @@
1
1
  import { compileCriteria, filterByCriteria } from "../db/criteria.js";
2
+ import { isLegacyFidelity } from "../protocol/fidelity.js";
2
3
  import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
4
+ import { nonExistentSubscriptionNack, resourceNotMappedNack, subscriptionAlreadyExistsNack, } from "../protocol/msgNack.js";
3
5
  import { stampRow } from "../protocol/rowUpdate.js";
4
6
  import { applyResourceAuth, requestingUser } from "./resourceAuth.js";
5
- // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with an
6
- // initial snapshot, then keep the SOURCE_REF registered so later inserts/amends/
7
- // deletes on this resource can be pushed on the same channel (see server.ts
8
- // broadcast()) until the client sends DATA_LOGOFF.
7
+ // The real dataserver's defaults (genesis-pal-dataserver ClientOptions,
8
+ // GSF 8.15.x) — also what foundation-comms' DatasourceDefaults send.
9
+ const DEFAULT_MAX_ROWS = 250;
10
+ const DEFAULT_MAX_VIEW = 1000;
11
+ function readCount(value, fallback) {
12
+ const count = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
13
+ return typeof count === 'number' && Number.isInteger(count) && count >= 0 ? count : fallback;
14
+ }
15
+ // MAX_ROWS is the page size, MAX_VIEW the most rows the client's view may
16
+ // hold across all pages. 0 means "no limit" for either, and MAX_ROWS is
17
+ // clamped to MAX_VIEW — both exactly as ClientOptions.parse does.
18
+ export function pagingOptions(details) {
19
+ const maxView = readCount(details.MAX_VIEW, DEFAULT_MAX_VIEW) || Infinity;
20
+ const maxRows = readCount(details.MAX_ROWS, DEFAULT_MAX_ROWS) || Infinity;
21
+ return { maxRows: Math.min(maxRows, maxView), maxView };
22
+ }
23
+ // The key a row is tracked under in Subscription.pending / held — undefined
24
+ // for a row without a ROW_REF, which no live change could ever be matched
25
+ // against (and which the client can't identify either).
26
+ export function pendingKey(rowRef) {
27
+ return rowRef === undefined || rowRef === null ? undefined : String(rowRef);
28
+ }
29
+ function buildPending(rows) {
30
+ const pending = new Map();
31
+ rows.forEach((row, index) => {
32
+ const key = pendingKey(row.DETAILS?.ROW_REF);
33
+ // A row with no ROW_REF (or a duplicate one) still has to be delivered;
34
+ // park it under a key no ROW_REF can produce rather than dropping it.
35
+ // Live changes can't reach it there, which is as much as the client
36
+ // could make of them: to it, rows sharing a ROW_REF are one row.
37
+ pending.set(key === undefined || pending.has(key) ? `\u0000${index}` : key, row);
38
+ });
39
+ return pending;
40
+ }
41
+ // The next page: up to MAX_ROWS waiting rows, but never more than the view
42
+ // has room for. The real NextRowsStrategy reads while the client index is
43
+ // below MAX_VIEW, so a full view gets an empty page, and a slot freed by a
44
+ // DELETE is filled by the next row waiting. Delivered rows move to `held`.
45
+ function takePage(subscription) {
46
+ const { pending, held } = subscription;
47
+ if (!pending)
48
+ return [];
49
+ const room = Math.max(0, Math.min(subscription.maxRows ?? Infinity, (subscription.maxView ?? Infinity) - (held?.size ?? 0)));
50
+ const page = [];
51
+ for (const [key, row] of pending) {
52
+ if (page.length >= room)
53
+ break;
54
+ page.push(row);
55
+ pending.delete(key);
56
+ held?.add(key);
57
+ }
58
+ return page;
59
+ }
60
+ // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
61
+ // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
62
+ // registered so MORE_ROWS can page the rest and later inserts/amends/deletes
63
+ // on this resource are pushed on the same channel (see server.ts broadcast())
64
+ // until the client sends DATA_LOGOFF.
9
65
  export function handleDataLogon(message, connection, config, store) {
66
+ const legacy = isLegacyFidelity(config);
10
67
  const details = message.DETAILS ?? {};
11
68
  const resourceName = details.DATASOURCE_NAME;
12
69
  const query = config.queries?.[resourceName];
13
70
  if (!query) {
14
- connection.reply(message, {
15
- MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
16
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown datasource: ${resourceName}` }],
17
- });
71
+ connection.reply(message, legacy
72
+ ? {
73
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
74
+ ERROR: [
75
+ { CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown datasource: ${resourceName}` },
76
+ ],
77
+ }
78
+ : resourceNotMappedNack(resourceName));
79
+ return;
80
+ }
81
+ const sourceRef = message.SOURCE_REF;
82
+ // The router refuses a second logon on a live SOURCE_REF and leaves the
83
+ // first subscription running. (Legacy mode silently replaced it.)
84
+ if (!legacy && connection.subscriptions.has(sourceRef)) {
85
+ connection.reply(message, subscriptionAlreadyExistsNack());
18
86
  return;
19
87
  }
20
88
  const criteria = details.CRITERIA_MATCH;
@@ -22,9 +90,16 @@ export function handleDataLogon(message, connection, config, store) {
22
90
  // Order matters: the query's own filter (the where-clause equivalent) and
23
91
  // the user's row permissioning narrow the set BEFORE the client's
24
92
  // CRITERIA_MATCH; hidden fields are stripped last, off the final rows.
93
+ // Paging applies after all of them.
25
94
  let rows = store.getResourceRows(query.source);
26
95
  if (query.filter)
27
96
  rows = rows.filter(query.filter);
97
+ // ROWS_COUNT is the real dataserver's index count: it ignores
98
+ // CRITERIA_MATCH and the user's rowFilter (recorded: ROWS_COUNT 10 with 6
99
+ // rows matching), which grid-pro and the AI assistant already allow for.
100
+ // The query's own filter is the where clause the index is built over, so
101
+ // the count is taken after it — an assumption, as no recording covers it.
102
+ const rowsCount = rows.length;
28
103
  rows = filterByCriteria(rows, criteria);
29
104
  // Permissioning is applied per row so the ROW_REF can still be computed from
30
105
  // the UNSTRIPPED row — pk fields may themselves be hidden from this user,
@@ -37,20 +112,96 @@ export function handleDataLogon(message, connection, config, store) {
37
112
  continue;
38
113
  stampedRows.push(stampRow(permitted, store.getRowRef(query.source, row), 'INSERT'));
39
114
  }
40
- connection.subscribe(message.SOURCE_REF, {
115
+ const subscription = {
41
116
  resourceName,
42
117
  getRowRef: (row) => store.getRowRef(query.source, row),
43
118
  predicate: compileCriteria(criteria),
44
119
  sequenceId: 1,
45
- });
46
- connection.reply(message, {
120
+ };
121
+ if (legacy) {
122
+ connection.subscribe(sourceRef, subscription);
123
+ connection.reply(message, {
124
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
125
+ ROW: stampedRows,
126
+ ROWS_COUNT: stampedRows.length,
127
+ SEQUENCE_ID: 1,
128
+ MORE_ROWS: false,
129
+ });
130
+ return;
131
+ }
132
+ // Every matched row starts out waiting; the first page moves up to
133
+ // MAX_ROWS of them into `held`. Rows the view has no room for (past
134
+ // MAX_VIEW) keep waiting, so MORE_ROWS stays true — as on the real server,
135
+ // which then answers every MORE_ROWS with an empty {MORE_ROWS: true} page.
136
+ const { maxRows, maxView } = pagingOptions(details);
137
+ const tracked = {
138
+ ...subscription,
139
+ pending: buildPending(stampedRows),
140
+ held: new Set(),
141
+ maxRows,
142
+ maxView,
143
+ };
144
+ const firstPage = takePage(tracked);
145
+ const pending = tracked.pending;
146
+ connection.subscribe(sourceRef, tracked);
147
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.LOGON_ACK, SEQUENCE_ID: 0 });
148
+ // No matching rows: the real dataserver sends neither ROW nor ROWS_COUNT.
149
+ connection.reply(message, firstPage.length === 0
150
+ ? { MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE, MORE_ROWS: false, SEQUENCE_ID: 1 }
151
+ : {
152
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
153
+ ROW: firstPage,
154
+ MORE_ROWS: pending.size > 0,
155
+ // Only on this first page (see rowsCount above).
156
+ ROWS_COUNT: rowsCount,
157
+ SEQUENCE_ID: 1,
158
+ });
159
+ }
160
+ // MORE_ROWS names its subscription in DETAILS.SOURCE_REF (foundation-comms'
161
+ // createMoreRowsMessage gives the request a SOURCE_REF of its own) or, when
162
+ // that is absent, in its own SOURCE_REF. The page goes out as a QUERY_UPDATE
163
+ // on the subscription's SOURCE_REF without ROWS_COUNT; MORE_ROWS_ACK answers
164
+ // the request's own SOURCE_REF, and only when DETAILS.SOURCE_REF was given —
165
+ // both as the real ConnectionManager.processMoreRows does. The ack goes first
166
+ // because the real server writes it while its dataserver is still reading the
167
+ // page. Subscriptions are per connection, so another socket can never page
168
+ // this one: its lookup misses and it gets the 404.
169
+ export function handleMoreRows(message, connection, config) {
170
+ if (isLegacyFidelity(config)) {
171
+ // Up to 15.47 MORE_ROWS was unrouted: logged, never answered.
172
+ console.warn(`[mock-server] Unhandled MESSAGE_TYPE: ${message.MESSAGE_TYPE}`);
173
+ return;
174
+ }
175
+ const detailsSourceRef = message.DETAILS?.SOURCE_REF;
176
+ const streamSourceRef = detailsSourceRef ?? message.SOURCE_REF;
177
+ const subscription = streamSourceRef === undefined ? undefined : connection.subscriptions.get(streamSourceRef);
178
+ if (!subscription) {
179
+ connection.reply(message, nonExistentSubscriptionNack());
180
+ return;
181
+ }
182
+ if (detailsSourceRef !== undefined) {
183
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.MORE_ROWS_ACK });
184
+ }
185
+ const page = takePage(subscription);
186
+ subscription.sequenceId += 1;
187
+ // No rows to send still gets an answer — an empty QUERY_UPDATE, as the real
188
+ // QueryClient sends when a read finds nothing: MORE_ROWS: false at the end
189
+ // of the data, true while rows are waiting for room in a full view.
190
+ connection.send({
47
191
  MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
48
- ROW: stampedRows,
49
- ROWS_COUNT: stampedRows.length,
50
- SEQUENCE_ID: 1,
51
- MORE_ROWS: false,
192
+ SOURCE_REF: streamSourceRef,
193
+ ...(page.length > 0 ? { ROW: page } : {}),
194
+ MORE_ROWS: (subscription.pending?.size ?? 0) > 0,
195
+ SEQUENCE_ID: subscription.sequenceId,
52
196
  });
53
197
  }
54
- export function handleDataLogoff(message, connection) {
55
- connection.unsubscribe(message.SOURCE_REF);
198
+ export function handleDataLogoff(message, connection, config) {
199
+ const sourceRef = message.SOURCE_REF;
200
+ const subscribed = sourceRef !== undefined && connection.subscriptions.has(sourceRef);
201
+ if (subscribed)
202
+ connection.unsubscribe(sourceRef);
203
+ // Legacy mode never answered a logoff.
204
+ if (isLegacyFidelity(config))
205
+ return;
206
+ connection.reply(message, subscribed ? { MESSAGE_TYPE: MESSAGE_TYPE.LOGOFF_ACK } : nonExistentSubscriptionNack());
56
207
  }
@@ -1,5 +1,7 @@
1
1
  import { deriveFields } from "../db/metadata.js";
2
- import { ERROR_CODE } from "../protocol/messageTypes.js";
2
+ import { isLegacyFidelity } from "../protocol/fidelity.js";
3
+ import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
+ import { resourceNotMappedNack } from "../protocol/msgNack.js";
3
5
  // JSON_SCHEMA_REQUEST shares its request envelope with META_REQUEST (same
4
6
  // DETAILS.FEATURE resource-name field) but its reply is a completely
5
7
  // different shape: Connect.getJSONSchema doesn't read response.DETAILS at
@@ -10,7 +12,9 @@ import { ERROR_CODE } from "../protocol/messageTypes.js";
10
12
  // (it reads Object.keys(...properties) to know which fields to copy from the
11
13
  // selected row into the commit DETAILS). Route this to handleMetaRequest by
12
14
  // mistake and forms silently render with zero fields, and delete can never
13
- // build a payload — no error, just a dead button.
15
+ // build a payload — no error, just a dead button. The real success reply is
16
+ // JSON_SCHEMA_FEATURE_ACK with INBOUND/OUTBOUND at the top level (legacy mode
17
+ // echoed JSON_SCHEMA_REQUEST).
14
18
  function jsonSchemaType(fieldType) {
15
19
  switch (fieldType) {
16
20
  case 'LONG':
@@ -73,11 +77,14 @@ export function handleJsonSchemaRequest(message, connection, config, store) {
73
77
  const table = override?.table ??
74
78
  resourceSource ??
75
79
  inferEventTable(eventName, Object.keys(config.tables ?? {}));
80
+ const legacy = isLegacyFidelity(config);
76
81
  if (!table) {
77
- connection.reply(message, {
78
- MESSAGE_TYPE: message.MESSAGE_TYPE,
79
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${eventName}` }],
80
- });
82
+ connection.reply(message, legacy
83
+ ? {
84
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
85
+ ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${eventName}` }],
86
+ }
87
+ : resourceNotMappedNack(eventName));
81
88
  return;
82
89
  }
83
90
  const pkOnly = override?.pkOnly ?? DELETE_EVENT_RE.test(eventName);
@@ -87,7 +94,7 @@ export function handleJsonSchemaRequest(message, connection, config, store) {
87
94
  fields = fields.filter((field) => pkFields.includes(field.NAME));
88
95
  const schema = buildSchema(fields, pkOnly ? pkFields : []);
89
96
  connection.reply(message, {
90
- MESSAGE_TYPE: message.MESSAGE_TYPE,
97
+ MESSAGE_TYPE: legacy ? message.MESSAGE_TYPE : MESSAGE_TYPE.JSON_SCHEMA_FEATURE_ACK,
91
98
  INBOUND: schema,
92
99
  OUTBOUND: schema,
93
100
  });
@@ -1,5 +1,7 @@
1
1
  import { deriveFields } from "../db/metadata.js";
2
- import { ERROR_CODE } from "../protocol/messageTypes.js";
2
+ import { isLegacyFidelity } from "../protocol/fidelity.js";
3
+ import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
4
+ import { resourceNotMappedNack } from "../protocol/msgNack.js";
3
5
  import { hiddenFields, requestingUser } from "./resourceAuth.js";
4
6
  // createMetaRequestMessage (foundation-comms/src/connect/message.ts) puts the
5
7
  // resource name in DETAILS.FEATURE — not DATASOURCE_NAME/RESOURCE_NAME despite
@@ -12,15 +14,22 @@ function resolveResourceName(message) {
12
14
  // DATASERVER resources need DETAILS.FIELD, REQUEST_SERVER ones need
13
15
  // DETAILS.REPLY_FIELD. Get either wrong and every grid silently reports
14
16
  // "Metadata Fetch failed" / "Unsupported resource type: undefined".
17
+ // The real reply is FEATURE_ACK (getMetadata reads only DETAILS, so the type
18
+ // is for fidelity; legacy mode echoed META_REQUEST).
15
19
  export function handleMetaRequest(message, connection, config, store) {
16
20
  const resourceName = resolveResourceName(message);
17
21
  const query = config.queries?.[resourceName];
18
22
  const requestReply = config.requestReplies?.[resourceName];
23
+ const legacy = isLegacyFidelity(config);
19
24
  if (!query && !requestReply) {
20
- connection.reply(message, {
21
- MESSAGE_TYPE: message.MESSAGE_TYPE,
22
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${resourceName}` }],
23
- });
25
+ connection.reply(message, legacy
26
+ ? {
27
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
28
+ ERROR: [
29
+ { CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${resourceName}` },
30
+ ],
31
+ }
32
+ : resourceNotMappedNack(resourceName));
24
33
  return;
25
34
  }
26
35
  const resolved = (query ?? requestReply);
@@ -34,7 +43,7 @@ export function handleMetaRequest(message, connection, config, store) {
34
43
  const hidden = hiddenFields(resolved.auth, requestingUser(connection));
35
44
  const fields = deriveFields(rows, resolved.fieldTypes).filter((field) => !hidden.has(field.NAME));
36
45
  connection.reply(message, {
37
- MESSAGE_TYPE: message.MESSAGE_TYPE,
46
+ MESSAGE_TYPE: legacy ? message.MESSAGE_TYPE : MESSAGE_TYPE.FEATURE_ACK,
38
47
  DETAILS: query
39
48
  ? { NAME: resourceName, TYPE: 'DATASERVER', FIELD: fields, KEY: [] }
40
49
  : { NAME: resourceName, TYPE: 'REQUEST_SERVER', REPLY_FIELD: fields },