@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.
@@ -6,10 +6,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const queryId_1 = __importDefault(require("./utils/queryId"));
7
7
  const randomId_1 = __importDefault(require("./utils/randomId"));
8
8
  const batchOnNextTick_1 = __importDefault(require("./utils/batchOnNextTick"));
9
- const applyQueryOptions_1 = __importDefault(require("./utils/applyQueryOptions"));
9
+ const incrementalQueryUpdate_1 = require("./utils/incrementalQueryUpdate");
10
+ const queryDelta_1 = require("./utils/queryDelta");
10
11
  const match_1 = __importDefault(require("./utils/match"));
11
12
  const modify_1 = __importDefault(require("./utils/modify"));
12
13
  const deepClone_1 = __importDefault(require("./utils/deepClone"));
14
+ // The ids a selector names outright, or `null` when it asks something the ids alone cannot answer.
15
+ // Deliberately strict: one key, `id`, holding a primitive or a lone `$in`. Anything else — another
16
+ // field alongside it, an operator, a nested condition — falls back to matching, because guessing
17
+ // wrong here would silently drop a row from a write.
18
+ /**
19
+ * Extracts the ids a selector names outright.
20
+ * @param selector - The selector to inspect.
21
+ * @returns The named ids, or `null` when the selector asks more than ids can answer.
22
+ */
23
+ function selectorIds(selector) {
24
+ if (selector == null || typeof selector !== 'object')
25
+ return null;
26
+ const keys = Object.keys(selector);
27
+ if (keys.length !== 1 || keys[0] !== 'id')
28
+ return null;
29
+ const value = selector.id;
30
+ if (value == null)
31
+ return null;
32
+ if (typeof value !== 'object')
33
+ return [value];
34
+ const valueKeys = Object.keys(value);
35
+ if (valueKeys.length !== 1 || valueKeys[0] !== '$in')
36
+ return null;
37
+ const inValues = value.$in;
38
+ return Array.isArray(inValues) ? inValues : null;
39
+ }
13
40
  class WorkerDataAdapter {
14
41
  worker;
15
42
  options;
@@ -20,6 +47,10 @@ class WorkerDataAdapter {
20
47
  collectionReady = new Map();
21
48
  batchExecutionHelpers = new Map();
22
49
  queries = {};
50
+ // Resolvers for `exec` calls that are still waiting for their response, keyed by message id.
51
+ // Together with the query registry above this is everything the shared dispatcher needs to route
52
+ // a message, which is why there is no longer a listener per request or per query.
53
+ pendingRequests = new Map();
23
54
  // Writes that have been issued but not yet confirmed by the worker. Their
24
55
  // effect is layered on top of each active query's last authoritative result
25
56
  // in `getQueryResult`, so a cursor reflects a write immediately instead of
@@ -33,6 +64,13 @@ class WorkerDataAdapter {
33
64
  // rollback.
34
65
  pendingWrites = new Map();
35
66
  pendingWriteSeq = 0;
67
+ // Bumped whenever a collection's pending writes change, in either direction. Anything derived
68
+ // from them is stale from that moment on.
69
+ pendingWriteVersions = new Map();
70
+ bumpPendingWriteVersion(collectionName) {
71
+ const current = this.pendingWriteVersions.get(collectionName) ?? 0;
72
+ this.pendingWriteVersions.set(collectionName, current + 1);
73
+ }
36
74
  constructor(worker, options) {
37
75
  this.worker = worker;
38
76
  this.options = options;
@@ -43,18 +81,115 @@ class WorkerDataAdapter {
43
81
  const timeoutId = setTimeout(() => {
44
82
  reject(new Error('WorkerDataAdapter initialization timed out'));
45
83
  }, 5000);
46
- const handleMessage = (event) => {
47
- const { type, workerId } = event.data;
48
- if (workerId !== this.id)
49
- return;
50
- if (type === 'ready') {
51
- resolve();
52
- clearTimeout(timeoutId);
53
- this.worker.removeEventListener('message', handleMessage);
54
- }
84
+ this.resolveWorkerReady = () => {
85
+ clearTimeout(timeoutId);
86
+ resolve();
55
87
  };
56
- this.worker.addEventListener('message', handleMessage);
57
88
  });
89
+ this.worker.addEventListener('message', this.handleWorkerMessage);
90
+ }
91
+ resolveWorkerReady = () => { };
92
+ // The one and only message listener this adapter installs. Every response and every query update
93
+ // is routed from here by a map lookup. Listening per request and per query instead meant each
94
+ // incoming message was offered to every listener in turn, and each of them re-serialized its own
95
+ // selector to decide the message was not for it — turning a write that touches N queries into
96
+ // N² selector serializations before any of the actual work started.
97
+ handleWorkerMessage = (event) => {
98
+ const message = event.data;
99
+ if (message == null)
100
+ return;
101
+ if (message.workerId !== this.id)
102
+ return;
103
+ if (message.type === 'ready') {
104
+ this.resolveWorkerReady();
105
+ return;
106
+ }
107
+ if (message.type === 'response') {
108
+ if (message.id == null)
109
+ return;
110
+ const pending = this.pendingRequests.get(message.id);
111
+ if (!pending)
112
+ return;
113
+ this.pendingRequests.delete(message.id);
114
+ this.log('response', message.data ?? message.error);
115
+ if (message.error) {
116
+ pending.reject(message.error);
117
+ }
118
+ else {
119
+ pending.resolve(message.data);
120
+ }
121
+ return;
122
+ }
123
+ if (message.type === 'queryUpdate')
124
+ this.handleQueryUpdate(message.data, message.error ?? null);
125
+ };
126
+ handleQueryUpdate(data, error) {
127
+ if (data == null)
128
+ return;
129
+ const { collectionName, qid, selector, options, state, items, delta, } = data;
130
+ if (collectionName == null)
131
+ return;
132
+ const collectionQueries = this.queries[collectionName];
133
+ if (!collectionQueries)
134
+ return;
135
+ // The host names the query outright; deriving the id from the selector is only for messages
136
+ // that predate that (and for tests that hand-roll one).
137
+ const id = qid ?? (selector === undefined ? undefined : (0, queryId_1.default)(selector, options));
138
+ if (id == null)
139
+ return;
140
+ const query = collectionQueries.get(id);
141
+ if (!query)
142
+ return;
143
+ this.log('queryUpdate', query.selector, query.options, state, data ?? error);
144
+ let nextItems = items;
145
+ let deltaToPublish;
146
+ if (delta != null) {
147
+ // A delta only makes sense against the result it was computed from. If this adapter is
148
+ // holding something else — a message lost, a query re-registered underneath, a host and an
149
+ // adapter that disagree — applying it anyway would leave a result that silently drifts from
150
+ // the store. Refusing it keeps the last coherent result instead, and the next full answer
151
+ // puts things right.
152
+ if (!(0, queryDelta_1.canApplyQueryDelta)(query.items, delta))
153
+ return;
154
+ if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
155
+ return;
156
+ const pendingBefore = this.flattenPendingWrites(collectionName);
157
+ const servedBefore = pendingBefore == null
158
+ ? null
159
+ : this.servedResult(collectionName, query);
160
+ nextItems = (0, queryDelta_1.applyQueryDelta)(query.items, delta);
161
+ if (servedBefore == null) {
162
+ // Nothing is layered on top of the stored result, so what the host described is exactly
163
+ // what a reader of this query will see change.
164
+ deltaToPublish = delta;
165
+ }
166
+ else {
167
+ // A write is still in flight, and its effect has been shown to readers all along. What
168
+ // they see change is the difference between the two layered results — which, for the
169
+ // ordinary case of the host confirming the write that is in flight, is nothing at all.
170
+ this.updateQuery(collectionName, {
171
+ selector: query.selector,
172
+ options: query.options,
173
+ }, { state, error, items: nextItems });
174
+ const stored = collectionQueries.get(id);
175
+ if (!stored)
176
+ return;
177
+ const servedDelta = (0, queryDelta_1.diffQueryResults)(servedBefore, this.servedResult(collectionName, stored));
178
+ if ((0, queryDelta_1.isEmptyQueryDelta)(servedDelta) && state === query.state)
179
+ return;
180
+ stored.stateChangeCallbacks
181
+ .forEach(callback => (0, queryDelta_1.callWithDelta)(callback, state, servedDelta));
182
+ return;
183
+ }
184
+ }
185
+ this.updateQuery(collectionName, {
186
+ selector: query.selector,
187
+ options: query.options,
188
+ }, { state, error, items: nextItems });
189
+ const updated = collectionQueries.get(id);
190
+ if (!updated)
191
+ return;
192
+ updated.stateChangeCallbacks.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, state, deltaToPublish));
58
193
  }
59
194
  async exec(method, collectionName, ...args) {
60
195
  await this.workerReady;
@@ -69,24 +204,7 @@ class WorkerDataAdapter {
69
204
  }
70
205
  return new Promise((resolve, reject) => {
71
206
  const messageId = (0, randomId_1.default)();
72
- const handleMessage = (event) => {
73
- const { id, workerId, type, data, error } = event.data;
74
- if (workerId !== this.id)
75
- return;
76
- if (type !== 'response')
77
- return;
78
- if (id !== messageId)
79
- return;
80
- this.log(method, 'result', data ?? error);
81
- if (error) {
82
- reject(error);
83
- }
84
- else {
85
- resolve(data);
86
- }
87
- this.worker.removeEventListener('message', handleMessage);
88
- };
89
- this.worker.addEventListener('message', handleMessage);
207
+ this.pendingRequests.set(messageId, { resolve, reject });
90
208
  this.worker.postMessage({
91
209
  id: messageId,
92
210
  workerId: this.id,
@@ -95,30 +213,156 @@ class WorkerDataAdapter {
95
213
  });
96
214
  });
97
215
  }
98
- // The items an active query currently holds, deduplicated by id — the only
99
- // items this adapter knows about, and the set a selector-based write can be
100
- // resolved against locally.
101
- observableItems(collectionName) {
102
- const byId = new Map();
103
- this.queries[collectionName]?.forEach((query) => {
104
- this.mergePendingWrites(collectionName, query.items).forEach((item) => {
105
- byId.set(item.id, item);
106
- });
216
+ /**
217
+ * Issues a call whose result nobody is waiting for, and makes sure a failure has somewhere to
218
+ * go. A bare rejection here would surface as an uncaught error — which is what a disposed
219
+ * collection produced every time a cursor was cleaned up after it.
220
+ * @param method - The method to call on the worker.
221
+ * @param collectionName - The collection it applies to.
222
+ * @param args - The remaining arguments.
223
+ * @param onError - Called when the call fails, in place of merely logging it.
224
+ */
225
+ execInBackground(method, collectionName, args = [], onError) {
226
+ this.exec(method, collectionName, ...args).catch((error) => {
227
+ if (onError) {
228
+ onError(error);
229
+ return;
230
+ }
231
+ this.log(method, 'failed', error);
107
232
  });
108
- return [...byId.values()];
109
233
  }
110
- mergePendingWrites(collectionName, items) {
234
+ queryItemsById(query) {
235
+ if (!query.itemsById) {
236
+ query.itemsById = new Map(query.items.map(item => [item.id, item]));
237
+ }
238
+ return query.itemsById;
239
+ }
240
+ // The pending writes of a collection collapsed into one upsert/delete view, newest write winning.
241
+ // `null` when there are none, which is the overwhelmingly common case and the one every caller
242
+ // below short-circuits on. Pending sets are tiny — a write or two in flight — so this is cheap in
243
+ // a way that touching each query's items is not.
244
+ flattenPendingWrites(collectionName) {
111
245
  const pending = this.pendingWrites.get(collectionName);
112
246
  if (!pending || pending.size === 0)
113
- return items;
114
- const merged = new Map(items.map(item => [item.id, item]));
247
+ return null;
248
+ const upserts = new Map();
249
+ const deletes = new Set();
115
250
  [...pending.entries()]
116
251
  .sort(([a], [b]) => a - b) // eslint-disable-line unicorn/no-array-sort -- unavailable on Hermes
117
252
  .forEach(([, write]) => {
118
- write.upserts.forEach((item, id) => merged.set(id, item));
119
- write.deletes.forEach(id => merged.delete(id));
253
+ write.upserts.forEach((item, id) => {
254
+ upserts.set(id, item);
255
+ deletes.delete(id);
256
+ });
257
+ write.deletes.forEach((id) => {
258
+ deletes.add(id);
259
+ upserts.delete(id);
260
+ });
261
+ });
262
+ return { upserts, deletes };
263
+ }
264
+ // The items an active query currently holds, deduplicated by id, plus whatever the pending writes
265
+ // add or remove — the only items this adapter knows about, and the set a selector-based write is
266
+ // resolved against locally. One pass over the queries rather than a merge per query.
267
+ // Whether a query's result is the items themselves rather than a projection of them. A write is
268
+ // resolved locally by applying its modifier to the item this adapter holds, and applying it to an
269
+ // item that has had fields removed produces something that is not the item — one that a selector
270
+ // naming a projected-away field no longer matches, so the row would vanish from every other
271
+ // query until the store answered. An item known only through a projection is therefore treated as
272
+ // not known at all: the write still happens, it simply is not shown before the store confirms it.
273
+ static providesFullItems(query) {
274
+ return query.options?.fields == null;
275
+ }
276
+ observableItems(collectionName) {
277
+ const byId = new Map();
278
+ this.queries[collectionName]?.forEach((query) => {
279
+ if (!WorkerDataAdapter.providesFullItems(query))
280
+ return;
281
+ query.items.forEach(item => byId.set(item.id, item));
282
+ });
283
+ const pending = this.flattenPendingWrites(collectionName);
284
+ if (pending) {
285
+ pending.upserts.forEach((item, id) => byId.set(id, item));
286
+ pending.deletes.forEach(id => byId.delete(id));
287
+ }
288
+ return [...byId.values()];
289
+ }
290
+ // The same answer as `observableItems` restricted to known ids — for the selector shapes that
291
+ // name them (`{ id }`, `{ id: { $in } }`), which is what an ordinary `updateOne`/`removeOne`
292
+ // carries. Costs a handful of map lookups instead of materialising every active query's result
293
+ // and running the matcher over all of it.
294
+ observableItemsByIds(collectionName, ids) {
295
+ const pending = this.flattenPendingWrites(collectionName);
296
+ const found = new Map();
297
+ ids.forEach((id) => {
298
+ if (pending?.deletes.has(id))
299
+ return;
300
+ const pendingItem = pending?.upserts.get(id);
301
+ if (pendingItem) {
302
+ found.set(id, pendingItem);
303
+ return;
304
+ }
305
+ const queries = this.queries[collectionName];
306
+ if (!queries)
307
+ return;
308
+ for (const query of queries.values()) {
309
+ if (!WorkerDataAdapter.providesFullItems(query))
310
+ continue;
311
+ const item = this.queryItemsById(query).get(id);
312
+ if (item) {
313
+ found.set(id, item);
314
+ return;
315
+ }
316
+ }
317
+ });
318
+ return [...found.values()];
319
+ }
320
+ // Whether any pending write changes what this query would return. Answered against the pending
321
+ // set (small) and the query's id index, never by walking its items: a query no in-flight write
322
+ // touches — nearly all of them, nearly always — keeps its own array, so its readers skip both the
323
+ // merge and the re-filtering that would follow it.
324
+ // What `getQueryResult` answers: the query's last confirmed result with whatever writes are still
325
+ // in flight folded into it. Only the items those writes touch are examined — the rest matched
326
+ // when the store produced them and are carried over untouched, which is both what makes this cost
327
+ // the size of the pending writes rather than the size of the result, and what keeps a projected
328
+ // result from being re-matched against fields its projection has already dropped.
329
+ servedResult(collectionName, query) {
330
+ const pendingVersion = this.pendingWriteVersions.get(collectionName) ?? 0;
331
+ if (query.served
332
+ && query.served.fromItems === query.items
333
+ && query.served.pendingVersion === pendingVersion) {
334
+ return query.served.items;
335
+ }
336
+ const items = this.computeServedResult(collectionName, query);
337
+ query.served = { items, fromItems: query.items, pendingVersion };
338
+ return items;
339
+ }
340
+ computeServedResult(collectionName, query) {
341
+ const pending = this.flattenPendingWrites(collectionName);
342
+ if (!pending)
343
+ return query.items;
344
+ const byId = this.queryItemsById(query);
345
+ let affected = false;
346
+ pending.deletes.forEach((id) => {
347
+ if (byId.has(id))
348
+ affected = true;
349
+ });
350
+ if (!affected) {
351
+ pending.upserts.forEach((item, id) => {
352
+ if (affected)
353
+ return;
354
+ if (byId.has(id))
355
+ affected = true;
356
+ else if (query.selector != null && (0, match_1.default)(item, query.selector))
357
+ affected = true;
358
+ });
359
+ }
360
+ if (!affected)
361
+ return query.items;
362
+ return (0, incrementalQueryUpdate_1.mergeChangesetIntoResult)(query.items, query.selector, query.options, {
363
+ upserts: [...pending.upserts.values()],
364
+ deletes: [...pending.deletes],
120
365
  });
121
- return [...merged.values()];
122
366
  }
123
367
  /**
124
368
  * Registers a write's effect locally and notifies every active query it
@@ -132,6 +376,11 @@ class WorkerDataAdapter {
132
376
  applyPendingWrite(collectionName, upserts, deletes) {
133
377
  if (upserts.length === 0 && deletes.length === 0)
134
378
  return () => { };
379
+ const affectedIds = new Set([...upserts.map(item => item.id), ...deletes]);
380
+ const affected = this.affectedQueries(collectionName, upserts, affectedIds);
381
+ // Captured before the write is registered, so the notification below can say what actually
382
+ // changed for a reader rather than just that something did.
383
+ const servedBefore = this.servedResults(collectionName, affected);
135
384
  const seq = this.pendingWriteSeq += 1;
136
385
  const pending = this.pendingWrites.get(collectionName)
137
386
  ?? new Map();
@@ -140,36 +389,70 @@ class WorkerDataAdapter {
140
389
  deletes: new Set(deletes),
141
390
  });
142
391
  this.pendingWrites.set(collectionName, pending);
143
- const affectedIds = new Set([...upserts.map(item => item.id), ...deletes]);
144
- this.notifyAffectedQueries(collectionName, upserts, affectedIds);
392
+ this.bumpPendingWriteVersion(collectionName);
393
+ this.notifyWithDeltas(collectionName, affected, servedBefore);
145
394
  return () => {
146
395
  const current = this.pendingWrites.get(collectionName);
147
396
  if (!current)
148
397
  return;
398
+ const affectedOnDrop = this.affectedQueries(collectionName, upserts, affectedIds);
399
+ const beforeDrop = this.servedResults(collectionName, affectedOnDrop);
149
400
  current.delete(seq);
150
401
  if (current.size === 0)
151
402
  this.pendingWrites.delete(collectionName);
152
- this.notifyAffectedQueries(collectionName, upserts, affectedIds);
403
+ this.bumpPendingWriteVersion(collectionName);
404
+ // By the time a write settles the host's own answer has usually already landed, so dropping
405
+ // the optimistic copy changes nothing a reader can see and produces no notification at all.
406
+ this.notifyWithDeltas(collectionName, affectedOnDrop, beforeDrop);
153
407
  };
154
408
  }
155
- // Re-runs the state-change callbacks of every query whose result the write
156
- // can have changed either because a written item matches its selector, or
157
- // because it already held one of the affected items (an update that moves an
158
- // item out of a query, or a removal).
159
- notifyAffectedQueries(collectionName, upserts, affectedIds) {
409
+ // The queries whose result the write can have changed either because a written item matches
410
+ // their selector, or because they already hold one of the affected items (an update that moves
411
+ // an item out of a query, or a removal).
412
+ affectedQueries(collectionName, upserts, affectedIds) {
413
+ const affected = [];
160
414
  this.queries[collectionName]?.forEach((query) => {
161
- const wasHolding = query.items.some(item => affectedIds.has(item.id));
415
+ const byId = this.queryItemsById(query);
416
+ let wasHolding = false;
417
+ affectedIds.forEach((id) => {
418
+ if (byId.has(id))
419
+ wasHolding = true;
420
+ });
162
421
  const nowMatches = upserts.some(item => query.selector != null
163
422
  && (0, match_1.default)(item, query.selector));
164
423
  if (!wasHolding && !nowMatches)
165
424
  return;
166
- query.stateChangeCallbacks.forEach(callback => callback(query.state));
425
+ affected.push(query);
426
+ });
427
+ return affected;
428
+ }
429
+ servedResults(collectionName, queries) {
430
+ return new Map(queries.map(query => [query, this.servedResult(collectionName, query)]));
431
+ }
432
+ // Tells each query what changed for someone reading it, and says nothing to a query where the
433
+ // answer is the same as before. A reader that has to re-run the query to find that out pays for
434
+ // the whole result to learn nothing.
435
+ notifyWithDeltas(collectionName, queries, servedBefore) {
436
+ queries.forEach((query) => {
437
+ const before = servedBefore.get(query);
438
+ if (before == null)
439
+ return;
440
+ const delta = (0, queryDelta_1.diffQueryResults)(before, this.servedResult(collectionName, query));
441
+ if ((0, queryDelta_1.isEmptyQueryDelta)(delta))
442
+ return;
443
+ query.stateChangeCallbacks.forEach(callback => (0, queryDelta_1.callWithDelta)(callback, query.state, delta));
167
444
  });
168
445
  }
169
446
  matchObservableItems(collectionName, selector, onlyFirst) {
170
447
  if (selector == null)
171
448
  return [];
172
- const matches = this.observableItems(collectionName).filter(item => (0, match_1.default)(item, selector));
449
+ // `updateOne({ id })` and `removeOne({ id })` are what an application writes most of the time,
450
+ // and they name exactly the rows they touch — no reason to materialise every active query's
451
+ // result and run the matcher over all of it to find them.
452
+ const ids = selectorIds(selector);
453
+ const matches = ids == null
454
+ ? this.observableItems(collectionName).filter(item => (0, match_1.default)(item, selector))
455
+ : this.observableItemsByIds(collectionName, ids);
173
456
  return onlyFirst ? matches.slice(0, 1) : matches;
174
457
  }
175
458
  resolveUpdate(collectionName, selector, modifier, onlyFirst) {
@@ -224,18 +507,22 @@ class WorkerDataAdapter {
224
507
  options: query.options,
225
508
  state: 'active',
226
509
  error: null,
227
- items: [],
228
510
  stateChangeCallbacks: [],
229
- eventHandler: existing?.eventHandler,
230
511
  ...existing,
231
512
  ...update,
513
+ // An update that says nothing about the items leaves them alone. A query going back to
514
+ // `'active'` while it is recomputed is exactly that, and letting it blank the result would
515
+ // leave every reader of this query with nothing to show until the recomputation lands.
516
+ ...update.items
517
+ ? { items: update.items, itemsById: undefined }
518
+ : { items: existing?.items ?? [] },
232
519
  };
233
520
  collectionQueries.set(id, newState);
234
521
  this.queries[collectionName] = collectionQueries;
235
522
  }
236
523
  createCollectionBackend(collection, indices) {
237
524
  this.queries[collection.name] = new Map();
238
- void this.exec('registerCollection', collection.name, indices);
525
+ this.execInBackground('registerCollection', collection.name, [indices]);
239
526
  this.collectionReady.set(collection.name, this.exec('isReady', collection.name));
240
527
  this.batchExecutionHelpers.set(collection.name, (0, batchOnNextTick_1.default)(async (method, args) => this.exec(method, collection.name, args)));
241
528
  return {
@@ -263,41 +550,24 @@ class WorkerDataAdapter {
263
550
  // methods for registering and unregistering queries that will be called from the collection during find/findOne
264
551
  registerQuery: (selector, options) => {
265
552
  this.updateQuery(collection.name, { selector, options }, { state: 'active', error: null, items: [] });
266
- void this.exec('registerQuery', collection.name, selector, options);
267
- const handler = (event) => {
268
- const { type, data, workerId, error } = event.data;
269
- if (type !== 'queryUpdate')
270
- return;
271
- if (data == null)
272
- return;
273
- const { collectionName, selector: responseSelector, options: responseOptions, state, items, } = data;
274
- if (workerId !== this.id)
275
- return;
276
- if (collectionName !== collection.name)
277
- return;
278
- if ((0, queryId_1.default)(responseSelector, responseOptions) !== (0, queryId_1.default)(selector, options))
279
- return;
280
- this.log('queryUpdate', responseSelector, responseOptions, state, data ?? error);
281
- this.updateQuery(collection.name, {
282
- selector: responseSelector,
283
- options: responseOptions,
284
- }, { state, error, items });
553
+ // A query the worker could not register will never answer. Left as a bare rejection it
554
+ // would surface as an uncaught error and the cursor would sit on its empty result forever,
555
+ // indistinguishable from a query with nothing to show; published as an error it reaches
556
+ // the collection's `query.error` event, which is what that event is for.
557
+ this.execInBackground('registerQuery', collection.name, [selector, options], (error) => {
285
558
  const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
286
559
  if (!query)
287
560
  return;
288
- query.stateChangeCallbacks.forEach(callback => callback(state));
289
- };
290
- this.worker.addEventListener('message', handler);
291
- this.updateQuery(collection.name, { selector, options }, { eventHandler: handler });
561
+ this.updateQuery(collection.name, { selector, options }, { state: 'error', error });
562
+ query.stateChangeCallbacks.forEach(callback => callback('error'));
563
+ });
292
564
  },
293
565
  unregisterQuery: (selector, options) => {
294
- const qid = (0, queryId_1.default)(selector, options);
295
- const query = this.queries[collection.name]?.get(qid);
296
- if (query?.eventHandler) {
297
- this.worker.removeEventListener('message', query.eventHandler);
298
- }
299
- this.queries[collection.name]?.delete(qid);
300
- void this.exec('unregisterQuery', collection.name, selector, options);
566
+ this.queries[collection.name]?.delete((0, queryId_1.default)(selector, options));
567
+ // Nothing holds the query any more, so a failure here has nobody to report to — but it
568
+ // still must not escape as an uncaught error, which is what a disposed collection would
569
+ // otherwise produce every time a cursor was cleaned up after it.
570
+ this.execInBackground('unregisterQuery', collection.name, [selector, options]);
301
571
  },
302
572
  getQueryState: (selector, options) => {
303
573
  const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
@@ -311,10 +581,7 @@ class WorkerDataAdapter {
311
581
  const query = this.queries[collection.name]?.get((0, queryId_1.default)(selector, options));
312
582
  if (!query)
313
583
  return [];
314
- const pending = this.pendingWrites.get(collection.name);
315
- if (!pending || pending.size === 0)
316
- return query.items;
317
- return (0, applyQueryOptions_1.default)(this.mergePendingWrites(collection.name, query.items), selector, options);
584
+ return this.servedResult(collection.name, query);
318
585
  },
319
586
  onQueryStateChange: (selector, options, callback) => {
320
587
  this.updateQuery(collection.name, { selector, options }, {
@@ -44,6 +44,8 @@ export default class WorkerDataAdapterHost<T extends BaseItem<I>, I = any> {
44
44
  private queryItems;
45
45
  private executeQuery;
46
46
  private ensureQuery;
47
+ private setQueryItems;
48
+ private queryItemIds;
47
49
  private emitQueryUpdate;
48
50
  private ensureStorageAdapter;
49
51
  private checkQueryUpdates;