@tanstack/db 0.0.12 → 0.0.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.
Files changed (49) hide show
  1. package/dist/cjs/SortedMap.cjs +38 -11
  2. package/dist/cjs/SortedMap.cjs.map +1 -1
  3. package/dist/cjs/SortedMap.d.cts +10 -0
  4. package/dist/cjs/collection.cjs +467 -95
  5. package/dist/cjs/collection.cjs.map +1 -1
  6. package/dist/cjs/collection.d.cts +81 -5
  7. package/dist/cjs/index.cjs +2 -0
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/index.d.cts +1 -0
  10. package/dist/cjs/optimistic-action.cjs +21 -0
  11. package/dist/cjs/optimistic-action.cjs.map +1 -0
  12. package/dist/cjs/optimistic-action.d.cts +39 -0
  13. package/dist/cjs/query/compiled-query.cjs +21 -11
  14. package/dist/cjs/query/compiled-query.cjs.map +1 -1
  15. package/dist/cjs/query/query-builder.cjs +2 -2
  16. package/dist/cjs/query/query-builder.cjs.map +1 -1
  17. package/dist/cjs/transactions.cjs +3 -1
  18. package/dist/cjs/transactions.cjs.map +1 -1
  19. package/dist/cjs/transactions.d.cts +4 -4
  20. package/dist/cjs/types.d.cts +45 -1
  21. package/dist/esm/SortedMap.d.ts +10 -0
  22. package/dist/esm/SortedMap.js +38 -11
  23. package/dist/esm/SortedMap.js.map +1 -1
  24. package/dist/esm/collection.d.ts +81 -5
  25. package/dist/esm/collection.js +467 -95
  26. package/dist/esm/collection.js.map +1 -1
  27. package/dist/esm/index.d.ts +1 -0
  28. package/dist/esm/index.js +2 -0
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/optimistic-action.d.ts +39 -0
  31. package/dist/esm/optimistic-action.js +21 -0
  32. package/dist/esm/optimistic-action.js.map +1 -0
  33. package/dist/esm/query/compiled-query.js +21 -11
  34. package/dist/esm/query/compiled-query.js.map +1 -1
  35. package/dist/esm/query/query-builder.js +2 -2
  36. package/dist/esm/query/query-builder.js.map +1 -1
  37. package/dist/esm/transactions.d.ts +4 -4
  38. package/dist/esm/transactions.js +3 -1
  39. package/dist/esm/transactions.js.map +1 -1
  40. package/dist/esm/types.d.ts +45 -1
  41. package/package.json +1 -1
  42. package/src/SortedMap.ts +46 -13
  43. package/src/collection.ts +624 -119
  44. package/src/index.ts +1 -0
  45. package/src/optimistic-action.ts +65 -0
  46. package/src/query/compiled-query.ts +36 -14
  47. package/src/query/query-builder.ts +2 -2
  48. package/src/transactions.ts +14 -5
  49. package/src/types.ts +48 -1
