@signaldb/svelte 2.0.0-beta.13 → 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.
@@ -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
  }
@@ -338,7 +405,7 @@ class Collection extends EventEmitter_1.default {
338
405
  callback();
339
406
  }),
340
407
  },
341
- bindEvents: (requery) => {
408
+ bindEvents: (requery, applyDelta) => {
342
409
  const handleRequery = () => {
343
410
  if (this.batchOperationInProgress) {
344
411
  this.postBatchCallbacks.add(requery);
@@ -346,13 +413,28 @@ class Collection extends EventEmitter_1.default {
346
413
  }
347
414
  requery();
348
415
  };
416
+ // A `transformAll` sits between the backend's result and what the cursor holds, and it
417
+ // is free to produce anything at all — so a delta describing the backend's result says
418
+ // nothing about the cursor's. Those collections keep comparing.
419
+ const canApplyDeltas = !this.options.transformAll && !options?.async;
349
420
  // register query if not yet registered
350
421
  const listeners = this.queryListeners({ selector, options });
351
422
  const didRegister = listeners === 0;
352
423
  if (didRegister)
353
424
  this.backend.registerQuery(selector, options || {});
354
425
  this.queryListeners({ selector, options }, listeners + 1);
355
- const queryStateChangeCleanup = this.backend.onQueryStateChange(selector, options || {}, (state) => {
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);
437
+ const queryStateChangeCleanup = this.backend.onQueryStateChange(selector, options || {}, (state, delta) => {
356
438
  // A failed query never reaches `'complete'`, so the cursor keeps
357
439
  // serving its neutral empty value — indistinguishable from "no
358
440
  // data" for anyone reading it. Surfacing the failure as an event
@@ -367,6 +449,14 @@ class Collection extends EventEmitter_1.default {
367
449
  }
368
450
  if (state !== 'complete')
369
451
  return;
452
+ this.reportIfLargeQuery(selector, options, registrationStack);
453
+ // Inside a batch the update is deferred to the end of it, by which point this delta
454
+ // is one of several and no longer describes the whole change — so the batch always
455
+ // ends in a comparison.
456
+ if (delta != null && canApplyDeltas && !this.batchOperationInProgress) {
457
+ applyDelta(delta);
458
+ return;
459
+ }
370
460
  handleRequery();
371
461
  });
372
462
  this.emit('observer.created', selector, options);
@@ -516,26 +606,30 @@ class Collection extends EventEmitter_1.default {
516
606
  if (!modifier)
517
607
  throw new Error('Invalid modifier');
518
608
  const { $setOnInsert, ...restModifier } = modifier;
519
- const item = await this.getItem(selector, { async: true });
520
- if (item == null) {
521
- if (options?.upsert) {
522
- // if upsert is enabled, insert a new item
523
- const newItem = (0, modify_1.default)({}, {
524
- ...restModifier,
525
- $set: {
526
- ...$setOnInsert,
527
- ...restModifier.$set,
528
- },
529
- });
530
- await this.insert(newItem);
531
- return 1;
532
- }
533
- return 0; // no item found, and upsert is not enabled
609
+ // Reading the item back before writing it is worth a round trip to the data layer only when
610
+ // something is waiting to inspect it: a validator gets to refuse the write, and it can only do
611
+ // that beforehand. Otherwise the backend's own answer says everything there is to know — what
612
+ // it returns is what changed, and an empty answer is what turns an upsert into an insert.
613
+ if (this.listenerCount('validate') > 0) {
614
+ const item = await this.getItem(selector, { async: true });
615
+ if (item != null)
616
+ this.emit('validate', (0, modify_1.default)((0, deepClone_1.default)(item), restModifier));
534
617
  }
535
- const modifiedItem = (0, modify_1.default)((0, deepClone_1.default)(item), restModifier);
536
- this.emit('validate', modifiedItem);
537
618
  const changes = await this.withPushState(() => this.backend.updateOne(selector, modifier));
538
- this.emit('changed', modifiedItem, restModifier);
619
+ if (changes.length === 0) {
620
+ if (!options?.upsert)
621
+ return 0; // no item found, and upsert is not enabled
622
+ const newItem = (0, modify_1.default)({}, {
623
+ ...restModifier,
624
+ $set: {
625
+ ...$setOnInsert,
626
+ ...restModifier.$set,
627
+ },
628
+ });
629
+ await this.insert(newItem);
630
+ return 1;
631
+ }
632
+ changes.forEach(item => this.emit('changed', item, restModifier));
539
633
  this.emit('updateOne', selector, modifier);
540
634
  this.executeInDebugMode(callstack => this.emit('_debug.updateOne', callstack, selector, modifier));
541
635
  return changes.length;
@@ -557,27 +651,27 @@ class Collection extends EventEmitter_1.default {
557
651
  if (!modifier)
558
652
  throw new Error('Invalid modifier');
559
653
  const { $setOnInsert, ...restModifier } = modifier;
560
- const items = await this.getItems(selector, { async: true });
561
- if (items.length === 0) {
562
- if (options?.upsert) {
563
- // if upsert is enabled, insert a new item
564
- const newItem = (0, modify_1.default)({}, {
565
- ...restModifier,
566
- $set: {
567
- ...$setOnInsert,
568
- ...restModifier.$set,
569
- },
570
- });
571
- await this.insert(newItem);
572
- return 1;
573
- }
574
- return 0; // no items found, and upsert is not enabled
654
+ // See `updateOne`: the items are only fetched up front for the sake of a validator.
655
+ if (this.listenerCount('validate') > 0) {
656
+ const items = await this.getItems(selector, { async: true });
657
+ items.forEach((item) => {
658
+ this.emit('validate', (0, modify_1.default)((0, deepClone_1.default)(item), restModifier));
659
+ });
575
660
  }
576
- items.forEach((item) => {
577
- const modifiedItem = (0, modify_1.default)((0, deepClone_1.default)(item), restModifier);
578
- this.emit('validate', modifiedItem);
579
- });
580
661
  const changes = await this.withPushState(() => this.backend.updateMany(selector, modifier));
662
+ if (changes.length === 0) {
663
+ if (!options?.upsert)
664
+ return 0; // no items found, and upsert is not enabled
665
+ const newItem = (0, modify_1.default)({}, {
666
+ ...restModifier,
667
+ $set: {
668
+ ...$setOnInsert,
669
+ ...restModifier.$set,
670
+ },
671
+ });
672
+ await this.insert(newItem);
673
+ return 1;
674
+ }
581
675
  changes.forEach((item) => {
582
676
  this.emit('changed', item, restModifier);
583
677
  });
@@ -599,18 +693,20 @@ class Collection extends EventEmitter_1.default {
599
693
  throw new Error('Collection is disposed');
600
694
  if (!selector)
601
695
  throw new Error('Invalid selector');
602
- const item = await this.getItem(selector, { async: true });
603
- if (item == null) {
604
- if (options?.upsert) {
605
- await this.insert(replacement);
606
- return 1;
607
- }
608
- return 0; // no item found, and upsert is not enabled
696
+ // See `updateOne`: the item is only fetched up front for the sake of a validator.
697
+ if (this.listenerCount('validate') > 0) {
698
+ const item = await this.getItem(selector, { async: true });
699
+ if (item != null)
700
+ this.emit('validate', { id: item.id, ...replacement });
609
701
  }
610
- const modifiedItem = { id: item.id, ...replacement };
611
- this.emit('validate', modifiedItem);
612
702
  const changes = await this.withPushState(() => this.backend.replaceOne(selector, replacement));
613
- this.emit('changed', modifiedItem, replacement);
703
+ if (changes.length === 0) {
704
+ if (!options?.upsert)
705
+ return 0; // no item found, and upsert is not enabled
706
+ await this.insert(replacement);
707
+ return 1;
708
+ }
709
+ changes.forEach(item => this.emit('changed', item, replacement));
614
710
  this.emit('replaceOne', selector, replacement);
615
711
  this.executeInDebugMode(callstack => this.emit('_debug.replaceOne', callstack, selector, replacement));
616
712
  return changes.length;
@@ -2,6 +2,7 @@ import type { BaseItem, FieldSpecifier, SortSpecifier } from './Collection';
2
2
  import type Collection from './Collection';
3
3
  import type Modifier from './types/Modifier';
4
4
  import type Selector from './types/Selector';
5
+ import type { QueryDelta } from './utils/queryDelta';
5
6
  export interface QueryOptions<T extends BaseItem> {
6
7
  /** Sort order (default: natural order) */
7
8
  sort?: SortSpecifier<T> | undefined;
@@ -12,7 +13,19 @@ export interface QueryOptions<T extends BaseItem> {
12
13
  /** Dictionary of fields to return or exclude. */
13
14
  fields?: FieldSpecifier<T> | undefined;
14
15
  }
15
- export type StateChangeCallback = (state: 'active' | 'complete' | 'error') => void;
16
+ /**
17
+ * Notified when a query's state changes.
18
+ *
19
+ * A `'complete'` notification may carry a delta describing how the result changed since the last
20
+ * one. An adapter that can produce one saves its listeners from rediscovering the change by
21
+ * comparing the whole result against the whole previous result; one that cannot simply omits it,
22
+ * and its listeners fall back to exactly that comparison.
23
+ *
24
+ * A delta is only ever passed when it is relative to what `getQueryResult` returned the last time
25
+ * it was asked. An adapter that layers anything on top of its stored result — an optimistic write
26
+ * still in flight, for instance — must omit the delta for as long as it does.
27
+ */
28
+ export type StateChangeCallback<T extends BaseItem = BaseItem> = (state: 'active' | 'complete' | 'error', delta?: QueryDelta<T>) => void;
16
29
  export interface CollectionBackend<T extends BaseItem<I>, I> {
17
30
  insert(item: T): Promise<T>;
18
31
  updateOne(selector: Selector<T>, modifier: Modifier<T>): Promise<T[]>;
@@ -32,7 +45,7 @@ export interface CollectionBackend<T extends BaseItem<I>, I> {
32
45
  getQueryError<O extends QueryOptions<T>>(selector: Selector<T>, options: O): Error | null;
33
46
  getQueryResult<O extends QueryOptions<T>>(selector: Selector<T>, options: O): T[];
34
47
  executeQuery<O extends QueryOptions<T>>(selector: Selector<T>, options: O): Promise<T[]>;
35
- onQueryStateChange<O extends QueryOptions<T>>(selector: Selector<T>, options: O, callback: StateChangeCallback): () => void;
48
+ onQueryStateChange<O extends QueryOptions<T>>(selector: Selector<T>, options: O, callback: StateChangeCallback<T>): () => void;
36
49
  dispose(): Promise<void>;
37
50
  isReady(): Promise<void>;
38
51
  }
@@ -10,7 +10,9 @@ const EventEmitter_1 = __importDefault(require("./utils/EventEmitter"));
10
10
  const isEqual_1 = __importDefault(require("./utils/isEqual"));
11
11
  const match_1 = __importDefault(require("./utils/match"));
12
12
  const modify_1 = __importDefault(require("./utils/modify"));
13
- const project_1 = __importDefault(require("./utils/project"));
13
+ const projectItems_1 = __importDefault(require("./utils/projectItems"));
14
+ const incrementalQueryUpdate_1 = __importDefault(require("./utils/incrementalQueryUpdate"));
15
+ const queryDelta_1 = require("./utils/queryDelta");
14
16
  const queryId_1 = __importDefault(require("./utils/queryId"));
15
17
  const serializeValue_1 = __importDefault(require("./utils/serializeValue"));
16
18
  const sortItems_1 = __importDefault(require("./utils/sortItems"));
@@ -142,15 +144,7 @@ class DefaultDataAdapter {
142
144
  const sorted = sort ? (0, sortItems_1.default)(items, sort) : items;
143
145
  const skipped = skip ? sorted.slice(skip) : sorted;
144
146
  const limited = limit ? skipped.slice(0, limit) : skipped;
145
- const idExcluded = fields && fields.id === 0;
146
- return limited.map((item) => {
147
- if (!fields)
148
- return item;
149
- return {
150
- ...idExcluded ? {} : { id: item.id },
151
- ...(0, project_1.default)(item, fields),
152
- };
153
- });
147
+ return (0, projectItems_1.default)(limited, fields);
154
148
  }
155
149
  flushQueuedQueryUpdates(collection) {
156
150
  if (!this.queuedQueryUpdates.get(collection.name))
@@ -159,6 +153,10 @@ class DefaultDataAdapter {
159
153
  if (!changes || !hasPendingUpdates(changes))
160
154
  return;
161
155
  this.queuedQueryUpdates.set(collection.name, { added: [], modified: [], removed: [] });
156
+ const changeset = {
157
+ upserts: [...changes.added, ...changes.modified],
158
+ deletes: changes.removed.map(item => item.id),
159
+ };
162
160
  const flatItems = [...changes.added, ...changes.modified, ...changes.removed];
163
161
  const itemIds = new Set(flatItems.map(i => i.id));
164
162
  const queries = [
@@ -173,17 +171,27 @@ class DefaultDataAdapter {
173
171
  return flatItems.some(item => (0, match_1.default)(item, selector));
174
172
  });
175
173
  queries.forEach(({ selector, options }) => {
176
- this.executeAndCacheQuery(collection, selector, options);
174
+ this.executeAndCacheQuery(collection, selector, options, changeset);
177
175
  });
178
176
  }
179
- executeAndCacheQuery(collection, selector, options) {
180
- const result = this.executeQuery(collection, selector, options);
177
+ executeAndCacheQuery(collection, selector, options, changes) {
178
+ const cached = this.cachedQueryResults.get(collection.name)?.get((0, queryId_1.default)(selector, options));
179
+ // With the change in hand and the previous result cached, the query can be brought up to date
180
+ // without walking every item this collection holds — and the listeners can be told what
181
+ // changed instead of being left to compare the two results themselves.
182
+ const incremental = changes && cached
183
+ ? (0, incrementalQueryUpdate_1.default)(cached, selector, options, changes)
184
+ : null;
185
+ const result = incremental ?? this.executeQuery(collection, selector, options);
186
+ const delta = cached ? (0, queryDelta_1.diffQueryResults)(cached, result) : undefined;
181
187
  this.cachedQueryResults.set(collection.name, this.cachedQueryResults.get(collection.name) || new Map());
182
188
  this.cachedQueryResults.get(collection.name)?.set((0, queryId_1.default)(selector, options), result);
183
189
  const emitter = this.queryEmitters.get(collection.name);
184
190
  if (!emitter)
185
191
  return;
186
- emitter.emit('change', selector, options, 'complete');
192
+ if (delta && (0, queryDelta_1.isEmptyQueryDelta)(delta))
193
+ return;
194
+ emitter.emit('change', selector, options, 'complete', delta);
187
195
  }
188
196
  updateQueries(collection, changes) {
189
197
  this.queuedQueryUpdates.set(collection.name, this.queuedQueryUpdates.get(collection.name) || { added: [], modified: [], removed: [] });
@@ -336,10 +344,10 @@ class DefaultDataAdapter {
336
344
  const emitter = this.queryEmitters.get(collection.name);
337
345
  if (!emitter)
338
346
  throw new Error(`Query emitter not found for collection ${collection.name}`);
339
- const handler = (querySelector, queryOptions, state) => {
347
+ const handler = (querySelector, queryOptions, state, delta) => {
340
348
  if ((0, queryId_1.default)(querySelector, queryOptions) !== (0, queryId_1.default)(selector, options))
341
349
  return;
342
- callback(state);
350
+ (0, queryDelta_1.callWithDelta)(callback, state, delta);
343
351
  };
344
352
  emitter.on('change', handler);
345
353
  return () => {
@@ -22,12 +22,33 @@ export default class WorkerDataAdapter implements DataAdapter {
22
22
  private collectionReady;
23
23
  private batchExecutionHelpers;
24
24
  private queries;
25
+ private pendingRequests;
25
26
  private pendingWrites;
26
27
  private pendingWriteSeq;
28
+ private pendingWriteVersions;
29
+ private bumpPendingWriteVersion;
27
30
  constructor(worker: WorkerDataAdapterEndpoint, options: WorkerDataAdapterOptions);
31
+ private resolveWorkerReady;
32
+ private handleWorkerMessage;
33
+ private handleQueryUpdate;
28
34
  private exec;
35
+ /**
36
+ * Issues a call whose result nobody is waiting for, and makes sure a failure has somewhere to
37
+ * go. A bare rejection here would surface as an uncaught error — which is what a disposed
38
+ * collection produced every time a cursor was cleaned up after it.
39
+ * @param method - The method to call on the worker.
40
+ * @param collectionName - The collection it applies to.
41
+ * @param args - The remaining arguments.
42
+ * @param onError - Called when the call fails, in place of merely logging it.
43
+ */
44
+ private execInBackground;
45
+ private queryItemsById;
46
+ private flattenPendingWrites;
47
+ private static providesFullItems;
29
48
  private observableItems;
30
- private mergePendingWrites;
49
+ private observableItemsByIds;
50
+ private servedResult;
51
+ private computeServedResult;
31
52
  /**
32
53
  * Registers a write's effect locally and notifies every active query it
33
54
  * touches, then returns a function that drops it again once the write
@@ -38,7 +59,9 @@ export default class WorkerDataAdapter implements DataAdapter {
38
59
  * @returns A function that drops the pending write and re-notifies.
39
60
  */
40
61
  private applyPendingWrite;
41
- private notifyAffectedQueries;
62
+ private affectedQueries;
63
+ private servedResults;
64
+ private notifyWithDeltas;
42
65
  private matchObservableItems;
43
66
  private resolveUpdate;
44
67
  private resolveRemoval;