@signaldb/svelte 2.0.0-beta.14 → 2.0.0-beta.15

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(callback) {
61
- Collection.batchOperationInProgress = true;
62
- const execute = () => Collection.collections.reduce((memo, collection) => () => {
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
- Collection.batchOperationInProgress = false;
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.
@@ -607,8 +607,16 @@ class WorkerDataAdapter {
607
607
  this.isDisposed = true;
608
608
  this.worker.terminate?.();
609
609
  },
610
+ // Awaits the promise `createCollectionBackend` already started rather
611
+ // than asking the worker again. Readiness happens once and never goes
612
+ // back, so a repeated question can only get the same answer — but it
613
+ // still cost a full round trip each time, and callers ask often: a
614
+ // repository helper that awaits `collection.ready()` before touching a
615
+ // record turns a thousand-record sync into a thousand extra messages.
616
+ // One app measured 2,273 `isReady` messages in a single session, more
617
+ // than any other message type it produced.
610
618
  isReady: async () => {
611
- await this.exec('isReady', collection.name);
619
+ await this.collectionReady.get(collection.name);
612
620
  },
613
621
  };
614
622
  }
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.14",
4
+ "version": "2.0.0-beta.15",
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",