@signaldb/svelte 2.0.0-beta.17 → 2.0.0-beta.18

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.
@@ -44,10 +44,15 @@ export default class WorkerDataAdapter implements DataAdapter {
44
44
  private execInBackground;
45
45
  private queryItemsById;
46
46
  private flattenPendingWrites;
47
+ private static pushPendingEntry;
48
+ private static dropPendingEntry;
49
+ private static writeFlatEntry;
47
50
  private static providesFullItems;
48
51
  private observableItems;
49
52
  private observableItemsByIds;
50
53
  private servedResult;
54
+ private advanceServedResult;
55
+ private changesetForIds;
51
56
  private computeServedResult;
52
57
  /**
53
58
  * Registers a write's effect locally and notifies every active query it
@@ -62,6 +62,11 @@ class WorkerDataAdapter {
62
62
  // own `queryUpdate` has already landed (it is posted before the write's
63
63
  // response, and message order is preserved), on failure dropping it is the
64
64
  // rollback.
65
+ //
66
+ // Held in three shapes, because each answers a different question cheaply and none of them
67
+ // answers all three: `writes` says which ids a settling write touched, `byId` says what an id
68
+ // looked like before the newest write touched it, and `flat` is the collapsed view every reader
69
+ // actually wants. They are maintained together, as writes arrive and settle.
65
70
  pendingWrites = new Map();
66
71
  pendingWriteSeq = 0;
67
72
  // Bumped whenever a collection's pending writes change, in either direction. Anything derived
@@ -261,27 +266,51 @@ class WorkerDataAdapter {
261
266
  }
262
267
  // The pending writes of a collection collapsed into one upsert/delete view, newest write winning.
263
268
  // `null` when there are none, which is the overwhelmingly common case and the one every caller
264
- // below short-circuits on. Pending sets are tiny a write or two in flight so this is cheap in
265
- // a way that touching each query's items is not.
269
+ // below short-circuits on. Maintained by the two helpers under it rather than rebuilt here, so
270
+ // this is a lookup whatever the size of the pending set — see `PendingWriteState`. The returned
271
+ // view is the live one: callers read it, never mutate it.
266
272
  flattenPendingWrites(collectionName) {
267
- const pending = this.pendingWrites.get(collectionName);
268
- if (!pending || pending.size === 0)
273
+ const state = this.pendingWrites.get(collectionName);
274
+ if (!state || state.writes.size === 0)
269
275
  return null;
270
- const upserts = new Map();
271
- const deletes = new Set();
272
- [...pending.entries()]
273
- .sort(([a], [b]) => a - b) // eslint-disable-line unicorn/no-array-sort -- unavailable on Hermes
274
- .forEach(([, write]) => {
275
- write.upserts.forEach((item, id) => {
276
- upserts.set(id, item);
277
- deletes.delete(id);
278
- });
279
- write.deletes.forEach((id) => {
280
- deletes.add(id);
281
- upserts.delete(id);
282
- });
283
- });
284
- return { upserts, deletes };
276
+ return state.flat;
277
+ }
278
+ // Records one id's contribution from the write being registered. `seq` only ever grows, so the
279
+ // new entry is by construction the newest one for this id and therefore the one that wins.
280
+ static pushPendingEntry(state, id, item, seq) {
281
+ const stack = state.byId.get(id);
282
+ if (stack)
283
+ stack.push({ seq, item });
284
+ else
285
+ state.byId.set(id, [{ seq, item }]);
286
+ WorkerDataAdapter.writeFlatEntry(state, id, item);
287
+ }
288
+ // Takes one id's contribution back out as its write settles, and restores whatever the write
289
+ // below it said — or removes the id entirely when that was the only one.
290
+ static dropPendingEntry(state, id, seq) {
291
+ const stack = state.byId.get(id);
292
+ if (!stack)
293
+ return;
294
+ const index = stack.findIndex(entry => entry.seq === seq);
295
+ if (index !== -1)
296
+ stack.splice(index, 1);
297
+ const top = stack.at(-1);
298
+ if (!top) {
299
+ state.byId.delete(id);
300
+ state.flat.upserts.delete(id);
301
+ state.flat.deletes.delete(id);
302
+ return;
303
+ }
304
+ WorkerDataAdapter.writeFlatEntry(state, id, top.item);
305
+ }
306
+ static writeFlatEntry(state, id, item) {
307
+ if (item === null) {
308
+ state.flat.deletes.add(id);
309
+ state.flat.upserts.delete(id);
310
+ return;
311
+ }
312
+ state.flat.upserts.set(id, item);
313
+ state.flat.deletes.delete(id);
285
314
  }
286
315
  // The items an active query currently holds, deduplicated by id, plus whatever the pending writes
287
316
  // add or remove — the only items this adapter knows about, and the set a selector-based write is
@@ -355,10 +384,60 @@ class WorkerDataAdapter {
355
384
  && query.served.pendingVersion === pendingVersion) {
356
385
  return query.served.items;
357
386
  }
358
- const items = this.computeServedResult(collectionName, query);
387
+ const items = this.advanceServedResult(collectionName, query, pendingVersion)
388
+ ?? this.computeServedResult(collectionName, query);
359
389
  query.served = { items, fromItems: query.items, pendingVersion };
360
390
  return items;
361
391
  }
392
+ // One step forward from the answer this query already had, instead of rebuilding it from every
393
+ // pending write. Only the ids the newest change touched are looked at, so a burst of writes costs
394
+ // each of them the size of that write — where rebuilding re-matched and re-projected the whole
395
+ // pending set per write, which is quadratic in the burst and was measured at twelve seconds for
396
+ // four thousand unsettled inserts against a single matching query.
397
+ //
398
+ // `null` means the step is not available and the caller has to rebuild: nothing to step from, a
399
+ // gap of more than one version, or a query shape whose result cannot be derived from itself — a
400
+ // projection cannot be re-matched against a selector naming fields it dropped, and a limited
401
+ // query is a window whose content can depend on rows it does not hold.
402
+ advanceServedResult(collectionName, query, pendingVersion) {
403
+ const served = query.served;
404
+ if (!served || served.fromItems !== query.items)
405
+ return null;
406
+ if (served.pendingVersion !== pendingVersion - 1)
407
+ return null;
408
+ if (!WorkerDataAdapter.providesFullItems(query) || query.options?.limit != null)
409
+ return null;
410
+ const state = this.pendingWrites.get(collectionName);
411
+ if (state?.lastChange == null || state.lastChange.version !== pendingVersion)
412
+ return null;
413
+ return (0, incrementalQueryUpdate_1.mergeChangesetIntoResult)(served.items, query.selector, query.options, this.changesetForIds(collectionName, query, state.lastChange.ids));
414
+ }
415
+ // What the named ids look like now — the pending value if one is still in flight for them, and
416
+ // otherwise whatever the last confirmed result said, which is what a settling write reverts to.
417
+ // The same rule answers a write arriving and a write settling, so both take the step above.
418
+ changesetForIds(collectionName, query, ids) {
419
+ const pending = this.flattenPendingWrites(collectionName);
420
+ const stored = this.queryItemsById(query);
421
+ const upserts = [];
422
+ const deletes = [];
423
+ ids.forEach((id) => {
424
+ const pendingItem = pending?.upserts.get(id);
425
+ if (pendingItem) {
426
+ upserts.push(pendingItem);
427
+ return;
428
+ }
429
+ if (pending?.deletes.has(id)) {
430
+ deletes.push(id);
431
+ return;
432
+ }
433
+ const storedItem = stored.get(id);
434
+ if (storedItem)
435
+ upserts.push(storedItem);
436
+ else
437
+ deletes.push(id);
438
+ });
439
+ return { upserts, deletes };
440
+ }
362
441
  computeServedResult(collectionName, query) {
363
442
  const pending = this.flattenPendingWrites(collectionName);
364
443
  if (!pending)
@@ -404,25 +483,49 @@ class WorkerDataAdapter {
404
483
  // changed for a reader rather than just that something did.
405
484
  const servedBefore = this.servedResults(collectionName, affected);
406
485
  const seq = this.pendingWriteSeq += 1;
407
- const pending = this.pendingWrites.get(collectionName)
408
- ?? new Map();
409
- pending.set(seq, {
486
+ const state = this.pendingWrites.get(collectionName)
487
+ ?? {
488
+ writes: new Map(),
489
+ byId: new Map(),
490
+ flat: { upserts: new Map(), deletes: new Set() },
491
+ lastChange: null,
492
+ };
493
+ const write = {
410
494
  upserts: new Map(upserts.map(item => [item.id, item])),
411
495
  deletes: new Set(deletes),
412
- });
413
- this.pendingWrites.set(collectionName, pending);
496
+ };
497
+ state.writes.set(seq, write);
498
+ // Upserts first, then deletes, so a write naming the same id in both ends as a delete — the
499
+ // order the collapsed view was folded in when it was still rebuilt from scratch.
500
+ write.upserts.forEach((item, id) => WorkerDataAdapter.pushPendingEntry(state, id, item, seq));
501
+ write.deletes.forEach(id => WorkerDataAdapter.pushPendingEntry(state, id, null, seq));
502
+ this.pendingWrites.set(collectionName, state);
414
503
  this.bumpPendingWriteVersion(collectionName);
504
+ const addedVersion = this.pendingWriteVersions.get(collectionName) ?? 0;
505
+ state.lastChange = { version: addedVersion, ids: [...affectedIds] };
415
506
  this.notifyWithDeltas(collectionName, affected, servedBefore);
416
507
  return () => {
417
508
  const current = this.pendingWrites.get(collectionName);
418
509
  if (!current)
419
510
  return;
511
+ const settled = current.writes.get(seq);
512
+ // Already dropped: settling twice must not take a *later* write's contribution back out.
513
+ if (!settled)
514
+ return;
420
515
  const affectedOnDrop = this.affectedQueries(collectionName, upserts, affectedIds);
421
516
  const beforeDrop = this.servedResults(collectionName, affectedOnDrop);
422
- current.delete(seq);
423
- if (current.size === 0)
517
+ current.writes.delete(seq);
518
+ settled.upserts.forEach((item, id) => WorkerDataAdapter.dropPendingEntry(current, id, seq));
519
+ settled.deletes.forEach(id => WorkerDataAdapter.dropPendingEntry(current, id, seq));
520
+ // With nothing left in flight the state is dropped entirely, and a query's served result is
521
+ // its stored one again — which `computeServedResult` answers without looking at anything.
522
+ if (current.writes.size === 0)
424
523
  this.pendingWrites.delete(collectionName);
425
524
  this.bumpPendingWriteVersion(collectionName);
525
+ if (current.writes.size > 0) {
526
+ const droppedVersion = this.pendingWriteVersions.get(collectionName) ?? 0;
527
+ current.lastChange = { version: droppedVersion, ids: [...affectedIds] };
528
+ }
426
529
  // By the time a write settles the host's own answer has usually already landed, so dropping
427
530
  // the optimistic copy changes nothing a reader can see and produces no notification at all.
428
531
  this.notifyWithDeltas(collectionName, affectedOnDrop, beforeDrop);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@signaldb/svelte",
3
3
  "type": "module",
4
- "version": "2.0.0-beta.17",
4
+ "version": "2.0.0-beta.18",
5
5
  "scripts": {
6
6
  "build": "tsc -d --noEmit false",
7
7
  "analyze-bundle": "bundle-analyzer ./dist --upload-token=$BUNDLE_ANALYZER_UPLOAD_TOKEN --bundle-name=@signaldb/svelte",