@cratis/arc 20.60.1 → 20.60.2

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 (93) hide show
  1. package/dist/cjs/queries/HubConnectionKeepAlive.js +24 -4
  2. package/dist/cjs/queries/HubConnectionKeepAlive.js.map +1 -1
  3. package/dist/cjs/queries/QueryInstanceCache.js +35 -12
  4. package/dist/cjs/queries/QueryInstanceCache.js.map +1 -1
  5. package/dist/cjs/queries/ServerSentEventHubConnection.js +38 -10
  6. package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
  7. package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
  8. package/dist/cjs/queries/index.js +2 -0
  9. package/dist/cjs/queries/index.js.map +1 -1
  10. package/dist/cjs/queries/reconcileQueryData.js +84 -0
  11. package/dist/cjs/queries/reconcileQueryData.js.map +1 -0
  12. package/dist/esm/queries/HubConnectionKeepAlive.d.ts +5 -3
  13. package/dist/esm/queries/HubConnectionKeepAlive.d.ts.map +1 -1
  14. package/dist/esm/queries/HubConnectionKeepAlive.js +24 -4
  15. package/dist/esm/queries/HubConnectionKeepAlive.js.map +1 -1
  16. package/dist/esm/queries/QueryInstanceCache.d.ts +1 -0
  17. package/dist/esm/queries/QueryInstanceCache.d.ts.map +1 -1
  18. package/dist/esm/queries/QueryInstanceCache.js +35 -12
  19. package/dist/esm/queries/QueryInstanceCache.js.map +1 -1
  20. package/dist/esm/queries/ServerSentEventHubConnection.d.ts +1 -0
  21. package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  22. package/dist/esm/queries/ServerSentEventHubConnection.js +38 -10
  23. package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
  24. package/dist/esm/queries/WebSocketHubConnection.d.ts +1 -0
  25. package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
  26. package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
  27. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.d.ts +2 -0
  28. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.d.ts.map +1 -0
  29. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.js +37 -0
  30. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.js.map +1 -0
  31. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.d.ts +2 -0
  32. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.d.ts.map +1 -0
  33. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.js +22 -0
  34. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.js.map +1 -0
  35. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.d.ts +2 -0
  36. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.d.ts.map +1 -0
  37. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.js +32 -0
  38. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.js.map +1 -0
  39. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.d.ts +2 -0
  40. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.d.ts.map +1 -0
  41. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.js +22 -0
  42. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.js.map +1 -0
  43. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.d.ts +2 -0
  44. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.d.ts.map +1 -0
  45. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.js +39 -0
  46. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.js.map +1 -0
  47. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.d.ts +2 -0
  48. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.d.ts.map +1 -0
  49. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.js +28 -0
  50. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.js.map +1 -0
  51. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.d.ts +2 -0
  52. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.d.ts.map +1 -0
  53. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.js +36 -0
  54. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.js.map +1 -0
  55. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.d.ts +2 -0
  56. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.d.ts.map +1 -0
  57. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.js +20 -0
  58. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.js.map +1 -0
  59. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.d.ts +2 -0
  60. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.d.ts.map +1 -0
  61. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.js +15 -0
  62. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.js.map +1 -0
  63. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.d.ts +2 -0
  64. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.d.ts.map +1 -0
  65. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.js +20 -0
  66. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.js.map +1 -0
  67. package/dist/esm/queries/index.d.ts +1 -0
  68. package/dist/esm/queries/index.d.ts.map +1 -1
  69. package/dist/esm/queries/index.js +1 -0
  70. package/dist/esm/queries/index.js.map +1 -1
  71. package/dist/esm/queries/reconcileQueryData.d.ts +2 -0
  72. package/dist/esm/queries/reconcileQueryData.d.ts.map +1 -0
  73. package/dist/esm/queries/reconcileQueryData.js +82 -0
  74. package/dist/esm/queries/reconcileQueryData.js.map +1 -0
  75. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  76. package/package.json +1 -1
  77. package/queries/HubConnectionKeepAlive.ts +30 -3
  78. package/queries/QueryInstanceCache.ts +61 -17
  79. package/queries/ServerSentEventHubConnection.ts +45 -10
  80. package/queries/WebSocketHubConnection.ts +6 -0
  81. package/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.ts +50 -0
  82. package/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.ts +28 -0
  83. package/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.ts +43 -0
  84. package/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.ts +34 -0
  85. package/queries/for_ServerSentEventHubConnection/when_keep_alive_is_idle/triggers_reconnect.ts +4 -4
  86. package/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.ts +64 -0
  87. package/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.ts +50 -0
  88. package/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.ts +46 -0
  89. package/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.ts +26 -0
  90. package/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.ts +22 -0
  91. package/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.ts +31 -0
  92. package/queries/index.ts +1 -0
  93. package/queries/reconcileQueryData.ts +98 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/arc",
