remult 3.4.0-next.2 → 3.4.0-next.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.
package/esm/internals.js CHANGED
@@ -7,5 +7,6 @@ export { __updateEntityBasedOnWhere } from './src/filter/filter-interfaces.js';
7
7
  export const actionInfo = remultStatic.actionInfo;
8
8
  export { isOfType } from './src/isOfType.js';
9
9
  export { flags } from './src/remult3/remult3.js';
10
+ export { SseSubscriptionClient } from './src/live-query/SseSubscriptionClient.js';
10
11
  export { DataProviderPromiseWrapper } from './src/data-interfaces.js';
11
12
  export { pagedQueryResult } from './src/remult3/pagedQueryResult.js';
@@ -46,7 +46,11 @@ export class LiveQueryClient {
46
46
  });
47
47
  }
48
48
  catch (err) {
49
- onResult.error(err);
49
+ // drop the half-registered channel so the next subscribe retries
50
+ // instead of joining a channel that was never subscribed
51
+ this.channels.delete(key);
52
+ q.listeners.forEach((l) => l.error(err));
53
+ this.closeIfNoListeners();
50
54
  throw err;
51
55
  }
52
56
  }
@@ -98,6 +102,8 @@ export class LiveQueryClient {
98
102
  if (!q) {
99
103
  this.queries.set(eventTypeKey, (q = new LiveQuerySubscriber(repo, { entityKey: repo.metadata.key, options }, this.getUserId())));
100
104
  q.subscribeCode = () => {
105
+ q.snapshotReady = false;
106
+ q.droppedWhilePending = false;
101
107
  q.unsubscribeChannel();
102
108
  let unsubscribeToChannel = () => { };
103
109
  q.unsubscribeChannel = () => {
@@ -119,7 +125,10 @@ export class LiveQueryClient {
119
125
  this.runPromise(r.unsubscribe());
120
126
  };
121
127
  snapshotDone = true;
122
- return this.runPromise(q.setAllItems(r.result));
128
+ return this.runPromise(q.setAllItems(r.result).then(() => {
129
+ if (q.droppedWhilePending)
130
+ return this.runKeepAlive();
131
+ }));
123
132
  };
124
133
  this.runPromise(this.subscribeChannel(q.queryChannel, {
125
134
  next: (value) => this.runPromise(q.handle(value)),
@@ -135,7 +144,7 @@ export class LiveQueryClient {
135
144
  }
136
145
  unsubscribeToChannel = unsub;
137
146
  if (snapshotDone)
138
- return this.runPromise(this.runKeepAlive({ checkVersions: true }));
147
+ return this.runPromise(this.runKeepAlive());
139
148
  })
140
149
  .catch((err) => {
141
150
  q.listeners.forEach((l) => l.error(err));
@@ -185,21 +194,24 @@ export class LiveQueryClient {
185
194
  return false;
186
195
  return Date.now() - conn.lastServerEvent > flags.sseStaleMs;
187
196
  }
188
- async runKeepAlive(opts) {
197
+ async runKeepAlive() {
189
198
  if (this.keepAliveInFlight)
190
199
  return this.keepAliveInFlight;
191
- this.keepAliveInFlight = this.sendKeepAlive(opts).finally(() => {
200
+ this.keepAliveInFlight = this.sendKeepAlive().finally(() => {
192
201
  this.keepAliveInFlight = undefined;
193
202
  });
194
203
  return this.keepAliveInFlight;
195
204
  }
196
- async sendKeepAlive(opts) {
205
+ /** Resolves true when a query was refetched. */
206
+ async sendKeepAlive() {
197
207
  const ids = [];
198
208
  for (const q of this.queries.values()) {
209
+ if (!q.snapshotReady)
210
+ continue;
199
211
  ids.push(q.queryChannel);
200
212
  }
201
213
  if (ids.length === 0)
202
- return;
214
+ return false;
203
215
  let p = this.apiProvider();
204
216
  const raw = await this.runPromise(remultStatic.actionInfo.runActionWithoutBlockingUI(() => buildRestDataProvider(p.httpClient).post(p.url + '/' + liveQueryKeepAliveRoute, { queryIds: ids })));
205
217
  const unknownIds = Array.isArray(raw)
@@ -208,12 +220,13 @@ export class LiveQueryClient {
208
220
  const versions = Array.isArray(raw)
209
221
  ? {}
210
222
  : (raw?.versions ?? {});
211
- const stale = opts?.checkVersions || this.isSseStale();
212
223
  let reloaded = false;
213
224
  for (const q of this.queries.values()) {
214
225
  const serverVersion = versions[q.queryChannel];
215
226
  const unknown = unknownIds.includes(q.queryChannel);
216
- const mismatch = stale && serverVersion !== undefined && serverVersion !== q.version;
227
+ // server returns versions on every keep-alive, so a missed message is
228
+ // caught even while SSE pings look healthy
229
+ const mismatch = serverVersion !== undefined && serverVersion !== q.version;
217
230
  if (unknown || mismatch) {
218
231
  reloaded = true;
219
232
  if (serverVersion !== undefined)
@@ -221,14 +234,7 @@ export class LiveQueryClient {
221
234
  q.subscribeCode();
222
235
  }
223
236
  }
224
- if (this.isSseStale()) {
225
- this.keepAliveBackoffMs = reloaded
226
- ? flags.liveQueryPollWhenStaleMs
227
- : Math.min(this.keepAliveBackoffMs * 2, flags.liveQueryKeepAliveMs);
228
- }
229
- else {
230
- this.keepAliveBackoffMs = flags.liveQueryPollWhenStaleMs;
231
- }
237
+ return reloaded;
232
238
  }
233
239
  async maybeKeepAlive() {
234
240
  const stale = this.isSseStale();
@@ -242,7 +248,18 @@ export class LiveQueryClient {
242
248
  this.lastKeepAliveAt = Date.now();
243
249
  if (stale)
244
250
  this.openedConnection?.resume?.(true);
245
- await this.runKeepAlive();
251
+ let reloaded = false;
252
+ try {
253
+ reloaded = await this.runKeepAlive();
254
+ }
255
+ finally {
256
+ // runs on a failed post too, so an unreachable server is not polled
257
+ // every second; channel-only clients (no query ids) back off as well
258
+ if (this.isSseStale())
259
+ this.keepAliveBackoffMs = reloaded
260
+ ? flags.liveQueryPollWhenStaleMs
261
+ : Math.min(this.keepAliveBackoffMs * 2, flags.liveQueryKeepAliveMs);
262
+ }
246
263
  }
247
264
  detachForeground = () => { };
248
265
  attachForeground() {
@@ -255,7 +272,7 @@ export class LiveQueryClient {
255
272
  this.foregroundKeepAliveTimer = undefined;
256
273
  this.keepAliveBackoffMs = flags.liveQueryPollWhenStaleMs;
257
274
  this.lastKeepAliveAt = 0;
258
- this.runPromise(this.runKeepAlive({ checkVersions: true }));
275
+ this.runPromise(this.runKeepAlive());
259
276
  }, 300);
260
277
  });
261
278
  this.detachForeground = () => {
@@ -267,19 +284,30 @@ export class LiveQueryClient {
267
284
  this.detachForeground = () => { };
268
285
  };
269
286
  }
287
+ startKeepAlive() {
288
+ // openConnection may reject and be retried with queries still alive
289
+ if (this.interval !== undefined)
290
+ return;
291
+ this.lastKeepAliveAt = Date.now();
292
+ this.keepAliveBackoffMs = flags.liveQueryPollWhenStaleMs;
293
+ this.attachForeground();
294
+ this.interval = setInterval(() => {
295
+ // a failed post is transient: the backoff covers it, next tick retries
296
+ this.runPromise(this.maybeKeepAlive().catch(() => { }));
297
+ }, Math.min(flags.liveQueryPollWhenStaleMs, flags.liveQueryKeepAliveMs));
298
+ }
270
299
  openIfNoOpened() {
271
300
  if (!this.client) {
272
- this.lastKeepAliveAt = Date.now();
273
- this.keepAliveBackoffMs = flags.liveQueryPollWhenStaleMs;
274
- this.attachForeground();
275
- this.interval = setInterval(() => {
276
- this.runPromise(this.maybeKeepAlive());
277
- }, Math.min(flags.liveQueryPollWhenStaleMs, flags.liveQueryKeepAliveMs));
301
+ this.startKeepAlive();
278
302
  return this.runPromise((this.client = this.apiProvider()
279
303
  .subscriptionClient.openConnection(() => {
280
304
  for (const q of this.queries.values()) {
281
305
  q.subscribeCode();
282
306
  }
307
+ for (const c of this.channels.values()) {
308
+ for (const l of c.listeners)
309
+ l.reconnect?.();
310
+ }
283
311
  })
284
312
  .then((c) => {
285
313
  this.openedConnection = c;
@@ -1,17 +1,21 @@
1
1
  import { buildRestDataProvider } from '../buildRestDataProvider.js';
2
2
  import { remult } from '../remult-proxy.js';
3
3
  import { remultStatic } from '../remult-static.js';
4
+ import { flags } from '../remult3/remult3.js';
4
5
  import { streamUrl } from './SubscriptionChannel.js';
5
6
  export class SseSubscriptionClient {
6
7
  openConnection(onReconnect) {
7
8
  let connectionId;
8
9
  const channels = new Map();
10
+ const errorHandlers = new Map();
9
11
  const provider = buildRestDataProvider(remult.apiClient.httpClient);
10
12
  let source;
11
13
  let retryCount = 0;
12
14
  let closed = false;
13
15
  let connected = false;
16
+ let everConnected = false;
14
17
  let reconnectTimer;
18
+ let lastConnectAt = 0;
15
19
  let connectionReady;
16
20
  let connectionPromise = new Promise((res) => {
17
21
  connectionReady = res;
@@ -36,15 +40,16 @@ export class SseSubscriptionClient {
36
40
  function resume(force) {
37
41
  if (closed)
38
42
  return;
39
- retryCount = 0;
40
- clearReconnectTimer();
43
+ const open = source && source.readyState !== EVENT_SOURCE_CLOSED;
41
44
  if (force) {
45
+ // a source still inside its handshake window is not a zombie yet
46
+ if (open && Date.now() - lastConnectAt < flags.sseStaleMs)
47
+ return;
42
48
  createConnection();
43
49
  return;
44
50
  }
45
- const open = source &&
46
- typeof EventSource !== 'undefined' &&
47
- source.readyState !== EventSource.CLOSED;
51
+ retryCount = 0;
52
+ clearReconnectTimer();
48
53
  if (!open)
49
54
  createConnection();
50
55
  }
@@ -56,23 +61,39 @@ export class SseSubscriptionClient {
56
61
  clearReconnectTimer();
57
62
  source?.close();
58
63
  },
59
- async subscribe(channel, handler) {
64
+ async subscribe(channel, handler, onError) {
60
65
  let listeners = channels.get(channel);
66
+ let onErrors = errorHandlers.get(channel);
61
67
  if (!listeners) {
62
68
  channels.set(channel, (listeners = []));
63
- await connectionPromise;
64
- if (!closed)
65
- await subscribeToChannel(channel);
69
+ errorHandlers.set(channel, (onErrors = []));
70
+ try {
71
+ await connectionPromise;
72
+ if (!closed)
73
+ await subscribeToChannel(channel);
74
+ }
75
+ catch (err) {
76
+ // otherwise the next subscribe sees the entry and never retries
77
+ channels.delete(channel);
78
+ errorHandlers.delete(channel);
79
+ throw err;
80
+ }
66
81
  }
67
82
  listeners.push(handler);
83
+ onErrors.push(onError);
68
84
  return () => {
69
- listeners.splice(listeners.indexOf(handler, 1));
85
+ const i = listeners.indexOf(handler);
86
+ if (i >= 0) {
87
+ listeners.splice(i, 1);
88
+ onErrors.splice(i, 1);
89
+ }
70
90
  if (listeners.length == 0) {
71
91
  remultStatic.actionInfo.runActionWithoutBlockingUI(() => provider.post(remult.apiClient.url + '/' + streamUrl + '/unsubscribe', {
72
92
  channel: channel,
73
93
  clientId: connectionId,
74
94
  }));
75
95
  channels.delete(channel);
96
+ errorHandlers.delete(channel);
76
97
  }
77
98
  };
78
99
  },
@@ -86,6 +107,7 @@ export class SseSubscriptionClient {
86
107
  clearReconnectTimer();
87
108
  if (source)
88
109
  source.close();
110
+ lastConnectAt = Date.now();
89
111
  source = SseSubscriptionClient.createEventSource(remult.apiClient.url + '/' + streamUrl);
90
112
  source.onmessage = (e) => {
91
113
  noteServerEvent();
@@ -109,16 +131,23 @@ export class SseSubscriptionClient {
109
131
  connectionId = e.data;
110
132
  retryCount = 0;
111
133
  noteServerEvent();
112
- if (connected) {
113
- for (const channel of channels.keys()) {
114
- await subscribeToChannel(channel);
134
+ // a connection the server forgot counts as a reconnect too: every
135
+ // channel must be re-subscribed and live queries refetched
136
+ const wasConnected = everConnected;
137
+ connected = everConnected = true;
138
+ if (wasConnected) {
139
+ for (const channel of [...channels.keys()]) {
140
+ try {
141
+ await subscribeToChannel(channel);
142
+ }
143
+ catch (err) {
144
+ // one refused channel must not block the others
145
+ errorHandlers.get(channel)?.forEach((f) => f(err));
146
+ }
115
147
  }
116
148
  onReconnect();
117
149
  }
118
- else {
119
- connected = true;
120
- connectionReady();
121
- }
150
+ connectionReady();
122
151
  });
123
152
  }
124
153
  createConnection();
@@ -130,15 +159,13 @@ export class SseSubscriptionClient {
130
159
  clientId: connectionId,
131
160
  });
132
161
  });
133
- if (result === ConnectionNotFoundError) {
162
+ if (result === ConnectionNotFoundError && connected) {
134
163
  connected = false;
135
164
  connectionPromise = new Promise((res) => {
136
165
  connectionReady = res;
137
166
  });
138
167
  createConnection();
139
168
  await connectionPromise;
140
- if (!closed)
141
- await subscribeToChannel(channel);
142
169
  }
143
170
  }
144
171
  }
@@ -149,3 +176,5 @@ export class SseSubscriptionClient {
149
176
  }
150
177
  }
151
178
  export const ConnectionNotFoundError = 'client connection not found';
179
+ // EventSource.CLOSED; the global is absent in Node
180
+ const EVENT_SOURCE_CLOSED = 2;
@@ -14,14 +14,19 @@ export class LiveQuerySubscriber {
14
14
  unsubscribe = () => { };
15
15
  unsubscribeChannel = () => { };
16
16
  unsubscribeQuery = () => { };
17
+ snapshotReady = false;
18
+ droppedWhilePending = false;
17
19
  async setAllItems(result) {
20
+ // server re-registers the query at version 0; reset before the await so a
21
+ // version message landing mid-parse is judged against the new baseline
22
+ this.version = 0;
18
23
  const items = await getRepositoryInternals(this.repo)._fromJsonArray(result, this.query.options);
19
24
  this.forListeners((listener) => {
20
25
  listener(() => {
21
26
  return items;
22
27
  });
23
28
  }, this.allItemsMessage(items));
24
- this.version = 0;
29
+ this.snapshotReady = true;
25
30
  }
26
31
  allItemsMessage(items) {
27
32
  return [
@@ -55,16 +60,24 @@ export class LiveQuerySubscriber {
55
60
  };
56
61
  }
57
62
  async handle(messages) {
63
+ // the pending snapshot supersedes anything in flight; the keep-alive
64
+ // after it lands catches whatever was dropped here
65
+ if (!this.snapshotReady) {
66
+ this.droppedWhilePending = true;
67
+ return;
68
+ }
58
69
  const ver = messages.find((m) => m.type === 'version');
59
- if (ver) {
60
- if (ver.from < this.version)
61
- return;
62
- if (ver.from > this.version) {
63
- this.version = ver.to;
64
- this.subscribeCode?.();
65
- return;
66
- }
70
+ // any gap, forward or backward, means a message was missed or two servers
71
+ // published the same version with different diffs: refetch instead of drop
72
+ if (ver && ver.from !== this.version) {
73
+ this.version = ver.to;
74
+ this.subscribeCode?.();
75
+ return;
67
76
  }
77
+ // bump before the await below, so a second message delivered in the same
78
+ // tick is judged against this one and not mistaken for a gap
79
+ if (ver)
80
+ this.version = ver.to;
68
81
  const data = messages.filter((m) => m.type !== 'version');
69
82
  {
70
83
  let x = data.filter(({ type }) => type == 'add' || type == 'replace');
@@ -102,7 +115,8 @@ export class LiveQuerySubscriber {
102
115
  return items;
103
116
  });
104
117
  }, data);
105
- this.version = ver ? ver.to : this.version + (data.length > 0 ? 1 : 0);
118
+ if (!ver && data.length > 0)
119
+ this.version++;
106
120
  }
107
121
  defaultQueryState = [];
108
122
  listeners = [];
@@ -200,6 +214,16 @@ export const liveQueryKeepAliveRoute = '_liveQueryKeepAlive';
200
214
  * });
201
215
  * ```
202
216
  *
217
+ * #### Recovering after a dropped connection
218
+ * Messages published while the connection was down are not replayed.
219
+ * Use the `reconnect` listener to refetch state:
220
+ * ```ts
221
+ * chart.subscribe({
222
+ * next: (message) => (chartData = message.data),
223
+ * reconnect: () => loadChartData(),
224
+ * });
225
+ * ```
226
+ *
203
227
  */
204
228
  export class SubscriptionChannel {
205
229
  channelKey;
@@ -16,8 +16,8 @@ const wnn = [
16
16
  ];
17
17
  export const flags = {
18
18
  error500RetryCount: 4,
19
- /** Client treats SSE as dead if no event for this long */
20
- sseStaleMs: 20_000,
19
+ /** Client treats SSE as dead if no event for this long. Server pings every 15s; two missed pings plus slack, so a stalled event loop does not trigger a reconnect storm */
20
+ sseStaleMs: 40_000,
21
21
  /** HTTP keep-alive while SSE is healthy (touches lastUsed) */
22
22
  liveQueryKeepAliveMs: 30_000,
23
23
  /** Version poll while SSE is stale */
package/internals.d.ts CHANGED
@@ -15,5 +15,6 @@ export declare const actionInfo: {
15
15
  export { isOfType } from './src/isOfType.js';
16
16
  export type { ClassType } from './classType.js';
17
17
  export { flags } from './src/remult3/remult3.js';
18
+ export { SseSubscriptionClient } from './src/live-query/SseSubscriptionClient.js';
18
19
  export { DataProviderPromiseWrapper } from './src/data-interfaces.js';
19
20
  export { pagedQueryResult } from './src/remult3/pagedQueryResult.js';
package/internals.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pagedQueryResult = exports.DataProviderPromiseWrapper = exports.flags = exports.isOfType = exports.actionInfo = exports.__updateEntityBasedOnWhere = exports.getEntitySettings = exports.fieldOptionsEnricher = exports.getControllerRef = exports.controllerRefImpl = exports.decorateColumnSettings = exports.getRelationFieldInfo = exports.getRelationInfo = exports.sqlRelationsFilter = exports.sqlRelations = void 0;
3
+ exports.pagedQueryResult = exports.DataProviderPromiseWrapper = exports.SseSubscriptionClient = exports.flags = exports.isOfType = exports.actionInfo = exports.__updateEntityBasedOnWhere = exports.getEntitySettings = exports.fieldOptionsEnricher = exports.getControllerRef = exports.controllerRefImpl = exports.decorateColumnSettings = exports.getRelationFieldInfo = exports.getRelationInfo = exports.sqlRelationsFilter = exports.sqlRelations = void 0;
4
4
  var remult_static_js_1 = require("./src/remult-static.js");
5
5
  var sql_relations_js_1 = require("./src/data-providers/sql-relations.js");
6
6
  Object.defineProperty(exports, "sqlRelations", { enumerable: true, get: function () { return sql_relations_js_1.sqlRelations; } });
@@ -22,6 +22,8 @@ var isOfType_js_1 = require("./src/isOfType.js");
22
22
  Object.defineProperty(exports, "isOfType", { enumerable: true, get: function () { return isOfType_js_1.isOfType; } });
23
23
  var remult3_js_1 = require("./src/remult3/remult3.js");
24
24
  Object.defineProperty(exports, "flags", { enumerable: true, get: function () { return remult3_js_1.flags; } });
25
+ var SseSubscriptionClient_js_1 = require("./src/live-query/SseSubscriptionClient.js");
26
+ Object.defineProperty(exports, "SseSubscriptionClient", { enumerable: true, get: function () { return SseSubscriptionClient_js_1.SseSubscriptionClient; } });
25
27
  var data_interfaces_js_1 = require("./src/data-interfaces.js");
26
28
  Object.defineProperty(exports, "DataProviderPromiseWrapper", { enumerable: true, get: function () { return data_interfaces_js_1.DataProviderPromiseWrapper; } });
27
29
  var pagedQueryResult_js_1 = require("./src/remult3/pagedQueryResult.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remult",
3
- "version": "3.4.0-next.2",
3
+ "version": "3.4.0-next.4",
4
4
  "description": "A CRUD framework for full-stack TypeScript",
5
5
  "homepage": "https://remult.dev",
6
6
  "repository": {
@@ -67,7 +67,11 @@ var LiveQueryClient = /** @class */ (function () {
67
67
  return [3 /*break*/, 6];
68
68
  case 5:
69
69
  err_1 = _b.sent();
70
- onResult.error(err_1);
70
+ // drop the half-registered channel so the next subscribe retries
71
+ // instead of joining a channel that was never subscribed
72
+ this.channels.delete(key);
73
+ q_1.listeners.forEach(function (l) { return l.error(err_1); });
74
+ this.closeIfNoListeners();
71
75
  throw err_1;
72
76
  case 6:
73
77
  q_1.listeners.push(onResult);
@@ -122,6 +126,8 @@ var LiveQueryClient = /** @class */ (function () {
122
126
  if (!q) {
123
127
  _this.queries.set(eventTypeKey, (q = new SubscriptionChannel_js_1.LiveQuerySubscriber(repo, { entityKey: repo.metadata.key, options: options }, _this.getUserId())));
124
128
  q.subscribeCode = function () {
129
+ q.snapshotReady = false;
130
+ q.droppedWhilePending = false;
125
131
  q.unsubscribeChannel();
126
132
  var unsubscribeToChannel = function () { };
127
133
  q.unsubscribeChannel = function () {
@@ -143,7 +149,10 @@ var LiveQueryClient = /** @class */ (function () {
143
149
  _this.runPromise(r.unsubscribe());
144
150
  };
145
151
  snapshotDone = true;
146
- return _this.runPromise(q.setAllItems(r.result));
152
+ return _this.runPromise(q.setAllItems(r.result).then(function () {
153
+ if (q.droppedWhilePending)
154
+ return _this.runKeepAlive();
155
+ }));
147
156
  };
148
157
  _this.runPromise(_this.subscribeChannel(q.queryChannel, {
149
158
  next: function (value) { return _this.runPromise(q.handle(value)); },
@@ -159,7 +168,7 @@ var LiveQueryClient = /** @class */ (function () {
159
168
  }
160
169
  unsubscribeToChannel = unsub;
161
170
  if (snapshotDone)
162
- return _this.runPromise(_this.runKeepAlive({ checkVersions: true }));
171
+ return _this.runPromise(_this.runKeepAlive());
163
172
  })
164
173
  .catch(function (err) {
165
174
  q.listeners.forEach(function (l) { return l.error(err); });
@@ -202,22 +211,23 @@ var LiveQueryClient = /** @class */ (function () {
202
211
  return false;
203
212
  return Date.now() - conn.lastServerEvent > remult3_js_1.flags.sseStaleMs;
204
213
  };
205
- LiveQueryClient.prototype.runKeepAlive = function (opts) {
214
+ LiveQueryClient.prototype.runKeepAlive = function () {
206
215
  return tslib_1.__awaiter(this, void 0, void 0, function () {
207
216
  var _this = this;
208
217
  return tslib_1.__generator(this, function (_a) {
209
218
  if (this.keepAliveInFlight)
210
219
  return [2 /*return*/, this.keepAliveInFlight];
211
- this.keepAliveInFlight = this.sendKeepAlive(opts).finally(function () {
220
+ this.keepAliveInFlight = this.sendKeepAlive().finally(function () {
212
221
  _this.keepAliveInFlight = undefined;
213
222
  });
214
223
  return [2 /*return*/, this.keepAliveInFlight];
215
224
  });
216
225
  });
217
226
  };
218
- LiveQueryClient.prototype.sendKeepAlive = function (opts) {
227
+ /** Resolves true when a query was refetched. */
228
+ LiveQueryClient.prototype.sendKeepAlive = function () {
219
229
  return tslib_1.__awaiter(this, void 0, void 0, function () {
220
- var ids, _a, _b, q, p, raw, unknownIds, versions, stale, reloaded, _c, _d, q, serverVersion, unknown, mismatch;
230
+ var ids, _a, _b, q, p, raw, unknownIds, versions, reloaded, _c, _d, q, serverVersion, unknown, mismatch;
221
231
  var e_1, _e, e_2, _f;
222
232
  var _g, _h;
223
233
  return tslib_1.__generator(this, function (_j) {
@@ -227,6 +237,8 @@ var LiveQueryClient = /** @class */ (function () {
227
237
  try {
228
238
  for (_a = tslib_1.__values(this.queries.values()), _b = _a.next(); !_b.done; _b = _a.next()) {
229
239
  q = _b.value;
240
+ if (!q.snapshotReady)
241
+ continue;
230
242
  ids.push(q.queryChannel);
231
243
  }
232
244
  }
@@ -238,7 +250,7 @@ var LiveQueryClient = /** @class */ (function () {
238
250
  finally { if (e_1) throw e_1.error; }
239
251
  }
240
252
  if (ids.length === 0)
241
- return [2 /*return*/];
253
+ return [2 /*return*/, false];
242
254
  p = this.apiProvider();
243
255
  return [4 /*yield*/, this.runPromise(remult_static_js_1.remultStatic.actionInfo.runActionWithoutBlockingUI(function () {
244
256
  return (0, buildRestDataProvider_js_1.buildRestDataProvider)(p.httpClient).post(p.url + '/' + SubscriptionChannel_js_1.liveQueryKeepAliveRoute, { queryIds: ids });
@@ -251,14 +263,13 @@ var LiveQueryClient = /** @class */ (function () {
251
263
  versions = Array.isArray(raw)
252
264
  ? {}
253
265
  : ((_h = raw === null || raw === void 0 ? void 0 : raw.versions) !== null && _h !== void 0 ? _h : {});
254
- stale = (opts === null || opts === void 0 ? void 0 : opts.checkVersions) || this.isSseStale();
255
266
  reloaded = false;
256
267
  try {
257
268
  for (_c = tslib_1.__values(this.queries.values()), _d = _c.next(); !_d.done; _d = _c.next()) {
258
269
  q = _d.value;
259
270
  serverVersion = versions[q.queryChannel];
260
271
  unknown = unknownIds.includes(q.queryChannel);
261
- mismatch = stale && serverVersion !== undefined && serverVersion !== q.version;
272
+ mismatch = serverVersion !== undefined && serverVersion !== q.version;
262
273
  if (unknown || mismatch) {
263
274
  reloaded = true;
264
275
  if (serverVersion !== undefined)
@@ -274,22 +285,14 @@ var LiveQueryClient = /** @class */ (function () {
274
285
  }
275
286
  finally { if (e_2) throw e_2.error; }
276
287
  }
277
- if (this.isSseStale()) {
278
- this.keepAliveBackoffMs = reloaded
279
- ? remult3_js_1.flags.liveQueryPollWhenStaleMs
280
- : Math.min(this.keepAliveBackoffMs * 2, remult3_js_1.flags.liveQueryKeepAliveMs);
281
- }
282
- else {
283
- this.keepAliveBackoffMs = remult3_js_1.flags.liveQueryPollWhenStaleMs;
284
- }
285
- return [2 /*return*/];
288
+ return [2 /*return*/, reloaded];
286
289
  }
287
290
  });
288
291
  });
289
292
  };
290
293
  LiveQueryClient.prototype.maybeKeepAlive = function () {
291
294
  return tslib_1.__awaiter(this, void 0, void 0, function () {
292
- var stale, interval;
295
+ var stale, interval, reloaded;
293
296
  var _a, _b;
294
297
  return tslib_1.__generator(this, function (_c) {
295
298
  switch (_c.label) {
@@ -305,10 +308,23 @@ var LiveQueryClient = /** @class */ (function () {
305
308
  this.lastKeepAliveAt = Date.now();
306
309
  if (stale)
307
310
  (_b = (_a = this.openedConnection) === null || _a === void 0 ? void 0 : _a.resume) === null || _b === void 0 ? void 0 : _b.call(_a, true);
308
- return [4 /*yield*/, this.runKeepAlive()];
311
+ reloaded = false;
312
+ _c.label = 1;
309
313
  case 1:
310
- _c.sent();
311
- return [2 /*return*/];
314
+ _c.trys.push([1, , 3, 4]);
315
+ return [4 /*yield*/, this.runKeepAlive()];
316
+ case 2:
317
+ reloaded = _c.sent();
318
+ return [3 /*break*/, 4];
319
+ case 3:
320
+ // runs on a failed post too, so an unreachable server is not polled
321
+ // every second; channel-only clients (no query ids) back off as well
322
+ if (this.isSseStale())
323
+ this.keepAliveBackoffMs = reloaded
324
+ ? remult3_js_1.flags.liveQueryPollWhenStaleMs
325
+ : Math.min(this.keepAliveBackoffMs * 2, remult3_js_1.flags.liveQueryKeepAliveMs);
326
+ return [7 /*endfinally*/];
327
+ case 4: return [2 /*return*/];
312
328
  }
313
329
  });
314
330
  });
@@ -325,7 +341,7 @@ var LiveQueryClient = /** @class */ (function () {
325
341
  _this.foregroundKeepAliveTimer = undefined;
326
342
  _this.keepAliveBackoffMs = remult3_js_1.flags.liveQueryPollWhenStaleMs;
327
343
  _this.lastKeepAliveAt = 0;
328
- _this.runPromise(_this.runKeepAlive({ checkVersions: true }));
344
+ _this.runPromise(_this.runKeepAlive());
329
345
  }, 300);
330
346
  });
331
347
  this.detachForeground = function () {
@@ -337,31 +353,65 @@ var LiveQueryClient = /** @class */ (function () {
337
353
  _this.detachForeground = function () { };
338
354
  };
339
355
  };
356
+ LiveQueryClient.prototype.startKeepAlive = function () {
357
+ var _this = this;
358
+ // openConnection may reject and be retried with queries still alive
359
+ if (this.interval !== undefined)
360
+ return;
361
+ this.lastKeepAliveAt = Date.now();
362
+ this.keepAliveBackoffMs = remult3_js_1.flags.liveQueryPollWhenStaleMs;
363
+ this.attachForeground();
364
+ this.interval = setInterval(function () {
365
+ // a failed post is transient: the backoff covers it, next tick retries
366
+ _this.runPromise(_this.maybeKeepAlive().catch(function () { }));
367
+ }, Math.min(remult3_js_1.flags.liveQueryPollWhenStaleMs, remult3_js_1.flags.liveQueryKeepAliveMs));
368
+ };
340
369
  LiveQueryClient.prototype.openIfNoOpened = function () {
341
370
  var _this = this;
342
371
  if (!this.client) {
343
- this.lastKeepAliveAt = Date.now();
344
- this.keepAliveBackoffMs = remult3_js_1.flags.liveQueryPollWhenStaleMs;
345
- this.attachForeground();
346
- this.interval = setInterval(function () {
347
- _this.runPromise(_this.maybeKeepAlive());
348
- }, Math.min(remult3_js_1.flags.liveQueryPollWhenStaleMs, remult3_js_1.flags.liveQueryKeepAliveMs));
372
+ this.startKeepAlive();
349
373
  return this.runPromise((this.client = this.apiProvider()
350
374
  .subscriptionClient.openConnection(function () {
351
- var e_3, _a;
375
+ var e_3, _a, e_4, _b, e_5, _c;
376
+ var _d;
352
377
  try {
353
- for (var _b = tslib_1.__values(_this.queries.values()), _c = _b.next(); !_c.done; _c = _b.next()) {
354
- var q = _c.value;
378
+ for (var _e = tslib_1.__values(_this.queries.values()), _f = _e.next(); !_f.done; _f = _e.next()) {
379
+ var q = _f.value;
355
380
  q.subscribeCode();
356
381
  }
357
382
  }
358
383
  catch (e_3_1) { e_3 = { error: e_3_1 }; }
359
384
  finally {
360
385
  try {
361
- if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
386
+ if (_f && !_f.done && (_a = _e.return)) _a.call(_e);
362
387
  }
363
388
  finally { if (e_3) throw e_3.error; }
364
389
  }
390
+ try {
391
+ for (var _g = tslib_1.__values(_this.channels.values()), _h = _g.next(); !_h.done; _h = _g.next()) {
392
+ var c = _h.value;
393
+ try {
394
+ for (var _j = (e_5 = void 0, tslib_1.__values(c.listeners)), _k = _j.next(); !_k.done; _k = _j.next()) {
395
+ var l = _k.value;
396
+ (_d = l.reconnect) === null || _d === void 0 ? void 0 : _d.call(l);
397
+ }
398
+ }
399
+ catch (e_5_1) { e_5 = { error: e_5_1 }; }
400
+ finally {
401
+ try {
402
+ if (_k && !_k.done && (_c = _j.return)) _c.call(_j);
403
+ }
404
+ finally { if (e_5) throw e_5.error; }
405
+ }
406
+ }
407
+ }
408
+ catch (e_4_1) { e_4 = { error: e_4_1 }; }
409
+ finally {
410
+ try {
411
+ if (_h && !_h.done && (_b = _g.return)) _b.call(_g);
412
+ }
413
+ finally { if (e_4) throw e_4.error; }
414
+ }
365
415
  })
366
416
  .then(function (c) {
367
417
  _this.openedConnection = c;
@@ -387,7 +437,7 @@ var MessageChannel = /** @class */ (function () {
387
437
  MessageChannel.prototype.handle = function (message) {
388
438
  return tslib_1.__awaiter(this, void 0, void 0, function () {
389
439
  var _a, _b, l;
390
- var e_4, _c;
440
+ var e_6, _c;
391
441
  return tslib_1.__generator(this, function (_d) {
392
442
  try {
393
443
  for (_a = tslib_1.__values(this.listeners), _b = _a.next(); !_b.done; _b = _a.next()) {
@@ -395,12 +445,12 @@ var MessageChannel = /** @class */ (function () {
395
445
  l.next(message);
396
446
  }
397
447
  }
398
- catch (e_4_1) { e_4 = { error: e_4_1 }; }
448
+ catch (e_6_1) { e_6 = { error: e_6_1 }; }
399
449
  finally {
400
450
  try {
401
451
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
402
452
  }
403
- finally { if (e_4) throw e_4.error; }
453
+ finally { if (e_6) throw e_6.error; }
404
454
  }
405
455
  return [2 /*return*/];
406
456
  });
@@ -5,6 +5,7 @@ var tslib_1 = require("tslib");
5
5
  var buildRestDataProvider_js_1 = require("../buildRestDataProvider.js");
6
6
  var remult_proxy_js_1 = require("../remult-proxy.js");
7
7
  var remult_static_js_1 = require("../remult-static.js");
8
+ var remult3_js_1 = require("../remult3/remult3.js");
8
9
  var SubscriptionChannel_js_1 = require("./SubscriptionChannel.js");
9
10
  var SseSubscriptionClient = /** @class */ (function () {
10
11
  function SseSubscriptionClient() {
@@ -12,12 +13,15 @@ var SseSubscriptionClient = /** @class */ (function () {
12
13
  SseSubscriptionClient.prototype.openConnection = function (onReconnect) {
13
14
  var connectionId;
14
15
  var channels = new Map();
16
+ var errorHandlers = new Map();
15
17
  var provider = (0, buildRestDataProvider_js_1.buildRestDataProvider)(remult_proxy_js_1.remult.apiClient.httpClient);
16
18
  var source;
17
19
  var retryCount = 0;
18
20
  var closed = false;
19
21
  var connected = false;
22
+ var everConnected = false;
20
23
  var reconnectTimer;
24
+ var lastConnectAt = 0;
21
25
  var connectionReady;
22
26
  var connectionPromise = new Promise(function (res) {
23
27
  connectionReady = res;
@@ -42,15 +46,16 @@ var SseSubscriptionClient = /** @class */ (function () {
42
46
  function resume(force) {
43
47
  if (closed)
44
48
  return;
45
- retryCount = 0;
46
- clearReconnectTimer();
49
+ var open = source && source.readyState !== EVENT_SOURCE_CLOSED;
47
50
  if (force) {
51
+ // a source still inside its handshake window is not a zombie yet
52
+ if (open && Date.now() - lastConnectAt < remult3_js_1.flags.sseStaleMs)
53
+ return;
48
54
  createConnection();
49
55
  return;
50
56
  }
51
- var open = source &&
52
- typeof EventSource !== 'undefined' &&
53
- source.readyState !== EventSource.CLOSED;
57
+ retryCount = 0;
58
+ clearReconnectTimer();
54
59
  if (!open)
55
60
  createConnection();
56
61
  }
@@ -62,27 +67,44 @@ var SseSubscriptionClient = /** @class */ (function () {
62
67
  clearReconnectTimer();
63
68
  source === null || source === void 0 ? void 0 : source.close();
64
69
  },
65
- subscribe: function (channel, handler) {
70
+ subscribe: function (channel, handler, onError) {
66
71
  return tslib_1.__awaiter(this, void 0, void 0, function () {
67
- var listeners;
72
+ var listeners, onErrors, err_1;
68
73
  return tslib_1.__generator(this, function (_a) {
69
74
  switch (_a.label) {
70
75
  case 0:
71
76
  listeners = channels.get(channel);
72
- if (!!listeners) return [3 /*break*/, 3];
77
+ onErrors = errorHandlers.get(channel);
78
+ if (!!listeners) return [3 /*break*/, 6];
73
79
  channels.set(channel, (listeners = []));
74
- return [4 /*yield*/, connectionPromise];
80
+ errorHandlers.set(channel, (onErrors = []));
81
+ _a.label = 1;
75
82
  case 1:
76
- _a.sent();
77
- if (!!closed) return [3 /*break*/, 3];
78
- return [4 /*yield*/, subscribeToChannel(channel)];
83
+ _a.trys.push([1, 5, , 6]);
84
+ return [4 /*yield*/, connectionPromise];
79
85
  case 2:
80
86
  _a.sent();
81
- _a.label = 3;
87
+ if (!!closed) return [3 /*break*/, 4];
88
+ return [4 /*yield*/, subscribeToChannel(channel)];
82
89
  case 3:
90
+ _a.sent();
91
+ _a.label = 4;
92
+ case 4: return [3 /*break*/, 6];
93
+ case 5:
94
+ err_1 = _a.sent();
95
+ // otherwise the next subscribe sees the entry and never retries
96
+ channels.delete(channel);
97
+ errorHandlers.delete(channel);
98
+ throw err_1;
99
+ case 6:
83
100
  listeners.push(handler);
101
+ onErrors.push(onError);
84
102
  return [2 /*return*/, function () {
85
- listeners.splice(listeners.indexOf(handler, 1));
103
+ var i = listeners.indexOf(handler);
104
+ if (i >= 0) {
105
+ listeners.splice(i, 1);
106
+ onErrors.splice(i, 1);
107
+ }
86
108
  if (listeners.length == 0) {
87
109
  remult_static_js_1.remultStatic.actionInfo.runActionWithoutBlockingUI(function () {
88
110
  return provider.post(remult_proxy_js_1.remult.apiClient.url + '/' + SubscriptionChannel_js_1.streamUrl + '/unsubscribe', {
@@ -91,6 +113,7 @@ var SseSubscriptionClient = /** @class */ (function () {
91
113
  });
92
114
  });
93
115
  channels.delete(channel);
116
+ errorHandlers.delete(channel);
94
117
  }
95
118
  }];
96
119
  }
@@ -108,6 +131,7 @@ var SseSubscriptionClient = /** @class */ (function () {
108
131
  clearReconnectTimer();
109
132
  if (source)
110
133
  source.close();
134
+ lastConnectAt = Date.now();
111
135
  source = SseSubscriptionClient.createEventSource(remult_proxy_js_1.remult.apiClient.url + '/' + SubscriptionChannel_js_1.streamUrl);
112
136
  source.onmessage = function (e) {
113
137
  noteServerEvent();
@@ -127,50 +151,59 @@ var SseSubscriptionClient = /** @class */ (function () {
127
151
  scheduleReconnect();
128
152
  };
129
153
  source.addEventListener('connectionId', function (e) { return tslib_1.__awaiter(_this, void 0, void 0, function () {
130
- var _a, _b, channel, e_1_1;
154
+ var wasConnected, _a, _b, channel, err_2, e_1_1;
131
155
  var e_1, _c;
132
- return tslib_1.__generator(this, function (_d) {
133
- switch (_d.label) {
156
+ var _d;
157
+ return tslib_1.__generator(this, function (_e) {
158
+ switch (_e.label) {
134
159
  case 0:
135
160
  //@ts-ignore
136
161
  connectionId = e.data;
137
162
  retryCount = 0;
138
163
  noteServerEvent();
139
- if (!connected) return [3 /*break*/, 9];
140
- _d.label = 1;
164
+ wasConnected = everConnected;
165
+ connected = everConnected = true;
166
+ if (!wasConnected) return [3 /*break*/, 11];
167
+ _e.label = 1;
141
168
  case 1:
142
- _d.trys.push([1, 6, 7, 8]);
143
- _a = tslib_1.__values(channels.keys()), _b = _a.next();
144
- _d.label = 2;
169
+ _e.trys.push([1, 8, 9, 10]);
170
+ _a = tslib_1.__values(tslib_1.__spreadArray([], tslib_1.__read(channels.keys()), false)), _b = _a.next();
171
+ _e.label = 2;
145
172
  case 2:
146
- if (!!_b.done) return [3 /*break*/, 5];
173
+ if (!!_b.done) return [3 /*break*/, 7];
147
174
  channel = _b.value;
148
- return [4 /*yield*/, subscribeToChannel(channel)];
175
+ _e.label = 3;
149
176
  case 3:
150
- _d.sent();
151
- _d.label = 4;
177
+ _e.trys.push([3, 5, , 6]);
178
+ return [4 /*yield*/, subscribeToChannel(channel)];
152
179
  case 4:
180
+ _e.sent();
181
+ return [3 /*break*/, 6];
182
+ case 5:
183
+ err_2 = _e.sent();
184
+ // one refused channel must not block the others
185
+ (_d = errorHandlers.get(channel)) === null || _d === void 0 ? void 0 : _d.forEach(function (f) { return f(err_2); });
186
+ return [3 /*break*/, 6];
187
+ case 6:
153
188
  _b = _a.next();
154
189
  return [3 /*break*/, 2];
155
- case 5: return [3 /*break*/, 8];
156
- case 6:
157
- e_1_1 = _d.sent();
190
+ case 7: return [3 /*break*/, 10];
191
+ case 8:
192
+ e_1_1 = _e.sent();
158
193
  e_1 = { error: e_1_1 };
159
- return [3 /*break*/, 8];
160
- case 7:
194
+ return [3 /*break*/, 10];
195
+ case 9:
161
196
  try {
162
197
  if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
163
198
  }
164
199
  finally { if (e_1) throw e_1.error; }
165
200
  return [7 /*endfinally*/];
166
- case 8:
201
+ case 10:
167
202
  onReconnect();
168
- return [3 /*break*/, 10];
169
- case 9:
170
- connected = true;
203
+ _e.label = 11;
204
+ case 11:
171
205
  connectionReady();
172
- _d.label = 10;
173
- case 10: return [2 /*return*/];
206
+ return [2 /*return*/];
174
207
  }
175
208
  });
176
209
  }); });
@@ -190,7 +223,7 @@ var SseSubscriptionClient = /** @class */ (function () {
190
223
  })];
191
224
  case 1:
192
225
  result = _a.sent();
193
- if (!(result === exports.ConnectionNotFoundError)) return [3 /*break*/, 4];
226
+ if (!(result === exports.ConnectionNotFoundError && connected)) return [3 /*break*/, 3];
194
227
  connected = false;
195
228
  connectionPromise = new Promise(function (res) {
196
229
  connectionReady = res;
@@ -199,12 +232,8 @@ var SseSubscriptionClient = /** @class */ (function () {
199
232
  return [4 /*yield*/, connectionPromise];
200
233
  case 2:
201
234
  _a.sent();
202
- if (!!closed) return [3 /*break*/, 4];
203
- return [4 /*yield*/, subscribeToChannel(channel)];
204
- case 3:
205
- _a.sent();
206
- _a.label = 4;
207
- case 4: return [2 /*return*/];
235
+ _a.label = 3;
236
+ case 3: return [2 /*return*/];
208
237
  }
209
238
  });
210
239
  });
@@ -219,3 +248,5 @@ var SseSubscriptionClient = /** @class */ (function () {
219
248
  }());
220
249
  exports.SseSubscriptionClient = SseSubscriptionClient;
221
250
  exports.ConnectionNotFoundError = 'client connection not found';
251
+ // EventSource.CLOSED; the global is absent in Node
252
+ var EVENT_SOURCE_CLOSED = 2;
@@ -4,6 +4,8 @@ export interface SubscriptionListener<type> {
4
4
  next(message: type): void;
5
5
  error(err: any): void;
6
6
  complete(): void;
7
+ /** The connection was re-established; messages published meanwhile were lost, refetch state if needed. */
8
+ reconnect?(): void;
7
9
  }
8
10
  export type Unsubscribe = VoidFunction;
9
11
  export interface SubscriptionClientConnection {
@@ -106,6 +108,16 @@ export declare type LiveQueryChange = {
106
108
  * });
107
109
  * ```
108
110
  *
111
+ * #### Recovering after a dropped connection
112
+ * Messages published while the connection was down are not replayed.
113
+ * Use the `reconnect` listener to refetch state:
114
+ * ```ts
115
+ * chart.subscribe({
116
+ * next: (message) => (chartData = message.data),
117
+ * reconnect: () => loadChartData(),
118
+ * });
119
+ * ```
120
+ *
109
121
  */
110
122
  export declare class SubscriptionChannel<messageType> {
111
123
  channelKey: string;
@@ -15,6 +15,8 @@ var LiveQuerySubscriber = /** @class */ (function () {
15
15
  this.unsubscribe = function () { };
16
16
  this.unsubscribeChannel = function () { };
17
17
  this.unsubscribeQuery = function () { };
18
+ this.snapshotReady = false;
19
+ this.droppedWhilePending = false;
18
20
  this.defaultQueryState = [];
19
21
  this.listeners = [];
20
22
  this.id = String(crypto.randomUUID());
@@ -31,7 +33,11 @@ var LiveQuerySubscriber = /** @class */ (function () {
31
33
  var items;
32
34
  return tslib_1.__generator(this, function (_a) {
33
35
  switch (_a.label) {
34
- case 0: return [4 /*yield*/, (0, repository_internals_js_1.getRepositoryInternals)(this.repo)._fromJsonArray(result, this.query.options)];
36
+ case 0:
37
+ // server re-registers the query at version 0; reset before the await so a
38
+ // version message landing mid-parse is judged against the new baseline
39
+ this.version = 0;
40
+ return [4 /*yield*/, (0, repository_internals_js_1.getRepositoryInternals)(this.repo)._fromJsonArray(result, this.query.options)];
35
41
  case 1:
36
42
  items = _a.sent();
37
43
  this.forListeners(function (listener) {
@@ -39,7 +45,7 @@ var LiveQuerySubscriber = /** @class */ (function () {
39
45
  return items;
40
46
  });
41
47
  }, this.allItemsMessage(items));
42
- this.version = 0;
48
+ this.snapshotReady = true;
43
49
  return [2 /*return*/];
44
50
  }
45
51
  });
@@ -99,18 +105,26 @@ var LiveQuerySubscriber = /** @class */ (function () {
99
105
  return tslib_1.__generator(this, function (_b) {
100
106
  switch (_b.label) {
101
107
  case 0:
108
+ // the pending snapshot supersedes anything in flight; the keep-alive
109
+ // after it lands catches whatever was dropped here
110
+ if (!this.snapshotReady) {
111
+ this.droppedWhilePending = true;
112
+ return [2 /*return*/];
113
+ }
102
114
  ver = messages.find(function (m) {
103
115
  return m.type === 'version';
104
116
  });
105
- if (ver) {
106
- if (ver.from < this.version)
107
- return [2 /*return*/];
108
- if (ver.from > this.version) {
109
- this.version = ver.to;
110
- (_a = this.subscribeCode) === null || _a === void 0 ? void 0 : _a.call(this);
111
- return [2 /*return*/];
112
- }
117
+ // any gap, forward or backward, means a message was missed or two servers
118
+ // published the same version with different diffs: refetch instead of drop
119
+ if (ver && ver.from !== this.version) {
120
+ this.version = ver.to;
121
+ (_a = this.subscribeCode) === null || _a === void 0 ? void 0 : _a.call(this);
122
+ return [2 /*return*/];
113
123
  }
124
+ // bump before the await below, so a second message delivered in the same
125
+ // tick is judged against this one and not mistaken for a gap
126
+ if (ver)
127
+ this.version = ver.to;
114
128
  data = messages.filter(function (m) { return m.type !== 'version'; });
115
129
  x = data.filter(function (_a) {
116
130
  var type = _a.type;
@@ -171,7 +185,8 @@ var LiveQuerySubscriber = /** @class */ (function () {
171
185
  return items;
172
186
  });
173
187
  }, data);
174
- this.version = ver ? ver.to : this.version + (data.length > 0 ? 1 : 0);
188
+ if (!ver && data.length > 0)
189
+ this.version++;
175
190
  return [2 /*return*/];
176
191
  }
177
192
  });
@@ -265,6 +280,16 @@ exports.liveQueryKeepAliveRoute = '_liveQueryKeepAlive';
265
280
  * });
266
281
  * ```
267
282
  *
283
+ * #### Recovering after a dropped connection
284
+ * Messages published while the connection was down are not replayed.
285
+ * Use the `reconnect` listener to refetch state:
286
+ * ```ts
287
+ * chart.subscribe({
288
+ * next: (message) => (chartData = message.data),
289
+ * reconnect: () => loadChartData(),
290
+ * });
291
+ * ```
292
+ *
268
293
  */
269
294
  var SubscriptionChannel = /** @class */ (function () {
270
295
  /**
@@ -1220,7 +1220,7 @@ export type ClassFieldDecorator<entityType, valueType> = ((target: any, context:
1220
1220
  };
1221
1221
  export declare const flags: {
1222
1222
  error500RetryCount: number;
1223
- /** Client treats SSE as dead if no event for this long */
1223
+ /** Client treats SSE as dead if no event for this long. Server pings every 15s; two missed pings plus slack, so a stalled event loop does not trigger a reconnect storm */
1224
1224
  sseStaleMs: number;
1225
1225
  /** HTTP keep-alive while SSE is healthy (touches lastUsed) */
1226
1226
  liveQueryKeepAliveMs: number;
@@ -19,8 +19,8 @@ var wnn = [
19
19
  ];
20
20
  exports.flags = {
21
21
  error500RetryCount: 4,
22
- /** Client treats SSE as dead if no event for this long */
23
- sseStaleMs: 20000,
22
+ /** Client treats SSE as dead if no event for this long. Server pings every 15s; two missed pings plus slack, so a stalled event loop does not trigger a reconnect storm */
23
+ sseStaleMs: 40000,
24
24
  /** HTTP keep-alive while SSE is healthy (touches lastUsed) */
25
25
  liveQueryKeepAliveMs: 30000,
26
26
  /** Version poll while SSE is stale */