@tanstack/db 0.5.12 → 0.5.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.
@@ -52,6 +52,11 @@ export class CollectionSubscription
52
52
  {
53
53
  private loadedInitialState = false
54
54
 
55
+ // Flag to skip filtering in filterAndFlipChanges.
56
+ // This is separate from loadedInitialState because we want to allow
57
+ // requestSnapshot to still work even when filtering is skipped.
58
+ private skipFiltering = false
59
+
55
60
  // Flag to indicate that we have sent at least 1 snapshot.
56
61
  // While `snapshotSent` is false we filter out all changes from subscription to the collection.
57
62
  private snapshotSent = false
@@ -79,6 +84,16 @@ export class CollectionSubscription
79
84
  private _status: SubscriptionStatus = `ready`
80
85
  private pendingLoadSubsetPromises: Set<Promise<void>> = new Set()
81
86
 
87
+ // Cleanup function for truncate event listener
88
+ private truncateCleanup: (() => void) | undefined
89
+
90
+ // Truncate buffering state
91
+ // When a truncate occurs, we buffer changes until all loadSubset refetches complete
92
+ // This prevents a flash of missing content between deletes and new inserts
93
+ private isBufferingForTruncate = false
94
+ private truncateBuffer: Array<Array<ChangeMessage<any, any>>> = []
95
+ private pendingTruncateRefetches: Set<Promise<void>> = new Set()
96
+
82
97
  public get status(): SubscriptionStatus {
83
98
  return this._status
84
99
  }
@@ -111,6 +126,123 @@ export class CollectionSubscription
111
126
  this.filteredCallback = options.whereExpression
112
127
  ? createFilteredCallback(this.callback, options)
113
128
  : this.callback
129
+
130
+ // Listen for truncate events to re-request data after must-refetch
131
+ // When a truncate happens (e.g., from a 409 must-refetch), all collection data is cleared.
132
+ // We need to re-request all previously loaded subsets to repopulate the data.
133
+ this.truncateCleanup = this.collection.on(`truncate`, () => {
134
+ this.handleTruncate()
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Handle collection truncate event by resetting state and re-requesting subsets.
140
+ * This is called when the sync layer receives a must-refetch and clears all data.
141
+ *
142
+ * To prevent a flash of missing content, we buffer all changes (deletes from truncate
143
+ * and inserts from refetch) until all loadSubset promises resolve, then emit them together.
144
+ */
145
+ private handleTruncate() {
146
+ // Copy the loaded subsets before clearing (we'll re-request them)
147
+ const subsetsToReload = [...this.loadedSubsets]
148
+
149
+ // Only buffer if there's an actual loadSubset handler that can do async work.
150
+ // Without a loadSubset handler, there's nothing to re-request and no reason to buffer.
151
+ // This prevents unnecessary buffering in eager sync mode or when loadSubset isn't implemented.
152
+ const hasLoadSubsetHandler = this.collection._sync.syncLoadSubsetFn !== null
153
+
154
+ // If there are no subsets to reload OR no loadSubset handler, just reset state
155
+ if (subsetsToReload.length === 0 || !hasLoadSubsetHandler) {
156
+ this.snapshotSent = false
157
+ this.loadedInitialState = false
158
+ this.limitedSnapshotRowCount = 0
159
+ this.lastSentKey = undefined
160
+ this.loadedSubsets = []
161
+ return
162
+ }
163
+
164
+ // Start buffering BEFORE we receive the delete events from the truncate commit
165
+ // This ensures we capture both the deletes and subsequent inserts
166
+ this.isBufferingForTruncate = true
167
+ this.truncateBuffer = []
168
+ this.pendingTruncateRefetches.clear()
169
+
170
+ // Reset snapshot/pagination tracking state
171
+ // Note: We don't need to populate sentKeys here because filterAndFlipChanges
172
+ // will skip the delete filter when isBufferingForTruncate is true
173
+ this.snapshotSent = false
174
+ this.loadedInitialState = false
175
+ this.limitedSnapshotRowCount = 0
176
+ this.lastSentKey = undefined
177
+
178
+ // Clear the loadedSubsets array since we're re-requesting fresh
179
+ this.loadedSubsets = []
180
+
181
+ // Defer the loadSubset calls to a microtask so the truncate commit's delete events
182
+ // are buffered BEFORE the loadSubset calls potentially trigger nested commits.
183
+ // This ensures correct event ordering: deletes first, then inserts.
184
+ queueMicrotask(() => {
185
+ // Check if we were unsubscribed while waiting
186
+ if (!this.isBufferingForTruncate) {
187
+ return
188
+ }
189
+
190
+ // Re-request all previously loaded subsets and track their promises
191
+ for (const options of subsetsToReload) {
192
+ const syncResult = this.collection._sync.loadSubset(options)
193
+
194
+ // Track this loadSubset call so we can unload it later
195
+ this.loadedSubsets.push(options)
196
+ this.trackLoadSubsetPromise(syncResult)
197
+
198
+ // Track the promise for buffer flushing
199
+ if (syncResult instanceof Promise) {
200
+ this.pendingTruncateRefetches.add(syncResult)
201
+ syncResult
202
+ .catch(() => {
203
+ // Ignore errors - we still want to flush the buffer even if some requests fail
204
+ })
205
+ .finally(() => {
206
+ this.pendingTruncateRefetches.delete(syncResult)
207
+ this.checkTruncateRefetchComplete()
208
+ })
209
+ }
210
+ }
211
+
212
+ // If all loadSubset calls were synchronous (returned true), flush now
213
+ // At this point, delete events have already been buffered from the truncate commit
214
+ if (this.pendingTruncateRefetches.size === 0) {
215
+ this.flushTruncateBuffer()
216
+ }
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Check if all truncate refetch promises have completed and flush buffer if so
222
+ */
223
+ private checkTruncateRefetchComplete() {
224
+ if (
225
+ this.pendingTruncateRefetches.size === 0 &&
226
+ this.isBufferingForTruncate
227
+ ) {
228
+ this.flushTruncateBuffer()
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Flush the truncate buffer, emitting all buffered changes to the callback
234
+ */
235
+ private flushTruncateBuffer() {
236
+ this.isBufferingForTruncate = false
237
+
238
+ // Flatten all buffered changes into a single array for atomic emission
239
+ // This ensures consumers see all truncate changes (deletes + inserts) in one callback
240
+ const merged = this.truncateBuffer.flat()
241
+ if (merged.length > 0) {
242
+ this.filteredCallback(merged)
243
+ }
244
+
245
+ this.truncateBuffer = []
114
246
  }
115
247
 
116
248
  setOrderByIndex(index: IndexInterface<any>) {
@@ -174,7 +306,16 @@ export class CollectionSubscription
174
306
 
175
307
  emitEvents(changes: Array<ChangeMessage<any, any>>) {
176
308
  const newChanges = this.filterAndFlipChanges(changes)
177
- this.filteredCallback(newChanges)
309
+
310
+ if (this.isBufferingForTruncate) {
311
+ // Buffer the changes instead of emitting immediately
312
+ // This prevents a flash of missing content during truncate/refetch
313
+ if (newChanges.length > 0) {
314
+ this.truncateBuffer.push(newChanges)
315
+ }
316
+ } else {
317
+ this.filteredCallback(newChanges)
318
+ }
178
319
  }
179
320
 
180
321
  /**
@@ -244,6 +385,13 @@ export class CollectionSubscription
244
385
  (change) => !this.sentKeys.has(change.key),
245
386
  )
246
387
 
388
+ // Add keys to sentKeys BEFORE calling callback to prevent race condition.
389
+ // If a change event arrives while the callback is executing, it will see
390
+ // the keys already in sentKeys and filter out duplicates correctly.
391
+ for (const change of filteredSnapshot) {
392
+ this.sentKeys.add(change.key)
393
+ }
394
+
247
395
  this.snapshotSent = true
248
396
  this.callback(filteredSnapshot)
249
397
  return true
@@ -367,6 +515,13 @@ export class CollectionSubscription
367
515
  // Use the current count as the offset for this load
368
516
  const currentOffset = this.limitedSnapshotRowCount
369
517
 
518
+ // Add keys to sentKeys BEFORE calling callback to prevent race condition.
519
+ // If a change event arrives while the callback is executing, it will see
520
+ // the keys already in sentKeys and filter out duplicates correctly.
521
+ for (const change of changes) {
522
+ this.sentKeys.add(change.key)
523
+ }
524
+
370
525
  this.callback(changes)
371
526
 
372
527
  // Update the row count and last key after sending (for next call's offset/cursor)
@@ -441,25 +596,52 @@ export class CollectionSubscription
441
596
  * Filters and flips changes for keys that have not been sent yet.
442
597
  * Deletes are filtered out for keys that have not been sent yet.
443
598
  * Updates are flipped into inserts for keys that have not been sent yet.
599
+ * Duplicate inserts are filtered out to prevent D2 multiplicity > 1.
444
600
  */
445
601
  private filterAndFlipChanges(changes: Array<ChangeMessage<any, any>>) {
446
- if (this.loadedInitialState) {
447
- // We loaded the entire initial state
602
+ if (this.loadedInitialState || this.skipFiltering) {
603
+ // We loaded the entire initial state or filtering is explicitly skipped
448
604
  // so no need to filter or flip changes
449
605
  return changes
450
606
  }
451
607
 
608
+ // When buffering for truncate, we need all changes (including deletes) to pass through.
609
+ // This is important because:
610
+ // 1. If loadedInitialState was previously true, sentKeys will be empty
611
+ // (trackSentKeys early-returns when loadedInitialState is true)
612
+ // 2. The truncate deletes are for keys that WERE sent to the subscriber
613
+ // 3. We're collecting all changes atomically, so filtering doesn't make sense
614
+ const skipDeleteFilter = this.isBufferingForTruncate
615
+
452
616
  const newChanges = []
453
617
  for (const change of changes) {
454
618
  let newChange = change
455
- if (!this.sentKeys.has(change.key)) {
619
+ const keyInSentKeys = this.sentKeys.has(change.key)
620
+
621
+ if (!keyInSentKeys) {
456
622
  if (change.type === `update`) {
457
623
  newChange = { ...change, type: `insert`, previousValue: undefined }
458
624
  } else if (change.type === `delete`) {
459
- // filter out deletes for keys that have not been sent
460
- continue
625
+ // Filter out deletes for keys that have not been sent,
626
+ // UNLESS we're buffering for truncate (where all deletes should pass through)
627
+ if (!skipDeleteFilter) {
628
+ continue
629
+ }
461
630
  }
462
631
  this.sentKeys.add(change.key)
632
+ } else {
633
+ // Key was already sent - handle based on change type
634
+ if (change.type === `insert`) {
635
+ // Filter out duplicate inserts - the key was already inserted.
636
+ // This prevents D2 multiplicity from going above 1, which would
637
+ // cause deletes to not properly remove items (multiplicity would
638
+ // go from 2 to 1 instead of 1 to 0).
639
+ continue
640
+ } else if (change.type === `delete`) {
641
+ // Remove from sentKeys so future inserts for this key are allowed
642
+ // (e.g., after truncate + reinsert)
643
+ this.sentKeys.delete(change.key)
644
+ }
463
645
  }
464
646
  newChanges.push(newChange)
465
647
  }
@@ -467,18 +649,42 @@ export class CollectionSubscription
467
649
  }
468
650
 
469
651
  private trackSentKeys(changes: Array<ChangeMessage<any, string | number>>) {
470
- if (this.loadedInitialState) {
471
- // No need to track sent keys if we loaded the entire state.
472
- // Since we sent everything, all keys must have been observed.
652
+ if (this.loadedInitialState || this.skipFiltering) {
653
+ // No need to track sent keys if we loaded the entire state or filtering is skipped.
654
+ // Since filtering won't be applied, all keys are effectively "observed".
473
655
  return
474
656
  }
475
657
 
476
658
  for (const change of changes) {
477
- this.sentKeys.add(change.key)
659
+ if (change.type === `delete`) {
660
+ // Remove deleted keys from sentKeys so future re-inserts are allowed
661
+ this.sentKeys.delete(change.key)
662
+ } else {
663
+ // For inserts and updates, track the key as sent
664
+ this.sentKeys.add(change.key)
665
+ }
478
666
  }
479
667
  }
480
668
 
669
+ /**
670
+ * Mark that the subscription should not filter any changes.
671
+ * This is used when includeInitialState is explicitly set to false,
672
+ * meaning the caller doesn't want initial state but does want ALL future changes.
673
+ */
674
+ markAllStateAsSeen() {
675
+ this.skipFiltering = true
676
+ }
677
+
481
678
  unsubscribe() {
679
+ // Clean up truncate event listener
680
+ this.truncateCleanup?.()
681
+ this.truncateCleanup = undefined
682
+
683
+ // Clean up truncate buffer state
684
+ this.isBufferingForTruncate = false
685
+ this.truncateBuffer = []
686
+ this.pendingTruncateRefetches.clear()
687
+
482
688
  // Unload all subsets that this subscription loaded
483
689
  // We pass the exact same LoadSubsetOptions we used for loadSubset
484
690
  for (const options of this.loadedSubsets) {
@@ -12,10 +12,11 @@ import { deepEquals } from '../utils'
12
12
  import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js'
13
13
  import type { StandardSchemaV1 } from '@standard-schema/spec'
14
14
  import type {
15
- ChangeMessage,
15
+ ChangeMessageOrDeleteKeyMessage,
16
16
  CleanupFn,
17
17
  CollectionConfig,
18
18
  LoadSubsetOptions,
19
+ OptimisticChangeMessage,
19
20
  SyncConfigRes,
20
21
  } from '../types'
21
22
  import type { CollectionImpl } from './index.js'
@@ -94,7 +95,12 @@ export class CollectionSyncManager<
94
95
  deletedKeys: new Set(),
95
96
  })
96
97
  },
97
- write: (messageWithoutKey: Omit<ChangeMessage<TOutput>, `key`>) => {
98
+ write: (
99
+ messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage<
100
+ TOutput,
101
+ TKey
102
+ >,
103
+ ) => {
98
104
  const pendingTransaction =
99
105
  this.state.pendingSyncedTransactions[
100
106
  this.state.pendingSyncedTransactions.length - 1
@@ -105,12 +111,18 @@ export class CollectionSyncManager<
105
111
  if (pendingTransaction.committed) {
106
112
  throw new SyncTransactionAlreadyCommittedWriteError()
107
113
  }
108
- const key = this.config.getKey(messageWithoutKey.value)
109
114
 
110
- let messageType = messageWithoutKey.type
115
+ let key: TKey | undefined = undefined
116
+ if (`key` in messageWithOptionalKey) {
117
+ key = messageWithOptionalKey.key
118
+ } else {
119
+ key = this.config.getKey(messageWithOptionalKey.value)
120
+ }
121
+
122
+ let messageType = messageWithOptionalKey.type
111
123
 
112
124
  // Check if an item with this key already exists when inserting
113
- if (messageWithoutKey.type === `insert`) {
125
+ if (messageWithOptionalKey.type === `insert`) {
114
126
  const insertingIntoExistingSynced = this.state.syncedData.has(key)
115
127
  const hasPendingDeleteForKey =
116
128
  pendingTransaction.deletedKeys.has(key)
@@ -124,7 +136,7 @@ export class CollectionSyncManager<
124
136
  const existingValue = this.state.syncedData.get(key)
125
137
  if (
126
138
  existingValue !== undefined &&
127
- deepEquals(existingValue, messageWithoutKey.value)
139
+ deepEquals(existingValue, messageWithOptionalKey.value)
128
140
  ) {
129
141
  // The "insert" is an echo of a value we already have locally.
130
142
  // Treat it as an update so we preserve optimistic intent without
@@ -142,11 +154,11 @@ export class CollectionSyncManager<
142
154
  }
143
155
  }
144
156
 
145
- const message: ChangeMessage<TOutput> = {
146
- ...messageWithoutKey,
157
+ const message = {
158
+ ...messageWithOptionalKey,
147
159
  type: messageType,
148
160
  key,
149
- }
161
+ } as OptimisticChangeMessage<TOutput, TKey>
150
162
  pendingTransaction.operations.push(message)
151
163
 
152
164
  if (messageType === `delete`) {
package/src/types.ts CHANGED
@@ -328,7 +328,7 @@ export interface SyncConfig<
328
328
  sync: (params: {
329
329
  collection: Collection<T, TKey, any, any, any>
330
330
  begin: () => void
331
- write: (message: Omit<ChangeMessage<T>, `key`>) => void
331
+ write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void
332
332
  commit: () => void
333
333
  markReady: () => void
334
334
  truncate: () => void
@@ -361,12 +361,28 @@ export interface ChangeMessage<
361
361
  metadata?: Record<string, unknown>
362
362
  }
363
363
 
364
- export interface OptimisticChangeMessage<
364
+ export type DeleteKeyMessage<TKey extends string | number = string | number> =
365
+ Omit<ChangeMessage<any, TKey>, `value` | `previousValue` | `type`> & {
366
+ type: `delete`
367
+ }
368
+
369
+ export type ChangeMessageOrDeleteKeyMessage<
365
370
  T extends object = Record<string, unknown>,
366
- > extends ChangeMessage<T> {
367
- // Is this change message part of an active transaction. Only applies to optimistic changes.
368
- isActive?: boolean
369
- }
371
+ TKey extends string | number = string | number,
372
+ > = Omit<ChangeMessage<T>, `key`> | DeleteKeyMessage<TKey>
373
+
374
+ export type OptimisticChangeMessage<
375
+ T extends object = Record<string, unknown>,
376
+ TKey extends string | number = string | number,
377
+ > =
378
+ | (ChangeMessage<T> & {
379
+ // Is this change message part of an active transaction. Only applies to optimistic changes.
380
+ isActive?: boolean
381
+ })
382
+ | (DeleteKeyMessage<TKey> & {
383
+ // Is this change message part of an active transaction. Only applies to optimistic changes.
384
+ isActive?: boolean
385
+ })
370
386
 
371
387
  /**
372
388
  * The Standard Schema interface.
@@ -894,3 +910,6 @@ export type WritableDeep<T> = T extends BuiltIns
894
910
  : T extends object
895
911
  ? WritableObjectDeep<T>
896
912
  : unknown
913
+
914
+ export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
915
+ Partial<Pick<T, K>>