@genesislcap/mock-server 15.48.0 → 15.50.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.
@@ -1,30 +1,116 @@
1
1
  import { compileCriteria, filterByCriteria } from '../db/criteria.ts';
2
2
  import type { Store } from '../db/store.ts';
3
- import type { Connection } from '../protocol/connection.ts';
3
+ import type { Connection, Subscription } from '../protocol/connection.ts';
4
+ import { isLegacyFidelity } from '../protocol/fidelity.ts';
4
5
  import { MESSAGE_TYPE, ERROR_CODE } from '../protocol/messageTypes.ts';
6
+ import {
7
+ nonExistentSubscriptionNack,
8
+ resourceNotMappedNack,
9
+ subscriptionAlreadyExistsNack,
10
+ } from '../protocol/msgNack.ts';
5
11
  import { stampRow } from '../protocol/rowUpdate.ts';
6
12
  import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
7
13
  import { applyResourceAuth, requestingUser } from './resourceAuth.ts';
8
14
 
9
- // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with an
10
- // initial snapshot, then keep the SOURCE_REF registered so later inserts/amends/
11
- // deletes on this resource can be pushed on the same channel (see server.ts
12
- // broadcast()) until the client sends DATA_LOGOFF.
15
+ // The real dataserver's defaults (genesis-pal-dataserver ClientOptions,
16
+ // GSF 8.15.x) — also what foundation-comms' DatasourceDefaults send.
17
+ const DEFAULT_MAX_ROWS = 250;
18
+ const DEFAULT_MAX_VIEW = 1000;
19
+
20
+ function readCount(value: unknown, fallback: number): number {
21
+ const count = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
22
+ return typeof count === 'number' && Number.isInteger(count) && count >= 0 ? count : fallback;
23
+ }
24
+
25
+ // MAX_ROWS is the page size, MAX_VIEW the most rows the client's view may
26
+ // hold across all pages. 0 means "no limit" for either, and MAX_ROWS is
27
+ // clamped to MAX_VIEW — both exactly as ClientOptions.parse does.
28
+ export function pagingOptions(details: Row): { maxRows: number; maxView: number } {
29
+ const maxView = readCount(details.MAX_VIEW, DEFAULT_MAX_VIEW) || Infinity;
30
+ const maxRows = readCount(details.MAX_ROWS, DEFAULT_MAX_ROWS) || Infinity;
31
+ return { maxRows: Math.min(maxRows, maxView), maxView };
32
+ }
33
+
34
+ // The key a row is tracked under in Subscription.pending / held — undefined
35
+ // for a row without a ROW_REF, which no live change could ever be matched
36
+ // against (and which the client can't identify either).
37
+ export function pendingKey(rowRef: unknown): string | undefined {
38
+ return rowRef === undefined || rowRef === null ? undefined : String(rowRef);
39
+ }
40
+
41
+ function buildPending(rows: Row[]): Map<string, Row> {
42
+ const pending = new Map<string, Row>();
43
+ rows.forEach((row, index) => {
44
+ const key = pendingKey(row.DETAILS?.ROW_REF);
45
+ // A row with no ROW_REF (or a duplicate one) still has to be delivered;
46
+ // park it under a key no ROW_REF can produce rather than dropping it.
47
+ // Live changes can't reach it there, which is as much as the client
48
+ // could make of them: to it, rows sharing a ROW_REF are one row.
49
+ pending.set(key === undefined || pending.has(key) ? `\u0000${index}` : key, row);
50
+ });
51
+ return pending;
52
+ }
53
+
54
+ // The next page: up to MAX_ROWS waiting rows, but never more than the view
55
+ // has room for. The real NextRowsStrategy reads while the client index is
56
+ // below MAX_VIEW, so a full view gets an empty page, and a slot freed by a
57
+ // DELETE is filled by the next row waiting. Delivered rows move to `held`.
58
+ function takePage(subscription: Subscription): Row[] {
59
+ const { pending, held } = subscription;
60
+ if (!pending) return [];
61
+ const room = Math.max(
62
+ 0,
63
+ Math.min(
64
+ subscription.maxRows ?? Infinity,
65
+ (subscription.maxView ?? Infinity) - (held?.size ?? 0),
66
+ ),
67
+ );
68
+ const page: Row[] = [];
69
+ for (const [key, row] of pending) {
70
+ if (page.length >= room) break;
71
+ page.push(row);
72
+ pending.delete(key);
73
+ held?.add(key);
74
+ }
75
+ return page;
76
+ }
77
+
78
+ // DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
79
+ // LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
80
+ // registered so MORE_ROWS can page the rest and later inserts/amends/deletes
81
+ // on this resource are pushed on the same channel (see server.ts broadcast())
82
+ // until the client sends DATA_LOGOFF.
13
83
  export function handleDataLogon(
14
84
  message: GenesisMessage,
15
85
  connection: Connection,
16
86
  config: MockServerConfig,
17
87
  store: Store,
18
88
  ): void {
89
+ const legacy = isLegacyFidelity(config);
19
90
  const details = message.DETAILS ?? {};
20
91
  const resourceName = details.DATASOURCE_NAME;
21
92
  const query = config.queries?.[resourceName];
22
93
 
23
94
  if (!query) {
24
- connection.reply(message, {
25
- MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
26
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown datasource: ${resourceName}` }],
27
- });
95
+ connection.reply(
96
+ message,
97
+ legacy
98
+ ? {
99
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
100
+ ERROR: [
101
+ { CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown datasource: ${resourceName}` },
102
+ ],
103
+ }
104
+ : resourceNotMappedNack(resourceName),
105
+ );
106
+ return;
107
+ }
108
+
109
+ const sourceRef = message.SOURCE_REF!;
110
+ // The router refuses a second logon on a live SOURCE_REF and leaves the
111
+ // first subscription running. (Legacy mode silently replaced it.)
112
+ if (!legacy && connection.subscriptions.has(sourceRef)) {
113
+ connection.reply(message, subscriptionAlreadyExistsNack());
28
114
  return;
29
115
  }
