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