@ethlete/query 6.0.0-next.1 → 6.0.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ethlete/query
2
2
 
3
+ ## 6.0.0-next.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [#2926](https://github.com/ethlete-io/ethdk/pull/2926) [`801d112`](https://github.com/ethlete-io/ethdk/commit/801d1122b55d24762b9e656d3a8ed70c561ec06f) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix query form emitting twice if a sort field gets cleared
8
+
9
+ ## 6.0.0-next.2
10
+
11
+ ### Minor Changes
12
+
13
+ - [`252fb8f`](https://github.com/ethlete-io/ethdk/commit/252fb8fe498a129b2f2dbf8f196aec4e1f2327a3) Thanks [@TomTomB](https://github.com/TomTomB)! - Report `leaderStatusChange` and `leaderInstanceCountChange` events in bearer auth tracking feature
14
+
15
+ ### Patch Changes
16
+
17
+ - [#2923](https://github.com/ethlete-io/ethdk/pull/2923) [`62bd4ae`](https://github.com/ethlete-io/ethdk/commit/62bd4ae092ad931fc8706ba7518cd26e17ef82a9) Thanks [@github-actions](https://github.com/apps/github-actions)! - Remove useless queryKey field from auth provider tracking events
18
+
3
19
  ## 6.0.0-next.1
4
20
 
5
21
  ### Patch Changes
@@ -5165,6 +5165,9 @@ const setupLeaderElection = () => {
5165
5165
  const storageKey = 'ethlete-auth-leader-heartbeat';
5166
5166
  const tabId = `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
5167
5167
  const isLeader = signal(false, ...(ngDevMode ? [{ debugName: "isLeader" }] : []));
5168
+ const instanceCount = signal(1, ...(ngDevMode ? [{ debugName: "instanceCount" }] : []));
5169
+ const knownTabs = new Map();
5170
+ knownTabs.set(tabId, Date.now());
5168
5171
  let channel = null;
5169
5172
  let heartbeatIntervalId = null;
5170
5173
  let leaderCheckIntervalId = null;
@@ -5172,6 +5175,7 @@ const setupLeaderElection = () => {
5172
5175
  isLeader.set(true);
5173
5176
  return {
5174
5177
  isLeader: isLeader.asReadonly(),
5178
+ instanceCount: instanceCount.asReadonly(),
5175
5179
  becomeLeader: () => {
5176
5180
  /* no-op */
5177
5181
  },
@@ -5181,6 +5185,7 @@ const setupLeaderElection = () => {
5181
5185
  };
5182
5186
  }
5183
5187
  channel = new BroadcastChannel(channelName);
5188
+ channel.postMessage({ type: 'instance-register', tabId });
5184
5189
  const updateLeaderHeartbeat = (newTabId = tabId) => {
5185
5190
  try {
5186
5191
  localStorage.setItem(storageKey, JSON.stringify({ tabId: newTabId, timestamp: Date.now() }));
@@ -5227,48 +5232,60 @@ const setupLeaderElection = () => {
5227
5232
  const heartbeat = getLeaderHeartbeat();
5228
5233
  const now = Date.now();
5229
5234
  if (!heartbeat || now - heartbeat.timestamp > leaderTimeout) {
5230
- // No leader or leader is dead
5231
5235
  if (!isLeader()) {
5232
5236
  becomeLeader();
5233
5237
  }
5234
5238
  }
5235
5239
  else if (heartbeat.tabId === tabId) {
5236
- // We are the leader, update heartbeat
5237
5240
  if (!isLeader()) {
5238
5241
  isLeader.set(true);
5239
5242
  }
5240
5243
  updateLeaderHeartbeat();
5241
5244
  }
5242
5245
  else {
5243
- // Another tab is leader
5244
5246
  if (isLeader()) {
5245
5247
  isLeader.set(false);
5246
5248
  }
5247
5249
  }
5248
5250
  };
5249
- // Handle messages from other tabs
5250
5251
  channel.onmessage = (event) => {
5251
5252
  const message = event.data;
5252
5253
  if (message.type === 'heartbeat') {
5254
+ if (message.tabId !== tabId) {
5255
+ knownTabs.set(message.tabId, Date.now());
5256
+ }
5253
5257
  if (message.tabId !== tabId && isLeader()) {
5254
- // Another tab claims to be leader, resolve conflict
5255
5258
  const heartbeat = getLeaderHeartbeat();
5256
5259
  if (heartbeat && heartbeat.tabId === message.tabId) {
5257
- // Other tab is legitimate leader
5258
5260
  releaseLeadership();
5259
5261
  }
5260
5262
  }
5261
5263
  }
5264
+ else if (message.type === 'tab-heartbeat') {
5265
+ if (message.tabId !== tabId) {
5266
+ knownTabs.set(message.tabId, Date.now());
5267
+ }
5268
+ }
5269
+ else if (message.type === 'instance-register') {
5270
+ if (message.tabId !== tabId) {
5271
+ knownTabs.set(message.tabId, Date.now());
5272
+ instanceCount.set(knownTabs.size);
5273
+ channel?.postMessage({ type: 'tab-heartbeat', tabId });
5274
+ }
5275
+ }
5276
+ else if (message.type === 'instance-unregister') {
5277
+ if (message.tabId !== tabId) {
5278
+ knownTabs.delete(message.tabId);
5279
+ instanceCount.set(knownTabs.size);
5280
+ }
5281
+ }
5262
5282
  else if (message.type === 'leader-claim') {
5263
5283
  if (message.tabId !== tabId && isLeader()) {
5264
- // Conflict: both tabs think they're leader
5265
- // Let the tab with earlier heartbeat win
5266
5284
  const heartbeat = getLeaderHeartbeat();
5267
5285
  if (heartbeat && heartbeat.tabId === message.tabId) {
5268
5286
  releaseLeadership();
5269
5287
  }
5270
5288
  else {
5271
- // We were leader first, send heartbeat to assert dominance
5272
5289
  const assertMessage = {
5273
5290
  type: 'heartbeat',
5274
5291
  tabId,
@@ -5279,35 +5296,40 @@ const setupLeaderElection = () => {
5279
5296
  }
5280
5297
  else if (message.type === 'leader-release') {
5281
5298
  if (message.tabId !== tabId) {
5282
- // Leader released, try to become new leader
5283
5299
  setTimeout(() => checkLeaderStatus(), 100);
5284
5300
  }
5285
5301
  }
5286
5302
  };
5287
- // Try to become leader on startup
5288
5303
  checkLeaderStatus();
5289
- // Send heartbeat periodically if we're the leader
5290
5304
  heartbeatIntervalId = setInterval(() => {
5305
+ knownTabs.set(tabId, Date.now());
5291
5306
  if (isLeader()) {
5292
5307
  updateLeaderHeartbeat();
5293
- const message = {
5294
- type: 'heartbeat',
5295
- tabId,
5296
- };
5297
- channel?.postMessage(message);
5308
+ channel?.postMessage({ type: 'heartbeat', tabId });
5309
+ }
5310
+ else {
5311
+ channel?.postMessage({ type: 'tab-heartbeat', tabId });
5312
+ }
5313
+ const now = Date.now();
5314
+ let changed = false;
5315
+ for (const [id, lastSeen] of knownTabs) {
5316
+ if (id !== tabId && now - lastSeen > leaderTimeout) {
5317
+ knownTabs.delete(id);
5318
+ changed = true;
5319
+ }
5320
+ }
5321
+ if (changed) {
5322
+ instanceCount.set(knownTabs.size);
5298
5323
  }
5299
5324
  }, heartbeatInterval);
5300
- // Check leader status periodically
5301
5325
  leaderCheckIntervalId = setInterval(() => {
5302
5326
  checkLeaderStatus();
5303
5327
  }, heartbeatInterval);
5304
- // Handle tab visibility changes
5305
5328
  const handleVisibilityChange = () => {
5306
5329
  if (document.hidden && isLeader()) {
5307
5330
  // Tab is hidden but we're leader - keep leadership but other tabs will take over if we die
5308
5331
  }
5309
5332
  else if (!document.hidden && !isLeader()) {
5310
- // Tab became visible and we're not leader - check if we should become leader
5311
5333
  checkLeaderStatus();
5312
5334
  }
5313
5335
  };
@@ -5319,10 +5341,10 @@ const setupLeaderElection = () => {
5319
5341
  if (leaderCheckIntervalId) {
5320
5342
  clearInterval(leaderCheckIntervalId);
5321
5343
  }
5344
+ channel?.postMessage({ type: 'instance-unregister', tabId });
5322
5345
  releaseLeadership();
5323
5346
  document.removeEventListener('visibilitychange', handleVisibilityChange);
5324
5347
  channel?.close();
5325
- // Clean up storage if we were the leader
5326
5348
  const heartbeat = getLeaderHeartbeat();
5327
5349
  if (heartbeat?.tabId === tabId) {
5328
5350
  try {
@@ -5336,6 +5358,7 @@ const setupLeaderElection = () => {
5336
5358
  destroyRef.onDestroy(cleanup);
5337
5359
  return {
5338
5360
  isLeader: isLeader.asReadonly(),
5361
+ instanceCount: instanceCount.asReadonly(),
5339
5362
  becomeLeader,
5340
5363
  cleanup,
5341
5364
  };
@@ -5602,10 +5625,15 @@ const createLeaderElection = (config) => {
5602
5625
  const leaderElectionEnabled = multiTabSyncEnabled &&
5603
5626
  (typeof multiTabSyncConfig === 'object' ? (multiTabSyncConfig?.leaderElection ?? true) : true);
5604
5627
  const leaderElection = leaderElectionEnabled ? setupLeaderElection() : null;
5605
- return () => (leaderElectionEnabled ? (leaderElection?.isLeader() ?? true) : true);
5628
+ const isLeaderFn = () => (leaderElectionEnabled ? (leaderElection?.isLeader() ?? true) : true);
5629
+ const leaderElectionContext = leaderElection
5630
+ ? { isLeader: leaderElection.isLeader, instanceCount: leaderElection.instanceCount }
5631
+ : undefined;
5632
+ return { isLeaderFn, leaderElectionContext };
5606
5633
  };
5607
5634
  const createBearerAuthProviderImpl = (config) => {
5608
5635
  const injector = inject(Injector);
5636
+ const destroyRef = inject(DestroyRef);
5609
5637
  const queryClient = config.queryClientRef[1]();
5610
5638
  const accessToken = signal(null, ...(ngDevMode ? [{ debugName: "accessToken" }] : []));
5611
5639
  const refreshToken = signal(null, ...(ngDevMode ? [{ debugName: "refreshToken" }] : []));
@@ -5645,7 +5673,7 @@ const createBearerAuthProviderImpl = (config) => {
5645
5673
  bearerDecryptFn: config.bearerDecryptFn,
5646
5674
  queryClient,
5647
5675
  repository: queryClient.repository,
5648
- isLeader,
5676
+ isLeader: isLeader.isLeaderFn,
5649
5677
  afterTokenRefresh$,
5650
5678
  queries: queries,
5651
5679
  };
@@ -5658,8 +5686,10 @@ const createBearerAuthProviderImpl = (config) => {
5658
5686
  bearerData,
5659
5687
  logout,
5660
5688
  injector,
5689
+ destroyRef,
5661
5690
  setTokens,
5662
- isLeader,
5691
+ isLeader: isLeader.isLeaderFn,
5692
+ leaderElection: isLeader.leaderElectionContext,
5663
5693
  afterTokenRefresh$,
5664
5694
  queries: queries,
5665
5695
  };
@@ -6074,7 +6104,7 @@ const createTrackingFeature = (context, config) => {
6074
6104
  }
6075
6105
  }
6076
6106
  };
6077
- const emit = (event, data) => {
6107
+ const fireHandlers = (event, data) => {
6078
6108
  const eventHandlers = handlers.get(event);
6079
6109
  if (eventHandlers) {
6080
6110
  eventHandlers.forEach((handler) => {
@@ -6087,6 +6117,27 @@ const createTrackingFeature = (context, config) => {
6087
6117
  });
6088
6118
  }
6089
6119
  };
6120
+ const forwardingChannel = context.leaderElection && typeof BroadcastChannel !== 'undefined'
6121
+ ? new BroadcastChannel('ethlete-auth-tracking')
6122
+ : null;
6123
+ if (forwardingChannel) {
6124
+ forwardingChannel.onmessage = (messageEvent) => {
6125
+ if (context.isLeader()) {
6126
+ fireHandlers(messageEvent.data.event, messageEvent.data.data);
6127
+ }
6128
+ };
6129
+ context.destroyRef.onDestroy(() => forwardingChannel.close());
6130
+ }
6131
+ const emit = (event, data) => {
6132
+ if (forwardingChannel && !context.isLeader()) {
6133
+ forwardingChannel.postMessage({ event, data });
6134
+ return;
6135
+ }
6136
+ fireHandlers(event, data);
6137
+ };
6138
+ const emitDirect = (event, data) => {
6139
+ fireHandlers(event, data);
6140
+ };
6090
6141
  const trackedQueries = new Map();
6091
6142
  Object.entries(context.queries).forEach(([key, queryEntry]) => {
6092
6143
  const query = queryEntry; // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -6103,17 +6154,11 @@ const createTrackingFeature = (context, config) => {
6103
6154
  const prevState = trackedQueries.get(key);
6104
6155
  if (response && !loading && !error && prevState?.lastResult !== 'success') {
6105
6156
  trackedQueries.set(key, { loading: false, lastResult: 'success' });
6106
- emit(`${key}Success`, {
6107
- queryKey: key,
6108
- snapshot,
6109
- });
6157
+ emit(`${key}Success`, { snapshot });
6110
6158
  }
6111
6159
  if (error && !loading && prevState?.lastResult !== 'error') {
6112
6160
  trackedQueries.set(key, { loading: false, lastResult: 'error' });
6113
- emit(`${key}Failure`, {
6114
- queryKey: key,
6115
- error,
6116
- });
6161
+ emit(`${key}Failure`, { error });
6117
6162
  }
6118
6163
  if (loading) {
6119
6164
  trackedQueries.set(key, { loading: true, lastResult: null });
@@ -6143,6 +6188,17 @@ const createTrackingFeature = (context, config) => {
6143
6188
  }
6144
6189
  });
6145
6190
  }
6191
+ if (context.leaderElection) {
6192
+ const { isLeader: isLeaderSignal, instanceCount: instanceCountSignal } = context.leaderElection;
6193
+ effect(() => {
6194
+ emitDirect('leaderStatusChange', { isLeader: isLeaderSignal() });
6195
+ });
6196
+ effect(() => {
6197
+ emitDirect('leaderInstanceCountChange', {
6198
+ count: instanceCountSignal(),
6199
+ });
6200
+ });
6201
+ }
6146
6202
  return {
6147
6203
  on,
6148
6204
  off,
@@ -6966,8 +7022,11 @@ class QueryForm {
6966
7022
  continue;
6967
7023
  const valueGotRemoved = value === ET_PROPERTY_REMOVED;
6968
7024
  if (valueGotRemoved) {
6969
- field.control.setValue(this._getDefaultValue(key), { emitEvent: false });
6970
- didValueChanges = true;
7025
+ const defaultValue = this._getDefaultValue(key);
7026
+ if (!equal(field.control.value, defaultValue)) {
7027
+ field.control.setValue(defaultValue, { emitEvent: false });
7028
+ didValueChanges = true;
7029
+ }
6971
7030
  continue;
6972
7031
  }
6973
7032
  let deserializedValue = value;
@@ -7139,6 +7198,21 @@ class QueryForm {
7139
7198
  });
7140
7199
  }
7141
7200
  _handleFormChange(forceOverwrite = false) {
7201
+ // Normalize values that have a valueToQueryParamTransformFn returning null/undefined to their
7202
+ // field default before capturing the new value. This prevents intermediate "empty object" states
7203
+ // (e.g. Sort emitting { active: '', direction: '' } when cleared) from being
7204
+ // emitted as distinct form values.
7205
+ for (const [key, field] of Object.entries(this._fields)) {
7206
+ if (!field.data.valueToQueryParamTransformFn)
7207
+ continue;
7208
+ const rawValue = this._formValue[key];
7209
+ if (this._isDefaultValue(key, rawValue))
7210
+ continue;
7211
+ const serialized = field.data.valueToQueryParamTransformFn(rawValue);
7212
+ if (serialized !== null && serialized !== undefined)
7213
+ continue;
7214
+ this.form.controls[key]?.setValue(this._getDefaultValue(key), { emitEvent: false });
7215
+ }
7142
7216
  const currentVal = clone(this._currentFormValue$.value);
7143
7217
  const newVal = clone(this._formValue);
7144
7218
  if (equal(currentVal, newVal)) {