@cratis/arc 20.60.0 → 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 (140) 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/NullObservableQueryConnection.js +7 -2
  4. package/dist/cjs/queries/NullObservableQueryConnection.js.map +1 -1
  5. package/dist/cjs/queries/ObservableQueryFor.js +11 -1
  6. package/dist/cjs/queries/ObservableQueryFor.js.map +1 -1
  7. package/dist/cjs/queries/QueryFor.js +1 -21
  8. package/dist/cjs/queries/QueryFor.js.map +1 -1
  9. package/dist/cjs/queries/QueryInstanceCache.js +35 -12
  10. package/dist/cjs/queries/QueryInstanceCache.js.map +1 -1
  11. package/dist/cjs/queries/QueryResult.js +30 -0
  12. package/dist/cjs/queries/QueryResult.js.map +1 -1
  13. package/dist/cjs/queries/ServerSentEventHubConnection.js +38 -10
  14. package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
  15. package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
  16. package/dist/cjs/queries/index.js +2 -0
  17. package/dist/cjs/queries/index.js.map +1 -1
  18. package/dist/cjs/queries/reconcileQueryData.js +84 -0
  19. package/dist/cjs/queries/reconcileQueryData.js.map +1 -0
  20. package/dist/esm/queries/HubConnectionKeepAlive.d.ts +5 -3
  21. package/dist/esm/queries/HubConnectionKeepAlive.d.ts.map +1 -1
  22. package/dist/esm/queries/HubConnectionKeepAlive.js +24 -4
  23. package/dist/esm/queries/HubConnectionKeepAlive.js.map +1 -1
  24. package/dist/esm/queries/NullObservableQueryConnection.d.ts +3 -1
  25. package/dist/esm/queries/NullObservableQueryConnection.d.ts.map +1 -1
  26. package/dist/esm/queries/NullObservableQueryConnection.js +7 -2
  27. package/dist/esm/queries/NullObservableQueryConnection.js.map +1 -1
  28. package/dist/esm/queries/ObservableQueryFor.d.ts +2 -0
  29. package/dist/esm/queries/ObservableQueryFor.d.ts.map +1 -1
  30. package/dist/esm/queries/ObservableQueryFor.js +11 -1
  31. package/dist/esm/queries/ObservableQueryFor.js.map +1 -1
  32. package/dist/esm/queries/QueryFor.d.ts.map +1 -1
  33. package/dist/esm/queries/QueryFor.js +1 -21
  34. package/dist/esm/queries/QueryFor.js.map +1 -1
  35. package/dist/esm/queries/QueryInstanceCache.d.ts +1 -0
  36. package/dist/esm/queries/QueryInstanceCache.d.ts.map +1 -1
  37. package/dist/esm/queries/QueryInstanceCache.js +35 -12
  38. package/dist/esm/queries/QueryInstanceCache.js.map +1 -1
  39. package/dist/esm/queries/QueryResult.d.ts +5 -0
  40. package/dist/esm/queries/QueryResult.d.ts.map +1 -1
  41. package/dist/esm/queries/QueryResult.js +30 -0
  42. package/dist/esm/queries/QueryResult.js.map +1 -1
  43. package/dist/esm/queries/ServerSentEventHubConnection.d.ts +1 -0
  44. package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  45. package/dist/esm/queries/ServerSentEventHubConnection.js +38 -10
  46. package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
  47. package/dist/esm/queries/WebSocketHubConnection.d.ts +1 -0
  48. package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
  49. package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
  50. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.d.ts +2 -0
  51. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.d.ts.map +1 -0
  52. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.js +37 -0
  53. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.js.map +1 -0
  54. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.d.ts +2 -0
  55. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.d.ts.map +1 -0
  56. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.js +22 -0
  57. package/dist/esm/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.js.map +1 -0
  58. package/dist/esm/queries/for_ObservableQueryFor/given/an_observable_query_with_validator.d.ts +23 -0
  59. package/dist/esm/queries/for_ObservableQueryFor/given/an_observable_query_with_validator.d.ts.map +1 -0
  60. package/dist/esm/queries/for_ObservableQueryFor/given/an_observable_query_with_validator.js +28 -0
  61. package/dist/esm/queries/for_ObservableQueryFor/given/an_observable_query_with_validator.js.map +1 -0
  62. package/dist/esm/queries/for_ObservableQueryFor/when_performing/with_client_validation_failing.d.ts +2 -0
  63. package/dist/esm/queries/for_ObservableQueryFor/when_performing/with_client_validation_failing.d.ts.map +1 -0
  64. package/dist/esm/queries/for_ObservableQueryFor/when_performing/with_client_validation_failing.js +21 -0
  65. package/dist/esm/queries/for_ObservableQueryFor/when_performing/with_client_validation_failing.js.map +1 -0
  66. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_failing.d.ts +2 -0
  67. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_failing.d.ts.map +1 -0
  68. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_failing.js +18 -0
  69. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_failing.js.map +1 -0
  70. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_passing.d.ts +2 -0
  71. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_passing.d.ts.map +1 -0
  72. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_passing.js +21 -0
  73. package/dist/esm/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_passing.js.map +1 -0
  74. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.d.ts +2 -0
  75. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.d.ts.map +1 -0
  76. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.js +32 -0
  77. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.js.map +1 -0
  78. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.d.ts +2 -0
  79. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.d.ts.map +1 -0
  80. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.js +22 -0
  81. package/dist/esm/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.js.map +1 -0
  82. 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
  83. 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
  84. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.js +39 -0
  85. 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
  86. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.d.ts +2 -0
  87. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.d.ts.map +1 -0
  88. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.js +28 -0
  89. package/dist/esm/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.js.map +1 -0
  90. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.d.ts +2 -0
  91. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.d.ts.map +1 -0
  92. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.js +36 -0
  93. package/dist/esm/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.js.map +1 -0
  94. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.d.ts +2 -0
  95. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.d.ts.map +1 -0
  96. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.js +20 -0
  97. package/dist/esm/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.js.map +1 -0
  98. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.d.ts +2 -0
  99. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.d.ts.map +1 -0
  100. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.js +15 -0
  101. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.js.map +1 -0
  102. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.d.ts +2 -0
  103. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.d.ts.map +1 -0
  104. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.js +20 -0
  105. package/dist/esm/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.js.map +1 -0
  106. package/dist/esm/queries/index.d.ts +1 -0
  107. package/dist/esm/queries/index.d.ts.map +1 -1
  108. package/dist/esm/queries/index.js +1 -0
  109. package/dist/esm/queries/index.js.map +1 -1
  110. package/dist/esm/queries/reconcileQueryData.d.ts +2 -0
  111. package/dist/esm/queries/reconcileQueryData.d.ts.map +1 -0
  112. package/dist/esm/queries/reconcileQueryData.js +82 -0
  113. package/dist/esm/queries/reconcileQueryData.js.map +1 -0
  114. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +1 -1
  116. package/queries/HubConnectionKeepAlive.ts +30 -3
  117. package/queries/NullObservableQueryConnection.ts +5 -2
  118. package/queries/ObservableQueryFor.ts +15 -1
  119. package/queries/QueryFor.ts +1 -21
  120. package/queries/QueryInstanceCache.ts +61 -17
  121. package/queries/QueryResult.ts +34 -0
  122. package/queries/ServerSentEventHubConnection.ts +45 -10
  123. package/queries/WebSocketHubConnection.ts +6 -0
  124. package/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_running.ts +50 -0
  125. package/queries/for_HubConnectionKeepAlive/when_reconfiguring/while_stopped.ts +28 -0
  126. package/queries/for_ObservableQueryFor/given/an_observable_query_with_validator.ts +41 -0
  127. package/queries/for_ObservableQueryFor/when_performing/with_client_validation_failing.ts +30 -0
  128. package/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_failing.ts +26 -0
  129. package/queries/for_ObservableQueryFor/when_subscribing/with_client_validation_passing.ts +32 -0
  130. package/queries/for_QueryInstanceCache/when_setting_last_result/with_a_partially_changed_result.ts +43 -0
  131. package/queries/for_QueryInstanceCache/when_setting_last_result/with_a_re_delivered_identical_result.ts +34 -0
  132. package/queries/for_ServerSentEventHubConnection/when_keep_alive_is_idle/triggers_reconnect.ts +4 -4
  133. package/queries/for_ServerSentEventHubConnection/when_server_advertises_a_longer_keep_alive_interval/does_not_reconnect_at_the_assumed_threshold.ts +64 -0
  134. package/queries/for_ServerSentEventHubConnection/when_server_reports_keep_alive_disabled/never_reconnects_from_idle.ts +50 -0
  135. package/queries/for_reconcileQueryData/when_items_are_added_or_removed/reuses_the_surviving_items.ts +46 -0
  136. package/queries/for_reconcileQueryData/when_one_item_changed/only_the_changed_item_gets_a_new_reference.ts +26 -0
  137. package/queries/for_reconcileQueryData/when_reconciling_an_unchanged_collection/preserves_all_references.ts +22 -0
  138. package/queries/for_reconcileQueryData/when_reconciling_non_collection_payloads/compares_by_value.ts +31 -0
  139. package/queries/index.ts +1 -0
  140. 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.0",
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
  /**
@@ -13,8 +13,11 @@ export class NullObservableQueryConnection<TDataType> implements IObservableQuer
13
13
  /**
14
14
  * Initializes a new instance of the {@link NullObservableQueryConnection} class.
15
15
  * @param {TDataType} defaultValue The default value to serve.
16
+ * @param {QueryResult<TDataType>} result Optional result to serve instead of an empty one. Supply it when the
17
+ * subscription failed for a reason the subscriber must be able to see — client-side validation in particular,
18
+ * which would otherwise be indistinguishable from a query that simply has no data yet.
16
19
  */
