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

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.
@@ -82,16 +82,25 @@ export default class AsyncDataAdapter implements DataAdapter {
82
82
  * @param qid - query id
83
83
  * @param state - new state
84
84
  * @param error - error if state is 'error', null otherwise
85
+ * @param delta - what changed about the result, when that is known
85
86
  */
86
87
  private publishState;
87
88
  private publishResult;
89
+ private queryItemIds;
88
90
  private getIndexInfo;
89
91
  private queryItems;
90
92
  private executeQuery;
91
93
  /**
92
- * After mutations, recompute and push updates for affected active queries
94
+ * After mutations, bring every affected active query up to date.
95
+ *
96
+ * A query whose previous result is enough to answer the change is brought up to date from that
97
+ * result alone — no round trip to the storage, and no detour through `'active'`, because there is
98
+ * no window in which the query is stale. Only a query the change cannot be reasoned about
99
+ * locally — a window onto a larger set, or one that has never been answered — goes back to the
100
+ * store, and that one gets the same retry and reporting behaviour a freshly registered query
101
+ * gets: a refresh that fails silently leaves exactly the same dead cursor.
93
102
  * @param collectionName - name of the collection
94
- * @param affectedItems - item states before and/or after the mutation
103
+ * @param changes - the items the write created, updated or removed
95
104
  */
96
105
  private checkQueryUpdates;
97
106
  private insert;
@@ -12,7 +12,9 @@ const isEqual_1 = __importDefault(require("./utils/isEqual"));
12
12
  const getIndexInfo_1 = __importDefault(require("./getIndexInfo"));
13
13
  const getMatchingKeys_1 = __importDefault(require("./utils/getMatchingKeys"));
14
14
  const sortItems_1 = __importDefault(require("./utils/sortItems"));
15
- const project_1 = __importDefault(require("./utils/project"));
15
+ const projectItems_1 = __importDefault(require("./utils/projectItems"));
16
+ const incrementalQueryUpdate_1 = __importDefault(require("./utils/incrementalQueryUpdate"));
17
+ const queryDelta_1 = require("./utils/queryDelta");
16
18
  /**
17
19
  * Carries the context needed to act on a failed query. The bare storage error
18
20
  * on its own does not say which collection or selector produced it, which made
@@ -35,6 +37,27 @@ class QueryError extends Error {
35
37
  }
36
38
  }
37
39
  exports.QueryError = QueryError;
40
+ /**
41
+ * Turns the item states a write produced into the upsert/delete split a query update needs.
42
+ *
43
+ * The two lists are not symmetric: an item that is still there after the write is described by its
44
+ * new state, while an item that is gone — removed, or given a new id — is described by the id it
45
+ * used to have and nothing else. Mixing the states from before and after a write into one list
46
+ * loses exactly that distinction.
47
+ * @template T - The type of the items.
48
+ * @param previousItems - The items as they were before the write.
49
+ * @param modifiedItems - The items as they are after it.
50
+ * @returns The changeset describing the write.
51
+ */
52
+ function toChangeset(previousItems, modifiedItems) {
53
+ const modifiedIds = new Set(modifiedItems.map(item => item.id));
54
+ return {
55
+ upserts: modifiedItems,
56
+ deletes: previousItems
57
+ .map(item => item.id)
58
+ .filter(id => !modifiedIds.has(id)),
59
+ };
60
+ }
38
61
  const DEFAULT_RETRY_ATTEMPTS = 3;
39
62
  const defaultRetryDelay = (attempt) => 100 * (4 ** (attempt - 1));
40
63
  const wait = (ms) => new Promise((resolve) => {
@@ -254,8 +277,14 @@ class AsyncDataAdapter {
254
277
  return;
255
278
  try {
256
279
  const items = await this.executeQuery(collectionName, selector, options);
280
+ const rec = this.queries.get(collectionName)?.get(qid);
281
+ // A query answered for the first time has no previous result to be relative to; whoever is
282
+ // waiting on it holds nothing yet and needs the whole thing.
283
+ const delta = rec?.answered
284
+ ? (0, queryDelta_1.diffQueryResults)(rec.items, items)
285
+ : undefined;
257
286
  this.publishResult(collectionName, qid, items);
258
- this.publishState(collectionName, qid, 'complete', null);
287
+ this.publishState(collectionName, qid, 'complete', null, delta);
259
288
  return;
260
289
  }
261
290
  catch (error) {
@@ -274,8 +303,9 @@ class AsyncDataAdapter {
274
303
  * @param qid - query id
275
304
  * @param state - new state
276
305
  * @param error - error if state is 'error', null otherwise
306
+ * @param delta - what changed about the result, when that is known
277
307
  */
278
- publishState(collectionName, qid, state, error) {
308
+ publishState(collectionName, qid, state, error, delta) {
279
309
  const rec = this.queries.get(collectionName)?.get(qid);
280
310
  if (!rec)
281
311
  return;
@@ -289,7 +319,7 @@ class AsyncDataAdapter {
289
319
  const subscribers = [...rec.listeners];
290
320
  for (const callback of subscribers) {
291
321
  try {
292
- callback(state);
322
+ (0, queryDelta_1.callWithDelta)(callback, state, delta);
293
323
  }
294
324
  catch (error_) {
295
325
  this.onError(error_);
@@ -301,6 +331,13 @@ class AsyncDataAdapter {
301
331
  if (!rec)
302
332
  return;
303
333
  rec.items = items;
334
+ rec.itemIds = undefined;
335
+ rec.answered = true;
336
+ }
337
+ queryItemIds(rec) {
338
+ if (!rec.itemIds)
339
+ rec.itemIds = new Set(rec.items.map(item => item.id));
340
+ return rec.itemIds;
304
341
  }
305
342
  async getIndexInfo(collectionName, selector) {
306
343
  const storageAdapter = this.storageAdapters.get(collectionName);
@@ -395,37 +432,60 @@ class AsyncDataAdapter {
395
432
  const sorted = sort ? (0, sortItems_1.default)(items, sort) : items;
396
433
  const skipped = skip ? sorted.slice(skip) : sorted;
397
434
  const limited = limit ? skipped.slice(0, limit) : skipped;
398
- const idExcluded = fields && fields.id === 0;
399
- return limited.map((item) => {
400
- if (!fields)
401
- return item;
402
- return { ...(idExcluded ? {} : { id: item.id }), ...(0, project_1.default)(item, fields) };
403
- });
435
+ return (0, projectItems_1.default)(limited, fields);
404
436
  }
405
437
  /**
406
- * After mutations, recompute and push updates for affected active queries
438
+ * After mutations, bring every affected active query up to date.
439
+ *
440
+ * A query whose previous result is enough to answer the change is brought up to date from that
441
+ * result alone — no round trip to the storage, and no detour through `'active'`, because there is
442
+ * no window in which the query is stale. Only a query the change cannot be reasoned about
443
+ * locally — a window onto a larger set, or one that has never been answered — goes back to the
444
+ * store, and that one gets the same retry and reporting behaviour a freshly registered query
445
+ * gets: a refresh that fails silently leaves exactly the same dead cursor.
407
446
  * @param collectionName - name of the collection
408
- * @param affectedItems - item states before and/or after the mutation
447
+ * @param changes - the items the write created, updated or removed
409
448
  */
410
- async checkQueryUpdates(collectionName, affectedItems) {
449
+ async checkQueryUpdates(collectionName, changes) {
411
450
  const registry = this.queries.get(collectionName);
412
451
  if (!registry)
413
452
  throw new Error(`Collection ${collectionName} not initialized!`);
414
453
  if (registry.size === 0)
415
454
  return;
416
- // Find active queries matching an item state before or after the mutation
417
- const affected = [...registry.values()].filter(({ selector }) => affectedItems.some(item => (0, match_1.default)(item, selector)));
455
+ if (changes.upserts.length === 0 && changes.deletes.length === 0)
456
+ return;
457
+ // A query is affected when the write produces something it should hold, or takes away
458
+ // something it already holds. The second half is what the written items cannot tell us on
459
+ // their own: an item that no longer matches, or that is gone, is invisible to the matcher.
460
+ const affected = [...registry.values()].filter((rec) => {
461
+ const ids = this.queryItemIds(rec);
462
+ if (changes.deletes.some(id => ids.has(id)))
463
+ return true;
464
+ return changes.upserts.some(item => ids.has(item.id) || (0, match_1.default)(item, rec.selector));
465
+ });
418
466
  if (affected.length === 0)
419
467
  return;
420
- // Mark active and notify…
421
- for (const { selector, options } of affected) {
468
+ const needsReExecution = [];
469
+ for (const rec of affected) {
470
+ const { selector, options } = rec;
471
+ const incremental = rec.answered
472
+ ? (0, incrementalQueryUpdate_1.default)(rec.items, selector, options, changes)
473
+ : null;
474
+ if (incremental == null) {
475
+ needsReExecution.push(rec);
476
+ continue;
477
+ }
422
478
  const qid = (0, queryId_1.default)(selector, options);
423
- this.publishState(collectionName, qid, 'active', null);
479
+ const delta = (0, queryDelta_1.diffQueryResults)(rec.items, incremental);
480
+ if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
481
+ continue;
482
+ this.publishResult(collectionName, qid, incremental);
483
+ this.publishState(collectionName, qid, 'complete', null, delta);
424
484
  }
425
- // …then recompute and complete, with the same retry/report behaviour a
426
- // freshly registered query gets — a mutation-triggered refresh that fails
427
- // silently leaves exactly the same dead cursor.
428
- await Promise.all(affected.map(({ selector, options }) => this.runQuery(collectionName, selector, options)));
485
+ for (const { selector, options } of needsReExecution) {
486
+ this.publishState(collectionName, (0, queryId_1.default)(selector, options), 'active', null);
487
+ }
488
+ await Promise.all(needsReExecution.map(({ selector, options }) => this.runQuery(collectionName, selector, options)));
429
489
  }
430
490
  async insert(collectionName, newItem) {
431
491
  const storage = this.storageAdapters.get(collectionName);
@@ -435,7 +495,7 @@ class AsyncDataAdapter {
435
495
  if (existingItems.length > 0)
436
496
  throw new Error(`Item with id ${String(newItem.id)} already exists`);
437
497
  await storage.insert([newItem]);
438
- await this.checkQueryUpdates(collectionName, [newItem]);
498
+ await this.checkQueryUpdates(collectionName, { upserts: [newItem], deletes: [] });
439
499
  return newItem;
440
500
  }
441
501
  async updateOne(collectionName, selector, modifier) {
@@ -454,7 +514,7 @@ class AsyncDataAdapter {
454
514
  throw new Error(`Item with id ${String(modified.id)} already exists`);
455
515
  }
456
516
  await storage.replace([modified]);
457
- await this.checkQueryUpdates(collectionName, [item, modified]);
517
+ await this.checkQueryUpdates(collectionName, toChangeset([item], [modified]));
458
518
  return [modified];
459
519
  }
460
520
  async updateMany(collectionName, selector, modifier) {
@@ -475,7 +535,7 @@ class AsyncDataAdapter {
475
535
  return modified;
476
536
  }));
477
537
  await storage.replace(changed);
478
- await this.checkQueryUpdates(collectionName, [...items, ...changed]);
538
+ await this.checkQueryUpdates(collectionName, toChangeset(items, changed));
479
539
  return changed;
480
540
  }
481
541
  async replaceOne(collectionName, selector, replacement) {
@@ -493,7 +553,7 @@ class AsyncDataAdapter {
493
553
  throw new Error(`Item with id ${String(modified.id)} already exists`);
494
554
  }
495
555
  await storage.replace([modified]);
496
- await this.checkQueryUpdates(collectionName, [item, modified]);
556
+ await this.checkQueryUpdates(collectionName, toChangeset([item], [modified]));
497
557
  return [modified];
498
558
  }
499
559
  async removeOne(collectionName, selector) {
@@ -505,7 +565,7 @@ class AsyncDataAdapter {
505
565
  if (item == null)
506
566
  return [];
507
567
  await storage.remove([item]);
508
- await this.checkQueryUpdates(collectionName, [item]);
568
+ await this.checkQueryUpdates(collectionName, { upserts: [], deletes: [item.id] });
509
569
  return [item];
510
570
  }
511
571
  async removeMany(collectionName, selector) {
@@ -516,7 +576,10 @@ class AsyncDataAdapter {
516
576
  if (items.length === 0)
517
577
  return [];
518
578
  await storage.remove(items);
519
- await this.checkQueryUpdates(collectionName, items);
579
+ await this.checkQueryUpdates(collectionName, {
580
+ upserts: [],
581
+ deletes: items.map(item => item.id),
582
+ });
520
583
  return items;
521
584
  }
522
585
  }
@@ -1,4 +1,5 @@
1
1
  import type ReactivityAdapter from '../types/ReactivityAdapter';
2
+ import type { QueryDelta } from '../utils/queryDelta';
2
3
  import type { BaseItem, FindOptions, Transform } from './types';
3
4
  import type { ObserveCallbacks } from './Observer';
4
5
  /**
@@ -25,7 +26,7 @@ export interface QueryStateAccessor {
25
26
  }
26
27
  export interface CursorOptions<T extends BaseItem, U = T, Async extends boolean = false> extends FindOptions<T, Async> {
27
28
  transform?: Transform<T, U>;
28
- bindEvents?: (requery: () => void) => () => void;
29
+ bindEvents?: (requery: () => void, applyDelta: (delta: QueryDelta<T>) => void) => () => void;
29
30
  queryState?: QueryStateAccessor;
30
31
  }
31
32
  /**
@@ -152,4 +153,13 @@ export default class Cursor<T extends BaseItem, U = T, Async extends boolean = f
152
153
  * criteria have changed, and you want to ensure the cursor reflects the latest state.
153
154
  */
154
155
  requery(): void;
156
+ /**
157
+ * Brings the cursor up to date from a description of what changed, rather than by re-running the
158
+ * query and comparing the result with the previous one.
159
+ *
160
+ * Falls back to `requery` when the delta does not fit the result the cursor currently holds, so
161
+ * a caller never has to decide which of the two is safe.
162
+ * @param delta - The change to apply.
163
+ */
164
+ applyDelta(delta: QueryDelta<T>): void;
155
165
  }
@@ -142,7 +142,11 @@ class Cursor {
142
142
  const requery = () => {
143
143
  observer.runChecks(this.getItems);
144
144
  };
145
- const cleanup = this.options.bindEvents && this.options.bindEvents(requery);
145
+ const applyDelta = (delta) => {
146
+ observer.applyDelta(delta, this.getItems);
147
+ };
148
+ const cleanup = this.options.bindEvents
149
+ && this.options.bindEvents(requery, applyDelta);
146
150
  return () => {
147
151
  if (cleanup)
148
152
  cleanup();
@@ -331,5 +335,18 @@ class Cursor {
331
335
  return;
332
336
  this.observer.runChecks(this.getItems);
333
337
  }
338
+ /**
339
+ * Brings the cursor up to date from a description of what changed, rather than by re-running the
340
+ * query and comparing the result with the previous one.
341
+ *
342
+ * Falls back to `requery` when the delta does not fit the result the cursor currently holds, so
343
+ * a caller never has to decide which of the two is safe.
344
+ * @param delta - The change to apply.
345
+ */
346
+ applyDelta(delta) {
347
+ if (!this.observer)
348
+ return;
349
+ this.observer.applyDelta(delta, this.getItems);
350
+ }
334
351
  }
335
352
  exports.default = Cursor;
@@ -1,3 +1,4 @@
1
+ import type { QueryDelta } from '../utils/queryDelta';
1
2
  type AddedCallback<T> = (item: T) => void;
2
3
  type AddedBeforeCallback<T> = (item: T, before: T) => void;
3
4
  type ChangedCallback<T> = (item: T) => void;
@@ -42,6 +43,36 @@ export default class Observer<T extends {
42
43
  * @param getItems - A function that returns a promise resolving to the new items or the items themselves.
43
44
  */
44
45
  runChecks(getItems: () => Promise<T[]> | T[]): void;
46
+ /**
47
+ * Brings the observer up to date from a description of what changed, instead of from the new
48
+ * result.
49
+ *
50
+ * `runChecks` has to rediscover the change by comparing the whole new result against the whole
51
+ * old one — a cost proportional to the result, paid on every write, to find out that one row
52
+ * moved. When the change is already known it can simply be reported, and the cost becomes
53
+ * proportional to the change.
54
+ *
55
+ * The delta must have been computed against exactly the result this observer holds. If it was
56
+ * not, this reports nothing, falls back to `runChecks`, and returns `false`.
57
+ *
58
+ * Note that the reported moves are minimal, where a comparison reports every item whose
59
+ * neighbour changed. Applying them yields the same order either way — there are simply fewer of
60
+ * them.
61
+ * @param delta - The change to report.
62
+ * @param getItems - Used to fall back to a comparison when the delta cannot be applied.
63
+ * @returns Whether the delta was applied.
64
+ */
65
+ applyDelta(delta: QueryDelta<T>, getItems: () => Promise<T[]> | T[]): boolean;
66
+ /**
67
+ * Reports a delta and adopts the result it produces.
68
+ *
69
+ * The single place the callbacks are fired from, whether the change arrived as a delta or was
70
+ * found by comparing two results — so the two can never disagree about what a consumer is told.
71
+ * @param delta - The change to report.
72
+ * @param nextItems - The result the delta produces.
73
+ */
74
+ private emitDelta;
75
+ private finishCheck;
45
76
  private checkItems;
46
77
  private stopped;
47
78
  /**
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const isEqual_1 = __importDefault(require("../utils/isEqual"));
7
7
  const uniqueBy_1 = __importDefault(require("../utils/uniqueBy"));
8
+ const queryDelta_1 = require("../utils/queryDelta");
8
9
  /**
9
10
  * Represents an observer that tracks changes in a collection of items and triggers
10
11
  * callbacks for various events such as addition, removal, and modification of items.
@@ -74,58 +75,97 @@ class Observer {
74
75
  this.checkItems(result);
75
76
  }
76
77
  }
77
- checkItems(newItems) {
78
- const oldItemsMap = new Map(this.previousItems.map((item, index) => [
79
- item.id,
80
- { item, index, beforeItem: this.previousItems[index + 1] || null },
81
- ]));
82
- const newItemsMap = new Map(newItems.map((item, index) => [
83
- item.id,
84
- { item, index, beforeItem: newItems[index + 1] || null },
85
- ]));
86
- if (this.hasCallbacks(['changed', 'changedField', 'movedBefore', 'removed'])) {
87
- // Check for removed or changed items
88
- oldItemsMap.forEach(({ item: oldItem, index, beforeItem: oldBeforeItem }) => {
89
- const newItem = newItemsMap.get(oldItem.id);
90
- if (newItem) {
91
- if (this.hasCallbacks(['changed', 'changedField']) // If the item exists but has changed, call 'changed' callback
92
- && !(0, isEqual_1.default)(newItem.item, oldItem)) {
93
- this.call('changed', newItem.item);
94
- if (this.hasCallbacks(['changedField'])) {
95
- // check for changed fields and call 'changedField' callback
96
- const keys = (0, uniqueBy_1.default)([
97
- ...Object.keys(newItem.item),
98
- ...Object.keys(oldItem),
99
- ], value => value);
100
- keys.forEach((key) => {
101
- if ((0, isEqual_1.default)(newItem.item[key], oldItem[key]))
102
- return;
103
- this.call('changedField', newItem.item, key, oldItem[key], newItem.item[key]);
104
- });
105
- }
106
- }
107
- // If the item's beforeItem has changed, call 'movedBefore' callback
108
- if (newItem.index !== index && newItem.beforeItem?.id !== oldBeforeItem?.id) {
109
- this.call('movedBefore', newItem.item, newItem.beforeItem);
110
- }
111
- }
112
- else {
113
- // If the item no longer exists, call 'removed' callback
78
+ /**
79
+ * Brings the observer up to date from a description of what changed, instead of from the new
80
+ * result.
81
+ *
82
+ * `runChecks` has to rediscover the change by comparing the whole new result against the whole
83
+ * old one a cost proportional to the result, paid on every write, to find out that one row
84
+ * moved. When the change is already known it can simply be reported, and the cost becomes
85
+ * proportional to the change.
86
+ *
87
+ * The delta must have been computed against exactly the result this observer holds. If it was
88
+ * not, this reports nothing, falls back to `runChecks`, and returns `false`.
89
+ *
90
+ * Note that the reported moves are minimal, where a comparison reports every item whose
91
+ * neighbour changed. Applying them yields the same order either way — there are simply fewer of
92
+ * them.
93
+ * @param delta - The change to report.
94
+ * @param getItems - Used to fall back to a comparison when the delta cannot be applied.
95
+ * @returns Whether the delta was applied.
96
+ */
97
+ applyDelta(delta, getItems) {
98
+ if (!(0, queryDelta_1.canApplyQueryDelta)(this.previousItems, delta)) {
99
+ this.runChecks(getItems);
100
+ return false;
101
+ }
102
+ if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
103
+ return true;
104
+ this.emitDelta(delta, (0, queryDelta_1.applyQueryDelta)(this.previousItems, delta));
105
+ return true;
106
+ }
107
+ /**
108
+ * Reports a delta and adopts the result it produces.
109
+ *
110
+ * The single place the callbacks are fired from, whether the change arrived as a delta or was
111
+ * found by comparing two results — so the two can never disagree about what a consumer is told.
112
+ * @param delta - The change to report.
113
+ * @param nextItems - The result the delta produces.
114
+ */
115
+ emitDelta(delta, nextItems) {
116
+ if (this.isEmpty()) {
117
+ this.finishCheck(nextItems);
118
+ return;
119
+ }
120
+ const beforeOf = (index) => nextItems[index + 1] || null;
121
+ // Indexing the previous result costs its whole length, and most deltas do not need it: an item
122
+ // that changed is reported by its new value, and only a listener asking which *field* changed,
123
+ // or one asking about a removal, needs to see what was there before.
124
+ const needsPreviousItems = (delta.removed.length > 0 && this.hasCallbacks(['removed']))
125
+ || (delta.changed.length > 0 && this.hasCallbacks(['changedField']));
126
+ const previousById = needsPreviousItems
127
+ ? new Map(this.previousItems.map(item => [item.id, item]))
128
+ : null;
129
+ if (this.hasCallbacks(['changed', 'changedField'])) {
130
+ delta.changed.forEach((item) => {
131
+ this.call('changed', item);
132
+ if (!this.hasCallbacks(['changedField']))
133
+ return;
134
+ const oldItem = previousById?.get(item.id);
135
+ if (!oldItem)
136
+ return;
137
+ const keys = (0, uniqueBy_1.default)([
138
+ ...Object.keys(item),
139
+ ...Object.keys(oldItem),
140
+ ], value => value);
141
+ keys.forEach((key) => {
142
+ if ((0, isEqual_1.default)(item[key], oldItem[key]))
143
+ return;
144
+ this.call('changedField', item, key, oldItem[key], item[key]);
145
+ });
146
+ });
147
+ }
148
+ if (this.hasCallbacks(['removed'])) {
149
+ delta.removed.forEach((id) => {
150
+ const oldItem = previousById?.get(id);
151
+ if (oldItem)
114
152
  this.call('removed', oldItem);
115
- }
116
153
  });
117
154
  }
118
155
  if (this.hasCallbacks(['added', 'addedBefore'])) {
119
- // Check for added items
120
- newItems.forEach((newItem, index) => {
121
- const oldItem = oldItemsMap.get(newItem.id);
122
- if (oldItem)
123
- return;
124
- // If the item is newly added, call 'added' and 'addedBefore' callbacks
125
- this.call('added', newItem);
126
- this.call('addedBefore', newItem, newItems[index + 1] || null);
156
+ delta.added.forEach(({ index, item }) => {
157
+ this.call('added', item);
158
+ this.call('addedBefore', item, beforeOf(index));
127
159
  });
128
160
  }
161
+ if (this.hasCallbacks(['movedBefore'])) {
162
+ delta.moved.forEach(({ index }) => {
163
+ this.call('movedBefore', nextItems[index], beforeOf(index));
164
+ });
165
+ }
166
+ this.finishCheck(nextItems);
167
+ }
168
+ finishCheck(newItems) {
129
169
  // Store new items as previous items for next check
130
170
  this.previousItems = newItems;
131
171
  Object.keys(this.callbacks).forEach((key) => {
@@ -140,6 +180,12 @@ class Observer {
140
180
  }));
141
181
  });
142
182
  }
183
+ checkItems(newItems) {
184
+ // Derives the change and reports it through the same path a change that arrived ready-made
185
+ // takes. Comparing and then reporting item by item, as this used to, meant the two paths could
186
+ // describe the same change differently — most visibly in how many moves they reported.
187
+ this.emitDelta((0, queryDelta_1.diffQueryResults)(this.previousItems, newItems), newItems);
188
+ }
143
189
  stopped = false;
144
190
  /**
145
191
  * Stops the observer by unbinding all events and cleaning up resources.