@tanstack/db 0.0.12 → 0.0.13

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 (47) 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 +80 -4
  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/types.d.cts +44 -0
  20. package/dist/esm/SortedMap.d.ts +10 -0
  21. package/dist/esm/SortedMap.js +38 -11
  22. package/dist/esm/SortedMap.js.map +1 -1
  23. package/dist/esm/collection.d.ts +80 -4
  24. package/dist/esm/collection.js +467 -95
  25. package/dist/esm/collection.js.map +1 -1
  26. package/dist/esm/index.d.ts +1 -0
  27. package/dist/esm/index.js +2 -0
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/optimistic-action.d.ts +39 -0
  30. package/dist/esm/optimistic-action.js +21 -0
  31. package/dist/esm/optimistic-action.js.map +1 -0
  32. package/dist/esm/query/compiled-query.js +21 -11
  33. package/dist/esm/query/compiled-query.js.map +1 -1
  34. package/dist/esm/query/query-builder.js +2 -2
  35. package/dist/esm/query/query-builder.js.map +1 -1
  36. package/dist/esm/transactions.js +3 -1
  37. package/dist/esm/transactions.js.map +1 -1
  38. package/dist/esm/types.d.ts +44 -0
  39. package/package.json +1 -1
  40. package/src/SortedMap.ts +46 -13
  41. package/src/collection.ts +624 -119
  42. package/src/index.ts +1 -0
  43. package/src/optimistic-action.ts +65 -0
  44. package/src/query/compiled-query.ts +36 -14
  45. package/src/query/query-builder.ts +2 -2
  46. package/src/transactions.ts +6 -1
  47. package/src/types.ts +47 -0
package/src/collection.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  ChangeListener,
7
7
  ChangeMessage,
8
8
  CollectionConfig,
9
+ CollectionStatus,
9
10
  Fn,
10
11
  InsertConfig,
11
12
  OperationConfig,
@@ -138,10 +139,12 @@ export class CollectionImpl<
138
139
  T extends object = Record<string, unknown>,
139
140
  TKey extends string | number = string | number,