17
- constructor(readonly defaultValue: TDataType) {
20
+ constructor(readonly defaultValue: TDataType, readonly result?: QueryResult<TDataType>) {
18
21
  }
19
22
 
20
23
  /** @inheritdoc */
@@ -29,7 +32,7 @@ export class NullObservableQueryConnection<TDataType> implements IObservableQuer
29
32
 
30
33
  /** @inheritdoc */
31
34
  connect(dataReceived: DataReceived<TDataType>) {
32
- dataReceived(QueryResult.empty(this.defaultValue));
35
+ dataReceived(this.result ?? QueryResult.empty(this.defaultValue));
33
36
  }
34
37
 
35
38
  /** @inheritdoc */
@@ -4,6 +4,7 @@
4
4
  import { IObservableQueryFor, OnNextResult } from './IObservableQueryFor';
5
5
  import { ObservableQuerySubscription } from './ObservableQuerySubscription';
6
6
  import { ValidateRequestArguments } from './ValidateRequestArguments';
7
+ import { QueryValidator } from './QueryValidator';
7
8
  import { IObservableQueryConnection } from './IObservableQueryConnection';
8
9
  import { NullObservableQueryConnection } from './NullObservableQueryConnection';
9
10
  import { createObservableQueryConnection } from './ObservableQueryConnectionFactory';
@@ -38,6 +39,7 @@ export abstract class ObservableQueryFor<TDataType, TParameters = object> implem
38
39
  abstract readonly route: string;
39
40
  abstract readonly defaultValue: TDataType;
40
41
  readonly roles: string[] = [];
42
+ readonly validation?: QueryValidator<any>;
41
43
  /** Backend fully-qualified query name used when subscribing via the SSE hub. Overridden in generated proxies. */
42
44
  readonly queryName?: string;
43
45
  abstract readonly parameterDescriptors: ParameterDescriptor[];
@@ -97,7 +99,14 @@ export abstract class ObservableQueryFor<TDataType, TParameters = object> implem
97
99
  this._connection.disconnect();
98
100
  }
