@cratis/arc 20.1.1 → 20.1.4

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 (154) hide show
  1. package/Globals.ts +7 -0
  2. package/dist/cjs/Globals.d.ts +2 -0
  3. package/dist/cjs/Globals.d.ts.map +1 -1
  4. package/dist/cjs/Globals.js +1 -0
  5. package/dist/cjs/Globals.js.map +1 -1
  6. package/dist/cjs/identity/IdentityProvider.d.ts +2 -1
  7. package/dist/cjs/identity/IdentityProvider.d.ts.map +1 -1
  8. package/dist/cjs/identity/IdentityProvider.js +16 -2
  9. package/dist/cjs/identity/IdentityProvider.js.map +1 -1
  10. package/dist/cjs/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.d.ts +2 -0
  11. package/dist/cjs/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.d.ts.map +1 -0
  12. package/dist/cjs/queries/QueryInstanceCache.d.ts +8 -0
  13. package/dist/cjs/queries/QueryInstanceCache.d.ts.map +1 -1
  14. package/dist/cjs/queries/QueryInstanceCache.js +70 -9
  15. package/dist/cjs/queries/QueryInstanceCache.js.map +1 -1
  16. package/dist/cjs/queries/QueryResult.d.ts +1 -0
  17. package/dist/cjs/queries/QueryResult.d.ts.map +1 -1
  18. package/dist/cjs/queries/QueryResult.js +18 -0
  19. package/dist/cjs/queries/QueryResult.js.map +1 -1
  20. package/dist/cjs/queries/ServerSentEventHubConnection.d.ts +1 -0
  21. package/dist/cjs/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  22. package/dist/cjs/queries/ServerSentEventHubConnection.js +16 -2
  23. package/dist/cjs/queries/ServerSentEventHubConnection.js.map +1 -1
  24. package/dist/cjs/queries/WebSocketHubConnection.d.ts +1 -0
  25. package/dist/cjs/queries/WebSocketHubConnection.d.ts.map +1 -1
  26. package/dist/cjs/queries/WebSocketHubConnection.js +11 -0
  27. package/dist/cjs/queries/WebSocketHubConnection.js.map +1 -1
  28. package/dist/cjs/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.d.ts +2 -0
  29. package/dist/cjs/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.d.ts.map +1 -0
  30. package/dist/cjs/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.d.ts +2 -0
  31. package/dist/cjs/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.d.ts.map +1 -0
  32. package/dist/cjs/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.d.ts +2 -0
  33. package/dist/cjs/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.d.ts.map +1 -0
  34. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.d.ts +2 -0
  35. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.d.ts.map +1 -0
  36. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.d.ts +2 -0
  37. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.d.ts.map +1 -0
  38. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.d.ts +2 -0
  39. package/dist/cjs/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.d.ts.map +1 -0
  40. package/dist/cjs/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.d.ts +2 -0
  41. package/dist/cjs/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.d.ts.map +1 -0
  42. package/dist/cjs/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.d.ts +2 -0
  43. package/dist/cjs/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.d.ts.map +1 -0
  44. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.d.ts +2 -0
  45. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.d.ts.map +1 -0
  46. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.d.ts +2 -0
  47. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.d.ts.map +1 -0
  48. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.d.ts +2 -0
  49. package/dist/cjs/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.d.ts.map +1 -0
  50. package/dist/cjs/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts +2 -0
  51. package/dist/cjs/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts.map +1 -0
  52. package/dist/cjs/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts +2 -0
  53. package/dist/cjs/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts.map +1 -0
  54. package/dist/esm/Globals.d.ts +2 -0
  55. package/dist/esm/Globals.d.ts.map +1 -1
  56. package/dist/esm/Globals.js +1 -0
  57. package/dist/esm/Globals.js.map +1 -1
  58. package/dist/esm/identity/IdentityProvider.d.ts +2 -1
  59. package/dist/esm/identity/IdentityProvider.d.ts.map +1 -1
  60. package/dist/esm/identity/IdentityProvider.js +16 -2
  61. package/dist/esm/identity/IdentityProvider.js.map +1 -1
  62. package/dist/esm/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.d.ts +2 -0
  63. package/dist/esm/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.d.ts.map +1 -0
  64. package/dist/esm/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.js +19 -0
  65. package/dist/esm/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.js.map +1 -0
  66. package/dist/esm/queries/QueryInstanceCache.d.ts +8 -0
  67. package/dist/esm/queries/QueryInstanceCache.d.ts.map +1 -1
  68. package/dist/esm/queries/QueryInstanceCache.js +70 -9
  69. package/dist/esm/queries/QueryInstanceCache.js.map +1 -1
  70. package/dist/esm/queries/QueryResult.d.ts +1 -0
  71. package/dist/esm/queries/QueryResult.d.ts.map +1 -1
  72. package/dist/esm/queries/QueryResult.js +18 -0
  73. package/dist/esm/queries/QueryResult.js.map +1 -1
  74. package/dist/esm/queries/ServerSentEventHubConnection.d.ts +1 -0
  75. package/dist/esm/queries/ServerSentEventHubConnection.d.ts.map +1 -1
  76. package/dist/esm/queries/ServerSentEventHubConnection.js +16 -2
  77. package/dist/esm/queries/ServerSentEventHubConnection.js.map +1 -1
  78. package/dist/esm/queries/WebSocketHubConnection.d.ts +1 -0
  79. package/dist/esm/queries/WebSocketHubConnection.d.ts.map +1 -1
  80. package/dist/esm/queries/WebSocketHubConnection.js +11 -0
  81. package/dist/esm/queries/WebSocketHubConnection.js.map +1 -1
  82. package/dist/esm/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.d.ts +2 -0
  83. package/dist/esm/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.d.ts.map +1 -0
  84. package/dist/esm/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.js +23 -0
  85. package/dist/esm/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.js.map +1 -0
  86. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.d.ts +2 -0
  87. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.d.ts.map +1 -0
  88. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.js +23 -0
  89. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.js.map +1 -0
  90. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.d.ts +2 -0
  91. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.d.ts.map +1 -0
  92. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.js +21 -0
  93. package/dist/esm/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.js.map +1 -0
  94. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.d.ts +2 -0
  95. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.d.ts.map +1 -0
  96. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.js +17 -0
  97. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.js.map +1 -0
  98. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.d.ts +2 -0
  99. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.d.ts.map +1 -0
  100. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.js +23 -0
  101. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.js.map +1 -0
  102. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.d.ts +2 -0
  103. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.d.ts.map +1 -0
  104. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.js +22 -0
  105. package/dist/esm/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.js.map +1 -0
  106. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.d.ts +2 -0
  107. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.d.ts.map +1 -0
  108. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.js +28 -0
  109. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.js.map +1 -0
  110. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.d.ts +2 -0
  111. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.d.ts.map +1 -0
  112. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.js +25 -0
  113. package/dist/esm/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.js.map +1 -0
  114. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.d.ts +2 -0
  115. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.d.ts.map +1 -0
  116. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.js +25 -0
  117. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.js.map +1 -0
  118. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.d.ts +2 -0
  119. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.d.ts.map +1 -0
  120. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.js +12 -0
  121. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.js.map +1 -0
  122. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.d.ts +2 -0
  123. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.d.ts.map +1 -0
  124. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.js +23 -0
  125. package/dist/esm/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.js.map +1 -0
  126. package/dist/esm/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts +2 -0
  127. package/dist/esm/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts.map +1 -0
  128. package/dist/esm/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js +35 -0
  129. package/dist/esm/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js.map +1 -0
  130. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts +2 -0
  131. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.d.ts.map +1 -0
  132. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js +33 -0
  133. package/dist/esm/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.js.map +1 -0
  134. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  135. package/identity/IdentityProvider.ts +23 -2
  136. package/identity/for_IdentityProvider/when_refreshing/with_unauthorized_response.ts +26 -0
  137. package/package.json +1 -1
  138. package/queries/QueryInstanceCache.ts +133 -12
  139. package/queries/QueryResult.ts +19 -0
  140. package/queries/ServerSentEventHubConnection.ts +18 -2
  141. package/queries/WebSocketHubConnection.ts +11 -0
  142. package/queries/for_QueryInstanceCache/when_acquiring/after_release_in_development_mode.ts +31 -0
  143. package/queries/for_QueryInstanceCache/when_deferring_dispose/with_cancellation_before_timeout.ts +31 -0
  144. package/queries/for_QueryInstanceCache/when_deferring_dispose/without_cancellation.ts +28 -0
  145. package/queries/for_QueryInstanceCache/when_disposing/an_empty_cache.ts +21 -0
  146. package/queries/for_QueryInstanceCache/when_disposing/with_active_subscriptions.ts +30 -0
  147. package/queries/for_QueryInstanceCache/when_disposing/with_pending_deferred_cleanup.ts +31 -0
  148. package/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_in_development_mode.ts +36 -0
  149. package/queries/for_QueryInstanceCache/when_releasing/the_only_subscriber_outside_development_mode.ts +33 -0
  150. package/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_active_subscriptions.ts +32 -0
  151. package/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_no_subscriptions.ts +18 -0
  152. package/queries/for_QueryInstanceCache/when_tearing_down_all_subscriptions/with_pending_deferred_cleanup.ts +33 -0
  153. package/queries/for_ServerSentEventHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.ts +51 -0
  154. package/queries/for_WebSocketHubConnection/when_receiving_unauthorized/notifies_subscriber_and_removes_subscription.ts +47 -0