3
- "version": "20.60.1",
3
+ "version": "20.60.2",
4
4
  "description": "",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -22,7 +22,8 @@
22
22
  export class HubConnectionKeepAlive {
23
23
  private _lastActivityTime = Date.now();
24
24
  private _timer?: ReturnType<typeof setInterval>;
25
- private readonly _idleThresholdMs: number;
25
+ private _intervalMs: number;
26
+ private _idleThresholdMs: number;
26
27
 
27
28
  /**
28
29
  * Initializes a new instance of {@link HubConnectionKeepAlive}.
@@ -35,11 +36,37 @@ export class HubConnectionKeepAlive {
35
36
  * cadence to account for network latency and timer jitter.
36
37
  */
37
38
  constructor(
38
- private readonly _intervalMs: number,
39
+ intervalMs: number,
39
40
  private readonly _onIdle: () => void,
40
41
  idleThresholdMs?: number,
41
42
  ) {
42
- this._idleThresholdMs = idleThresholdMs ?? _intervalMs;
43
+ this._intervalMs = intervalMs;
44
+ this._idleThresholdMs = idleThresholdMs ?? intervalMs;
45
+ }
46
+
47
+ /**
48
+ * Gets how long (in milliseconds) without activity before the connection is considered idle.
49
+ */
50
+ get idleThresholdMs(): number {
51
+ return this._idleThresholdMs;
52
+ }
53
+
54
+ /**
55
+ * Change the check interval and idle threshold, restarting the timer when it is already running.
56
+ *
57
+ * Used when the peer advertises its actual keep-alive cadence after connecting, so the client
58
+ * stops relying on an assumed default that may not match the server's configuration.
59
+ * @param {number} intervalMs How often (in milliseconds) to check for idle connections.
60
+ * @param {number} idleThresholdMs How long (in milliseconds) without activity before the connection is considered idle.
61
+ */
62
+ reconfigure(intervalMs: number, idleThresholdMs: number): void {
63
+ const wasRunning = this._timer !== undefined;
64
+ this._intervalMs = intervalMs;
65
+ this._idleThresholdMs = idleThresholdMs;
66
+
67
+ if (wasRunning) {
68
+ this.start();
69
+ }
43
70
  }
44
71
 
45
72
  /**
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { QueryResultWithState } from './QueryResultWithState';
5
5
  import { CacheDiagnostics, CacheEntryDiagnostics } from './ObservableQueryDiagnosticsSnapshot';
6
+ import { reconcileQueryData } from './reconcileQueryData';
6
7
 
7
8
  /**
8
9
  * Represents a key that uniquely identifies a query instance in the cache, based on the query type name and its serialized arguments.
@@ -171,25 +172,68 @@ export class QueryInstanceCache {
171
172
  setLastResult<TDataType>(key: QueryCacheKey, result: QueryResultWithState<TDataType>): void {
172
173
  const entry = this._entries.get(key);
173
174
 
174
- if (entry) {
175
- const previousResult = entry.lastResult as QueryResultWithState<TDataType> | undefined;
176
- entry.lastResult = result as QueryResultWithState<unknown>;
177
-
178
- // Suppress re-renders when the server re-sends identical data after a reconnect.
179
- // We only compare `data` and `isSuccess` — other fields (e.g. changeSet) are
180
- // ephemeral and do not affect what the user sees.
181
- if (
182
- previousResult !== undefined &&
183
- previousResult.isSuccess === result.isSuccess &&
184
- JSON.stringify(previousResult.data) === JSON.stringify(result.data)
185
- ) {
186
- return;
187
- }
175
+ if (!entry) {
176
+ return;
177
+ }
188
178
 
189
- for (const listener of entry.listeners) {
190
- (listener as QueryCacheListener<TDataType>)(result);
191
- }
179
+ const previousResult = entry.lastResult as QueryResultWithState<TDataType> | undefined;
180
+
181
+ // Reconcile the incoming payload against the one already held so items that did not actually
182
+ // change keep their previous references. A full snapshot — which is what the server re-sends
183
+ // whenever a subscription is re-established — otherwise arrives as all-new references and
184
+ // makes every consumer treat every item as changed.
185
+ const reconciled = previousResult !== undefined
186
+ ? this.withReconciledData(previousResult, result)
187
+ : result;
188
+
189
+ entry.lastResult = reconciled as QueryResultWithState<unknown>;
190
+
191
+ // Reconciliation returns the previous data reference when nothing changed, so identity is
192
+ // enough to detect it. Only `data` and `isSuccess` matter here — the other fields (e.g.
193
+ // changeSet) are ephemeral and do not affect what the user sees.
194
+ if (
195
+ previousResult !== undefined &&
196
+ previousResult.isSuccess === reconciled.isSuccess &&
197
+ previousResult.data === reconciled.data
198
+ ) {
199
+ return;
192
200
  }
201
+
202
+ for (const listener of entry.listeners) {
203
+ (listener as QueryCacheListener<TDataType>)(reconciled);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Returns the incoming result with its data reconciled against the previous result, or the
209
+ * incoming result unchanged when reconciliation found nothing to carry over.
210
+ * @template TDataType The type of data returned by the query.
211
+ * @param previous The previously held result.
212
+ * @param next The freshly received result.
213
+ * @returns A result whose data preserves references for everything that did not change.
214
+ */
215
+ private withReconciledData<TDataType>(
216
+ previous: QueryResultWithState<TDataType>,
217
+ next: QueryResultWithState<TDataType>
218
+ ): QueryResultWithState<TDataType> {
219
+ const reconciledData = reconcileQueryData(previous.data, next.data);
220
+
221
+ if (reconciledData === next.data) {
222
+ return next;
223
+ }
224
+
225
+ return new QueryResultWithState<TDataType>(
226
+ reconciledData,
227
+ next.paging,
228
+ next.isSuccess,
229
+ next.isAuthorized,
230
+ next.isValid,
231
+ next.validationResults,
232
+ next.hasExceptions,
233
+ next.exceptionMessages,
234
+ next.exceptionStackTrace,
235
+ next.isPerforming,
236
+ next.changeSet);
193
237
  }
194
238
 
195
239
  /**
@@ -17,6 +17,15 @@ interface ActiveSubscription {
17
17
  callback: DataReceived<any>;
18
18
  }
19
19
 
20
+ /**
21
+ * How many keep-alive intervals of silence are tolerated before the connection is considered dead.
22
+ *
23
+ * The server guarantees a message every interval, so this is pure slack for latency and jitter.
24
+ * It must stay above 1 — a threshold at or below the server's own cadence makes the client and
25
+ * server timers race, and the client tears down connections the server considers healthy.
26
+ */
27
+ const IDLE_THRESHOLD_FACTOR = 2;
28
+
20
29
  /**
21
30
  * A multiplexed SSE hub connection that uses EventSource for server→client streaming
22
31
  * and fetch POST requests for client→server subscribe/unsubscribe commands.
@@ -45,8 +54,9 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
45
54
  * @param {string} _subscribeUrl The subscribe POST endpoint URL.
46
55
  * @param {string} _unsubscribeUrl The unsubscribe POST endpoint URL.
47
56
  * @param {string} _microservice The microservice name to pass as a query argument.
48
- * @param {number} keepAliveIntervalMs How long without any server message before the connection
49
- * is considered stale and a reconnect is forced (default: 30 000 ms).
57
+ * @param {number} keepAliveIntervalMs The keep-alive cadence to assume until the server advertises
58
+ * its own on the {@link HubMessageType.Connected} message (default: 30 000 ms). The connection is
59
+ * considered stale after {@link IDLE_THRESHOLD_FACTOR} times this without any server message.
50
60
  * @param {number} connectTimeoutMs How long to wait for the {@link HubMessageType.Connected}
51
61
  * message after the HTTP connection opens before giving up and retrying (default: 15 000 ms).
52
62
  * @param {IReconnectPolicy} _policy The reconnect policy to use (default: {@link ReconnectPolicy}).
@@ -64,18 +74,20 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
64
74
  // inactivity — if the server stops sending messages (including its own keep-alive
65
75
  // pings) for the entire idle threshold, the connection is stale and we reconnect.
66
76
  //
67
- // The idle threshold is set to 1.5× the check interval so the server's keep-alive
68
- // ping (which fires on the same cadence) has time to arrive over the network before
69
- // the client declares the connection dead. Without this tolerance the client's timer
70
- // and the server's timer race the client often fires first and reconnects
71
- // unnecessarily.
72
- const idleThresholdMs = Math.round(keepAliveIntervalMs * 1.5);
77
+ // The server guarantees a message at least every keep-alive interval, so the threshold
78
+ // only has to absorb network latency, timer jitter and server-side scheduling hiccups.
79
+ // A hard TCP drop surfaces immediately through `onerror`, so this watchdog only needs to
80
+ // catch silent black-holes that makes a generous tolerance strictly better than a tight
81
+ // one, which would tear down healthy connections.
82
+ //
83
+ // The interval below is only the starting assumption; the server advertises its real
84
+ // cadence on the Connected message and {@link handleConnected} reconfigures from it.
73
85
  this._keepAlive = new HubConnectionKeepAlive(keepAliveIntervalMs, () => {
74
86
  if (!this._disconnected && this._subscriptions.size > 0) {
75
- console.warn(`SSE hub: no messages received for ${idleThresholdMs}ms, reconnecting '${this._sseUrl}'`);
87
+ console.warn(`SSE hub: no messages received for ${this._keepAlive.idleThresholdMs}ms, reconnecting '${this._sseUrl}'`);
76
88
  this.reconnect();
77
89
  }
78
- }, idleThresholdMs);
90
+ }, keepAliveIntervalMs * IDLE_THRESHOLD_FACTOR);
79
91
  }
80
92
 
81
93
  /** @inheritdoc */
@@ -277,6 +289,8 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
277
289
  // Connected message arrived — cancel the connect timeout.
278
290
  this.clearConnectTimeout();
279
291
 
292
+ this.applyServerKeepAliveInterval(message.keepAliveIntervalMs);
293
+
280
294
  // Send all pending subscriptions now that we have a connection ID.
281
295
  for (const [queryId, sub] of this._pendingSubscriptions) {
282
296
  this.sendSubscribe(queryId, sub.request);
@@ -284,6 +298,27 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
284
298
  this._pendingSubscriptions.clear();
285
299
  }
286
300
 
301
+ /**
302
+ * Align the idle watchdog with the keep-alive cadence the server actually runs on.
303
+ *
304
+ * Without this the client assumes the default interval, so a server configured with a longer
305
+ * interval — or with keep-alive switched off entirely — would look silent and be reconnected
306
+ * on a loop even though it is perfectly healthy.
307
+ * @param {number | undefined} serverIntervalMs The interval advertised by the server, if any.
308
+ */
309
+ private applyServerKeepAliveInterval(serverIntervalMs?: number): void {
310
+ if (serverIntervalMs === undefined) return;
311
+
312
+ // Keep-alive disabled server-side: silence is expected, so watching for it would guarantee
313
+ // an endless reconnect loop. Hard drops still surface through `onerror`.
314
+ if (serverIntervalMs <= 0) {
315
+ this._keepAlive.stop();
316
+ return;
317
+ }
318
+
319
+ this._keepAlive.reconfigure(serverIntervalMs, serverIntervalMs * IDLE_THRESHOLD_FACTOR);
320
+ }
321
+
287
322
  private handleQueryResult(message: HubMessage): void {
288
323
  if (!message.queryId) return;
289
324
 
@@ -33,6 +33,12 @@ export interface HubMessage {
33
33
  queryId?: string;
34
34
  payload?: any;
35
35
  timestamp?: number;
36
+
37
+ /**
38
+ * The server's keep-alive interval in milliseconds, or 0 when keep-alive is disabled.
39
+ * Only present on {@link HubMessageType.Connected} messages.
40
+ */
41
+ keepAliveIntervalMs?: number;
36
42
  }
37
43
 
38
44
  /**
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { HubConnectionKeepAlive } from '../../HubConnectionKeepAlive';
6
+
7
+ describe('when reconfiguring a running keep-alive', () => {
8
+ let clock: sinon.SinonFakeTimers;
9
+ let onIdle: sinon.SinonStub;
10
+ let keepAlive: HubConnectionKeepAlive;
11
+
12
+ const originalIntervalMs = 500;
13
+ const originalThresholdMs = 750;
14
+ const newIntervalMs = 1000;
15
+ const newThresholdMs = 2000;
16
+
17
+ beforeEach(() => {
18
+ clock = sinon.useFakeTimers();
19
+ onIdle = sinon.stub();
20
+ keepAlive = new HubConnectionKeepAlive(originalIntervalMs, onIdle, originalThresholdMs);
21
+ keepAlive.start();
22
+ keepAlive.reconfigure(newIntervalMs, newThresholdMs);
23
+ });
24
+
25
+ afterEach(() => {
26
+ keepAlive.stop();
27
+ clock.restore();
28
+ sinon.restore();
29
+ });
30
+
31
+ it('should expose the new idle threshold', () => keepAlive.idleThresholdMs.should.equal(newThresholdMs));
32
+
33
+ describe('and the original threshold elapses', () => {
34
+ beforeEach(() => {
35
+ // Well past the original 750ms threshold, but short of the new 2000ms one.
36
+ clock.tick(originalThresholdMs + 1);
37
+ });
38
+
39
+ it('should not invoke the onIdle callback', () => onIdle.called.should.be.false);
40
+ });
41
+
42
+ describe('and the new threshold elapses', () => {
43
+ beforeEach(() => {
44
+ // Checks now fire every 1000ms; the tick at 2000ms sees 2000ms of inactivity.
45
+ clock.tick(newThresholdMs + 1);
46
+ });
47
+
48
+ it('should invoke the onIdle callback', () => onIdle.calledOnce.should.be.true);
49
+ });
50
+ });
@@ -0,0 +1,28 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { HubConnectionKeepAlive } from '../../HubConnectionKeepAlive';
6
+
7
+ describe('when reconfiguring a keep-alive that has not been started', () => {
8
+ let clock: sinon.SinonFakeTimers;
9
+ let onIdle: sinon.SinonStub;
10
+ let keepAlive: HubConnectionKeepAlive;
11
+
12
+ beforeEach(() => {
13
+ clock = sinon.useFakeTimers();
14
+ onIdle = sinon.stub();
15
+ keepAlive = new HubConnectionKeepAlive(500, onIdle, 750);
16
+ keepAlive.reconfigure(1000, 2000);
17
+ clock.tick(10000);
18
+ });
19
+
20
+ afterEach(() => {
21
+ keepAlive.stop();
22
+ clock.restore();
23
+ sinon.restore();
24
+ });
25
+
26
+ it('should not start the timer', () => onIdle.called.should.be.false);
27
+ it('should still apply the new idle threshold', () => keepAlive.idleThresholdMs.should.equal(2000));
28
+ });
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { QueryInstanceCache } from '../../QueryInstanceCache';
6
+ import { QueryResultWithState } from '../../QueryResultWithState';
7
+
8
+ type Item = { id: string; name: string };
9
+
10
+ describe('when a re-delivered snapshot changed only one of its items', () => {
11
+ const key = 'MyQuery::';
12
+ let cache: QueryInstanceCache;
13
+ let listener: sinon.SinonStub;
14
+ let stored: QueryResultWithState<Item[]>;
15
+
16
+ const first = QueryResultWithState.empty<Item[]>([
17
+ { id: 'a', name: 'First' },
18
+ { id: 'b', name: 'Second' }
19
+ ]);
20
+
21
+ const second = QueryResultWithState.empty<Item[]>([
22
+ { id: 'a', name: 'First' },
23
+ { id: 'b', name: 'Second changed' }
24
+ ]);
25
+
26
+ beforeEach(() => {
27
+ cache = new QueryInstanceCache();
28
+ cache.getOrCreate(key, () => ({}));
29
+ listener = sinon.stub();
30
+ cache.addListener(key, listener);
31
+
32
+ cache.setLastResult(key, first);
33
+ cache.setLastResult(key, second);
34
+ stored = cache.getLastResult<Item[]>(key)!;
35
+ });
36
+
37
+ afterEach(() => sinon.restore());
38
+
39
+ it('should notify for both results', () => listener.calledTwice.should.be.true);
40
+ it('should keep the reference of the unchanged item', () => stored.data[0].should.equal(first.data[0]));
41
+ it('should take the new reference for the changed item', () => stored.data[1].should.equal(second.data[1]));
42
+ it('should carry the changed value', () => stored.data[1].name.should.equal('Second changed'));
43
+ });
@@ -0,0 +1,34 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { QueryInstanceCache } from '../../QueryInstanceCache';
6
+ import { QueryResultWithState } from '../../QueryResultWithState';
7
+
8
+ type Item = { id: string; name: string };
9
+
10
+ describe('when the server re-delivers an identical snapshot after re-subscribing', () => {
11
+ const key = 'MyQuery::';
12
+ let cache: QueryInstanceCache;
13
+ let listener: sinon.SinonStub;
14
+
15
+ const first = QueryResultWithState.empty<Item[]>([{ id: 'a', name: 'First' }]);
16
+
17
+ // A fresh deserialization of the same payload — equal in value, all-new references.
18
+ const second = QueryResultWithState.empty<Item[]>([{ id: 'a', name: 'First' }]);
19
+
20
+ beforeEach(() => {
21
+ cache = new QueryInstanceCache();
22
+ cache.getOrCreate(key, () => ({}));
23
+ listener = sinon.stub();
24
+ cache.addListener(key, listener);
25
+
26
+ cache.setLastResult(key, first);
27
+ cache.setLastResult(key, second);
28
+ });
29
+
30
+ afterEach(() => sinon.restore());
31
+
32
+ it('should notify only for the first result', () => listener.calledOnce.should.be.true);
33
+ it('should keep holding the original data reference', () => cache.getLastResult<Item[]>(key)!.data.should.equal(first.data));
34
+ });
@@ -33,10 +33,10 @@ describe('when keep-alive interval elapses without any server message', given(a_
33
33
  // Deliver Connected so the connection is fully established.
34
34
  context.simulateMessage({ type: HubMessageType.Connected, payload: 'conn-123' });
35
35
 
36
- // Advance past the idle threshold (1.5× keep-alive interval) without any further messages.
37
- // The check interval fires every KEEP_ALIVE_MS; the idle threshold is KEEP_ALIVE_MS * 1.5.
38
- // After the first interval tick (500ms) the elapsed idle time (500ms) is below the
39
- // threshold (750ms). At the second tick (1000ms) the elapsed time exceeds the threshold.
36
+ // Advance past the idle threshold (IDLE_THRESHOLD_FACTOR × keep-alive interval) without any
37
+ // further messages. The check interval fires every KEEP_ALIVE_MS; the idle threshold is
38
+ // KEEP_ALIVE_MS * 2. After the first interval tick (500ms) the elapsed idle time (500ms) is
39
+ // below the threshold (1000ms). At the second tick (1000ms) the elapsed time reaches it.
40
40
  clock.tick(KEEP_ALIVE_MS * 2 + 1);
41
41
  });
42
42
 
@@ -0,0 +1,64 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { a_server_sent_event_hub_connection } from '../given/a_server_sent_event_hub_connection';
6
+ import { given } from '../../../given';
7
+ import { HubMessageType } from '../../WebSocketHubConnection';
8
+
9
+ const ASSUMED_KEEP_ALIVE_MS = 500;
10
+ const SERVER_KEEP_ALIVE_MS = 2000;
11
+
12
+ describe('when the server advertises a longer keep-alive interval than the client assumed', given(a_server_sent_event_hub_connection, context => {
13
+ let clock: sinon.SinonFakeTimers;
14
+
15
+ beforeEach(() => {
16
+ clock = sinon.useFakeTimers();
17
+ context.setup();
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
20
+ const { ServerSentEventHubConnection } = require('../../ServerSentEventHubConnection');
21
+ context.connection = new ServerSentEventHubConnection(
22
+ 'http://localhost/.cratis/queries/sse',
23
+ 'http://localhost/.cratis/queries/sse/subscribe',
24
+ 'http://localhost/.cratis/queries/sse/unsubscribe',
25
+ '',
26
+ ASSUMED_KEEP_ALIVE_MS,
27
+ 15000,
28
+ context.policy
29
+ );
30
+
31
+ context.connection.subscribe('q1', { queryName: 'MyQuery' }, sinon.stub());
32
+ context.simulateOpen();
33
+
34
+ context.simulateMessage({
35
+ type: HubMessageType.Connected,
36
+ payload: 'conn-123',
37
+ keepAliveIntervalMs: SERVER_KEEP_ALIVE_MS
38
+ });
39
+ });
40
+
41
+ afterEach(() => {
42
+ clock.restore();
43
+ sinon.restore();
44
+ });
45
+
46
+ describe('and the assumed threshold elapses', () => {
47
+ beforeEach(() => {
48
+ // Far past the assumed threshold (2 × 500ms), but the server only promised a message
49
+ // every 2000ms — tearing the connection down here is exactly the bug being fixed.
50
+ clock.tick(ASSUMED_KEEP_ALIVE_MS * 2 + 1);
51
+ });
52
+
53
+ it('should not schedule a reconnect', () => (context.policy.schedule as sinon.SinonStub).called.should.be.false);
54
+ });
55
+
56
+ describe('and the advertised threshold elapses', () => {
57
+ beforeEach(() => {
58
+ // Checks now run on the server's 2000ms cadence; the tick at 4000ms sees 4000ms of silence.
59
+ clock.tick(SERVER_KEEP_ALIVE_MS * 2 + 1);
60
+ });
61
+
62
+ it('should schedule a reconnect', () => (context.policy.schedule as sinon.SinonStub).calledOnce.should.be.true);
63
+ });
64
+ }));
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import sinon from 'sinon';
5
+ import { a_server_sent_event_hub_connection } from '../given/a_server_sent_event_hub_connection';
6
+ import { given } from '../../../given';
7
+ import { HubMessageType } from '../../WebSocketHubConnection';
8
+
9
+ const ASSUMED_KEEP_ALIVE_MS = 500;
10
+
11
+ describe('when the server reports that keep-alive is disabled', given(a_server_sent_event_hub_connection, context => {
12
+ let clock: sinon.SinonFakeTimers;
13
+
14
+ beforeEach(() => {
15
+ clock = sinon.useFakeTimers();
16
+ context.setup();
17
+
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
19
+ const { ServerSentEventHubConnection } = require('../../ServerSentEventHubConnection');
20
+ context.connection = new ServerSentEventHubConnection(
21
+ 'http://localhost/.cratis/queries/sse',
22
+ 'http://localhost/.cratis/queries/sse/subscribe',
23
+ 'http://localhost/.cratis/queries/sse/unsubscribe',
24
+ '',
25
+ ASSUMED_KEEP_ALIVE_MS,
26
+ 15000,
27
+ context.policy
28
+ );
29
+
30
+ context.connection.subscribe('q1', { queryName: 'MyQuery' }, sinon.stub());
31
+ context.simulateOpen();
32
+
33
+ context.simulateMessage({
34
+ type: HubMessageType.Connected,
35
+ payload: 'conn-123',
36
+ keepAliveIntervalMs: 0
37
+ });
38
+
39
+ // A server that never pings is silent by design — watching for silence would otherwise
40
+ // reconnect on a loop forever. Hard drops still surface through onerror.
41
+ clock.tick(ASSUMED_KEEP_ALIVE_MS * 20);
42
+ });
43
+
44
+ afterEach(() => {
45
+ clock.restore();
46
+ sinon.restore();
47
+ });
48
+
49
+ it('should never schedule a reconnect from inactivity', () => (context.policy.schedule as sinon.SinonStub).called.should.be.false);
50
+ }));
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { reconcileQueryData } from '../../reconcileQueryData';
5
+
6
+ describe('when reconciling a snapshot with an item added', () => {
7
+ const previous = [
8
+ { id: 'a', name: 'First' },
9
+ { id: 'b', name: 'Second' }
10
+ ];
11
+
12
+ const next = [
13
+ { id: 'a', name: 'First' },
14
+ { id: 'b', name: 'Second' },
15
+ { id: 'c', name: 'Third' }
16
+ ];
17
+
18
+ const result = reconcileQueryData(previous, next);
19
+
20
+ it('should return a new collection', () => result.should.not.equal(previous));
21
+ it('should have the new length', () => result.length.should.equal(3));
22
+ it('should keep the references of the surviving items', () => {
23
+ result[0].should.equal(previous[0]);
24
+ result[1].should.equal(previous[1]);
25
+ });
26
+ it('should take the new reference for the added item', () => result[2].should.equal(next[2]));
27
+ });
28
+
29
+ describe('when reconciling a snapshot with an item removed', () => {
30
+ const previous = [
31
+ { id: 'a', name: 'First' },
32
+ { id: 'b', name: 'Second' },
33
+ { id: 'c', name: 'Third' }
34
+ ];
35
+
36
+ const next = [
37
+ { id: 'a', name: 'First' },
38
+ { id: 'c', name: 'Third' }
39
+ ];
40
+
41
+ const result = reconcileQueryData(previous, next);
42
+
43
+ it('should have the new length', () => result.length.should.equal(2));
44
+ it('should keep the reference of the item that stayed in place', () => result[0].should.equal(previous[0]));
45
+ it('should keep the reference of the item that shifted position', () => result[1].should.equal(previous[2]));
46
+ });
@@ -0,0 +1,26 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { reconcileQueryData } from '../../reconcileQueryData';
5
+
6
+ describe('when reconciling a snapshot in which a single item changed', () => {
7
+ const previous = [
8
+ { id: 'a', name: 'First' },
9
+ { id: 'b', name: 'Second' },
10
+ { id: 'c', name: 'Third' }
11
+ ];
12
+
13
+ const next = [
14
+ { id: 'a', name: 'First' },
15
+ { id: 'b', name: 'Second changed' },
16
+ { id: 'c', name: 'Third' }
17
+ ];
18
+
19
+ const result = reconcileQueryData(previous, next);
20
+
21
+ it('should return a new collection', () => result.should.not.equal(previous));
22
+ it('should keep the reference of the first unchanged item', () => result[0].should.equal(previous[0]));
23
+ it('should keep the reference of the last unchanged item', () => result[2].should.equal(previous[2]));
24
+ it('should take the new reference for the changed item', () => result[1].should.equal(next[1]));
25
+ it('should carry the changed value', () => result[1].name.should.equal('Second changed'));
26
+ });
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { reconcileQueryData } from '../../reconcileQueryData';
5
+
6
+ describe('when reconciling a re-delivered snapshot that did not change', () => {
7
+ const previous = [
8
+ { id: 'a', name: 'First' },
9
+ { id: 'b', name: 'Second' }
10
+ ];
11
+
12
+ // A fresh deserialization of the same payload — equal in value, all-new references.
13
+ const next = [
14
+ { id: 'a', name: 'First' },
15
+ { id: 'b', name: 'Second' }
16
+ ];
17
+
18
+ const result = reconcileQueryData(previous, next);
19
+
20
+ it('should return the previous collection itself', () => result.should.equal(previous));
21
+ it('should not adopt any of the new references', () => result.forEach((item, index) => item.should.equal(previous[index])));
22
+ });