remult 3.4.0-next.2 → 3.4.0-next.5

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 (54) hide show
  1. package/esm/index.js +7 -6
  2. package/esm/internals.js +1 -0
  3. package/esm/remult-d1.js +1 -0
  4. package/esm/remult-knex/index.js +112 -2
  5. package/esm/remult-mongo.js +17 -8
  6. package/esm/remult-sqlite-core.js +1 -0
  7. package/esm/src/data-api.js +13 -2
  8. package/esm/src/data-interfaces.js +4 -4
  9. package/esm/src/data-providers/array-entity-data-provider.js +14 -2
  10. package/esm/src/data-providers/in-memory-database.js +12 -1
  11. package/esm/src/data-providers/indexed-db-data-provider.js +844 -0
  12. package/esm/src/data-providers/json-data-provider.js +2 -2
  13. package/esm/src/data-providers/rest-data-provider.js +11 -12
  14. package/esm/src/data-providers/sql-database.js +110 -43
  15. package/esm/src/live-query/LiveQueryClient.js +53 -25
  16. package/esm/src/live-query/SseSubscriptionClient.js +49 -20
  17. package/esm/src/live-query/SubscriptionChannel.js +34 -10
  18. package/esm/src/remult3/RepositoryImplementation.js +135 -60
  19. package/esm/src/remult3/remult3.js +2 -2
  20. package/index.d.ts +31 -17
  21. package/index.js +5 -2
  22. package/internals.d.ts +1 -0
  23. package/internals.js +3 -1
  24. package/package.json +1 -1
  25. package/remult-d1.d.ts +1 -0
  26. package/remult-d1.js +1 -0
  27. package/remult-knex/index.js +329 -44
  28. package/remult-mongo.js +80 -36
  29. package/remult-sqlite-core.d.ts +1 -0
  30. package/remult-sqlite-core.js +1 -0
  31. package/src/data-api.js +38 -21
  32. package/src/data-interfaces.d.ts +9 -3
  33. package/src/data-interfaces.js +4 -4
  34. package/src/data-providers/array-entity-data-provider.d.ts +2 -2
  35. package/src/data-providers/array-entity-data-provider.js +92 -14
  36. package/src/data-providers/in-memory-database.d.ts +5 -2
  37. package/src/data-providers/in-memory-database.js +22 -1
  38. package/src/data-providers/indexed-db-data-provider.d.ts +61 -0
  39. package/src/data-providers/indexed-db-data-provider.js +1459 -0
  40. package/src/data-providers/json-data-provider.js +2 -2
  41. package/src/data-providers/rest-data-provider.d.ts +2 -3
  42. package/src/data-providers/rest-data-provider.js +18 -17
  43. package/src/data-providers/sql-database.js +254 -105
  44. package/src/entity.d.ts +12 -0
  45. package/src/live-query/LiveQueryClient.js +87 -37
  46. package/src/live-query/SseSubscriptionClient.js +75 -44
  47. package/src/live-query/SubscriptionChannel.d.ts +12 -0
  48. package/src/live-query/SubscriptionChannel.js +36 -11
  49. package/src/remult3/RepositoryImplementation.d.ts +7 -4
  50. package/src/remult3/RepositoryImplementation.js +521 -326
  51. package/src/remult3/remult3.d.ts +7 -5
  52. package/src/remult3/remult3.js +2 -2
  53. package/src/sql-command.d.ts +2 -0
  54. package/src/standard-schema/index.d.ts +1 -1
@@ -49,8 +49,8 @@ class JsonEntityDataProvider {
49
49
  return x;
50
50
  }))));
51
51
  }
52
- delete(id) {
53
- return (this.p = this.p.then(() => this.loadEntityData((dp, save) => dp.delete(id).then(async (x) => {
52
+ delete(ids) {
53
+ return (this.p = this.p.then(() => this.loadEntityData((dp, save) => dp.delete(ids).then(async (x) => {
54
54
  await save();
55
55
  return x;
56
56
  }))));
@@ -303,21 +303,20 @@ export class RestEntityDataProvider {
303
303
  }
304
304
  return result;
305
305
  }
306
- async delete(id) {
307
- if (id == '')
308
- await this.deleteMany(Filter.fromEntityFilter(this.entity, this.entity.idMetadata.getIdFilter(id)));
309
- else
310
- return this.http().delete(this.url() + '/' + encodeURIComponent(id));
306
+ async delete(ids) {
307
+ if (ids.length === 0)
308
+ return;
309
+ if (ids.length === 1 && ids[0] !== '')
310
+ return this.http().delete(this.url() + '/' + encodeURIComponent(ids[0]));
311
+ await this.deleteMany(Filter.fromEntityFilter(this.entity, this.entity.idMetadata.getIdFilter(...ids)));
311
312
  }
312
313
  insert(data, options) {
313
314
  return this.http()
314
- .post(this.url() + (options?.select === 'none' ? '?_select=$none' : ''), this.translateToJson(data))
315
- .then((y) => this.translateFromJson(y));
316
- }
317
- insertMany(data, options) {
318
- return this.http()
319
- .post(this.url() + (options?.select === 'none' ? '?_select=$none' : ''), data.map((data) => this.translateToJson(data)))
320
- .then((y) => y.map((y) => this.translateFromJson(y)));
315
+ .post(this.url() + (options?.select === 'none' ? '?_select=$none' : ''), data.map((row) => this.translateToJson(row)))
316
+ .then((y) => {
317
+ const rows = Array.isArray(y) ? y : [y];
318
+ return rows.map((row) => row ? this.translateFromJson(row) : undefined);
319
+ });
321
320
  }
322
321
  }
323
322
  export function addFilterToUrlAndReturnTrueIfSuccessful(filter, url) {
@@ -117,6 +117,8 @@ export class SqlDatabase {
117
117
  doesNotSupportReturningSyntax: this.sql.doesNotSupportReturningSyntax,
118
118
  doesNotSupportReturningSyntaxOnlyForUpdate: this.sql.doesNotSupportReturningSyntaxOnlyForUpdate,
119
119
  orderByNullsFirst: this.sql.orderByNullsFirst,
120
+ afterMutation: this.sql.afterMutation,
121
+ maxParametersInOneSqlStatement: this.sql.maxParametersInOneSqlStatement,
120
122
  }));
121
123
  }
122
124
  finally {
@@ -193,6 +195,35 @@ export class SqlDatabase {
193
195
  createdEntities = [];
194
196
  end;
195
197
  }
198
+ const defaultMaxParametersInOneSqlStatement = 2000;
199
+ function splitByMaxParameters(items, parametersInItem, maxParameters) {
200
+ const batches = [];
201
+ let batch = [];
202
+ let params = 0;
203
+ for (const item of items) {
204
+ const n = parametersInItem(item);
205
+ if (batch.length > 0 && params + n > maxParameters) {
206
+ batches.push(batch);
207
+ batch = [];
208
+ params = 0;
209
+ }
210
+ batch.push(item);
211
+ params += n;
212
+ }
213
+ if (batch.length)
214
+ batches.push(batch);
215
+ return batches;
216
+ }
217
+ function countInsertBindParameters(entity, e, row) {
218
+ let n = 0;
219
+ for (const x of entity.fields) {
220
+ if (isDbReadonly(x, e))
221
+ continue;
222
+ if (x.valueConverter.toDb(row[x.key]) != undefined)
223
+ n++;
224
+ }
225
+ return n;
226
+ }
196
227
  const icons = new Map([
197
228
  // CRUD
198
229
  ['INSERT', '⚪'], // Used to insert new data into a database.
@@ -441,11 +472,13 @@ class ActualSQLEntityDataProvider {
441
472
  return this.buildResultRow(colKeys, sqlResult.rows[0], sqlResult);
442
473
  });
443
474
  }
444
- async delete(id) {
475
+ async delete(ids) {
476
+ if (ids.length === 0)
477
+ return;
445
478
  let e = await this.init();
446
479
  let r = this.sql.createCommand();
447
480
  let f = new FilterConsumerBridgeToSqlRequest(r, e);
448
- Filter.fromEntityFilter(this.entity, this.entity.idMetadata.getIdFilter(id)).__applyToConsumer(f);
481
+ Filter.fromEntityFilter(this.entity, this.entity.idMetadata.getIdFilter(...ids)).__applyToConsumer(f);
449
482
  let statement = 'delete from ' + e.$entityName;
450
483
  statement += await f.resolveWhere();
451
484
  return r.execute(statement).then(() => {
@@ -453,56 +486,90 @@ class ActualSQLEntityDataProvider {
453
486
  });
454
487
  }
455
488
  async insert(data, options) {
489
+ if (data.length === 0)
490
+ return [];
456
491
  let e = await this.init();
492
+ const maxParams = this.strategy.maxParametersInOneSqlStatement ??
493
+ defaultMaxParametersInOneSqlStatement;
494
+ const batches = splitByMaxParameters(data, (row) => countInsertBindParameters(this.entity, e, row), maxParams);
495
+ const result = [];
496
+ for (const batch of batches)
497
+ result.push(...(await this.insertBatch(batch, e, options)));
498
+ return result;
499
+ }
500
+ //@internal
501
+ async insertOne(data, options) {
502
+ return (await this.insert([data], options))[0];
503
+ }
504
+ async insertBatch(batch, e, options) {
457
505
  let r = this.sql.createCommand();
458
- let cols = '';
459
- let vals = '';
460
- let added = false;
506
+ const cols = [];
461
507
  for (const x of this.entity.fields) {
462
- if (isDbReadonly(x, e)) {
463
- }
464
- else {
465
- let v = x.valueConverter.toDb(data[x.key]);
466
- if (v != undefined) {
467
- if (!added)
468
- added = true;
469
- else {
470
- cols += ', ';
471
- vals += ', ';
472
- }
473
- cols += e.$dbNameOf(x);
508
+ if (isDbReadonly(x, e))
509
+ continue;
510
+ if (batch.some((row) => x.valueConverter.toDb(row[x.key]) != undefined))
511
+ cols.push(x);
512
+ }
513
+ let colSql = '';
514
+ let vals = '';
515
+ for (let i = 0; i < cols.length; i++) {
516
+ if (i)
517
+ colSql += ', ';
518
+ colSql += e.$dbNameOf(cols[i]);
519
+ }
520
+ for (let i = 0; i < batch.length; i++) {
521
+ if (i)
522
+ vals += ',';
523
+ vals += '(';
524
+ for (let j = 0; j < cols.length; j++) {
525
+ if (j)
526
+ vals += ', ';
527
+ const x = cols[j];
528
+ const v = x.valueConverter.toDb(batch[i][x.key]);
529
+ if (v != undefined)
474
530
  vals += toDbSql(r, x, v);
475
- }
531
+ else if (x.allowNull)
532
+ vals += 'null';
533
+ else
534
+ vals += 'DEFAULT';
476
535
  }
536
+ vals += ')';
477
537
  }
478
- let statement = `insert into ${e.$entityName} (${cols}) values (${vals})`;
538
+ let statement = `insert into ${e.$entityName} (${colSql}) values ${vals}`;
479
539
  let { colKeys, select } = await this.buildSelect(e, r, undefined, undefined);
480
- if (!this.sql._getSourceSql().doesNotSupportReturningSyntax &&
481
- !(options?.select === 'none'))
540
+ const source = this.sql._getSourceSql();
541
+ const wantRows = options?.select !== 'none';
542
+ if (!source.doesNotSupportReturningSyntax && wantRows)
482
543
  statement += ' returning ' + select;
483
- return await r.execute(statement).then((sql) => {
484
- this.sql._getSourceSql().afterMutation?.();
485
- if (this.sql._getSourceSql().doesNotSupportReturningSyntax) {
486
- if (isAutoIncrement(this.entity.idMetadata.field)) {
487
- const id = sql.rows[0];
488
- if (typeof id !== 'number')
489
- throw new Error('Auto increment, for a database that is does not support returning syntax, should return an array with the single last added id. Instead it returned: ' +
490
- JSON.stringify(id));
491
- if (options?.select === 'none')
492
- return undefined;
493
- return this.find({
494
- where: new Filter((x) => x.isEqualTo(this.entity.idMetadata.field, id)),
495
- }).then((r) => r[0]);
496
- }
497
- else {
498
- if (options?.select === 'none')
499
- return undefined;
500
- return getRowAfterUpdate(this.entity, this, data, undefined, 'insert');
501
- }
544
+ const sql = await r.execute(statement);
545
+ source.afterMutation?.();
546
+ if (!wantRows)
547
+ return batch.map(() => undefined);
548
+ if (source.doesNotSupportReturningSyntax) {
549
+ if (isAutoIncrement(this.entity.idMetadata.field)) {
550
+ const lastId = sql.rows[0];
551
+ if (typeof lastId !== 'number')
552
+ throw new Error('Auto increment, for a database that is does not support returning syntax, should return an array with the single last added id. Instead it returned: ' +
553
+ JSON.stringify(lastId));
554
+ const ids = [];
555
+ for (let id = lastId - batch.length + 1; id <= lastId; id++)
556
+ ids.push(id);
557
+ return this.loadRowsByIds(ids);
502
558
  }
503
- if (options?.select === 'none')
504
- return undefined;
505
- return this.buildResultRow(colKeys, sql.rows[0], sql);
559
+ return this.loadRowsByIds(batch.map((row) => this.entity.idMetadata.getId(row)));
560
+ }
561
+ return sql.rows.map((row) => this.buildResultRow(colKeys, row, sql));
562
+ }
563
+ async loadRowsByIds(ids) {
564
+ const found = await this.find({
565
+ where: Filter.fromEntityFilter(this.entity, this.entity.idMetadata.getIdFilter(...ids)),
566
+ });
567
+ const byId = new Map(found.map((row) => [this.entity.idMetadata.getId(row) + '', row]));
568
+ return ids.map((id) => {
569
+ const row = byId.get(id + '');
570
+ if (!row)
571
+ throw new Error(`Failed to insert row - result contained ${found.length} rows`);
572
+ return row;
506
573
  });
507
574
  }
508
575
  }
@@ -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;