@@ -77,7 +77,7 @@ export class IdentityProvider extends IIdentityProvider {
77
77
  }
78
78
 
79
79
  static async refresh<TDetails extends object = object>(type?: Constructor<TDetails>): Promise<IIdentity<TDetails>> {
80
- IdentityProvider.clearCookie();
80
+ IdentityProvider.clearIdentityCookie();
81
81
  const origin = IdentityProvider.origin || Globals.origin || '';
82
82
  const apiBasePath = IdentityProvider.apiBasePath || Globals.apiBasePath || '';
83
83
  const route = joinPaths(apiBasePath, '/.cratis/me');
@@ -88,6 +88,10 @@ export class IdentityProvider extends IIdentityProvider {
88
88
  headers: IdentityProvider.httpHeadersCallback?.() ?? {}
89
89
  });
90
90
 
91
+ if (!response.ok) {
92
+ return IdentityProvider.notSet(type);
93
+ }
94
+
91
95
  const result = await response.json() as IdentityProviderResult;
92
96
  const details = type ? JsonSerializer.deserializeFromInstance(type, result.details) : result.details;
93
97
 
@@ -102,6 +106,18 @@ export class IdentityProvider extends IIdentityProvider {
102
106
  };
103
107
  }
104
108
 
109
+ private static notSet<TDetails extends object = object>(type?: Constructor<TDetails>): IIdentity<TDetails> {
110
+ return {
111
+ id: '',
112
+ name: '',
113
+ roles: [],
114
+ details: {} as TDetails,
115
+ isSet: false,
116
+ isInRole: () => false,
117
+ refresh: () => IdentityProvider.refresh(type)
118
+ };
119
+ }
120
+
105
121
  private static getCookie() {
106
122
  if (typeof document === 'undefined') return [];
107
123
  const decoded = decodeURIComponent(document.cookie);
@@ -114,7 +130,12 @@ export class IdentityProvider extends IIdentityProvider {
114
130
  return [];
115
131
  }
116
132
 
117
- private static clearCookie() {
133
+ /**
134
+ * Clears the identity cookie used by Arc to cache the current identity.
135
+ * Call this when the user logs out to ensure subsequent requests and WebSocket
136
+ * connections do not carry stale credentials.
137
+ */
138
+ static clearIdentityCookie(): void {
118
139
  if (typeof document === 'undefined') return;
119
140
  document.cookie = `${IdentityProvider.CookieName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
120
141
  }
@@ -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 { IdentityProvider } from '../../IdentityProvider';
5
+ import { IIdentity } from '../../IIdentity';
6
+ import { an_identity_provider } from '../given/an_identity_provider';
7
+ import { given } from '../../../given';
8
+
9
+ describe('when refreshing with unauthorized response', given(an_identity_provider, context => {
10
+ let result: IIdentity;
11
+
12
+ beforeEach(async () => {
13
+ context.fetchStub.resolves({
14
+ ok: false,
15
+ status: 401,
16
+ } as Response);
17
+
18
+ result = await IdentityProvider.refresh();
19
+ });
20
+
21
+ it('should return identity with isSet false', () => result.isSet.should.be.false);
22
+ it('should return empty identity id', () => result.id.should.equal(''));
23
+ it('should return empty identity name', () => result.name.should.equal(''));
24
+ it('should return empty roles', () => result.roles.should.be.empty);
25
+ it('should provide a refresh method', () => result.refresh.should.be.a('function'));
26
+ }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/arc",
3
- "version": "20.1.1",
3
+ "version": "20.1.4",
4
4
  "description": "",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -48,6 +48,13 @@ export interface QueryCacheEntry<TDataType> {
48
48
  * Whether an active subscription has been established for this entry.
49
49
  */
50
50
  subscribed: boolean;
51
+
52
+ /**
53
+ * Timer handle for deferred cleanup when running in development mode.
54
+ * Allows React StrictMode re-mounts to cancel the pending teardown
55
+ * so the connection is reused instead of torn down and recreated.
56
+ */
57
+ pendingCleanup?: ReturnType<typeof setTimeout>;
51
58
  }
52
59
 
53
60
  /**
@@ -60,6 +67,17 @@ export interface QueryCacheEntry<TDataType> {
60
67
  */
61
68
  export class QueryInstanceCache {
62
69
  private readonly _entries = new Map<QueryCacheKey, QueryCacheEntry<unknown>>();
70
+ private readonly _development: boolean;
71
+ private _pendingDispose?: ReturnType<typeof setTimeout>;
72
+
73
+ /**
74
+ * Initializes a new instance of {@link QueryInstanceCache}.
75
+ * @param development When true, teardown is deferred on release so React StrictMode
76
+ * re-mounts can re-acquire the entry without an unnecessary disconnect/reconnect cycle.
77
+ */
78
+ constructor(development: boolean = false) {
79
+ this._development = development;
80
+ }
63
81
 
64
82
  /**
65
83
  * Builds the cache key for a query.
@@ -113,6 +131,8 @@ export class QueryInstanceCache {
113
131
 
114
132
  /**
115
133
  * Increments the active subscriber count for the given key.
134
+ * If a deferred cleanup was pending (from a recent {@link release} in development mode),
135
+ * it is cancelled so the existing subscription is reused.
116
136
  * Call from `useEffect` setup to pair with {@link release} in the cleanup.
117
137
  * @param key The cache key produced by {@link buildKey}.
118
138
  */
@@ -120,6 +140,11 @@ export class QueryInstanceCache {
120
140
  const entry = this._entries.get(key);
121
141
 
122
142
  if (entry) {
143
+ if (entry.pendingCleanup !== undefined) {
144
+ clearTimeout(entry.pendingCleanup);
145
+ entry.pendingCleanup = undefined;
146
+ }
147
+
123
148
  entry.subscriberCount++;
124
149
  }
125
150
  }
@@ -207,6 +232,11 @@ export class QueryInstanceCache {
207
232
  /**
208
233
  * Decrements the subscriber count for the given key. When the count reaches zero the teardown
209
234
  * function is called (if set) and the entry is evicted.
235
+ *
236
+ * In development mode, both teardown and eviction are deferred by one microtask so that
237
+ * React StrictMode re-mounts can re-acquire the entry and cancel the cleanup. This prevents
238
+ * an unnecessary disconnect/reconnect cycle during the synthetic unmount/remount that
239
+ * StrictMode performs in development builds.
210
240
  * @param key The cache key produced by {@link buildKey}.
211
241
  */
212
242
  release(key: QueryCacheKey): void {
@@ -216,18 +246,33 @@ export class QueryInstanceCache {
216
246
  entry.subscriberCount--;
217
247
 
218
248
  if (entry.subscriberCount <= 0) {
219
- entry.subscribed = false;
220
- entry.teardown?.();
221
- entry.teardown = undefined;
222
-
223
- // Defer deletion so React Strict Mode re-mounts can re-acquire the entry.
224
- setTimeout(() => {
225
- const current = this._entries.get(key);
226
-
227
- if (current && current.subscriberCount <= 0) {
228
- this._entries.delete(key);
229
- }
230
- }, 0);
249
+ if (this._development) {
250
+ // Defer both teardown and deletion so StrictMode re-mounts can cancel.
251
+ entry.pendingCleanup = setTimeout(() => {
252
+ const current = this._entries.get(key);
253
+
254
+ if (current && current.subscriberCount <= 0) {
255
+ current.subscribed = false;
256
+ current.teardown?.();
257
+ current.teardown = undefined;
258
+ current.pendingCleanup = undefined;
259
+ this._entries.delete(key);
260
+ }
261
+ }, 0);
262
+ } else {
263
+ entry.subscribed = false;
264
+ entry.teardown?.();
265
+ entry.teardown = undefined;
266
+
267
+ // Defer deletion so React Strict Mode re-mounts can re-acquire the entry.
268
+ setTimeout(() => {
269
+ const current = this._entries.get(key);
270
+
271
+ if (current && current.subscriberCount <= 0) {
272
+ this._entries.delete(key);
273
+ }
274
+ }, 0);
275
+ }
231
276
  }
232
277
  }
233
278
  }
@@ -240,4 +285,80 @@ export class QueryInstanceCache {
240
285
  has(key: QueryCacheKey): boolean {
241
286
  return this._entries.has(key);
242
287
  }
288
+
289
+ /**
290
+ * Tears down all active subscriptions and marks every entry as not subscribed,
291
+ * but preserves entries, subscriber counts, and listeners. This allows
292
+ * hooks whose effects re-run afterward to detect that the entry is no longer
293
+ * subscribed and re-establish a fresh connection.
294
+ *
295
+ * Use this when the underlying transport must be replaced (e.g. after an
296
+ * authentication change that requires new WebSocket connections with updated
297
+ * credentials).
298
+ */
299
+ teardownAllSubscriptions(): void {
300
+ for (const [, entry] of this._entries) {
301
+ if (entry.pendingCleanup !== undefined) {
302
+ clearTimeout(entry.pendingCleanup);
303
+ entry.pendingCleanup = undefined;
304
+ }
305
+
306
+ entry.subscribed = false;
307
+ entry.teardown?.();
308
+ entry.teardown = undefined;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Immediately tears down all subscriptions, cancels any pending deferred cleanups,
314
+ * and evicts all entries. Call when the owning component (e.g. the {@link Arc} provider)
315
+ * unmounts permanently so that all query connections are closed synchronously.
316
+ */
317
+ dispose(): void {
318
+ for (const [, entry] of this._entries) {
319
+ if (entry.pendingCleanup !== undefined) {
320
+ clearTimeout(entry.pendingCleanup);
321
+ entry.pendingCleanup = undefined;
322
+ }
323
+
324
+ entry.subscribed = false;
325
+ entry.teardown?.();
326
+ entry.teardown = undefined;
327
+ }
328
+
329
+ this._entries.clear();
330
+ }
331
+
332
+ /**
333
+ * Schedules a deferred {@link dispose} using {@code setTimeout(0)}.
334
+ *
335
+ * This allows React StrictMode re-mounts to call {@link cancelPendingDispose}
336
+ * before the dispose fires, avoiding the destruction of cache entries that child
337
+ * effects are about to re-acquire.
338
+ *
339
+ * If a deferred dispose is already pending, it is replaced.
340
+ */
341
+ deferDispose(): void {
342
+ if (this._pendingDispose !== undefined) {
343
+ clearTimeout(this._pendingDispose);
344
+ }
345
+
346
+ this._pendingDispose = setTimeout(() => {
347
+ this._pendingDispose = undefined;
348
+ this.dispose();
349
+ }, 0);
350
+ }
351
+
352
+ /**
353
+ * Cancels a pending deferred dispose scheduled by {@link deferDispose}.
354
+ *
355
+ * Call from the {@code useEffect} setup phase so that a StrictMode re-mount
356
+ * prevents the synthetic unmount's deferred dispose from firing.
357
+ */
358
+ cancelPendingDispose(): void {
359
+ if (this._pendingDispose !== undefined) {
360
+ clearTimeout(this._pendingDispose);
361
+ this._pendingDispose = undefined;
362
+ }
363
+ }
243
364
  }
@@ -60,6 +60,25 @@ export class QueryResult<TDataType = object> implements IQueryResult<TDataType>
60
60
  }, Object, false);
61
61
  }
62
62
 
63
+ static unauthorized<TDataType>(): QueryResult<TDataType> {
64
+ return new QueryResult({
65
+ data: null as unknown as object,
66
+ isSuccess: false,
67
+ isAuthorized: false,
68
+ isValid: true,
69
+ hasExceptions: false,
70
+ validationResults: [],
71
+ exceptionMessages: [],
72
+ exceptionStackTrace: '',
73
+ paging: {
74
+ totalItems: 0,
75
+ totalPages: 0,
76
+ page: 0,
77
+ size: 0
78
+ }
79
+ }, Object, false);
80
+ }
81
+
63
82
  static noSuccess: QueryResult = new QueryResult({
64
83
  data: {},
65
84
  isSuccess: false,
@@ -238,6 +238,7 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
238
238
  break;
239
239
  case HubMessageType.Unauthorized:
240
240
  console.warn(`SSE hub: query '${message.queryId}' unauthorized`);
241
+ this.handleUnauthorized(message);
241
242
  break;
242
243
  case HubMessageType.Error:
243
244
  console.error(`SSE hub: query '${message.queryId}' error:`, message.payload);
@@ -272,6 +273,17 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
272
273
  sub.callback(result);
273
274
  }
274
275
 
276
+ private handleUnauthorized(message: HubMessage): void {
277
+ if (!message.queryId) return;
278
+
279
+ const sub = this._subscriptions.get(message.queryId);
280
+ if (!sub) return;
281
+
282
+ this._subscriptions.delete(message.queryId);
283
+ this._pendingSubscriptions.delete(message.queryId);
284
+ sub.callback(QueryResult.unauthorized());
285
+ }
286
+
275
287
  private sendSubscribe(queryId: string, request: SubscriptionRequest): void {
276
288
  if (!this._connectionId) return;
277
289
 
@@ -281,9 +293,11 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
281
293
  request,
282
294
  };
283
295
 
296
+ const customHeaders = Globals.httpHeadersCallback?.() ?? {};
297
+
284
298
  fetch(this._subscribeUrl, {
285
299
  method: 'POST',
286
- headers: { 'Content-Type': 'application/json' },
300
+ headers: { 'Content-Type': 'application/json', ...customHeaders },
287
301
  body: JSON.stringify(body),
288
302
  }).catch(error => {
289
303
  console.error(`SSE hub: subscribe POST failed for '${queryId}'`, error);
@@ -298,9 +312,11 @@ export class ServerSentEventHubConnection implements IObservableQueryHubConnecti
298
312
  queryId,
299
313
  };
300
314
 
315
+ const customHeaders = Globals.httpHeadersCallback?.() ?? {};
316
+
301
317
  fetch(this._unsubscribeUrl, {
302
318
  method: 'POST',
303
- headers: { 'Content-Type': 'application/json' },
319
+ headers: { 'Content-Type': 'application/json', ...customHeaders },
304
320
  body: JSON.stringify(body),
305
321
  }).catch(error => {
306
322
  console.error(`SSE hub: unsubscribe POST failed for '${queryId}'`, error);
@@ -257,6 +257,7 @@ export class WebSocketHubConnection {
257
257
  break;
258
258
  case HubMessageType.Unauthorized:
259
259
  console.warn(`Hub: query '${message.queryId}' unauthorized`);
260
+ this.handleUnauthorized(message);
260
261
  break;
261
262
  case HubMessageType.Error:
262
263
  console.error(`Hub: query '${message.queryId}' error:`, message.payload);
@@ -277,6 +278,16 @@ export class WebSocketHubConnection {
277
278
  sub.callback(result);
278
279
  }
279
280
 
281
+ private handleUnauthorized(message: HubMessage): void {
282
+ if (!message.queryId) return;
283
+
284
+ const sub = this._subscriptions.get(message.queryId);
285
+ if (!sub) return;
286
+
287
+ this._subscriptions.delete(message.queryId);
288
+ sub.callback(QueryResult.unauthorized());
289
+ }
290
+
280
291
  private handlePong(message: HubMessage): void {
281
292
  if (message.timestamp && this._lastPingSentTime) {
282
293
  const latency = Date.now() - message.timestamp;
@@ -0,0 +1,31 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when acquiring after release in development mode', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: boolean;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = false;
13
+ cache = new QueryInstanceCache(true);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled = true; });
17
+ cache.release('MyQuery::');
18
+
19
+ // Re-acquire before the deferred timer fires (simulates StrictMode re-mount).
20
+ cache.acquire('MyQuery::');
21
+ vi.advanceTimersByTime(0);
22
+ });
23
+
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ it('should not call teardown', () => teardownCalled.should.be.false);
29
+ it('should keep the entry', () => cache.has('MyQuery::').should.be.true);
30
+ it('should still report as subscribed', () => cache.isSubscribed('MyQuery::').should.be.true);
31
+ });
@@ -0,0 +1,31 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when canceling deferred dispose before timeout fires', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: boolean;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = false;
13
+ cache = new QueryInstanceCache(true);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled = true; });
17
+
18
+ // Simulates StrictMode: unmount defers dispose, remount cancels it.
19
+ cache.deferDispose();
20
+ cache.cancelPendingDispose();
21
+ vi.advanceTimersByTime(0);
22
+ });
23
+
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ it('should not call teardown', () => teardownCalled.should.be.false);
29
+ it('should keep the entry', () => cache.has('MyQuery::').should.be.true);
30
+ it('should still report as subscribed', () => cache.isSubscribed('MyQuery::').should.be.true);
31
+ });
@@ -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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when deferring dispose without cancellation', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: boolean;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = false;
13
+ cache = new QueryInstanceCache(true);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled = true; });
17
+
18
+ cache.deferDispose();
19
+ vi.advanceTimersByTime(0);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.useRealTimers();
24
+ });
25
+
26
+ it('should call teardown', () => teardownCalled.should.be.true);
27
+ it('should evict the entry', () => cache.has('MyQuery::').should.be.false);
28
+ });
@@ -0,0 +1,21 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when disposing an empty cache', () => {
7
+ let cache: QueryInstanceCache;
8
+ let threwError: boolean;
9
+
10
+ beforeEach(() => {
11
+ threwError = false;
12
+ cache = new QueryInstanceCache();
13
+ try {
14
+ cache.dispose();
15
+ } catch {
16
+ threwError = true;
17
+ }
18
+ });
19
+
20
+ it('should not throw', () => threwError.should.be.false);
21
+ });
@@ -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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when disposing with active subscriptions', () => {
7
+ let cache: QueryInstanceCache;
8
+ let firstTeardownCalled: boolean;
9
+ let secondTeardownCalled: boolean;
10
+
11
+ beforeEach(() => {
12
+ firstTeardownCalled = false;
13
+ secondTeardownCalled = false;
14
+ cache = new QueryInstanceCache();
15
+ cache.getOrCreate('QueryA::', () => ({}));
16
+ cache.acquire('QueryA::');
17
+ cache.setTeardown('QueryA::', () => { firstTeardownCalled = true; });
18
+
19
+ cache.getOrCreate('QueryB::', () => ({}));
20
+ cache.acquire('QueryB::');
21
+ cache.setTeardown('QueryB::', () => { secondTeardownCalled = true; });
22
+
23
+ cache.dispose();
24
+ });
25
+
26
+ it('should call teardown for the first entry', () => firstTeardownCalled.should.be.true);
27
+ it('should call teardown for the second entry', () => secondTeardownCalled.should.be.true);
28
+ it('should evict the first entry', () => cache.has('QueryA::').should.be.false);
29
+ it('should evict the second entry', () => cache.has('QueryB::').should.be.false);
30
+ });
@@ -0,0 +1,31 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when disposing with pending deferred cleanup', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: number;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = 0;
13
+ cache = new QueryInstanceCache(true);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled++; });
17
+ cache.release('MyQuery::');
18
+
19
+ // At this point a deferred cleanup is pending. Dispose should
20
+ // tear down immediately and cancel the deferred cleanup.
21
+ cache.dispose();
22
+ vi.advanceTimersByTime(0);
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ });
28
+
29
+ it('should call teardown exactly once', () => teardownCalled.should.equal(1));
30
+ it('should evict the entry', () => cache.has('MyQuery::').should.be.false);
31
+ });
@@ -0,0 +1,36 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when releasing the only subscriber in development mode', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: boolean;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = false;
13
+ cache = new QueryInstanceCache(true);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled = true; });
17
+ cache.release('MyQuery::');
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ it('should not call teardown synchronously', () => teardownCalled.should.be.false);
25
+ it('should keep the entry before the timer fires', () => cache.has('MyQuery::').should.be.true);
26
+ it('should still report as subscribed before the timer fires', () => cache.isSubscribed('MyQuery::').should.be.true);
27
+
28
+ describe('and the deferred timer fires', () => {
29
+ beforeEach(() => {
30
+ vi.advanceTimersByTime(0);
31
+ });
32
+
33
+ it('should call teardown', () => teardownCalled.should.be.true);
34
+ it('should evict the entry', () => cache.has('MyQuery::').should.be.false);
35
+ });
36
+ });
@@ -0,0 +1,33 @@
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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when releasing the only subscriber outside development mode', () => {
7
+ let cache: QueryInstanceCache;
8
+ let teardownCalled: boolean;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ teardownCalled = false;
13
+ cache = new QueryInstanceCache(false);
14
+ cache.getOrCreate('MyQuery::', () => ({}));
15
+ cache.acquire('MyQuery::');
16
+ cache.setTeardown('MyQuery::', () => { teardownCalled = true; });
17
+ cache.release('MyQuery::');
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ it('should call teardown synchronously', () => teardownCalled.should.be.true);
25
+
26
+ describe('and the deferred timer fires', () => {
27
+ beforeEach(() => {
28
+ vi.advanceTimersByTime(0);
29
+ });
30
+
31
+ it('should evict the entry', () => cache.has('MyQuery::').should.be.false);
32
+ });
33
+ });
@@ -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 { QueryInstanceCache } from '../../QueryInstanceCache';
5
+
6
+ describe('when tearing down all subscriptions with active subscriptions', () => {
7
+ let cache: QueryInstanceCache;
8
+ let firstTeardownCalled: boolean;
9
+ let secondTeardownCalled: boolean;
10
+
11
+ beforeEach(() => {
12
+ firstTeardownCalled = false;
13
+ secondTeardownCalled = false;
14
+ cache = new QueryInstanceCache();
15
+ cache.getOrCreate('QueryA::', () => ({}));
16
+ cache.acquire('QueryA::');
17
+ cache.setTeardown('QueryA::', () => { firstTeardownCalled = true; });
18
+
19
+ cache.getOrCreate('QueryB::', () => ({}));
20
+ cache.acquire('QueryB::');
21
+ cache.setTeardown('QueryB::', () => { secondTeardownCalled = true; });
22
+
23
+ cache.teardownAllSubscriptions();
24
+ });
25
+
26
+ it('should call teardown for the first entry', () => firstTeardownCalled.should.be.true);
27
+ it('should call teardown for the second entry', () => secondTeardownCalled.should.be.true);
28
+ it('should keep the first entry', () => cache.has('QueryA::').should.be.true);
29
+ it('should keep the second entry', () => cache.has('QueryB::').should.be.true);
30
+ it('should mark the first entry as not subscribed', () => cache.isSubscribed('QueryA::').should.be.false);
31
+ it('should mark the second entry as not subscribed', () => cache.isSubscribed('QueryB::').should.be.false);
32
+ });