@signaldb/svelte 2.0.0-beta.14 → 2.0.0-beta.16
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.
|
@@ -75,7 +75,22 @@ export default class Collection<T extends BaseItem<I> = BaseItem, I = any, E ext
|
|
|
75
75
|
private static fieldTracking;
|
|
76
76
|
private static onCreationCallbacks;
|
|
77
77
|
private static onDisposeCallbacks;
|
|
78
|
+
private static largeQueryWarningThreshold;
|
|
79
|
+
private static reportedLargeQueries;
|
|
78
80
|
static getCollections(): Collection<any, any, any, any>[];
|
|
81
|
+
/**
|
|
82
|
+
* Reports live queries whose result is larger than `rows`, once each, with
|
|
83
|
+
* the stack that registered them.
|
|
84
|
+
*
|
|
85
|
+
* A reactive query is re-evaluated whenever the data under it changes, and
|
|
86
|
+
* one registered from a long-lived place — a navigation bar, a provider
|
|
87
|
+
* near the root — keeps that cost for the lifetime of the application. There
|
|
88
|
+
* is otherwise nothing to see: the query works, and its price is only
|
|
89
|
+
* visible as an application that has grown slow. Finding one such query in a
|
|
90
|
+
* real app took a purpose-built profiler and the better part of a day.
|
|
91
|
+
* @param rows - Result size to report above, or `null` to switch the check off.
|
|
92
|
+
*/
|
|
93
|
+
static reportLargeQueries(rows: number | null): void;
|
|
79
94
|
static onCreation(callback: (collection: Collection<any>) => void): void;
|
|
80
95
|
static onDispose(callback: (collection: Collection<any>) => void): void;
|
|
81
96
|
/**
|
|
@@ -93,11 +108,27 @@ export default class Collection<T extends BaseItem<I> = BaseItem, I = any, E ext
|
|
|
93
108
|
* This improves performance by avoiding repetitive index recalculations and
|
|
94
109
|
* provides atomicity for the batch of operations.
|
|
95
110
|
* Supports both synchronous and asynchronous callbacks.
|
|
111
|
+
*
|
|
112
|
+
* **Without a `collections` argument this affects every collection in the
|
|
113
|
+
* process, not only the ones being written to.** Each of them defers every
|
|
114
|
+
* live query's requery until the batch ends. That is what makes a batch
|
|
115
|
+
* cheap for a handful of writes belonging to one event, and what makes it
|
|
116
|
+
* dangerous around a loop whose length is data-dependent: while it is open
|
|
117
|
+
* nothing anywhere updates, and everything deferred is flushed at once when
|
|
118
|
+
* it closes. One application wrapped a sync of roughly 1,100 records this
|
|
119
|
+
* way and its screens stopped resolving their data for the whole drain.
|
|
120
|
+
*
|
|
121
|
+
* Pass the collections being written to whenever that scope is known — it is
|
|
122
|
+
* both cheaper and safer. `Collection.batch([logs, versions], () => …)`
|
|
123
|
+
* defers those two and leaves everything else live.
|
|
124
|
+
* @param collections - The collections to batch. Omit to batch all of them.
|
|
96
125
|
* @param callback - The batch operation to execute.
|
|
97
126
|
* @returns A promise if the callback returns a promise, otherwise `void`.
|
|
98
127
|
*/
|
|
99
128
|
static batch<ReturnType>(callback: () => Promise<ReturnType>): Promise<void>;
|
|
100
129
|
static batch<ReturnType>(callback: () => ReturnType): void;
|
|
130
|
+
static batch<ReturnType>(collections: Collection<any, any, any, any>[], callback: () => Promise<ReturnType>): Promise<void>;
|
|
131
|
+
static batch<ReturnType>(collections: Collection<any, any, any, any>[], callback: () => ReturnType): void;
|
|
101
132
|
readonly name: string;
|
|
102
133
|
private backend;
|
|
103
134
|
private options;
|
|
@@ -132,6 +163,15 @@ export default class Collection<T extends BaseItem<I> = BaseItem, I = any, E ext
|
|
|
132
163
|
*/
|
|
133
164
|
constructor(options?: CollectionOptions<T, I, E, U>);
|
|
134
165
|
constructor(name: string, dataAdapter: DataAdapter, options?: CollectionOptions<T, I, E, U>);
|
|
166
|
+
/**
|
|
167
|
+
* Reports a live query the first time its result is found to be larger than
|
|
168
|
+
* the configured threshold. Once per query, because it re-runs on every
|
|
169
|
+
* write and a warning per write would be its own performance problem.
|
|
170
|
+
* @param selector - The query's selector.
|
|
171
|
+
* @param options - The query's options.
|
|
172
|
+
* @param registrationStack - Where the query was registered, if captured.
|
|
173
|
+
*/
|
|
174
|
+
private reportIfLargeQuery;
|
|
135
175
|
isBatchOperationInProgress(): boolean;
|
|
136
176
|
/**
|
|
137
177
|
* Checks whether the collection is currently performing a pull operation
|
|
@@ -29,9 +29,32 @@ class Collection extends EventEmitter_1.default {
|
|
|
29
29
|
static fieldTracking = false;
|
|
30
30
|
static onCreationCallbacks = [];
|
|
31
31
|
static onDisposeCallbacks = [];
|
|
32
|
+
// How many rows a live query may hold before it is reported as expensive.
|
|
33
|
+
// `null` disables the check, which is the default: it costs a stack capture
|
|
34
|
+
// per registered query, which is worth paying while developing and not in
|
|
35
|
+
// production. `enableDebugMode()` turns it on.
|
|
36
|
+
static largeQueryWarningThreshold = null;
|
|
37
|
+
static reportedLargeQueries = new Set();
|
|
32
38
|
static getCollections() {
|
|
33
39
|
return Collection.collections;
|
|
34
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Reports live queries whose result is larger than `rows`, once each, with
|
|
43
|
+
* the stack that registered them.
|
|
44
|
+
*
|
|
45
|
+
* A reactive query is re-evaluated whenever the data under it changes, and
|
|
46
|
+
* one registered from a long-lived place — a navigation bar, a provider
|
|
47
|
+
* near the root — keeps that cost for the lifetime of the application. There
|
|
48
|
+
* is otherwise nothing to see: the query works, and its price is only
|
|
49
|
+
* visible as an application that has grown slow. Finding one such query in a
|
|
50
|
+
* real app took a purpose-built profiler and the better part of a day.
|
|
51
|
+
* @param rows - Result size to report above, or `null` to switch the check off.
|
|
52
|
+
*/
|
|
53
|
+
static reportLargeQueries(rows) {
|
|
54
|
+
Collection.largeQueryWarningThreshold = rows;
|
|
55
|
+
if (rows == null)
|
|
56
|
+
Collection.reportedLargeQueries.clear();
|
|
57
|
+
}
|
|
35
58
|
static onCreation(callback) {
|
|
36
59
|
Collection.onCreationCallbacks.push(callback);
|
|
37
60
|
}
|
|
@@ -43,6 +66,12 @@ class Collection extends EventEmitter_1.default {
|
|
|
43
66
|
*/
|
|
44
67
|
static enableDebugMode = () => {
|
|
45
68
|
Collection.debugMode = true;
|
|
69
|
+
// A query large enough to matter is exactly the kind of thing debug mode
|
|
70
|
+
// exists to surface, and it is invisible otherwise. Call
|
|
71
|
+
// `reportLargeQueries()` afterwards to pick a different threshold or turn
|
|
72
|
+
// it off again.
|
|
73
|
+
if (Collection.largeQueryWarningThreshold == null)
|
|
74
|
+
Collection.reportLargeQueries(500);
|
|
46
75
|
Collection.collections.forEach((collection) => {
|
|
47
76
|
collection.setDebugMode(true);
|
|
48
77
|
});
|
|
@@ -57,13 +86,23 @@ class Collection extends EventEmitter_1.default {
|
|
|
57
86
|
collection.setFieldTracking(enable);
|
|
58
87
|
});
|
|
59
88
|
};
|
|
60
|
-
static batch(
|
|
61
|
-
|
|
62
|
-
const
|
|
89
|
+
static batch(collectionsOrCallback, maybeCallback) {
|
|
90
|
+
const scoped = Array.isArray(collectionsOrCallback);
|
|
91
|
+
const callback = (scoped ? maybeCallback : collectionsOrCallback);
|
|
92
|
+
if (typeof callback !== 'function')
|
|
93
|
+
throw new TypeError('Collection.batch requires a callback');
|
|
94
|
+
const collections = scoped ? collectionsOrCallback : Collection.collections;
|
|
95
|
+
// Only a batch that really covers every collection may claim the global
|
|
96
|
+
// flag; a scoped one must not make unrelated collections report themselves
|
|
97
|
+
// as batching through `isBatchOperationInProgress()`.
|
|
98
|
+
if (!scoped)
|
|
99
|
+
Collection.batchOperationInProgress = true;
|
|
100
|
+
const execute = () => collections.reduce((memo, collection) => () => {
|
|
63
101
|
return collection.batch(memo);
|
|
64
102
|
}, callback)();
|
|
65
103
|
const afterBatch = () => {
|
|
66
|
-
|
|
104
|
+
if (!scoped)
|
|
105
|
+
Collection.batchOperationInProgress = false;
|
|
67
106
|
};
|
|
68
107
|
let maybePromise;
|
|
69
108
|
try {
|
|
@@ -141,6 +180,34 @@ class Collection extends EventEmitter_1.default {
|
|
|
141
180
|
.catch(() => { });
|
|
142
181
|
Collection.onCreationCallbacks.forEach(callback => callback(this));
|
|
143
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Reports a live query the first time its result is found to be larger than
|
|
185
|
+
* the configured threshold. Once per query, because it re-runs on every
|
|
186
|
+
* write and a warning per write would be its own performance problem.
|
|
187
|
+
* @param selector - The query's selector.
|
|
188
|
+
* @param options - The query's options.
|
|
189
|
+
* @param registrationStack - Where the query was registered, if captured.
|
|
190
|
+
*/
|
|
191
|
+
reportIfLargeQuery(selector, options, registrationStack) {
|
|
192
|
+
const threshold = Collection.largeQueryWarningThreshold;
|
|
193
|
+
if (threshold == null)
|
|
194
|
+
return;
|
|
195
|
+
const id = `${this.name}:${(0, queryId_1.default)(selector, options)}`;
|
|
196
|
+
if (Collection.reportedLargeQueries.has(id))
|
|
197
|
+
return;
|
|
198
|
+
const rows = this.backend.getQueryResult(selector, options || {}).length;
|
|
199
|
+
if (rows <= threshold)
|
|
200
|
+
return;
|
|
201
|
+
Collection.reportedLargeQueries.add(id);
|
|
202
|
+
// The selector's *keys*, never its values: the shape is what identifies the
|
|
203
|
+
// problem — an empty one means the query holds the whole collection — and
|
|
204
|
+
// the values would put user data into a log.
|
|
205
|
+
const keys = selector && typeof selector === 'object' ? Object.keys(selector) : [];
|
|
206
|
+
// eslint-disable-next-line no-console
|
|
207
|
+
console.warn(`[SignalDB] Live query on "${this.name}" holds ${rows} rows `
|
|
208
|
+
+ `with selector {${keys.join(', ')}}. It is re-evaluated on every write to this `
|
|
209
|
+
+ `collection, for as long as it stays registered. ${registrationStack ?? ''}`);
|
|
210
|
+
}
|
|
144
211
|
isBatchOperationInProgress() {
|
|
145
212
|
return Collection.batchOperationInProgress || this.batchOperationInProgress;
|
|
146
213
|
}
|
|
@@ -356,6 +423,17 @@ class Collection extends EventEmitter_1.default {
|
|
|
356
423
|
if (didRegister)
|
|
357
424
|
this.backend.registerQuery(selector, options || {});
|
|
358
425
|
this.queryListeners({ selector, options }, listeners + 1);
|
|
426
|
+
// Captured at registration, not at completion: by the time the result
|
|
427
|
+
// arrives the stack is the adapter's, and the only useful thing to
|
|
428
|
+
// report is where the query was asked for.
|
|
429
|
+
const registrationStack = didRegister && Collection.largeQueryWarningThreshold != null
|
|
430
|
+
? new Error('query registered here').stack
|
|
431
|
+
: undefined;
|
|
432
|
+
// A synchronous adapter already holds the result here and never
|
|
433
|
+
// reports `'complete'`, so the check has to happen at both points.
|
|
434
|
+
// Reporting is once per query, which makes the overlap harmless.
|
|
435
|
+
if (didRegister)
|
|
436
|
+
this.reportIfLargeQuery(selector, options, registrationStack);
|
|
359
437
|
const queryStateChangeCleanup = this.backend.onQueryStateChange(selector, options || {}, (state, delta) => {
|
|
360
438
|
// A failed query never reaches `'complete'`, so the cursor keeps
|
|
361
439
|
// serving its neutral empty value — indistinguishable from "no
|
|
@@ -371,6 +449,7 @@ class Collection extends EventEmitter_1.default {
|
|
|
371
449
|
}
|
|
372
450
|
if (state !== 'complete')
|
|
373
451
|
return;
|
|
452
|
+
this.reportIfLargeQuery(selector, options, registrationStack);
|
|
374
453
|
// Inside a batch the update is deferred to the end of it, by which point this delta
|
|
375
454
|
// is one of several and no longer describes the whole change — so the batch always
|
|
376
455
|
// ends in a comparison.
|
|
@@ -148,11 +148,33 @@ class WorkerDataAdapter {
|
|
|
148
148
|
// holding something else — a message lost, a query re-registered underneath, a host and an
|
|
149
149
|
// adapter that disagree — applying it anyway would leave a result that silently drifts from
|
|
150
150
|
// the store. Refusing it keeps the last coherent result instead, and the next full answer
|
|
151
|
-
// puts things right.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
// puts things right. A delta describing no change at all is refused for the simpler reason
|
|
152
|
+
// that there is nothing to apply.
|
|
153
|
+
//
|
|
154
|
+
// The *state* the message carries is not part of that judgement, and used to be dropped
|
|
155
|
+
// along with the delta. A host that cannot answer a query from its previous result announces
|
|
156
|
+
// `'active'`, re-executes, and answers with a delta — which is empty whenever the write left
|
|
157
|
+
// this query's result as it was. The query then stayed `'active'` here for good, because no
|
|
158
|
+
// further message was coming to correct it: `Cursor#isLoading()` reported a first result
|
|
159
|
+
// still pending forever, and every screen gated on it showed its loading state forever.
|
|
160
|
+
const canApply = (0, queryDelta_1.canApplyQueryDelta)(query.items, delta);
|
|
161
|
+
if (!canApply || (0, queryDelta_1.isEmptyQueryDelta)(delta)) {
|
|
162
|
+
if (state === query.state)
|
|
163
|
+
return;
|
|
164
|
+
this.updateQuery(collectionName, {
|
|
165
|
+
selector: query.selector,
|
|
166
|
+
options: query.options,
|
|
167
|
+
}, { state, error });
|
|
168
|
+
const settled = collectionQueries.get(id);
|
|
169
|
+
if (!settled)
|
|
170
|
+
return;
|
|
171
|
+
// An empty delta is passed on as it came: it settles the query without asking anyone to
|
|
172
|
+
// recompute a result that did not change. One that could not be applied is not, so the
|
|
173
|
+
// consumer re-reads and the disagreement ends there.
|
|
174
|
+
settled.stateChangeCallbacks
|
|
175
|
+
.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, state, canApply ? delta : undefined));
|
|
155
176
|
return;
|
|
177
|
+
}
|
|
156
178
|
const pendingBefore = this.flattenPendingWrites(collectionName);
|
|
157
179
|
const servedBefore = pendingBefore == null
|
|
158
180
|
? null
|
|
@@ -607,8 +629,16 @@ class WorkerDataAdapter {
|
|
|
607
629
|
this.isDisposed = true;
|
|
608
630
|
this.worker.terminate?.();
|
|
609
631
|
},
|
|
632
|
+
// Awaits the promise `createCollectionBackend` already started rather
|
|
633
|
+
// than asking the worker again. Readiness happens once and never goes
|
|
634
|
+
// back, so a repeated question can only get the same answer — but it
|
|
635
|
+
// still cost a full round trip each time, and callers ask often: a
|
|
636
|
+
// repository helper that awaits `collection.ready()` before touching a
|
|
637
|
+
// record turns a thousand-record sync into a thousand extra messages.
|
|
638
|
+
// One app measured 2,273 `isReady` messages in a single session, more
|
|
639
|
+
// than any other message type it produced.
|
|
610
640
|
isReady: async () => {
|
|
611
|
-
await this.
|
|
641
|
+
await this.collectionReady.get(collection.name);
|
|
612
642
|
},
|
|
613
643
|
};
|
|
614
644
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signaldb/svelte",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.0.0-beta.
|
|
4
|
+
"version": "2.0.0-beta.16",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "tsc -d --noEmit false",
|
|
7
7
|
"analyze-bundle": "bundle-analyzer ./dist --upload-token=$BUNDLE_ANALYZER_UPLOAD_TOKEN --bundle-name=@signaldb/svelte",
|