99
101
 
100
- if (!this.validateArguments(args)) {
102
+ const clientValidationErrors = this.validation?.validate(args as object || {}) || [];
103
+ if (clientValidationErrors.length > 0) {
104
+ // Serve the failure through the connection rather than throwing, so a subscriber sees an invalid result
105
+ // on its normal callback path instead of the empty result an unestablished connection would emit.
106
+ this._connection = new NullObservableQueryConnection(
107
+ this.defaultValue,
108
+ QueryResult.validationFailed(clientValidationErrors, this));
109
+ } else if (!this.validateArguments(args)) {
101
110
  this._connection = new NullObservableQueryConnection(this.defaultValue);
102
111
  } else {
103
112
  this._connection = createObservableQueryConnection({
@@ -143,6 +152,11 @@ export abstract class ObservableQueryFor<TDataType, TParameters = object> implem
143
152
  async perform(args?: TParameters): Promise<QueryResult<TDataType>> {
144
153
  const noSuccess = { ...QueryResult.noSuccess, ...{ data: this.defaultValue } } as QueryResult<TDataType>;
145
154
 
155
+ const clientValidationErrors = this.validation?.validate(args as object || {}) || [];
156
+ if (clientValidationErrors.length > 0) {
157
+ return QueryResult.validationFailed(clientValidationErrors, this);
158
+ }
159
+
146
160
  if (!this.validateArguments(args)) {
147
161
  return new Promise<QueryResult<TDataType>>((resolve) => {
148
162
  resolve(noSuccess);
@@ -87,27 +87,7 @@ export abstract class QueryFor<TDataType, TParameters = object> implements IQuer
87
87
 
88
88
  const clientValidationErrors = this.validation?.validate(args as object || {}) || [];
89
89
  if (clientValidationErrors.length > 0) {
90
- return new QueryResult({
91
- data: this.defaultValue as object,
92
- isSuccess: false,
93
- isAuthorized: true,
94
- isValid: false,
95
- hasExceptions: false,
96
- validationResults: clientValidationErrors.map(_ => ({
97
- severity: _.severity,
98
- message: _.message,
99
- members: _.members,
100
- state: _.state
101
- })),
102
- exceptionMessages: [],
103
- exceptionStackTrace: '',
104
- paging: {
105
- totalItems: 0,
106
- totalPages: 0,
107
- page: 0,
108
- size: 0
109
- }
110
- }, this.modelType, this.enumerable) as QueryResult<TDataType>;
90
+ return QueryResult.validationFailed(clientValidationErrors, this);
111
91
  }
112
92
 
113
93
  if (!ValidateRequestArguments(this.constructor.name, this.requiredRequestParameters, args as object)) {
@@ -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
  /**
@@ -71,6 +71,40 @@ export class QueryResult<TDataType = object> implements IQueryResult<TDataType>
71
71
  }, Object, false);
72
72
  }
73
73
 
74
+ /**
75
+ * Creates a {@link QueryResult} representing a query rejected by client-side validation, mirroring
76
+ * {@link CommandResult.validationFailed} so a caller reads a failed query the same way it reads a failed command.
77
+ * @param {ValidationResult[]} validationResults The validation results that caused the failure.
78
+ * @param {object} query The query being rejected, which describes how to shape its own result.
79
+ * @returns {QueryResult<TDataType>} A result that is neither successful nor valid.
80
+ */
81
+ static validationFailed<TDataType>(
82
+ validationResults: ValidationResult[],
83
+ query: { readonly defaultValue: TDataType; readonly modelType: Constructor; readonly enumerable: boolean }): QueryResult<TDataType> {
84
+ const { defaultValue, modelType, enumerable } = query;
85
+ return new QueryResult({
86
+ data: defaultValue as object,
87
+ isSuccess: false,
88
+ isAuthorized: true,
89
+ isValid: false,
90
+ hasExceptions: false,
91
+ validationResults: validationResults.map(_ => ({
92
+ severity: _.severity,
93
+ message: _.message,
94
+ members: _.members,
95
+ state: _.state
96
+ })),
97
+ exceptionMessages: [],
98
+ exceptionStackTrace: '',
99
+ paging: {
100
+ totalItems: 0,
101
+ totalPages: 0,
102
+ page: 0,
103
+ size: 0
104
+ }
105
+ }, modelType, enumerable) as QueryResult<TDataType>;
106
+ }
107
+
74
108
  static unauthorized<TDataType>(): QueryResult<TDataType> {
75
109
  return new QueryResult({
76
110
  data: null as unknown as object,
@@ -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,41 @@
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 { ObservableQueryFor } from '../../ObservableQueryFor';
5
+ import { QueryValidator } from '../../QueryValidator';
6
+ import { ParameterDescriptor } from '../../../reflection/ParameterDescriptor';
7
+ import '../../../validation/RuleBuilderExtensions';
8
+
9
+ export interface ITestParams {
10
+ minAge: number;
11
+ }
12
+
13
+ export class TestObservableQueryValidator extends QueryValidator<ITestParams> {
14
+ constructor() {
15
+ super();
16
+ this.ruleFor((c: ITestParams) => c.minAge).greaterThanOrEqual(0).withMessage('Age must be positive');
17
+ }
18
+ }
19
+
20
+ export class TestObservableQuery extends ObservableQueryFor<string, ITestParams> {
21
+ readonly route = '/test';
22
+ readonly validation = new TestObservableQueryValidator();
23
+ readonly parameterDescriptors: ParameterDescriptor[] = [];
24
+ readonly defaultValue = '';
25
+
26
+ constructor() {
27
+ super(String, false);
28
+ }
29
+
30
+ get requiredRequestParameters(): string[] {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ export class an_observable_query_with_validator {
36
+ query: TestObservableQuery;
37
+
38
+ constructor() {
39
+ this.query = new TestObservableQuery();
40
+ }
41
+ }
@@ -0,0 +1,30 @@
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 { an_observable_query_with_validator } from '../given/an_observable_query_with_validator';
5
+ import { QueryResult } from '../../QueryResult';
6
+ import { given } from '../../../given';
7
+
8
+ describe('when performing with client validation failing', given(an_observable_query_with_validator, context => {
9
+ let result: QueryResult<string>;
10
+
11
+ beforeEach(async () => {
12
+ result = await context.query.perform({ minAge: -5 });
13
+ });
14
+
15
+ it('should not be success', () => {
16
+ result.isSuccess.should.be.false;
17
+ });
18
+
19
+ it('should not be valid', () => {
20
+ result.isValid.should.be.false;
21
+ });
22
+
23
+ it('should have validation results', () => {
24
+ result.validationResults.should.not.be.empty;
25
+ });
26
+
27
+ it('should have error for minAge', () => {
28
+ result.validationResults.some(_ => _.members.includes('minAge')).should.be.true;
29
+ });
30
+ }));
@@ -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 { an_observable_query_with_validator } from '../given/an_observable_query_with_validator';
5
+ import { QueryResult } from '../../QueryResult';
6
+ import { given } from '../../../given';
7
+
8
+ describe('when subscribing with client validation failing', given(an_observable_query_with_validator, context => {
9
+ let result: QueryResult<string>;
10
+
11
+ beforeEach(() => {
12
+ context.query.subscribe(_ => result = _, { minAge: -5 });
13
+ });
14
+
15
+ it('should push a result to the subscriber', () => {
16
+ result.should.not.be.undefined;
17
+ });
18
+
19
+ it('should not be valid', () => {
20
+ result.isValid.should.be.false;
21
+ });
22
+
23
+ it('should have error for minAge', () => {
24
+ result.validationResults.some(_ => _.members.includes('minAge')).should.be.true;
25
+ });
26
+ }));
@@ -0,0 +1,32 @@
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 { an_observable_query_with_validator } from '../given/an_observable_query_with_validator';
5
+ import { given } from '../../../given';
6
+
7
+ import * as sinon from 'sinon';
8
+ import { ObservableQuerySubscription } from '../../ObservableQuerySubscription';
9
+
10
+ describe('when subscribing with client validation passing', given(an_observable_query_with_validator, context => {
11
+ let callback: sinon.SinonStub;
12
+ let subscription: ObservableQuerySubscription<string>;
13
+
14
+ beforeEach(() => {
15
+ context.query.setOrigin('https://example.com'); // Set origin to avoid document access
16
+ callback = sinon.stub();
17
+
18
+ subscription = context.query.subscribe(callback, { minAge: 18 });
19
+ });
20
+
21
+ afterEach(() => {
22
+ if (subscription) {
23
+ subscription.unsubscribe();
24
+ }
25
+ });
26
+
27
+ // A real connection is established rather than one that immediately serves a result, which is what
28
+ // distinguishes a subscription that passed validation from one that was rejected by it.
29
+ it('should not call callback immediately', () => {
30
+ callback.called.should.be.false;
31
+ });
32
+ }));
@@ -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
+ });