140
141
  > {
141
- public transactions: SortedMap<string, Transaction<any>>
142
+ public config: CollectionConfig<T, TKey, any>
142
143
 
143
144
  // Core state - make public for testing
144
- public syncedData = new Map<TKey, T>()
145
+ public transactions: SortedMap<string, Transaction<any>>
146
+ public pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
147
+ public syncedData: Map<TKey, T> | SortedMap<TKey, T>
145
148
  public syncedMetadata = new Map<TKey, unknown>()
146
149
 
147
150
  // Optimistic state tracking - make public for testing
@@ -159,14 +162,23 @@ export class CollectionImpl<
159
162
  // This is populated by createCollection
160
163
  public utils: Record<string, Fn> = {}
161
164
 
162
- private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
165
+ // State used for computing the change events
163
166
  private syncedKeys = new Set<TKey>()
164
- public config: CollectionConfig<T, TKey, any>
167
+ private preSyncVisibleState = new Map<TKey, T>()
168
+ private recentlySyncedKeys = new Set<TKey>()
165
169
  private hasReceivedFirstCommit = false
170
+ private isCommittingSyncTransactions = false
166
171
 
167
172
  // Array to store one-time commit listeners
168
173
  private onFirstCommitCallbacks: Array<() => void> = []
169
174
 
175
+ // Lifecycle management
176
+ private _status: CollectionStatus = `idle`
177
+ private activeSubscribersCount = 0
178
+ private gcTimeoutId: ReturnType<typeof setTimeout> | null = null
179
+ private preloadPromise: Promise<void> | null = null
180
+ private syncCleanupFn: (() => void) | null = null
181
+
170
182
  /**
171
183
  * Register a callback to be executed on the next commit
172
184
  * Useful for preloading collections
@@ -178,6 +190,71 @@ export class CollectionImpl<
178
190
 
179
191
  public id = ``
180
192
 
193
+ /**
194
+ * Gets the current status of the collection
195
+ */
196
+ public get status(): CollectionStatus {
197
+ return this._status
198
+ }
199
+
200
+ /**
201
+ * Validates that the collection is in a usable state for data operations
202
+ * @private
203
+ */
204
+ private validateCollectionUsable(operation: string): void {
205
+ switch (this._status) {
206
+ case `error`:
207
+ throw new Error(
208
+ `Cannot perform ${operation} on collection "${this.id}" - collection is in error state. ` +
209
+ `Try calling cleanup() and restarting the collection.`
210
+ )
211
+ case `cleaned-up`:
212
+ throw new Error(
213
+ `Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. ` +
214
+ `The collection will automatically restart on next access.`
215
+ )
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Validates state transitions to prevent invalid status changes
221
+ * @private
222
+ */
223
+ private validateStatusTransition(
224
+ from: CollectionStatus,
225
+ to: CollectionStatus
226
+ ): void {
227
+ if (from === to) {
228
+ // Allow same state transitions
229
+ return
230
+ }
231
+ const validTransitions: Record<
232
+ CollectionStatus,
233
+ Array<CollectionStatus>
234
+ > = {
235
+ idle: [`loading`, `error`, `cleaned-up`],
236
+ loading: [`ready`, `error`, `cleaned-up`],
237
+ ready: [`cleaned-up`, `error`],
238
+ error: [`cleaned-up`, `idle`],
239
+ "cleaned-up": [`loading`, `error`],
240
+ }
241
+
242
+ if (!validTransitions[from].includes(to)) {
243
+ throw new Error(
244
+ `Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
245
+ )
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Safely update the collection status with validation
251
+ * @private
252
+ */
253
+ private setStatus(newStatus: CollectionStatus): void {
254
+ this.validateStatusTransition(this._status, newStatus)
255
+ this._status = newStatus
256
+ }
257
+
181
258
  /**
182
259
  * Creates a new Collection instance
183
260
  *
@@ -206,74 +283,276 @@ export class CollectionImpl<
206
283
 
207
284
  this.config = config
208
285
 
209
- // Start the sync process
210
- config.sync.sync({
211
- collection: this,
212
- begin: () => {
213
- this.pendingSyncedTransactions.push({
214
- committed: false,
215
- operations: [],
216
- })
217
- },
218
- write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
219
- const pendingTransaction =
220
- this.pendingSyncedTransactions[
221
- this.pendingSyncedTransactions.length - 1
222
- ]
223
- if (!pendingTransaction) {
224
- throw new Error(`No pending sync transaction to write to`)
225
- }
226
- if (pendingTransaction.committed) {
227
- throw new Error(
228
- `The pending sync transaction is already committed, you can't still write to it.`
229
- )
230
- }
231
- const key = this.getKeyFromItem(messageWithoutKey.value)
286
+ // Store in global collections store
287
+ collectionsStore.set(this.id, this)
232
288
 
233
- // Check if an item with this key already exists when inserting
234
- if (messageWithoutKey.type === `insert`) {
235
- if (
236
- this.syncedData.has(key) &&
237
- !pendingTransaction.operations.some(
238
- (op) => op.key === key && op.type === `delete`
289
+ // Set up data storage with optional comparison function
290
+ if (this.config.compare) {
291
+ this.syncedData = new SortedMap<TKey, T>(this.config.compare)
292
+ } else {
293
+ this.syncedData = new Map<TKey, T>()
294
+ }
295
+
296
+ // Only start sync immediately if explicitly enabled
297
+ if (config.startSync === true) {
298
+ this.startSync()
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Start sync immediately - internal method for compiled queries
304
+ * This bypasses lazy loading for special cases like live query results
305
+ */
306
+ public startSyncImmediate(): void {
307
+ this.startSync()
308
+ }
309
+
310
+ /**
311
+ * Start the sync process for this collection
312
+ * This is called when the collection is first accessed or preloaded
313
+ */
314
+ private startSync(): void {
315
+ if (this._status !== `idle` && this._status !== `cleaned-up`) {
316
+ return // Already started or in progress
317
+ }
318
+
319
+ this.setStatus(`loading`)
320
+
321
+ try {
322
+ const cleanupFn = this.config.sync.sync({
323
+ collection: this,
324
+ begin: () => {
325
+ this.pendingSyncedTransactions.push({
326
+ committed: false,
327
+ operations: [],
328
+ })
329
+ },
330
+ write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
331
+ const pendingTransaction =
332
+ this.pendingSyncedTransactions[
333
+ this.pendingSyncedTransactions.length - 1
334
+ ]
335
+ if (!pendingTransaction) {
336
+ throw new Error(`No pending sync transaction to write to`)
337
+ }
338
+ if (pendingTransaction.committed) {
339
+ throw new Error(
340
+ `The pending sync transaction is already committed, you can't still write to it.`
239
341
  )
240
- ) {
342
+ }
343
+ const key = this.getKeyFromItem(messageWithoutKey.value)
344
+
345
+ // Check if an item with this key already exists when inserting
346
+ if (messageWithoutKey.type === `insert`) {
347
+ if (
348
+ this.syncedData.has(key) &&
349
+ !pendingTransaction.operations.some(
350
+ (op) => op.key === key && op.type === `delete`
351
+ )
352
+ ) {
353
+ throw new Error(
354
+ `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
355
+ )
356
+ }
357
+ }
358
+
359
+ const message: ChangeMessage<T> = {
360
+ ...messageWithoutKey,
361
+ key,
362
+ }
363
+ pendingTransaction.operations.push(message)
364
+ },
365
+ commit: () => {
366
+ const pendingTransaction =
367
+ this.pendingSyncedTransactions[
368
+ this.pendingSyncedTransactions.length - 1
369
+ ]
370
+ if (!pendingTransaction) {
371
+ throw new Error(`No pending sync transaction to commit`)
372
+ }
373
+ if (pendingTransaction.committed) {
241
374
  throw new Error(
242
- `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
375
+ `The pending sync transaction is already committed, you can't commit it again.`
243
376
  )
244
377
  }
245
- }
246
378
 
247
- const message: ChangeMessage<T> = {
248
- ...messageWithoutKey,
249
- key,
250
- }
251
- pendingTransaction.operations.push(message)
252
- },
253
- commit: () => {
254
- const pendingTransaction =
255
- this.pendingSyncedTransactions[
256
- this.pendingSyncedTransactions.length - 1
257
- ]
258
- if (!pendingTransaction) {
259
- throw new Error(`No pending sync transaction to commit`)
379
+ pendingTransaction.committed = true
380
+ this.commitPendingTransactions()
381
+
382
+ // Update status to ready after first commit
383
+ if (this._status === `loading`) {
384
+ this.setStatus(`ready`)
385
+ }
386
+ },
387
+ })
388
+
389
+ // Store cleanup function if provided
390
+ this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null
391
+ } catch (error) {
392
+ this.setStatus(`error`)
393
+ throw error
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Preload the collection data by starting sync if not already started
399
+ * Multiple concurrent calls will share the same promise
400
+ */
401
+ public preload(): Promise<void> {
402
+ if (this.preloadPromise) {
403
+ return this.preloadPromise
404
+ }
405
+
406
+ this.preloadPromise = new Promise<void>((resolve, reject) => {
407
+ if (this._status === `ready`) {
408
+ resolve()
409
+ return
410
+ }
411
+
412
+ if (this._status === `error`) {
413
+ reject(new Error(`Collection is in error state`))
414
+ return
415
+ }
416
+
417
+ // Register callback BEFORE starting sync to avoid race condition
418
+ this.onFirstCommit(() => {
419
+ resolve()
420
+ })
421
+
422
+ // Start sync if collection hasn't started yet or was cleaned up
423
+ if (this._status === `idle` || this._status === `cleaned-up`) {
424
+ try {
425
+ this.startSync()
426
+ } catch (error) {
427
+ reject(error)
428
+ return
260
429
  }
261
- if (pendingTransaction.committed) {
430
+ }
431
+ })
432
+
433
+ return this.preloadPromise
434
+ }
435
+
436
+ /**
437
+ * Clean up the collection by stopping sync and clearing data
438
+ * This can be called manually or automatically by garbage collection
439
+ */
440
+ public async cleanup(): Promise<void> {
441
+ // Clear GC timeout
442
+ if (this.gcTimeoutId) {
443
+ clearTimeout(this.gcTimeoutId)
444
+ this.gcTimeoutId = null
445
+ }
446
+
447
+ // Stop sync - wrap in try/catch since it's user-provided code
448
+ try {
449
+ if (this.syncCleanupFn) {
450
+ this.syncCleanupFn()
451
+ this.syncCleanupFn = null
452
+ }
453
+ } catch (error) {
454
+ // Re-throw in a microtask to surface the error after cleanup completes
455
+ queueMicrotask(() => {
456
+ if (error instanceof Error) {
457
+ // Preserve the original error and stack trace
458
+ const wrappedError = new Error(
459
+ `Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
460
+ )
461
+ wrappedError.cause = error
462
+ wrappedError.stack = error.stack
463
+ throw wrappedError
464
+ } else {
262
465
  throw new Error(
263
- `The pending sync transaction is already committed, you can't commit it again.`
466
+ `Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
264
467
  )
265
468
  }
469
+ })
470
+ }
266
471
 
267
- pendingTransaction.committed = true
268
- this.commitPendingTransactions()
269
- },
270
- })
472
+ // Clear data
473
+ this.syncedData.clear()
474
+ this.syncedMetadata.clear()
475
+ this.derivedUpserts.clear()
476
+ this.derivedDeletes.clear()
477
+ this._size = 0
478
+ this.pendingSyncedTransactions = []
479
+ this.syncedKeys.clear()
480
+ this.hasReceivedFirstCommit = false
481
+ this.onFirstCommitCallbacks = []
482
+ this.preloadPromise = null
483
+
484
+ // Update status
485
+ this.setStatus(`cleaned-up`)
486
+
487
+ return Promise.resolve()
488
+ }
489
+
490
+ /**
491
+ * Start the garbage collection timer
492
+ * Called when the collection becomes inactive (no subscribers)
493
+ */
494
+ private startGCTimer(): void {
495
+ if (this.gcTimeoutId) {
496
+ clearTimeout(this.gcTimeoutId)
497
+ }
498
+
499
+ const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
500
+ this.gcTimeoutId = setTimeout(() => {
501
+ if (this.activeSubscribersCount === 0) {
502
+ this.cleanup()
503
+ }
504
+ }, gcTime)
505
+ }
506
+
507
+ /**
508
+ * Cancel the garbage collection timer
509
+ * Called when the collection becomes active again
510
+ */
511
+ private cancelGCTimer(): void {
512
+ if (this.gcTimeoutId) {
513
+ clearTimeout(this.gcTimeoutId)
514
+ this.gcTimeoutId = null
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Increment the active subscribers count and start sync if needed
520
+ */
521
+ private addSubscriber(): void {
522
+ this.activeSubscribersCount++
523
+ this.cancelGCTimer()
524
+
525
+ // Start sync if collection was cleaned up
526
+ if (this._status === `cleaned-up` || this._status === `idle`) {
527
+ this.startSync()
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Decrement the active subscribers count and start GC timer if needed
533
+ */
534
+ private removeSubscriber(): void {
535
+ this.activeSubscribersCount--
536
+
537
+ if (this.activeSubscribersCount === 0) {
538
+ this.activeSubscribersCount = 0
539
+ this.startGCTimer()
540
+ } else if (this.activeSubscribersCount < 0) {
541
+ throw new Error(
542
+ `Active subscribers count is negative - this should never happen`
543
+ )
544
+ }
271
545
  }
272
546
 
273
547
  /**
274
548
  * Recompute optimistic state from active transactions
275
549
  */
276
550
  private recomputeOptimisticState(): void {
551
+ // Skip redundant recalculations when we're in the middle of committing sync transactions
552
+ if (this.isCommittingSyncTransactions) {
553
+ return
554
+ }
555
+
277
556
  const previousState = new Map(this.derivedUpserts)
278
557
  const previousDeletes = new Set(this.derivedDeletes)
279
558
 
@@ -281,23 +560,31 @@ export class CollectionImpl<
281
560
  this.derivedUpserts.clear()
282
561
  this.derivedDeletes.clear()
283
562
 
284
- // Apply active transactions
285
- const activeTransactions = Array.from(this.transactions.values())
563
+ const activeTransactions: Array<Transaction<any>> = []
564
+ const completedTransactions: Array<Transaction<any>> = []
565
+
566
+ for (const transaction of this.transactions.values()) {
567
+ if (transaction.state === `completed`) {
568
+ completedTransactions.push(transaction)
569
+ } else if (![`completed`, `failed`].includes(transaction.state)) {
570
+ activeTransactions.push(transaction)
571
+ }
572
+ }
573
+
574
+ // Apply active transactions only (completed transactions are handled by sync operations)
286
575
  for (const transaction of activeTransactions) {
287
- if (![`completed`, `failed`].includes(transaction.state)) {
288
- for (const mutation of transaction.mutations) {
289
- if (mutation.collection === this) {
290
- switch (mutation.type) {
291
- case `insert`:
292
- case `update`:
293
- this.derivedUpserts.set(mutation.key, mutation.modified as T)
294
- this.derivedDeletes.delete(mutation.key)
295
- break
296
- case `delete`:
297
- this.derivedUpserts.delete(mutation.key)
298
- this.derivedDeletes.add(mutation.key)
299
- break
300
- }
576
+ for (const mutation of transaction.mutations) {
577
+ if (mutation.collection === this) {
578
+ switch (mutation.type) {
579
+ case `insert`:
580
+ case `update`:
581
+ this.derivedUpserts.set(mutation.key, mutation.modified as T)
582
+ this.derivedDeletes.delete(mutation.key)
583
+ break
584
+ case `delete`:
585
+ this.derivedUpserts.delete(mutation.key)
586
+ this.derivedDeletes.add(mutation.key)
587
+ break
301
588
  }
302
589
  }
303
590
  }
@@ -310,8 +597,58 @@ export class CollectionImpl<
310
597
  const events: Array<ChangeMessage<T, TKey>> = []
311
598
  this.collectOptimisticChanges(previousState, previousDeletes, events)
312
599
 
313
- // Emit all events at once
314
- this.emitEvents(events)
600
+ // Filter out events for recently synced keys to prevent duplicates
601
+ const filteredEventsBySyncStatus = events.filter(
602
+ (event) => !this.recentlySyncedKeys.has(event.key)
603
+ )
604
+
605
+ // Filter out redundant delete events if there are pending sync transactions
606
+ // that will immediately restore the same data, but only for completed transactions
607
+ if (this.pendingSyncedTransactions.length > 0) {
608
+ const pendingSyncKeys = new Set<TKey>()
609
+ const completedTransactionMutations = new Set<string>()
610
+
611
+ // Collect keys from pending sync operations
612
+ for (const transaction of this.pendingSyncedTransactions) {
613
+ for (const operation of transaction.operations) {
614
+ pendingSyncKeys.add(operation.key as TKey)
615
+ }
616
+ }
617
+
618
+ // Collect mutation IDs from completed transactions
619
+ for (const tx of completedTransactions) {
620
+ for (const mutation of tx.mutations) {
621
+ if (mutation.collection === this) {
622
+ completedTransactionMutations.add(mutation.mutationId)
623
+ }
624
+ }
625
+ }
626
+
627
+ // Only filter out delete events for keys that:
628
+ // 1. Have pending sync operations AND
629
+ // 2. Are from completed transactions (being cleaned up)
630
+ const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
631
+ if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
632
+ // Check if this delete is from clearing optimistic state of completed transactions
633
+ // We can infer this by checking if we have no remaining optimistic mutations for this key
634
+ const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
635
+ tx.mutations.some(
636
+ (m) => m.collection === this && m.key === event.key
637
+ )
638
+ )
639
+
640
+ if (!hasActiveOptimisticMutation) {
641
+ return false // Skip this delete event as sync will restore the data
642
+ }
643
+ }
644
+ return true
645
+ })
646
+
647
+ this.emitEvents(filteredEvents)
648
+ } else {
649
+ // Emit all events if no pending sync transactions
650
+ this.emitEvents(filteredEventsBySyncStatus)
651
+ }
315
652
  }
316
653
 
317
654
  /**
@@ -492,7 +829,10 @@ export class CollectionImpl<
492
829
  for (const key of this.keys()) {
493
830
  const value = this.get(key)
494
831
  if (value !== undefined) {
495
- yield value
832
+ const { _orderByIndex, ...copy } = value as T & {
833
+ _orderByIndex?: number | string
834
+ }
835
+ yield copy as T
496
836
  }
497
837
  }
498
838
  }
@@ -504,7 +844,10 @@ export class CollectionImpl<
504
844
  for (const key of this.keys()) {
505
845
  const value = this.get(key)
506
846
  if (value !== undefined) {
507
- yield [key, value]
847
+ const { _orderByIndex, ...copy } = value as T & {
848
+ _orderByIndex?: number | string
849
+ }
850
+ yield [key, copy as T]
508
851
  }
509
852
  }
510
853
  }
@@ -514,18 +857,46 @@ export class CollectionImpl<
514
857
  * This method processes operations from pending transactions and applies them to the synced data
515
858
  */
516
859
  commitPendingTransactions = () => {
517
- if (
518
- !Array.from(this.transactions.values()).some(
519
- ({ state }) => state === `persisting`
520
- )
521
- ) {
860
+ // Check if there are any persisting transaction
861
+ let hasPersistingTransaction = false
862
+ for (const transaction of this.transactions.values()) {
863
+ if (transaction.state === `persisting`) {
864
+ hasPersistingTransaction = true
865
+ break
866
+ }
867
+ }
868
+
869
+ if (!hasPersistingTransaction) {
870
+ // Set flag to prevent redundant optimistic state recalculations
871
+ this.isCommittingSyncTransactions = true
872
+
873
+ // First collect all keys that will be affected by sync operations
522
874
  const changedKeys = new Set<TKey>()
875
+ for (const transaction of this.pendingSyncedTransactions) {
876
+ for (const operation of transaction.operations) {
877
+ changedKeys.add(operation.key as TKey)
878
+ }
879
+ }
880
+
881
+ // Use pre-captured state if available (from optimistic scenarios),
882
+ // otherwise capture current state (for pure sync scenarios)
883
+ let currentVisibleState = this.preSyncVisibleState
884
+ if (currentVisibleState.size === 0) {
885
+ // No pre-captured state, capture it now for pure sync operations
886
+ currentVisibleState = new Map<TKey, T>()
887
+ for (const key of changedKeys) {
888
+ const currentValue = this.get(key)
889
+ if (currentValue !== undefined) {
890
+ currentVisibleState.set(key, currentValue)
891
+ }
892
+ }
893
+ }
894
+
523
895
  const events: Array<ChangeMessage<T, TKey>> = []
524
896
 
525
897
  for (const transaction of this.pendingSyncedTransactions) {
526
898
  for (const operation of transaction.operations) {
527
899
  const key = operation.key as TKey
528
- changedKeys.add(key)
529
900
  this.syncedKeys.add(key)
530
901
 
531
902
  // Update metadata
@@ -548,22 +919,10 @@ export class CollectionImpl<
548
919
  break
549
920
  }
550
921
 
551
- // Update synced data and collect events
552
- const previousValue = this.syncedData.get(key)
553
-
922
+ // Update synced data
554
923
  switch (operation.type) {
555
924
  case `insert`:
556
925
  this.syncedData.set(key, operation.value)
557
- if (
558
- !this.derivedDeletes.has(key) &&
559
- !this.derivedUpserts.has(key)
560
- ) {
561
- events.push({
562
- type: `insert`,
563
- key,
564
- value: operation.value,
565
- })
566
- }
567
926
  break
568
927
  case `update`: {
569
928
  const updatedValue = Object.assign(
@@ -572,38 +931,103 @@ export class CollectionImpl<
572
931
  operation.value
573
932
  )
574
933
  this.syncedData.set(key, updatedValue)
575
- if (
576
- !this.derivedDeletes.has(key) &&
577
- !this.derivedUpserts.has(key)
578
- ) {
579
- events.push({
580
- type: `update`,
581
- key,
582
- value: updatedValue,
583
- previousValue,
584
- })
585
- }
586
934
  break
587
935
  }
588
936
  case `delete`:
589
937
  this.syncedData.delete(key)
590
- if (
591
- !this.derivedDeletes.has(key) &&
592
- !this.derivedUpserts.has(key)
593
- ) {
594
- if (previousValue) {
595
- events.push({
596
- type: `delete`,
597
- key,
598
- value: previousValue,
599
- })
600
- }
601
- }
602
938
  break
603
939
  }
604
940
  }
605
941
  }
606
942
 
943
+ // Clear optimistic state since sync operations will now provide the authoritative data
944
+ this.derivedUpserts.clear()
945
+ this.derivedDeletes.clear()
946
+
947
+ // Reset flag and recompute optimistic state for any remaining active transactions
948
+ this.isCommittingSyncTransactions = false
949
+ for (const transaction of this.transactions.values()) {
950
+ if (![`completed`, `failed`].includes(transaction.state)) {
951
+ for (const mutation of transaction.mutations) {
952
+ if (mutation.collection === this) {
953
+ switch (mutation.type) {
954
+ case `insert`:
955
+ case `update`:
956
+ this.derivedUpserts.set(mutation.key, mutation.modified as T)
957
+ this.derivedDeletes.delete(mutation.key)
958
+ break
959
+ case `delete`:
960
+ this.derivedUpserts.delete(mutation.key)
961
+ this.derivedDeletes.add(mutation.key)
962
+ break
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+
969
+ // Check for redundant sync operations that match completed optimistic operations
970
+ const completedOptimisticOps = new Map<TKey, any>()
971
+
972
+ for (const transaction of this.transactions.values()) {
973
+ if (transaction.state === `completed`) {
974
+ for (const mutation of transaction.mutations) {
975
+ if (mutation.collection === this && changedKeys.has(mutation.key)) {
976
+ completedOptimisticOps.set(mutation.key, {
977
+ type: mutation.type,
978
+ value: mutation.modified,
979
+ })
980
+ }
981
+ }
982
+ }
983
+ }
984
+
985
+ // Now check what actually changed in the final visible state
986
+ for (const key of changedKeys) {
987
+ const previousVisibleValue = currentVisibleState.get(key)
988
+ const newVisibleValue = this.get(key) // This returns the new derived state
989
+
990
+ // Check if this sync operation is redundant with a completed optimistic operation
991
+ const completedOp = completedOptimisticOps.get(key)
992
+ const isRedundantSync =
993
+ completedOp &&
994
+ newVisibleValue !== undefined &&
995
+ this.deepEqual(completedOp.value, newVisibleValue)
996
+
997
+ if (!isRedundantSync) {
998
+ if (
999
+ previousVisibleValue === undefined &&
1000
+ newVisibleValue !== undefined
1001
+ ) {
1002
+ events.push({
1003
+ type: `insert`,
1004
+ key,
1005
+ value: newVisibleValue,
1006
+ })
1007
+ } else if (
1008
+ previousVisibleValue !== undefined &&
1009
+ newVisibleValue === undefined
1010
+ ) {
1011
+ events.push({
1012
+ type: `delete`,
1013
+ key,
1014
+ value: previousVisibleValue,
1015
+ })
1016
+ } else if (
1017
+ previousVisibleValue !== undefined &&
1018
+ newVisibleValue !== undefined &&
1019
+ !this.deepEqual(previousVisibleValue, newVisibleValue)
1020
+ ) {
1021
+ events.push({
1022
+ type: `update`,
1023
+ key,
1024
+ value: newVisibleValue,
1025
+ previousValue: previousVisibleValue,
1026
+ })
1027
+ }
1028
+ }
1029
+ }
1030
+
607
1031
  // Update cached size after synced data changes
608
1032
  this._size = this.calculateSize()
609
1033
 
@@ -612,6 +1036,14 @@ export class CollectionImpl<
612
1036
 
613
1037
  this.pendingSyncedTransactions = []
614
1038
 
1039
+ // Clear the pre-sync state since sync operations are complete
1040
+ this.preSyncVisibleState.clear()
1041
+
1042
+ // Clear recently synced keys after a microtask to allow recomputeOptimisticState to see them
1043
+ Promise.resolve().then(() => {
1044
+ this.recentlySyncedKeys.clear()
1045
+ })
1046
+
615
1047
  // Call any registered one-time commit listeners
616
1048
  if (!this.hasReceivedFirstCommit) {
617
1049
  this.hasReceivedFirstCommit = true
@@ -647,6 +1079,29 @@ export class CollectionImpl<
647
1079
  return `KEY::${this.id}/${key}`
648
1080
  }
649
1081
 
1082
+ private deepEqual(a: any, b: any): boolean {
1083
+ if (a === b) return true
1084
+ if (a == null || b == null) return false
1085
+ if (typeof a !== typeof b) return false
1086
+
1087
+ if (typeof a === `object`) {
1088
+ if (Array.isArray(a) !== Array.isArray(b)) return false
1089
+
1090
+ const keysA = Object.keys(a)
1091
+ const keysB = Object.keys(b)
1092
+ if (keysA.length !== keysB.length) return false
1093
+
1094
+ const keysBSet = new Set(keysB)
1095
+ for (const key of keysA) {
1096
+ if (!keysBSet.has(key)) return false
1097
+ if (!this.deepEqual(a[key], b[key])) return false
1098
+ }
1099
+ return true
1100
+ }
1101
+
1102
+ return false
1103
+ }
1104
+
650
1105
  private validateData(
651
1106
  data: unknown,
652
1107
  type: `insert` | `update`,
@@ -733,6 +1188,8 @@ export class CollectionImpl<
733
1188
  * insert({ text: "Buy groceries" }, { key: "grocery-task" })
734
1189
  */
735
1190
  insert = (data: T | Array<T>, config?: InsertConfig) => {
1191
+ this.validateCollectionUsable(`insert`)
1192
+
736
1193
  const ambientTransaction = getActiveTransaction()
737
1194
 
738
1195
  // If no ambient transaction exists, check for an onInsert handler early
@@ -878,6 +1335,8 @@ export class CollectionImpl<
878
1335
  throw new Error(`The first argument to update is missing`)
879
1336
  }
880
1337
 
1338
+ this.validateCollectionUsable(`update`)
1339
+
881
1340
  const ambientTransaction = getActiveTransaction()
882
1341
 
883
1342
  // If no ambient transaction exists, check for an onUpdate handler early
@@ -1043,6 +1502,8 @@ export class CollectionImpl<
1043
1502
  keys: Array<TKey> | TKey,
1044
1503
  config?: OperationConfig
1045
1504
  ): TransactionType<any> => {
1505
+ this.validateCollectionUsable(`delete`)
1506
+
1046
1507
  const ambientTransaction = getActiveTransaction()
1047
1508
 
1048
1509
  // If no ambient transaction exists, check for an onDelete handler early
@@ -1211,6 +1672,9 @@ export class CollectionImpl<
1211
1672
  callback: (changes: Array<ChangeMessage<T>>) => void,
1212
1673
  { includeInitialState = false }: { includeInitialState?: boolean } = {}
1213
1674
  ): () => void {
1675
+ // Start sync and track subscriber
1676
+ this.addSubscriber()
1677
+
1214
1678
  if (includeInitialState) {
1215
1679
  // First send the current state as changes
1216
1680
  callback(this.currentStateAsChanges())
@@ -1221,6 +1685,7 @@ export class CollectionImpl<
1221
1685
 
1222
1686
  return () => {
1223
1687
  this.changeListeners.delete(callback)
1688
+ this.removeSubscriber()
1224
1689
  }
1225
1690
  }
1226
1691
 
@@ -1232,6 +1697,9 @@ export class CollectionImpl<
1232
1697
  listener: ChangeListener<T, TKey>,
1233
1698
  { includeInitialState = false }: { includeInitialState?: boolean } = {}
1234
1699
  ): () => void {
1700
+ // Start sync and track subscriber
1701
+ this.addSubscriber()
1702
+
1235
1703
  if (!this.changeKeyListeners.has(key)) {
1236
1704
  this.changeKeyListeners.set(key, new Set())
1237
1705
  }
@@ -1257,6 +1725,40 @@ export class CollectionImpl<
1257
1725
  this.changeKeyListeners.delete(key)
1258
1726
  }
1259
1727
  }
1728
+ this.removeSubscriber()
1729
+ }
1730
+ }
1731
+
1732
+ /**
1733
+ * Capture visible state for keys that will be affected by pending sync operations
1734
+ * This must be called BEFORE onTransactionStateChange clears optimistic state
1735
+ */
1736
+ private capturePreSyncVisibleState(): void {
1737
+ if (this.pendingSyncedTransactions.length === 0) return
1738
+
1739
+ // Clear any previous capture
1740
+ this.preSyncVisibleState.clear()
1741
+
1742
+ // Get all keys that will be affected by sync operations
1743
+ const syncedKeys = new Set<TKey>()
1744
+ for (const transaction of this.pendingSyncedTransactions) {
1745
+ for (const operation of transaction.operations) {
1746
+ syncedKeys.add(operation.key as TKey)
1747
+ }
1748
+ }
1749
+
1750
+ // Mark keys as about to be synced to suppress intermediate events from recomputeOptimisticState
1751
+ for (const key of syncedKeys) {
1752
+ this.recentlySyncedKeys.add(key)
1753
+ }
1754
+
1755
+ // Only capture current visible state for keys that will be affected by sync operations
1756
+ // This is much more efficient than capturing the entire collection state
1757
+ for (const key of syncedKeys) {
1758
+ const currentValue = this.get(key)
1759
+ if (currentValue !== undefined) {
1760
+ this.preSyncVisibleState.set(key, currentValue)
1761
+ }
1260
1762
  }
1261
1763
  }
1262
1764
 
@@ -1265,6 +1767,9 @@ export class CollectionImpl<
1265
1767
  * This method should be called by the Transaction class when state changes
1266
1768
  */
1267
1769
  public onTransactionStateChange(): void {
1770
+ // CRITICAL: Capture visible state BEFORE clearing optimistic state
1771
+ this.capturePreSyncVisibleState()
1772
+
1268
1773
  this.recomputeOptimisticState()
1269
1774
  }
1270
1775
 
@@ -1279,7 +1784,7 @@ export class CollectionImpl<
1279
1784
  public asStoreMap(): Store<Map<TKey, T>> {
1280
1785
  if (!this._storeMap) {
1281
1786
  this._storeMap = new Store(new Map(this.entries()))
1282
- this.subscribeChanges(() => {
1787
+ this.changeListeners.add(() => {
1283
1788
  this._storeMap!.setState(() => new Map(this.entries()))
1284
1789
  })
1285
1790
  }
@@ -1297,7 +1802,7 @@ export class CollectionImpl<
1297
1802
  public asStoreArray(): Store<Array<T>> {
1298
1803
  if (!this._storeArray) {
1299
1804
  this._storeArray = new Store(this.toArray)
1300
- this.subscribeChanges(() => {
1805
+ this.changeListeners.add(() => {
1301
1806
  this._storeArray!.setState(() => this.toArray)
1302
1807
  })
1303
1808
  }