30
116
 
@@ -33,8 +119,15 @@ export function handleDataLogon(
33
119
  // Order matters: the query's own filter (the where-clause equivalent) and
34
120
  // the user's row permissioning narrow the set BEFORE the client's
35
121
  // CRITERIA_MATCH; hidden fields are stripped last, off the final rows.
122
+ // Paging applies after all of them.
36
123
  let rows = store.getResourceRows(query.source);
37
124
  if (query.filter) rows = rows.filter(query.filter);
125
+ // ROWS_COUNT is the real dataserver's index count: it ignores
126
+ // CRITERIA_MATCH and the user's rowFilter (recorded: ROWS_COUNT 10 with 6
127
+ // rows matching), which grid-pro and the AI assistant already allow for.
128
+ // The query's own filter is the where clause the index is built over, so
129
+ // the count is taken after it — an assumption, as no recording covers it.
130
+ const rowsCount = rows.length;
38
131
  rows = filterByCriteria(rows, criteria);
39
132
  // Permissioning is applied per row so the ROW_REF can still be computed from
40
133
  // the UNSTRIPPED row — pk fields may themselves be hidden from this user,
@@ -47,22 +140,115 @@ export function handleDataLogon(
47
140
  stampedRows.push(stampRow(permitted, store.getRowRef(query.source, row), 'INSERT'));
48
141
  }
49
142
 
50
- connection.subscribe(message.SOURCE_REF!, {
143
+ const subscription: Subscription = {
51
144
  resourceName,
52
145
  getRowRef: (row) => store.getRowRef(query.source, row),
53
146
  predicate: compileCriteria(criteria),
54
147
  sequenceId: 1,
55
- });
148
+ };
56
149
 
57
- connection.reply(message, {
150
+ if (legacy) {
151
+ connection.subscribe(sourceRef, subscription);
152
+ connection.reply(message, {
153
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
154
+ ROW: stampedRows,
155
+ ROWS_COUNT: stampedRows.length,
156
+ SEQUENCE_ID: 1,
157
+ MORE_ROWS: false,
158
+ });
159
+ return;
160
+ }
161
+
162
+ // Every matched row starts out waiting; the first page moves up to
163
+ // MAX_ROWS of them into `held`. Rows the view has no room for (past
164
+ // MAX_VIEW) keep waiting, so MORE_ROWS stays true — as on the real server,
165
+ // which then answers every MORE_ROWS with an empty {MORE_ROWS: true} page.
166
+ const { maxRows, maxView } = pagingOptions(details);
167
+ const tracked: Subscription = {
168
+ ...subscription,
169
+ pending: buildPending(stampedRows),
170
+ held: new Set<string>(),
171
+ maxRows,
172
+ maxView,
173
+ };
174
+ const firstPage = takePage(tracked);
175
+ const pending = tracked.pending!;
176
+ connection.subscribe(sourceRef, tracked);
177
+
178
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.LOGON_ACK, SEQUENCE_ID: 0 });
179
+ // No matching rows: the real dataserver sends neither ROW nor ROWS_COUNT.
180
+ connection.reply(
181
+ message,
182
+ firstPage.length === 0
183
+ ? { MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE, MORE_ROWS: false, SEQUENCE_ID: 1 }
184
+ : {
185
+ MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
186
+ ROW: firstPage,
187
+ MORE_ROWS: pending.size > 0,
188
+ // Only on this first page (see rowsCount above).
189
+ ROWS_COUNT: rowsCount,
190
+ SEQUENCE_ID: 1,
191
+ },
192
+ );
193
+ }
194
+
195
+ // MORE_ROWS names its subscription in DETAILS.SOURCE_REF (foundation-comms'
196
+ // createMoreRowsMessage gives the request a SOURCE_REF of its own) or, when
197
+ // that is absent, in its own SOURCE_REF. The page goes out as a QUERY_UPDATE
198
+ // on the subscription's SOURCE_REF without ROWS_COUNT; MORE_ROWS_ACK answers
199
+ // the request's own SOURCE_REF, and only when DETAILS.SOURCE_REF was given —
200
+ // both as the real ConnectionManager.processMoreRows does. The ack goes first
201
+ // because the real server writes it while its dataserver is still reading the
202
+ // page. Subscriptions are per connection, so another socket can never page
203
+ // this one: its lookup misses and it gets the 404.
204
+ export function handleMoreRows(
205
+ message: GenesisMessage,
206
+ connection: Connection,
207
+ config: MockServerConfig,
208
+ ): void {
209
+ if (isLegacyFidelity(config)) {
210
+ // Up to 15.47 MORE_ROWS was unrouted: logged, never answered.
211
+ console.warn(`[mock-server] Unhandled MESSAGE_TYPE: ${message.MESSAGE_TYPE}`);
212
+ return;
213
+ }
214
+ const detailsSourceRef: string | undefined = message.DETAILS?.SOURCE_REF;
215
+ const streamSourceRef = detailsSourceRef ?? message.SOURCE_REF;
216
+ const subscription =
217
+ streamSourceRef === undefined ? undefined : connection.subscriptions.get(streamSourceRef);
218
+ if (!subscription) {
219
+ connection.reply(message, nonExistentSubscriptionNack());
220
+ return;
221
+ }
222
+ if (detailsSourceRef !== undefined) {
223
+ connection.reply(message, { MESSAGE_TYPE: MESSAGE_TYPE.MORE_ROWS_ACK });
224
+ }
225
+
226
+ const page = takePage(subscription);
227
+ subscription.sequenceId += 1;
228
+ // No rows to send still gets an answer — an empty QUERY_UPDATE, as the real
229
+ // QueryClient sends when a read finds nothing: MORE_ROWS: false at the end
230
+ // of the data, true while rows are waiting for room in a full view.
231
+ connection.send({
58
232
  MESSAGE_TYPE: MESSAGE_TYPE.QUERY_UPDATE,
59
- ROW: stampedRows,
60
- ROWS_COUNT: stampedRows.length,
61
- SEQUENCE_ID: 1,
62
- MORE_ROWS: false,
233
+ SOURCE_REF: streamSourceRef,
234
+ ...(page.length > 0 ? { ROW: page } : {}),
235
+ MORE_ROWS: (subscription.pending?.size ?? 0) > 0,
236
+ SEQUENCE_ID: subscription.sequenceId,
63
237
  });
64
238
  }
