@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.
@@ -338,7 +338,7 @@ class Collection extends EventEmitter_1.default {
338
338
  callback();
339
339
  }),
340
340
  },
341
- bindEvents: (requery) => {
341
+ bindEvents: (requery, applyDelta) => {
342
342
  const handleRequery = () => {
343
343
  if (this.batchOperationInProgress) {
344
344
  this.postBatchCallbacks.add(requery);
@@ -346,13 +346,17 @@ class Collection extends EventEmitter_1.default {
346
346
  }
347
347
  requery();
348
348
  };
349
+ // A `transformAll` sits between the backend's result and what the cursor holds, and it
350
+ // is free to produce anything at all — so a delta describing the backend's result says
351
+ // nothing about the cursor's. Those collections keep comparing.
352
+ const canApplyDeltas = !this.options.transformAll && !options?.async;
349
353
  // register query if not yet registered
350
354
  const listeners = this.queryListeners({ selector, options });
351
355
  const didRegister = listeners === 0;
352
356
  if (didRegister)
353
357
  this.backend.registerQuery(selector, options || {});
354
358
  this.queryListeners({ selector, options }, listeners + 1);
355
- const queryStateChangeCleanup = this.backend.onQueryStateChange(selector, options || {}, (state) => {
359
+ const queryStateChangeCleanup = this.backend.onQueryStateChange(selector, options || {}, (state, delta) => {
356
360
  // A failed query never reaches `'complete'`, so the cursor keeps
357
361
  // serving its neutral empty value — indistinguishable from "no
358
362
  // data" for anyone reading it. Surfacing the failure as an event
@@ -367,6 +371,13 @@ class Collection extends EventEmitter_1.default {
367
371
  }
368
372
  if (state !== 'complete')
369
373
  return;
374
+ // Inside a batch the update is deferred to the end of it, by which point this delta
375
+ // is one of several and no longer describes the whole change — so the batch always
376
+ // ends in a comparison.
377
+ if (delta != null && canApplyDeltas && !this.batchOperationInProgress) {
378
+ applyDelta(delta);
379
+ return;
380
+ }
370
381
  handleRequery();
371
382
  });
372
383
  this.emit('observer.created', selector, options);
@@ -516,26 +527,30 @@ class Collection extends EventEmitter_1.default {
516
527
  if (!modifier)
517
528
  throw new Error('Invalid modifier');
518
529
  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
530
+ // Reading the item back before writing it is worth a round trip to the data layer only when
531
+ // something is waiting to inspect it: a validator gets to refuse the write, and it can only do
532
+ // that beforehand. Otherwise the backend's own answer says everything there is to know — what
533
+ // it returns is what changed, and an empty answer is what turns an upsert into an insert.
534
+ if (this.listenerCount('validate') > 0) {
535
+ const item = await this.getItem(selector, { async: true });
536
+ if (item != null)
537
+ this.emit('validate', (0, modify_1.default)((0, deepClone_1.default)(item), restModifier));
534
538
  }
535
- const modifiedItem = (0, modify_1.default)((0, deepClone_1.default)(item), restModifier);
536
- this.emit('validate', modifiedItem);
537
539
  const changes = await this.withPushState(() => this.backend.updateOne(selector, modifier));
538
- this.emit('changed', modifiedItem, restModifier);
540
+ if (changes.length === 0) {
541
+ if (!options?.upsert)
542
+ return 0; // no item found, and upsert is not enabled
543
+ const newItem = (0, modify_1.default)({}, {
544
+ ...restModifier,
545
+ $set: {
546
+ ...$setOnInsert,
547
+ ...restModifier.$set,
548
+ },
549
+ });
550
+ await this.insert(newItem);
551
+ return 1;
552
+ }
553
+ changes.forEach(item => this.emit('changed', item, restModifier));
539
554
  this.emit('updateOne', selector, modifier);
540
555
  this.executeInDebugMode(callstack => this.emit('_debug.updateOne', callstack, selector, modifier));
541
556
  return changes.length;
@@ -557,27 +572,27 @@ class Collection extends EventEmitter_1.default {
557
572
  if (!modifier)
558
573
  throw new Error('Invalid modifier');
559
574
  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
575
+ // See `updateOne`: the items are only fetched up front for the sake of a validator.
576
+ if (this.listenerCount('validate') > 0) {
577
+ const items = await this.getItems(selector, { async: true });
578
+ items.forEach((item) => {
579
+ this.emit('validate', (0, modify_1.default)((0, deepClone_1.default)(item), restModifier));
580
+ });
575
581
  }
576
- items.forEach((item) => {
577
- const modifiedItem = (0, modify_1.default)((0, deepClone_1.default)(item), restModifier);
578
- this.emit('validate', modifiedItem);
579
- });
580
582
  const changes = await this.withPushState(() => this.backend.updateMany(selector, modifier));
583
+ if (changes.length === 0) {
584
+ if (!options?.upsert)
585
+ return 0; // no items found, and upsert is not enabled
586
+ const newItem = (0, modify_1.default)({}, {
587
+ ...restModifier,
588
+ $set: {
589
+ ...$setOnInsert,
590
+ ...restModifier.$set,
591
+ },
592
+ });
593
+ await this.insert(newItem);
594
+ return 1;
595
+ }
581
596
  changes.forEach((item) => {
582
597
  this.emit('changed', item, restModifier);
583
598
  });
@@ -599,18 +614,20 @@ class Collection extends EventEmitter_1.default {
599
614
  throw new Error('Collection is disposed');
600
615
  if (!selector)
601
616
  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
617
+ // See `updateOne`: the item is only fetched up front for the sake of a validator.
618
+ if (this.listenerCount('validate') > 0) {
619
+ const item = await this.getItem(selector, { async: true });
620
+ if (item != null)
621
+ this.emit('validate', { id: item.id, ...replacement });
609
622
  }
610
- const modifiedItem = { id: item.id, ...replacement };
611
- this.emit('validate', modifiedItem);
612
623
  const changes = await this.withPushState(() => this.backend.replaceOne(selector, replacement));
613
- this.emit('changed', modifiedItem, replacement);
624
+ if (changes.length === 0) {
625
+ if (!options?.upsert)
626
+ return 0; // no item found, and upsert is not enabled
627
+ await this.insert(replacement);
628
+ return 1;
629
+ }
630
+ changes.forEach(item => this.emit('changed', item, replacement));
614
631
  this.emit('replaceOne', selector, replacement);
615
632
  this.executeInDebugMode(callstack => this.emit('_debug.replaceOne', callstack, selector, replacement));
616
633
  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;