@genesislcap/mock-server 15.49.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.
package/src/server.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import http from 'node:http';
2
2
  import { WebSocketServer, type WebSocket } from 'ws';
3
3
  import { Store } from './db/store.ts';
4
+ import { pendingKey } from './handlers/dataLogon.ts';
4
5
  import { createHttpRequestListener } from './handlers/http.ts';
5
6
  import { applyResourceAuth, requestingUser } from './handlers/resourceAuth.ts';
6
7
  import { Connection, type Subscription } from './protocol/connection.ts';
8
+ import { isLegacyFidelity } from './protocol/fidelity.ts';
7
9
  import { routeMessage } from './protocol/router.ts';
8
10
  import { stampRow, type RowOperation } from './protocol/rowUpdate.ts';
9
11
  import type {
@@ -65,8 +67,9 @@ export function createMockServer(config: MockServerConfig): MockServer {
65
67
  // grids reflect inserts/amends/deletes without the client re-querying.
66
68
  // DELETE rows are PK-only stubs (see broadcastTableChange), so a criteria
67
69
  // predicate would evaluate against missing fields and drop the push —
68
- // leaving the client holding a ghost row. Deliver every DELETE instead: a
69
- // client that never held that ROW_REF ignores it.
70
+ // leaving the client holding a ghost row. A DELETE therefore skips the
71
+ // predicate; which subscriptions it reaches is decided by what each client
72
+ // holds (trackClientView), or, in legacy mode, it reaches all of them.
70
73
  // 'error' is kept distinct from 'no-match' because only a clean no-match may
71
74
  // be turned into a DELETE below; a broken predicate must not evict rows.
72
75
  function evaluateSubscription(
@@ -103,6 +106,57 @@ export function createMockServer(config: MockServerConfig): MockServer {
103
106
  return operation === 'MODIFY' ? 'DELETE' : null;
104
107
  }
105
108
 
109
+ // Decides what one 'gsf' subscription's client gets for a live change, from
110
+ // what that client holds — as the real dataserver does with its client
111
+ // index — and keeps the subscription's pending/held sets up to date.
112
+ // `operation` is the change after the query filter and CRITERIA_MATCH
113
+ // (a MODIFY that left them arrives as a DELETE); the user's rowFilter is
114
+ // applied here too, so leaving it counts as leaving the subscription.
115
+ // - A row still waiting for MORE_ROWS (past the page, or past MAX_VIEW) is
116
+ // never pushed: the client has never seen it. A change replaces the
117
+ // waiting copy in place, so the page later delivers current values; a
118
+ // DELETE drops it.
119
+ // - A held row gets the MODIFY, or a DELETE when it is gone or out of scope.
120
+ // - A row the client doesn't hold that is (now) in scope is pushed as an
121
+ // INSERT and becomes held: a brand-new row, or one a MODIFY brought back
122
+ // into the criteria. Out of scope, the client is told nothing.
123
+ // An INSERT is pushed even when the view already holds MAX_VIEW rows; the
124
+ // real server would also evict the oldest row in the same frame (moving
125
+ // view), which this engine doesn't, so the view can grow past MAX_VIEW.
126
+ // A row without a ROW_REF can't be matched to anything the client holds,
127
+ // so changes to it aren't pushed.
128
+ // Returns the operation to push, or null to push nothing.
129
+ function trackClientView(
130
+ subscription: Subscription,
131
+ connection: Connection,
132
+ query: QueryDef | undefined,
133
+ operation: RowOperation,
134
+ row: Row,
135
+ rowRef: unknown,
136
+ ): RowOperation | null {
137
+ const key = pendingKey(rowRef);
138
+ const pending = subscription.pending;
139
+ const held = subscription.held;
140
+ if (key === undefined || !pending || !held) return null;
141
+ const permitted =
142
+ operation === 'DELETE'
143
+ ? undefined
144
+ : applyResourceAuth([row], query?.auth, requestingUser(connection))[0];
145
+ if (pending.has(key)) {
146
+ if (permitted) pending.set(key, stampRow(permitted, rowRef, 'INSERT'));
147
+ else pending.delete(key);
148
+ return null;
149
+ }
150
+ if (held.has(key)) {
151
+ if (permitted) return 'MODIFY';
152
+ held.delete(key);
153
+ return 'DELETE';
154
+ }
155
+ if (!permitted) return null;
156
+ held.add(key);
157
+ return 'INSERT';
158
+ }
159
+
106
160
  const broadcast: BroadcastFn = (resourceName, operation, row) => {
107
161
  const query = config.queries?.[resourceName];
108
162
  // The query's own filter (where-clause equivalent) applies to live pushes
@@ -111,7 +165,6 @@ export function createMockServer(config: MockServerConfig): MockServer {
111
165
  // rather than dropped: a swallowed removal leaves the client rendering the
112
166
  // row with its stale pre-modify values until a full reload. An INSERT
113
167
  // outside the filter is simply dropped (the client never held it).
114
- // DELETEs always go through: dropping an unknown ROW_REF is a no-op.
115
168
  let effectiveOperation = operation;
116
169
  if (operation !== 'DELETE' && query?.filter && !query.filter(row)) {
117
170
  if (operation !== 'MODIFY') return;
@@ -120,34 +173,43 @@ export function createMockServer(config: MockServerConfig): MockServer {
120
173
  for (const connection of connections) {
121
174
  for (const [sourceRef, subscription] of connection.subscriptions) {
122
175
  if (subscription.resourceName !== resourceName) continue;
123
- const rowOperation = resolveSubscriptionOperation(
176
+ const resolved = resolveSubscriptionOperation(
124
177
  subscription,
125
178
  sourceRef,
126
179
  effectiveOperation,
127
180
  row,
128
181
  );
129
- if (rowOperation === null) continue;
182
+ if (resolved === null) continue;
130
183
  // Per-user permissioning (auth maps / hideFields equivalent): the
131
184
  // ROW_REF is computed from the unstripped row, since pk fields may
132
185
  // themselves be hidden from this user.
133
186
  const rowRef = subscription.getRowRef(row);
187
+ const rowOperation = subscription.held
188
+ ? trackClientView(subscription, connection, query, resolved, row, rowRef)
189
+ : resolved;
190
+ if (rowOperation === null) continue;
134
191
  let outRow = row;
135
- if (rowOperation !== operation) {
136
- // Converted to a DELETE: send the row identity only — it is all the
137
- // client needs to drop the row, and the full row may carry columns
138
- // this user's hideFields strips.
139
- outRow = query ? store.rowStubFromKey(query.source, rowRef as string) : row;
140
- } else if (rowOperation !== 'DELETE' && query?.auth) {
192
+ if (rowOperation === 'DELETE') {
193
+ // A change turned into a DELETE: send the row identity only — it is
194
+ // all the client needs to drop the row, and the full row may carry
195
+ // columns this user's hideFields strips. (A real DELETE's row is a
196
+ // stub already.)
197
+ if (operation !== 'DELETE') {
198
+ outRow = query ? store.rowStubFromKey(query.source, rowRef as string) : row;
199
+ }
200
+ } else if (query?.auth) {
141
201
  const [permitted] = applyResourceAuth([row], query.auth, requestingUser(connection));
142
202
  if (!permitted) continue;
143
203
  outRow = permitted;
144
204
  }
145
205
  subscription.sequenceId += 1;
206
+ // A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
207
+ // server — those describe the snapshot. Legacy mode sent ROWS_COUNT: 1.
146
208
  connection.send({
147
209
  MESSAGE_TYPE: 'QUERY_UPDATE',
148
210
  SOURCE_REF: sourceRef,
149
211
  ROW: [stampRow(outRow, rowRef, rowOperation)],
150
- ROWS_COUNT: 1,
212
+ ...(isLegacyFidelity(config) ? { ROWS_COUNT: 1 } : {}),
151
213
  SEQUENCE_ID: subscription.sequenceId,
152
214
  });
153
215
  }
package/src/types.ts CHANGED
@@ -214,7 +214,20 @@ export type OnMessageHook = (
214
214
  ctx: OnMessageCtx,
215
215
  ) => GenesisMessage | false | void;
216
216
 
217
+ // How closely the wire envelopes follow the real server. 'gsf' (the default)
218
+ // matches GSF 8.15.x: LOGON_ACK before the snapshot, dataserver paging
219
+ // (MAX_ROWS / MAX_VIEW / MORE_ROWS), LOGOFF_ACK, FEATURE_ACK /
220
+ // JSON_SCHEMA_FEATURE_ACK, and the router's MSG_NACK for unknown resources and
221
+ // missing/duplicate subscriptions. 'legacy' restores the envelopes this engine
222
+ // sent up to 15.47 (every row in one snapshot, no LOGON_ACK/LOGOFF_ACK, typed
223
+ // UNKNOWN_RESOURCE replies that RESOLVE the client's pending promise where
224
+ // MSG_NACK rejects it) — an escape hatch for consumers built against those,
225
+ // to be removed in a later release.
226
+ export type FidelityMode = 'gsf' | 'legacy';
227
+
217
228
  export interface MockServerConfig {
229
+ // Defaults to 'gsf'. See FidelityMode and the README's "Wire fidelity".
230
+ fidelity?: FidelityMode;
218
231
  tables?: Record<string, TableDef>;
219
232
  views?: Record<string, ViewDef>;
220
233
  queries?: Record<string, QueryDef>;
@@ -223,7 +236,8 @@ export interface MockServerConfig {
223
236
  // Fallback for commit events with no registered handler — lets a project
224
237
  // with many events adopt incrementally (e.g. `() => ({})` acks everything
225
238
  // unhandled) instead of enumerating ack-only handlers. Unregistered events
226
- // NACK with UNKNOWN_RESOURCE when this is unset.
239
+ // get the router's MSG_NACK 404 when this is unset (EVENT_NACK with
240
+ // UNKNOWN_RESOURCE under fidelity: 'legacy').
227
241
  defaultEventHandler?: EventHandler;
228
242
  eventSchemas?: Record<string, EventSchemaOverride>;
229
243
  auth?: AuthConfig;