65
239
 
66
- export function handleDataLogoff(message: GenesisMessage, connection: Connection): void {
67
- connection.unsubscribe(message.SOURCE_REF!);
240
+ export function handleDataLogoff(
241
+ message: GenesisMessage,
242
+ connection: Connection,
243
+ config: MockServerConfig,
244
+ ): void {
245
+ const sourceRef = message.SOURCE_REF;
246
+ const subscribed = sourceRef !== undefined && connection.subscriptions.has(sourceRef);
247
+ if (subscribed) connection.unsubscribe(sourceRef);
248
+ // Legacy mode never answered a logoff.
249
+ if (isLegacyFidelity(config)) return;
250
+ connection.reply(
251
+ message,
252
+ subscribed ? { MESSAGE_TYPE: MESSAGE_TYPE.LOGOFF_ACK } : nonExistentSubscriptionNack(),
253
+ );
68
254
  }
@@ -1,7 +1,9 @@
1
1
  import { deriveFields } from '../db/metadata.ts';
2
2
  import type { Store } from '../db/store.ts';
3
3
  import type { Connection } from '../protocol/connection.ts';
4
- import { ERROR_CODE } from '../protocol/messageTypes.ts';
4
+ import { isLegacyFidelity } from '../protocol/fidelity.ts';
5
+ import { ERROR_CODE, MESSAGE_TYPE } from '../protocol/messageTypes.ts';
6
+ import { resourceNotMappedNack } from '../protocol/msgNack.ts';
5
7
  import type { FieldMetadata, GenesisMessage, MockServerConfig, Row } from '../types.ts';
6
8
 
7
9
  // JSON_SCHEMA_REQUEST shares its request envelope with META_REQUEST (same
@@ -14,7 +16,9 @@ import type { FieldMetadata, GenesisMessage, MockServerConfig, Row } from '../ty
14
16
  // (it reads Object.keys(...properties) to know which fields to copy from the
15
17
  // selected row into the commit DETAILS). Route this to handleMetaRequest by
16
18
  // mistake and forms silently render with zero fields, and delete can never
17
- // build a payload — no error, just a dead button.
19
+ // build a payload — no error, just a dead button. The real success reply is
20
+ // JSON_SCHEMA_FEATURE_ACK with INBOUND/OUTBOUND at the top level (legacy mode
21
+ // echoed JSON_SCHEMA_REQUEST).
18
22
 
19
23
  function jsonSchemaType(fieldType: string): string {
20
24
  switch (fieldType) {
@@ -89,11 +93,17 @@ export function handleJsonSchemaRequest(
89
93
  resourceSource ??
90
94
  inferEventTable(eventName, Object.keys(config.tables ?? {}));
91
95
 
96
+ const legacy = isLegacyFidelity(config);
92
97
  if (!table) {
93
- connection.reply(message, {
94
- MESSAGE_TYPE: message.MESSAGE_TYPE,
95
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${eventName}` }],
96
- });
98
+ connection.reply(
99
+ message,
100
+ legacy
101
+ ? {
102
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
103
+ ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${eventName}` }],
104
+ }
105
+ : resourceNotMappedNack(eventName),
106
+ );
97
107
  return;
98
108
  }
99
109
 
@@ -105,7 +115,7 @@ export function handleJsonSchemaRequest(
105
115
  const schema = buildSchema(fields, pkOnly ? pkFields : []);
106
116
 
107
117
  connection.reply(message, {
108
- MESSAGE_TYPE: message.MESSAGE_TYPE,
118
+ MESSAGE_TYPE: legacy ? message.MESSAGE_TYPE : MESSAGE_TYPE.JSON_SCHEMA_FEATURE_ACK,
109
119
  INBOUND: schema,
110
120
  OUTBOUND: schema,
111
121
  });
@@ -1,7 +1,9 @@
1
1
  import { deriveFields } from '../db/metadata.ts';
2
2
  import type { Store } from '../db/store.ts';
3
3
  import type { Connection } from '../protocol/connection.ts';
4
- import { ERROR_CODE } from '../protocol/messageTypes.ts';
4
+ import { isLegacyFidelity } from '../protocol/fidelity.ts';
5
+ import { ERROR_CODE, MESSAGE_TYPE } from '../protocol/messageTypes.ts';
6
+ import { resourceNotMappedNack } from '../protocol/msgNack.ts';
5
7
  import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
6
8
  import { hiddenFields, requestingUser } from './resourceAuth.ts';
7
9
 
@@ -19,6 +21,8 @@ function resolveResourceName(message: GenesisMessage): string {
19
21
  // DATASERVER resources need DETAILS.FIELD, REQUEST_SERVER ones need
20
22
  // DETAILS.REPLY_FIELD. Get either wrong and every grid silently reports
21
23
  // "Metadata Fetch failed" / "Unsupported resource type: undefined".
24
+ // The real reply is FEATURE_ACK (getMetadata reads only DETAILS, so the type
25
+ // is for fidelity; legacy mode echoed META_REQUEST).
22
26
  export function handleMetaRequest(
23
27
  message: GenesisMessage,
24
28
  connection: Connection,
@@ -29,11 +33,19 @@ export function handleMetaRequest(
29
33
  const query = config.queries?.[resourceName];
30
34
  const requestReply = config.requestReplies?.[resourceName];
31
35
 
36
+ const legacy = isLegacyFidelity(config);
32
37
  if (!query && !requestReply) {
33
- connection.reply(message, {
34
- MESSAGE_TYPE: message.MESSAGE_TYPE,
35
- ERROR: [{ CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${resourceName}` }],
36
- });
38
+ connection.reply(
39
+ message,
40
+ legacy
41
+ ? {
42
+ MESSAGE_TYPE: message.MESSAGE_TYPE,
43
+ ERROR: [
44
+ { CODE: ERROR_CODE.UNKNOWN_RESOURCE, TEXT: `Unknown resource: ${resourceName}` },
45
+ ],
46
+ }
47
+ : resourceNotMappedNack(resourceName),
48
+ );
37
49
  return;
38
50
  }
39
51
 
@@ -49,7 +61,7 @@ export function handleMetaRequest(
49
61
  const fields = deriveFields(rows, resolved.fieldTypes).filter((field) => !hidden.has(field.NAME));
50
62
 
51
63
  connection.reply(message, {
52
- MESSAGE_TYPE: message.MESSAGE_TYPE,
64
+ MESSAGE_TYPE: legacy ? message.MESSAGE_TYPE : MESSAGE_TYPE.FEATURE_ACK,
53
65
  DETAILS: query
54
66
  ? { NAME: resourceName, TYPE: 'DATASERVER', FIELD: fields, KEY: [] }
55
67
  : { NAME: resourceName, TYPE: 'REQUEST_SERVER', REPLY_FIELD: fields },
@@ -2,7 +2,9 @@ import { filterByCriteria } from '../db/criteria.ts';
2
2
  import type { Store } from '../db/store.ts';
3
3
  import type { Connection } from '../protocol/connection.ts';
4
4
  import { NackError } from '../protocol/errors.ts';
5
+ import { isLegacyFidelity } from '../protocol/fidelity.ts';
5
6
  import { ERROR_CODE } from '../protocol/messageTypes.ts';
7
+ import { resourceNotMappedNack } from '../protocol/msgNack.ts';
6
8
  import type { GenesisMessage, MockServerConfig, Row } from '../types.ts';
7
9
  import { applyResourceAuth, requestingUser } from './resourceAuth.ts';
8
10
 
@@ -29,15 +31,20 @@ export function handleRequestReply(
29
31
  const requestReply = config.requestReplies?.[resourceName];
30
32
 
31
33
  if (!requestReply || (!requestReply.resolver && !requestReply.source)) {
32
- connection.reply(message, {
33
- MESSAGE_TYPE: `REP_${resourceName}`,
34
- ERROR: [
35
- {
36
- CODE: ERROR_CODE.UNKNOWN_RESOURCE,
37
- TEXT: `Unknown request-reply resource: ${resourceName}`,
38
- },
39
- ],
40
- });
34
+ connection.reply(
35
+ message,
36
+ isLegacyFidelity(config)
37
+ ? {
38
+ MESSAGE_TYPE: `REP_${resourceName}`,
39
+ ERROR: [
40
+ {
41
+ CODE: ERROR_CODE.UNKNOWN_RESOURCE,
42
+ TEXT: `Unknown request-reply resource: ${resourceName}`,
43
+ },
44
+ ],
45
+ }
46
+ : resourceNotMappedNack(resourceName),
47
+ );
41
48
  return;
42
49
  }
43
50
 
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  EventHandlerCtx,
14
14
  EventHandlerResult,
15
15
  EventSchemaOverride,
16
+ FidelityMode,
16
17
  FieldMetadata,
17
18
  GenesisMessage,
18
19
  HttpRouteDef,
@@ -10,7 +10,23 @@ export interface Subscription {
10
10
  // the joined composite key) — bound to Store.getRowRef at subscribe time.
11
11
  getRowRef: (row: Row) => unknown;
12
12
  predicate: Predicate;
13
+ // SEQUENCE_ID of the last QUERY_UPDATE sent on this subscription (the
14
+ // snapshot is 1); every later page or live push takes the next one.
13
15
  sequenceId: number;
16
+ // The client's view, tracked like the real dataserver's client index (set
17
+ // on 'gsf'-fidelity subscriptions; legacy ones have none). Every row is in
18
+ // at most one of:
19
+ // - pending: matched but not sent yet — past this page, or past MAX_VIEW —
20
+ // in delivery order, stamped and permission-stripped. A live change to a
21
+ // waiting row updates or drops it here instead of being pushed.
22
+ // MORE_ROWS drains it, maxRows at a time, while the view has room.
23
+ // - held: sent to the client (on a page, or pushed as an INSERT). Only
24
+ // these get MODIFY and DELETE pushes.
25
+ // Both are keyed by String(ROW_REF) (see dataLogon.ts pendingKey).
26
+ pending?: Map<string, Row>;
27
+ held?: Set<string>;
28
+ maxRows?: number;
29
+ maxView?: number;
14
30
  }
15
31
 
16
32
  // Wraps one WebSocket client connection: auth state and its live DATA_LOGON
@@ -0,0 +1,8 @@
1
+ import type { MockServerConfig } from '../types.ts';
2
+
3
+ // True when the config opts back into the envelopes this engine sent up to
4
+ // 15.47 (see FidelityMode in types.ts). Read per message, like every other
5
+ // config field, so flipping it on a running server takes effect immediately.
6
+ export function isLegacyFidelity(config: MockServerConfig): boolean {
7
+ return config.fidelity === 'legacy';
8
+ }
@@ -17,10 +17,19 @@ export const MESSAGE_TYPE = {
17
17
  HEARTBEAT_PING: 'HEARTBEAT_PING',
18
18
  HEARTBEAT_PONG: 'HEARTBEAT_PONG',
19
19
  DATA_LOGON: 'DATA_LOGON',
20
+ LOGON_ACK: 'LOGON_ACK',
20
21
  DATA_LOGOFF: 'DATA_LOGOFF',
22
+ LOGOFF_ACK: 'LOGOFF_ACK',
21
23
  QUERY_UPDATE: 'QUERY_UPDATE',
24
+ MORE_ROWS: 'MORE_ROWS',
25
+ MORE_ROWS_ACK: 'MORE_ROWS_ACK',
22
26
  META_REQUEST: 'META_REQUEST',
27
+ FEATURE_ACK: 'FEATURE_ACK',
23
28
  JSON_SCHEMA_REQUEST: 'JSON_SCHEMA_REQUEST',
29
+ JSON_SCHEMA_FEATURE_ACK: 'JSON_SCHEMA_FEATURE_ACK',
30
+ // The router's generic rejection (unknown resource, missing or duplicate
31
+ // dataserver subscription) — see protocol/msgNack.ts.
32
+ MSG_NACK: 'MSG_NACK',
24
33
  RESOURCES_REQUEST: 'RESOURCES_REQUEST',
25
34
  RESOURCES_REQUEST_ACK: 'RESOURCES_REQUEST_ACK',
26
35
  RESOURCES_REQUEST_NACK: 'RESOURCES_REQUEST_NACK',
@@ -0,0 +1,50 @@
1
+ import type { Row } from '../types.ts';
2
+ import { MESSAGE_TYPE } from './messageTypes.ts';
3
+
4
+ const HTTP_NOT_FOUND = 404;
5
+ const HTTP_CONFLICT = 409;
6
+
7
+ // The real router's rejection frame (genesis-router MsgType.Outbound.Error,
8
+ // GSF 8.15.x): an integer HTTP status CODE, and the status line as
9
+ // STATUS_CODE, repeated under DETAILS. It is a different family from the
10
+ // handler-level EVENT_NACK/REP_ errors (string CODEs such as
11
+ // VALIDATION_ERROR) — the router sends it before a request ever reaches a
12
+ // service. foundation-comms' socket REJECTS the pending promise on MSG_NACK
13
+ // (connect/socket.ts), where a typed reply carrying ERROR resolves it.
14
+ function routerNack(code: number, reason: string, text: string): Row {
15
+ const STATUS_CODE = `${code} ${reason}`;
16
+ return {
17
+ MESSAGE_TYPE: MESSAGE_TYPE.MSG_NACK,
18
+ ERROR: [{ CODE: code, TEXT: text, STATUS_CODE }],
19
+ DETAILS: { ERROR: text, STATUS_CODE },
20
+ };
21
+ }
22
+
23
+ // The router resolves every message to a resource name first and answers this
24
+ // when no service serves it: DETAILS.DATASOURCE_NAME for DATA_LOGON, the name
25
+ // after REQ_ for request servers, DETAILS.FEATURE for META_REQUEST /
26
+ // JSON_SCHEMA_REQUEST, and the MESSAGE_TYPE itself for commit events. A
27
+ // missing name is the empty string, as on the real router.
28
+ export function resourceNotMappedNack(resourceName: string | undefined): Row {
29
+ return routerNack(
30
+ HTTP_NOT_FOUND,
31
+ 'Not Found',
32
+ `Resource ${resourceName ?? ''} is not mapped to a service`,
33
+ );
34
+ }
35
+
36
+ // MORE_ROWS or DATA_LOGOFF naming a SOURCE_REF this connection has no live
37
+ // subscription on (never logged on, already logged off, or another socket's).
38
+ export function nonExistentSubscriptionNack(): Row {
39
+ return routerNack(HTTP_NOT_FOUND, 'Not Found', 'Dataserver subscription does not exist');
40
+ }
41
+
42
+ // DATA_LOGON on a SOURCE_REF that already has a live subscription on this
43
+ // connection. The existing subscription is left untouched.
44
+ export function subscriptionAlreadyExistsNack(): Row {
45
+ return routerNack(
46
+ HTTP_CONFLICT,
47
+ 'Conflict',
48
+ 'Subscription exists for source ref. If using HTTP, try HTTP GET instead of HTTP POST',
49
+ );
50
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Store } from '../db/store.ts';
2
2
  import { handleLogin, handleLogout, type TokenRegistry } from '../handlers/auth.ts';
3
3
  import { handleCommitEvent, type CommitEventCtx } from '../handlers/commitEvent.ts';
4
- import { handleDataLogon, handleDataLogoff } from '../handlers/dataLogon.ts';
4
+ import { handleDataLogon, handleDataLogoff, handleMoreRows } from '../handlers/dataLogon.ts';
5
5
  import { handleHeartbeat } from '../handlers/heartbeat.ts';
6
6
  import { handleJsonSchemaRequest } from '../handlers/jsonSchema.ts';
7
7
  import { handleMetaRequest } from '../handlers/meta.ts';
@@ -9,6 +9,7 @@ import { handleRequestReply } from '../handlers/requestReply.ts';
9
9
  import { handleResourcesRequest } from '../handlers/resources.ts';
10
10
  import type { GenesisMessage, MockServerConfig } from '../types.ts';
11
11
  import type { Connection } from './connection.ts';
12
+ import { isLegacyFidelity } from './fidelity.ts';
12
13
  import { MESSAGE_TYPE, ERROR_CODE, isCommitEvent, isRequestReply } from './messageTypes.ts';
13
14
 
14
15
  // Message types a connection may send before authenticating when the OPT-IN
@@ -17,7 +18,11 @@ import { MESSAGE_TYPE, ERROR_CODE, isCommitEvent, isRequestReply } from './messa
17
18
  // requires the connection to have logged in over WS — or to present a
18
19
  // SESSION_AUTH_TOKEN this server issued (over either transport).
19
20
  // RESOURCES_REQUEST is allowed pre-auth so resource discovery can't deadlock
20
- // a client that fetches it before (or while) logging in.
21
+ // a client that fetches it before (or while) logging in. MORE_ROWS, like
22
+ // DATA_LOGOFF, can only act on a subscription the connection already opened
23
+ // through the gate (logout clears them), so it answers with its own precise
24
+ // 404 rather than a NOT_LOGGED_ON echo — except under fidelity: 'legacy',
25
+ // which keeps the 15.47 gate (see isPreAuth).
21
26
  //
22
27
  // The gate is OFF by default because real clients can't be assumed to
23
28
  // satisfy it: browser frame captures show that after logging in over
@@ -34,6 +39,11 @@ const PRE_AUTH_MESSAGE_TYPES = new Set<string>([
34
39
  MESSAGE_TYPE.RESOURCES_REQUEST,
35
40
  ]);
36
41
 
42
+ function isPreAuth(messageType: string, config: MockServerConfig): boolean {
43
+ if (messageType === MESSAGE_TYPE.MORE_ROWS) return !isLegacyFidelity(config);
44
+ return PRE_AUTH_MESSAGE_TYPES.has(messageType);
45
+ }
46
+
37
47
  function ensureAuthenticated(
38
48
  message: GenesisMessage,
39
49
  connection: Connection,
@@ -86,11 +96,7 @@ export function routeMessage(
86
96
  // away; the gate itself only applies to data traffic.
87
97
  ensureAuthenticated(message, connection, tokens);
88
98
  const requireAuth = config.auth?.requireAuth ?? false;
89
- if (
90
- requireAuth &&
91
- !PRE_AUTH_MESSAGE_TYPES.has(message.MESSAGE_TYPE) &&
92
- !connection.authenticated
93
- ) {
99
+ if (requireAuth && !isPreAuth(message.MESSAGE_TYPE, config) && !connection.authenticated) {
94
100
  return replyNotLoggedOn(message, connection);
95
101
  }
96
102
 
@@ -104,7 +110,9 @@ export function routeMessage(
104
110
  case MESSAGE_TYPE.DATA_LOGON:
105
111
  return handleDataLogon(message, connection, config, store);
106
112
  case MESSAGE_TYPE.DATA_LOGOFF:
107
- return handleDataLogoff(message, connection);
113
+ return handleDataLogoff(message, connection, config);
114
+ case MESSAGE_TYPE.MORE_ROWS:
115
+ return handleMoreRows(message, connection, config);
108
116
  case MESSAGE_TYPE.META_REQUEST:
109
117
  return handleMetaRequest(message, connection, config, store);
110
118
  case MESSAGE_TYPE.JSON_SCHEMA_REQUEST: