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.
- package/esm/index.js +7 -6
- package/esm/internals.js +1 -0
- package/esm/remult-d1.js +1 -0
- package/esm/remult-knex/index.js +112 -2
- package/esm/remult-mongo.js +17 -8
- package/esm/remult-sqlite-core.js +1 -0
- package/esm/src/data-api.js +13 -2
- package/esm/src/data-interfaces.js +4 -4
- package/esm/src/data-providers/array-entity-data-provider.js +14 -2
- package/esm/src/data-providers/in-memory-database.js +12 -1
- package/esm/src/data-providers/indexed-db-data-provider.js +844 -0
- package/esm/src/data-providers/json-data-provider.js +2 -2
- package/esm/src/data-providers/rest-data-provider.js +11 -12
- package/esm/src/data-providers/sql-database.js +110 -43
- package/esm/src/live-query/LiveQueryClient.js +53 -25
- package/esm/src/live-query/SseSubscriptionClient.js +49 -20
- package/esm/src/live-query/SubscriptionChannel.js +34 -10
- package/esm/src/remult3/RepositoryImplementation.js +135 -60
- package/esm/src/remult3/remult3.js +2 -2
- package/index.d.ts +31 -17
- package/index.js +5 -2
- package/internals.d.ts +1 -0
- package/internals.js +3 -1
- package/package.json +1 -1
- package/remult-d1.d.ts +1 -0
- package/remult-d1.js +1 -0
- package/remult-knex/index.js +329 -44
- package/remult-mongo.js +80 -36
- package/remult-sqlite-core.d.ts +1 -0
- package/remult-sqlite-core.js +1 -0
- package/src/data-api.js +38 -21
- package/src/data-interfaces.d.ts +9 -3
- package/src/data-interfaces.js +4 -4
- package/src/data-providers/array-entity-data-provider.d.ts +2 -2
- package/src/data-providers/array-entity-data-provider.js +92 -14
- package/src/data-providers/in-memory-database.d.ts +5 -2
- package/src/data-providers/in-memory-database.js +22 -1
- package/src/data-providers/indexed-db-data-provider.d.ts +61 -0
- package/src/data-providers/indexed-db-data-provider.js +1459 -0
- package/src/data-providers/json-data-provider.js +2 -2
- package/src/data-providers/rest-data-provider.d.ts +2 -3
- package/src/data-providers/rest-data-provider.js +18 -17
- package/src/data-providers/sql-database.js +254 -105
- package/src/entity.d.ts +12 -0
- package/src/live-query/LiveQueryClient.js +87 -37
- package/src/live-query/SseSubscriptionClient.js +75 -44
- package/src/live-query/SubscriptionChannel.d.ts +12 -0
- package/src/live-query/SubscriptionChannel.js +36 -11
- package/src/remult3/RepositoryImplementation.d.ts +7 -4
- package/src/remult3/RepositoryImplementation.js +521 -326
- package/src/remult3/remult3.d.ts +7 -5
- package/src/remult3/remult3.js +2 -2
- package/src/sql-command.d.ts +2 -0
- 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(
|
|
53
|
-
return (this.p = this.p.then(() => this.loadEntityData((dp, save) => dp.delete(
|
|
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(
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return this.http().delete(this.url() + '/' + encodeURIComponent(
|
|
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(
|
|
315
|
-
.then((y) =>
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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} (${
|
|
538
|
+
let statement = `insert into ${e.$entityName} (${colSql}) values ${vals}`;
|
|
479
539
|
let { colKeys, select } = await this.buildSelect(e, r, undefined, undefined);
|
|
480
|
-
|
|
481
|
-
|
|
540
|
+
const source = this.sql._getSourceSql();
|
|
541
|
+
const wantRows = options?.select !== 'none';
|
|
542
|
+
if (!source.doesNotSupportReturningSyntax && wantRows)
|
|
482
543
|
statement += ' returning ' + select;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
197
|
+
async runKeepAlive() {
|
|
189
198
|
if (this.keepAliveInFlight)
|
|
190
199
|
return this.keepAliveInFlight;
|
|
191
|
-
this.keepAliveInFlight = this.sendKeepAlive(
|
|
200
|
+
this.keepAliveInFlight = this.sendKeepAlive().finally(() => {
|
|
192
201
|
this.keepAliveInFlight = undefined;
|
|
193
202
|
});
|
|
194
203
|
return this.keepAliveInFlight;
|
|
195
204
|
}
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
await
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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;
|