@@ -32,7 +32,7 @@ class CollectionImpl {
32
32
  * @throws Error if sync config is missing
33
33
  */
34
34
  constructor(config) {
35
- this.syncedData = /* @__PURE__ */ new Map();
35
+ this.pendingSyncedTransactions = [];
36
36
  this.syncedMetadata = /* @__PURE__ */ new Map();
37
37
  this.derivedUpserts = /* @__PURE__ */ new Map();
38
38
  this.derivedDeletes = /* @__PURE__ */ new Set();
@@ -40,21 +40,48 @@ class CollectionImpl {
40
40
  this.changeListeners = /* @__PURE__ */ new Set();
41
41
  this.changeKeyListeners = /* @__PURE__ */ new Map();
42
42
  this.utils = {};
43
- this.pendingSyncedTransactions = [];
44
43
  this.syncedKeys = /* @__PURE__ */ new Set();
44
+ this.preSyncVisibleState = /* @__PURE__ */ new Map();
45
+ this.recentlySyncedKeys = /* @__PURE__ */ new Set();
45
46
  this.hasReceivedFirstCommit = false;
47
+ this.isCommittingSyncTransactions = false;
46
48
  this.onFirstCommitCallbacks = [];
49
+ this._status = `idle`;
50
+ this.activeSubscribersCount = 0;
51
+ this.gcTimeoutId = null;
52
+ this.preloadPromise = null;
53
+ this.syncCleanupFn = null;
47
54
  this.id = ``;
48
55
  this.commitPendingTransactions = () => {
49
- if (!Array.from(this.transactions.values()).some(
50
- ({ state }) => state === `persisting`
51
- )) {
56
+ let hasPersistingTransaction = false;
57
+ for (const transaction of this.transactions.values()) {
58
+ if (transaction.state === `persisting`) {
59
+ hasPersistingTransaction = true;
60
+ break;
61
+ }
62
+ }
63
+ if (!hasPersistingTransaction) {
64
+ this.isCommittingSyncTransactions = true;
52
65
  const changedKeys = /* @__PURE__ */ new Set();
66
+ for (const transaction of this.pendingSyncedTransactions) {
67
+ for (const operation of transaction.operations) {
68
+ changedKeys.add(operation.key);
69
+ }
70
+ }
71
+ let currentVisibleState = this.preSyncVisibleState;
72
+ if (currentVisibleState.size === 0) {
73
+ currentVisibleState = /* @__PURE__ */ new Map();
74
+ for (const key of changedKeys) {
75
+ const currentValue = this.get(key);
76
+ if (currentValue !== void 0) {
77
+ currentVisibleState.set(key, currentValue);
78
+ }
79
+ }
80
+ }
53
81
  const events = [];
54
82
  for (const transaction of this.pendingSyncedTransactions) {
55
83
  for (const operation of transaction.operations) {
56
84
  const key = operation.key;
57
- changedKeys.add(key);
58
85
  this.syncedKeys.add(key);
59
86
  switch (operation.type) {
60
87
  case `insert`:
@@ -74,17 +101,9 @@ class CollectionImpl {
74
101
  this.syncedMetadata.delete(key);
75
102
  break;
76
103
  }
77
- const previousValue = this.syncedData.get(key);
78
104
  switch (operation.type) {
79
105
  case `insert`:
80
106
  this.syncedData.set(key, operation.value);
81
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
82
- events.push({
83
- type: `insert`,
84
- key,
85
- value: operation.value
86
- });
87
- }
88
107
  break;
89
108
  case `update`: {
90
109
  const updatedValue = Object.assign(
@@ -93,34 +112,84 @@ class CollectionImpl {
93
112
  operation.value
94
113
  );
95
114
  this.syncedData.set(key, updatedValue);
96
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
97
- events.push({
98
- type: `update`,
99
- key,
100
- value: updatedValue,
101
- previousValue
102
- });
103
- }
104
115
  break;
105
116
  }
106
117
  case `delete`:
107
118
  this.syncedData.delete(key);
108
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
109
- if (previousValue) {
110
- events.push({
111
- type: `delete`,
112
- key,
113
- value: previousValue
114
- });
115
- }
116
- }
117
119
  break;
118
120
  }
119
121
  }
120
122
  }
123
+ this.derivedUpserts.clear();
124
+ this.derivedDeletes.clear();
125
+ this.isCommittingSyncTransactions = false;
126
+ for (const transaction of this.transactions.values()) {
127
+ if (![`completed`, `failed`].includes(transaction.state)) {
128
+ for (const mutation of transaction.mutations) {
129
+ if (mutation.collection === this) {
130
+ switch (mutation.type) {
131
+ case `insert`:
132
+ case `update`:
133
+ this.derivedUpserts.set(mutation.key, mutation.modified);
134
+ this.derivedDeletes.delete(mutation.key);
135
+ break;
136
+ case `delete`:
137
+ this.derivedUpserts.delete(mutation.key);
138
+ this.derivedDeletes.add(mutation.key);
139
+ break;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ const completedOptimisticOps = /* @__PURE__ */ new Map();
146
+ for (const transaction of this.transactions.values()) {
147
+ if (transaction.state === `completed`) {
148
+ for (const mutation of transaction.mutations) {
149
+ if (mutation.collection === this && changedKeys.has(mutation.key)) {
150
+ completedOptimisticOps.set(mutation.key, {
151
+ type: mutation.type,
152
+ value: mutation.modified
153
+ });
154
+ }
155
+ }
156
+ }
157
+ }
158
+ for (const key of changedKeys) {
159
+ const previousVisibleValue = currentVisibleState.get(key);
160
+ const newVisibleValue = this.get(key);
161
+ const completedOp = completedOptimisticOps.get(key);
162
+ const isRedundantSync = completedOp && newVisibleValue !== void 0 && this.deepEqual(completedOp.value, newVisibleValue);
163
+ if (!isRedundantSync) {
164
+ if (previousVisibleValue === void 0 && newVisibleValue !== void 0) {
165
+ events.push({
166
+ type: `insert`,
167
+ key,
168
+ value: newVisibleValue
169
+ });
170
+ } else if (previousVisibleValue !== void 0 && newVisibleValue === void 0) {
171
+ events.push({
172
+ type: `delete`,
173
+ key,
174
+ value: previousVisibleValue
175
+ });
176
+ } else if (previousVisibleValue !== void 0 && newVisibleValue !== void 0 && !this.deepEqual(previousVisibleValue, newVisibleValue)) {
177
+ events.push({
178
+ type: `update`,
179
+ key,
180
+ value: newVisibleValue,
181
+ previousValue: previousVisibleValue
182
+ });
183
+ }
184
+ }
185
+ }
121
186
  this._size = this.calculateSize();
122
187
  this.emitEvents(events);
123
188
  this.pendingSyncedTransactions = [];
189
+ this.preSyncVisibleState.clear();
190
+ Promise.resolve().then(() => {
191
+ this.recentlySyncedKeys.clear();
192
+ });
124
193
  if (!this.hasReceivedFirstCommit) {
125
194
  this.hasReceivedFirstCommit = true;
126
195
  const callbacks = [...this.onFirstCommitCallbacks];
@@ -130,6 +199,7 @@ class CollectionImpl {
130
199
  }
131
200
  };
132
201
  this.insert = (data, config2) => {
202
+ this.validateCollectionUsable(`insert`);
133
203
  const ambientTransaction = transactions.getActiveTransaction();
134
204
  if (!ambientTransaction && !this.config.onInsert) {
135
205
  throw new Error(
@@ -181,6 +251,7 @@ class CollectionImpl {
181
251
  }
182
252
  };
183
253
  this.delete = (keys, config2) => {
254
+ this.validateCollectionUsable(`delete`);
184
255
  const ambientTransaction = transactions.getActiveTransaction();
185
256
  if (!ambientTransaction && !this.config.onDelete) {
186
257
  throw new Error(
@@ -248,87 +319,305 @@ class CollectionImpl {
248
319
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
249
320
  );
250
321
  this.config = config;
251
- config.sync.sync({
252
- collection: this,
253
- begin: () => {
254
- this.pendingSyncedTransactions.push({
255
- committed: false,
256
- operations: []
257
- });
258
- },
259
- write: (messageWithoutKey) => {
260
- const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
261
- if (!pendingTransaction) {
262
- throw new Error(`No pending sync transaction to write to`);
263
- }
264
- if (pendingTransaction.committed) {
265
- throw new Error(
266
- `The pending sync transaction is already committed, you can't still write to it.`
267
- );
268
- }
269
- const key = this.getKeyFromItem(messageWithoutKey.value);
270
- if (messageWithoutKey.type === `insert`) {
271
- if (this.syncedData.has(key) && !pendingTransaction.operations.some(
272
- (op) => op.key === key && op.type === `delete`
273
- )) {
322
+ collectionsStore.set(this.id, this);
323
+ if (this.config.compare) {
324
+ this.syncedData = new SortedMap.SortedMap(this.config.compare);
325
+ } else {
326
+ this.syncedData = /* @__PURE__ */ new Map();
327
+ }
328
+ if (config.startSync === true) {
329
+ this.startSync();
330
+ }
331
+ }
332
+ /**
333
+ * Register a callback to be executed on the next commit
334
+ * Useful for preloading collections
335
+ * @param callback Function to call after the next commit
336
+ */
337
+ onFirstCommit(callback) {
338
+ this.onFirstCommitCallbacks.push(callback);
339
+ }
340
+ /**
341
+ * Gets the current status of the collection
342
+ */
343
+ get status() {
344
+ return this._status;
345
+ }
346
+ /**
347
+ * Validates that the collection is in a usable state for data operations
348
+ * @private
349
+ */
350
+ validateCollectionUsable(operation) {
351
+ switch (this._status) {
352
+ case `error`:
353
+ throw new Error(
354
+ `Cannot perform ${operation} on collection "${this.id}" - collection is in error state. Try calling cleanup() and restarting the collection.`
355
+ );
356
+ case `cleaned-up`:
357
+ throw new Error(
358
+ `Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. The collection will automatically restart on next access.`
359
+ );
360
+ }
361
+ }
362
+ /**
363
+ * Validates state transitions to prevent invalid status changes
364
+ * @private
365
+ */
366
+ validateStatusTransition(from, to) {
367
+ if (from === to) {
368
+ return;
369
+ }
370
+ const validTransitions = {
371
+ idle: [`loading`, `error`, `cleaned-up`],
372
+ loading: [`ready`, `error`, `cleaned-up`],
373
+ ready: [`cleaned-up`, `error`],
374
+ error: [`cleaned-up`, `idle`],
375
+ "cleaned-up": [`loading`, `error`]
376
+ };
377
+ if (!validTransitions[from].includes(to)) {
378
+ throw new Error(
379
+ `Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
380
+ );
381
+ }
382
+ }
383
+ /**
384
+ * Safely update the collection status with validation
385
+ * @private
386
+ */
387
+ setStatus(newStatus) {
388
+ this.validateStatusTransition(this._status, newStatus);
389
+ this._status = newStatus;
390
+ }
391
+ /**
392
+ * Start sync immediately - internal method for compiled queries
393
+ * This bypasses lazy loading for special cases like live query results
394
+ */
395
+ startSyncImmediate() {
396
+ this.startSync();
397
+ }
398
+ /**
399
+ * Start the sync process for this collection
400
+ * This is called when the collection is first accessed or preloaded
401
+ */
402
+ startSync() {
403
+ if (this._status !== `idle` && this._status !== `cleaned-up`) {
404
+ return;
405
+ }
406
+ this.setStatus(`loading`);
407
+ try {
408
+ const cleanupFn = this.config.sync.sync({
409
+ collection: this,
410
+ begin: () => {
411
+ this.pendingSyncedTransactions.push({
412
+ committed: false,
413
+ operations: []
414
+ });
415
+ },
416
+ write: (messageWithoutKey) => {
417
+ const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
418
+ if (!pendingTransaction) {
419
+ throw new Error(`No pending sync transaction to write to`);
420
+ }
421
+ if (pendingTransaction.committed) {
422
+ throw new Error(
423
+ `The pending sync transaction is already committed, you can't still write to it.`
424
+ );
425
+ }
426
+ const key = this.getKeyFromItem(messageWithoutKey.value);
427
+ if (messageWithoutKey.type === `insert`) {
428
+ if (this.syncedData.has(key) && !pendingTransaction.operations.some(
429
+ (op) => op.key === key && op.type === `delete`
430
+ )) {
431
+ throw new Error(
432
+ `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
433
+ );
434
+ }
435
+ }
436
+ const message = {
437
+ ...messageWithoutKey,
438
+ key
439
+ };
440
+ pendingTransaction.operations.push(message);
441
+ },
442
+ commit: () => {
443
+ const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
444
+ if (!pendingTransaction) {
445
+ throw new Error(`No pending sync transaction to commit`);
446
+ }
447
+ if (pendingTransaction.committed) {
274
448
  throw new Error(
275
- `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
449
+ `The pending sync transaction is already committed, you can't commit it again.`
276
450
  );
277
451
  }
452
+ pendingTransaction.committed = true;
453
+ this.commitPendingTransactions();
454
+ if (this._status === `loading`) {
455
+ this.setStatus(`ready`);
456
+ }
278
457
  }
279
- const message = {
280
- ...messageWithoutKey,
281
- key
282
- };
283
- pendingTransaction.operations.push(message);
284
- },
285
- commit: () => {
286
- const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
287
- if (!pendingTransaction) {
288
- throw new Error(`No pending sync transaction to commit`);
458
+ });
459
+ this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null;
460
+ } catch (error) {
461
+ this.setStatus(`error`);
462
+ throw error;
463
+ }
464
+ }
465
+ /**
466
+ * Preload the collection data by starting sync if not already started
467
+ * Multiple concurrent calls will share the same promise
468
+ */
469
+ preload() {
470
+ if (this.preloadPromise) {
471
+ return this.preloadPromise;
472
+ }
473
+ this.preloadPromise = new Promise((resolve, reject) => {
474
+ if (this._status === `ready`) {
475
+ resolve();
476
+ return;
477
+ }
478
+ if (this._status === `error`) {
479
+ reject(new Error(`Collection is in error state`));
480
+ return;
481
+ }
482
+ this.onFirstCommit(() => {
483
+ resolve();
484
+ });
485
+ if (this._status === `idle` || this._status === `cleaned-up`) {
486
+ try {
487
+ this.startSync();
488
+ } catch (error) {
489
+ reject(error);
490
+ return;
289
491
  }
290
- if (pendingTransaction.committed) {
492
+ }
493
+ });
494
+ return this.preloadPromise;
495
+ }
496
+ /**
497
+ * Clean up the collection by stopping sync and clearing data
498
+ * This can be called manually or automatically by garbage collection
499
+ */
500
+ async cleanup() {
501
+ if (this.gcTimeoutId) {
502
+ clearTimeout(this.gcTimeoutId);
503
+ this.gcTimeoutId = null;
504
+ }
505
+ try {
506
+ if (this.syncCleanupFn) {
507
+ this.syncCleanupFn();
508
+ this.syncCleanupFn = null;
509
+ }
510
+ } catch (error) {
511
+ queueMicrotask(() => {
512
+ if (error instanceof Error) {
513
+ const wrappedError = new Error(
514
+ `Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
515
+ );
516
+ wrappedError.cause = error;
517
+ wrappedError.stack = error.stack;
518
+ throw wrappedError;
519
+ } else {
291
520
  throw new Error(
292
- `The pending sync transaction is already committed, you can't commit it again.`
521
+ `Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
293
522
  );
294
523
  }
295
- pendingTransaction.committed = true;
296
- this.commitPendingTransactions();
524
+ });
525
+ }
526
+ this.syncedData.clear();
527
+ this.syncedMetadata.clear();
528
+ this.derivedUpserts.clear();
529
+ this.derivedDeletes.clear();
530
+ this._size = 0;
531
+ this.pendingSyncedTransactions = [];
532
+ this.syncedKeys.clear();
533
+ this.hasReceivedFirstCommit = false;
534
+ this.onFirstCommitCallbacks = [];
535
+ this.preloadPromise = null;
536
+ this.setStatus(`cleaned-up`);
537
+ return Promise.resolve();
538
+ }
539
+ /**
540
+ * Start the garbage collection timer
541
+ * Called when the collection becomes inactive (no subscribers)
542
+ */
543
+ startGCTimer() {
544
+ if (this.gcTimeoutId) {
545
+ clearTimeout(this.gcTimeoutId);
546
+ }
547
+ const gcTime = this.config.gcTime ?? 3e5;
548
+ this.gcTimeoutId = setTimeout(() => {
549
+ if (this.activeSubscribersCount === 0) {
550
+ this.cleanup();
297
551
  }
298
- });
552
+ }, gcTime);
299
553
  }
300
554
  /**
301
- * Register a callback to be executed on the next commit
302
- * Useful for preloading collections
303
- * @param callback Function to call after the next commit
555
+ * Cancel the garbage collection timer
556
+ * Called when the collection becomes active again
304
557
  */
305
- onFirstCommit(callback) {
306
- this.onFirstCommitCallbacks.push(callback);
558
+ cancelGCTimer() {
559
+ if (this.gcTimeoutId) {
560
+ clearTimeout(this.gcTimeoutId);
561
+ this.gcTimeoutId = null;
562
+ }
563
+ }
564
+ /**
565
+ * Increment the active subscribers count and start sync if needed
566
+ */
567
+ addSubscriber() {
568
+ this.activeSubscribersCount++;
569
+ this.cancelGCTimer();
570
+ if (this._status === `cleaned-up` || this._status === `idle`) {
571
+ this.startSync();
572
+ }
573
+ }
574
+ /**
575
+ * Decrement the active subscribers count and start GC timer if needed
576
+ */
577
+ removeSubscriber() {
578
+ this.activeSubscribersCount--;
579
+ if (this.activeSubscribersCount === 0) {
580
+ this.activeSubscribersCount = 0;
581
+ this.startGCTimer();
582
+ } else if (this.activeSubscribersCount < 0) {
583
+ throw new Error(
584
+ `Active subscribers count is negative - this should never happen`
585
+ );
586
+ }
307
587
  }
308
588
  /**
309
589
  * Recompute optimistic state from active transactions
310
590
  */
311
591
  recomputeOptimisticState() {
592
+ if (this.isCommittingSyncTransactions) {
593
+ return;
594
+ }
312
595
  const previousState = new Map(this.derivedUpserts);
313
596
  const previousDeletes = new Set(this.derivedDeletes);
314
597
  this.derivedUpserts.clear();
315
598
  this.derivedDeletes.clear();
316
- const activeTransactions = Array.from(this.transactions.values());
599
+ const activeTransactions = [];
600
+ const completedTransactions = [];
601
+ for (const transaction of this.transactions.values()) {
602
+ if (transaction.state === `completed`) {
603
+ completedTransactions.push(transaction);
604
+ } else if (![`completed`, `failed`].includes(transaction.state)) {
605
+ activeTransactions.push(transaction);
606
+ }
607
+ }
317
608
  for (const transaction of activeTransactions) {
318
- if (![`completed`, `failed`].includes(transaction.state)) {
319
- for (const mutation of transaction.mutations) {
320
- if (mutation.collection === this) {
321
- switch (mutation.type) {
322
- case `insert`:
323
- case `update`:
324
- this.derivedUpserts.set(mutation.key, mutation.modified);
325
- this.derivedDeletes.delete(mutation.key);
326
- break;
327
- case `delete`:
328
- this.derivedUpserts.delete(mutation.key);
329
- this.derivedDeletes.add(mutation.key);
330
- break;
331
- }
609
+ for (const mutation of transaction.mutations) {
610
+ if (mutation.collection === this) {
611
+ switch (mutation.type) {
612
+ case `insert`:
613
+ case `update`:
614
+ this.derivedUpserts.set(mutation.key, mutation.modified);
615
+ this.derivedDeletes.delete(mutation.key);
616
+ break;
617
+ case `delete`:
618
+ this.derivedUpserts.delete(mutation.key);
619
+ this.derivedDeletes.add(mutation.key);
620
+ break;
332
621
  }
333
622
  }
334
623
  }
@@ -336,7 +625,41 @@ class CollectionImpl {
336
625
  this._size = this.calculateSize();
337
626
  const events = [];
338
627
  this.collectOptimisticChanges(previousState, previousDeletes, events);
339
- this.emitEvents(events);
628
+ const filteredEventsBySyncStatus = events.filter(
629
+ (event) => !this.recentlySyncedKeys.has(event.key)
630
+ );
631
+ if (this.pendingSyncedTransactions.length > 0) {
632
+ const pendingSyncKeys = /* @__PURE__ */ new Set();
633
+ const completedTransactionMutations = /* @__PURE__ */ new Set();
634
+ for (const transaction of this.pendingSyncedTransactions) {
635
+ for (const operation of transaction.operations) {
636
+ pendingSyncKeys.add(operation.key);
637
+ }
638
+ }
639
+ for (const tx of completedTransactions) {
640
+ for (const mutation of tx.mutations) {
641
+ if (mutation.collection === this) {
642
+ completedTransactionMutations.add(mutation.mutationId);
643
+ }
644
+ }
645
+ }
646
+ const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
647
+ if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
648
+ const hasActiveOptimisticMutation = activeTransactions.some(
649
+ (tx) => tx.mutations.some(
650
+ (m) => m.collection === this && m.key === event.key
651
+ )
652
+ );
653
+ if (!hasActiveOptimisticMutation) {
654
+ return false;
655
+ }
656
+ }
657
+ return true;
658
+ });
659
+ this.emitEvents(filteredEvents);
660
+ } else {
661
+ this.emitEvents(filteredEventsBySyncStatus);
662
+ }
340
663
  }
341
664
  /**
342
665
  * Calculate the current size based on synced data and optimistic changes
@@ -473,7 +796,8 @@ class CollectionImpl {
473
796
  for (const key of this.keys()) {
474
797
  const value = this.get(key);
475
798
  if (value !== void 0) {
476
- yield value;
799
+ const { _orderByIndex, ...copy } = value;
800
+ yield copy;
477
801
  }
478
802
  }
479
803
  }
@@ -484,7 +808,8 @@ class CollectionImpl {
484
808
  for (const key of this.keys()) {
485
809
  const value = this.get(key);
486
810
  if (value !== void 0) {
487
- yield [key, value];
811
+ const { _orderByIndex, ...copy } = value;
812
+ yield [key, copy];
488
813
  }
489
814
  }
490
815
  }
@@ -507,6 +832,24 @@ class CollectionImpl {
507
832
  }
508
833
  return `KEY::${this.id}/${key}`;
509
834
  }
835
+ deepEqual(a, b) {
836
+ if (a === b) return true;
837
+ if (a == null || b == null) return false;
838
+ if (typeof a !== typeof b) return false;
839
+ if (typeof a === `object`) {
840
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
841
+ const keysA = Object.keys(a);
842
+ const keysB = Object.keys(b);
843
+ if (keysA.length !== keysB.length) return false;
844
+ const keysBSet = new Set(keysB);
845
+ for (const key of keysA) {
846
+ if (!keysBSet.has(key)) return false;
847
+ if (!this.deepEqual(a[key], b[key])) return false;
848
+ }
849
+ return true;
850
+ }
851
+ return false;
852
+ }
510
853
  validateData(data, type, key) {
511
854
  if (!this.config.schema) return data;
512
855
  const standardSchema = this.ensureStandardSchema(this.config.schema);
@@ -551,6 +894,7 @@ class CollectionImpl {
551
894
  if (typeof keys === `undefined`) {
552
895
  throw new Error(`The first argument to update is missing`);
553
896
  }
897
+ this.validateCollectionUsable(`update`);
554
898
  const ambientTransaction = transactions.getActiveTransaction();
555
899
  if (!ambientTransaction && !this.config.onUpdate) {
556
900
  throw new Error(
@@ -725,18 +1069,21 @@ class CollectionImpl {
725
1069
  * @returns A function that can be called to unsubscribe from the changes
726
1070
  */
727
1071
  subscribeChanges(callback, { includeInitialState = false } = {}) {
1072
+ this.addSubscriber();
728
1073
  if (includeInitialState) {
729
1074
  callback(this.currentStateAsChanges());
730
1075
  }
731
1076
  this.changeListeners.add(callback);
732
1077
  return () => {
733
1078
  this.changeListeners.delete(callback);
1079
+ this.removeSubscriber();
734
1080
  };
735
1081
  }
736
1082
  /**
737
1083
  * Subscribe to changes for a specific key
738
1084
  */
739
1085
  subscribeChangesKey(key, listener, { includeInitialState = false } = {}) {
1086
+ this.addSubscriber();
740
1087
  if (!this.changeKeyListeners.has(key)) {
741
1088
  this.changeKeyListeners.set(key, /* @__PURE__ */ new Set());
742
1089
  }
@@ -758,13 +1105,38 @@ class CollectionImpl {
758
1105
  this.changeKeyListeners.delete(key);
759
1106
  }
760
1107
  }
1108
+ this.removeSubscriber();
761
1109
  };
762
1110
  }
1111
+ /**
1112
+ * Capture visible state for keys that will be affected by pending sync operations
1113
+ * This must be called BEFORE onTransactionStateChange clears optimistic state
1114
+ */
1115
+ capturePreSyncVisibleState() {
1116
+ if (this.pendingSyncedTransactions.length === 0) return;
1117
+ this.preSyncVisibleState.clear();
1118
+ const syncedKeys = /* @__PURE__ */ new Set();
1119
+ for (const transaction of this.pendingSyncedTransactions) {
1120
+ for (const operation of transaction.operations) {
1121
+ syncedKeys.add(operation.key);
1122
+ }
1123
+ }
1124
+ for (const key of syncedKeys) {
1125
+ this.recentlySyncedKeys.add(key);
1126
+ }
1127
+ for (const key of syncedKeys) {
1128
+ const currentValue = this.get(key);
1129
+ if (currentValue !== void 0) {
1130
+ this.preSyncVisibleState.set(key, currentValue);
1131
+ }
1132
+ }
1133
+ }
763
1134
  /**
764
1135
  * Trigger a recomputation when transactions change
765
1136
  * This method should be called by the Transaction class when state changes
766
1137
  */
767
1138
  onTransactionStateChange() {
1139
+ this.capturePreSyncVisibleState();
768
1140
  this.recomputeOptimisticState();
769
1141
  }
770
1142
  /**
@@ -776,7 +1148,7 @@ class CollectionImpl {
776
1148
  asStoreMap() {
777
1149
  if (!this._storeMap) {
778
1150
  this._storeMap = new store.Store(new Map(this.entries()));
779
- this.subscribeChanges(() => {
1151
+ this.changeListeners.add(() => {
780
1152
  this._storeMap.setState(() => new Map(this.entries()));
781
1153
  });
782
1154
  }
@@ -791,7 +1163,7 @@ class CollectionImpl {
791
1163
  asStoreArray() {
792
1164
  if (!this._storeArray) {
793
1165
  this._storeArray = new store.Store(this.toArray);
794
- this.subscribeChanges(() => {
1166
+ this.changeListeners.add(() => {
795
1167
  this._storeArray.setState(() => this.toArray);
796
1168
  });
797
1169
  }