@tanstack/db 0.4.7 → 0.4.8

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 (41) hide show
  1. package/dist/cjs/collection/index.cjs.map +1 -1
  2. package/dist/cjs/collection/index.d.cts +2 -1
  3. package/dist/cjs/collection/lifecycle.cjs +2 -3
  4. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  5. package/dist/cjs/collection/state.cjs +22 -33
  6. package/dist/cjs/collection/state.cjs.map +1 -1
  7. package/dist/cjs/collection/state.d.cts +6 -2
  8. package/dist/cjs/collection/sync.cjs +4 -3
  9. package/dist/cjs/collection/sync.cjs.map +1 -1
  10. package/dist/cjs/indexes/auto-index.cjs +0 -3
  11. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  12. package/dist/cjs/query/live/collection-config-builder.cjs +54 -12
  13. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  14. package/dist/cjs/query/live/collection-config-builder.d.cts +17 -2
  15. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  16. package/dist/cjs/types.d.cts +3 -5
  17. package/dist/esm/collection/index.d.ts +2 -1
  18. package/dist/esm/collection/index.js.map +1 -1
  19. package/dist/esm/collection/lifecycle.js +2 -3
  20. package/dist/esm/collection/lifecycle.js.map +1 -1
  21. package/dist/esm/collection/state.d.ts +6 -2
  22. package/dist/esm/collection/state.js +22 -33
  23. package/dist/esm/collection/state.js.map +1 -1
  24. package/dist/esm/collection/sync.js +4 -3
  25. package/dist/esm/collection/sync.js.map +1 -1
  26. package/dist/esm/indexes/auto-index.js +0 -3
  27. package/dist/esm/indexes/auto-index.js.map +1 -1
  28. package/dist/esm/query/live/collection-config-builder.d.ts +17 -2
  29. package/dist/esm/query/live/collection-config-builder.js +54 -12
  30. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  31. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  32. package/dist/esm/types.d.ts +3 -5
  33. package/package.json +1 -1
  34. package/src/collection/index.ts +1 -1
  35. package/src/collection/lifecycle.ts +3 -4
  36. package/src/collection/state.ts +52 -48
  37. package/src/collection/sync.ts +7 -6
  38. package/src/indexes/auto-index.ts +0 -8
  39. package/src/query/live/collection-config-builder.ts +103 -24
  40. package/src/query/live/collection-subscriber.ts +3 -3
  41. package/src/types.ts +3 -5
@@ -12,11 +12,18 @@ import type { CollectionLifecycleManager } from "./lifecycle"
12
12
  import type { CollectionChangesManager } from "./changes"
13
13
  import type { CollectionIndexesManager } from "./indexes"
14
14
 
15
- interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
15
+ interface PendingSyncedTransaction<
16
+ T extends object = Record<string, unknown>,
17
+ TKey extends string | number = string | number,
18
+ > {
16
19
  committed: boolean
17
20
  operations: Array<OptimisticChangeMessage<T>>
18
21
  truncate?: boolean
19
22
  deletedKeys: Set<string | number>
23
+ optimisticSnapshot?: {
24
+ upserts: Map<TKey, T>
25
+ deletes: Set<TKey>
26
+ }
20
27
  }
21
28
 
22
29
  export class CollectionStateManager<
@@ -33,8 +40,9 @@ export class CollectionStateManager<
33
40
 
34
41
  // Core state - make public for testing
35
42
  public transactions: SortedMap<string, Transaction<any>>
36
- public pendingSyncedTransactions: Array<PendingSyncedTransaction<TOutput>> =
37
- []
43
+ public pendingSyncedTransactions: Array<
44
+ PendingSyncedTransaction<TOutput, TKey>
45
+ > = []
38
46
  public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
39
47
  public syncedMetadata = new Map<TKey, unknown>()
40
48
 
@@ -442,10 +450,10 @@ export class CollectionStateManager<
442
450
  },
443
451
  {
444
452
  committedSyncedTransactions: [] as Array<
445
- PendingSyncedTransaction<TOutput>
453
+ PendingSyncedTransaction<TOutput, TKey>
446
454
  >,
447
455
  uncommittedSyncedTransactions: [] as Array<
448
- PendingSyncedTransaction<TOutput>
456
+ PendingSyncedTransaction<TOutput, TKey>
449
457
  >,
450
458
  hasTruncateSync: false,
451
459
  }
@@ -455,6 +463,12 @@ export class CollectionStateManager<
455
463
  // Set flag to prevent redundant optimistic state recalculations
456
464
  this.isCommittingSyncTransactions = true
457
465
 
466
+ // Get the optimistic snapshot from the truncate transaction (captured when truncate() was called)
467
+ const truncateOptimisticSnapshot = hasTruncateSync
468
+ ? committedSyncedTransactions.find((t) => t.truncate)
469
+ ?.optimisticSnapshot
470
+ : null
471
+
458
472
  // First collect all keys that will be affected by sync operations
459
473
  const changedKeys = new Set<TKey>()
460
474
  for (const transaction of committedSyncedTransactions) {
@@ -484,13 +498,19 @@ export class CollectionStateManager<
484
498
  // Handle truncate operations first
485
499
  if (transaction.truncate) {
486
500
  // TRUNCATE PHASE
487
- // 1) Emit a delete for every currently-synced key so downstream listeners/indexes
501
+ // 1) Emit a delete for every visible key (synced + optimistic) so downstream listeners/indexes
488
502
  // observe a clear-before-rebuild. We intentionally skip keys already in
489
503
  // optimisticDeletes because their delete was previously emitted by the user.
490
- for (const key of this.syncedData.keys()) {
491
- if (this.optimisticDeletes.has(key)) continue
504
+ // Use the snapshot to ensure we emit deletes for all items that existed at truncate start.
505
+ const visibleKeys = new Set([
506
+ ...this.syncedData.keys(),
507
+ ...(truncateOptimisticSnapshot?.upserts.keys() || []),
508
+ ])
509
+ for (const key of visibleKeys) {
510
+ if (truncateOptimisticSnapshot?.deletes.has(key)) continue
492
511
  const previousValue =
493
- this.optimisticUpserts.get(key) || this.syncedData.get(key)
512
+ truncateOptimisticSnapshot?.upserts.get(key) ||
513
+ this.syncedData.get(key)
494
514
  if (previousValue !== undefined) {
495
515
  events.push({ type: `delete`, key, value: previousValue })
496
516
  }
@@ -574,41 +594,14 @@ export class CollectionStateManager<
574
594
  }
575
595
  }
576
596
 
577
- // Build re-apply sets from ACTIVE optimistic transactions against the new synced base
578
- // We do not copy maps; we compute intent directly from transactions to avoid drift.
579
- const reapplyUpserts = new Map<TKey, TOutput>()
580
- const reapplyDeletes = new Set<TKey>()
581
-
582
- for (const tx of this.transactions.values()) {
583
- if ([`completed`, `failed`].includes(tx.state)) continue
584
- for (const mutation of tx.mutations) {
585
- if (
586
- !this.isThisCollection(mutation.collection) ||
587
- !mutation.optimistic
588
- )
589
- continue
590
- const key = mutation.key as TKey
591
- switch (mutation.type) {
592
- case `insert`:
593
- reapplyUpserts.set(key, mutation.modified as TOutput)
594
- reapplyDeletes.delete(key)
595
- break
596
- case `update`: {
597
- const base = this.syncedData.get(key)
598
- const next = base
599
- ? (Object.assign({}, base, mutation.changes) as TOutput)
600
- : (mutation.modified as TOutput)
601
- reapplyUpserts.set(key, next)
602
- reapplyDeletes.delete(key)
603
- break
604
- }
605
- case `delete`:
606
- reapplyUpserts.delete(key)
607
- reapplyDeletes.add(key)
608
- break
609
- }
610
- }
611
- }
597
+ // Build re-apply sets from the snapshot taken at the start of this function.
598
+ // This prevents losing optimistic state if transactions complete during truncate processing.
599
+ const reapplyUpserts = new Map<TKey, TOutput>(
600
+ truncateOptimisticSnapshot!.upserts
601
+ )
602
+ const reapplyDeletes = new Set<TKey>(
603
+ truncateOptimisticSnapshot!.deletes
604
+ )
612
605
 
613
606
  // Emit inserts for re-applied upserts, skipping any keys that have an optimistic delete.
614
607
  // If the server also inserted/updated the same key in this batch, override that value
@@ -660,6 +653,20 @@ export class CollectionStateManager<
660
653
 
661
654
  // Reset flag and recompute optimistic state for any remaining active transactions
662
655
  this.isCommittingSyncTransactions = false
656
+
657
+ // If we had a truncate, restore the preserved optimistic state from the snapshot
658
+ // This includes items from transactions that may have completed during processing
659
+ if (hasTruncateSync && truncateOptimisticSnapshot) {
660
+ for (const [key, value] of truncateOptimisticSnapshot.upserts) {
661
+ this.optimisticUpserts.set(key, value)
662
+ }
663
+ for (const key of truncateOptimisticSnapshot.deletes) {
664
+ this.optimisticDeletes.add(key)
665
+ }
666
+ }
667
+
668
+ // Always overlay any still-active optimistic transactions so mutations that started
669
+ // after the truncate snapshot are preserved.
663
670
  for (const transaction of this.transactions.values()) {
664
671
  if (![`completed`, `failed`].includes(transaction.state)) {
665
672
  for (const mutation of transaction.mutations) {
@@ -785,12 +792,9 @@ export class CollectionStateManager<
785
792
  this.recentlySyncedKeys.clear()
786
793
  })
787
794
 
788
- // Call any registered one-time commit listeners
795
+ // Mark that we've received the first commit (for tracking purposes)
789
796
  if (!this.hasReceivedFirstCommit) {
790
797
  this.hasReceivedFirstCommit = true
791
- const callbacks = [...this.lifecycle.onFirstReadyCallbacks]
792
- this.lifecycle.onFirstReadyCallbacks = []
793
- callbacks.forEach((callback) => callback())
794
798
  }
795
799
  }
796
800
  }
@@ -148,12 +148,6 @@ export class CollectionSyncManager<
148
148
 
149
149
  pendingTransaction.committed = true
150
150
 
151
- // Update status to initialCommit when transitioning from loading
152
- // This indicates we're in the process of committing the first transaction
153
- if (this.lifecycle.status === `loading`) {
154
- this.lifecycle.setStatus(`initialCommit`)
155
- }
156
-
157
151
  this.state.commitPendingTransactions()
158
152
  },
159
153
  markReady: () => {
@@ -181,6 +175,13 @@ export class CollectionSyncManager<
181
175
  // - Subsequent synced ops applied on the fresh base
182
176
  // - Finally, optimistic mutations re-applied on top (single batch)
183
177
  pendingTransaction.truncate = true
178
+
179
+ // Capture optimistic state NOW to preserve it even if transactions complete
180
+ // before this truncate transaction is committed
181
+ pendingTransaction.optimisticSnapshot = {
182
+ upserts: new Map(this.state.optimisticUpserts),
183
+ deletes: new Set(this.state.optimisticDeletes),
184
+ }
184
185
  },
185
186
  })
186
187
  )
@@ -14,14 +14,6 @@ function shouldAutoIndex(collection: CollectionImpl<any, any, any, any, any>) {
14
14
  return false
15
15
  }
16
16
 
17
- // Don't auto-index during sync operations
18
- if (
19
- collection.status === `loading` ||
20
- collection.status === `initialCommit`
21
- ) {
22
- return false
23
- }
24
-
25
17
  return true
26
18
  }
27
19
 
@@ -21,10 +21,15 @@ import type {
21
21
  LiveQueryCollectionConfig,
22
22
  SyncState,
23
23
  } from "./types.js"
24
+ import type { AllCollectionEvents } from "../../collection/events.js"
24
25
 
25
26
  // Global counter for auto-generated collection IDs
26
27
  let liveQueryCollectionCounter = 0
27
28
 
29
+ type SyncMethods<TResult extends object> = Parameters<
30
+ SyncConfig<TResult>[`sync`]
31
+ >[0]
32
+
28
33
  export class CollectionConfigBuilder<
29
34
  TContext extends Context,
30
35
  TResult extends object = GetResult<TContext>,
@@ -44,6 +49,12 @@ export class CollectionConfigBuilder<
44
49
 
45
50
  private isGraphRunning = false
46
51
 
52
+ // Error state tracking
53
+ private isInErrorState = false
54
+
55
+ // Reference to the live query collection for error state transitions
56
+ private liveQueryCollection?: Collection<TResult, any, any>
57
+
47
58
  private graphCache: D2 | undefined
48
59
  private inputsCache: Record<string, RootStreamBuilder<unknown>> | undefined
49
60
  private pipelineCache: ResultStream | undefined
@@ -101,12 +112,12 @@ export class CollectionConfigBuilder<
101
112
  // This gives the callback a chance to load more data if needed,
102
113
  // that's used to optimize orderBy operators that set a limit,
103
114
  // in order to load some more data if we still don't have enough rows after the pipeline has run.
104
- // That can happend because even though we load N rows, the pipeline might filter some of these rows out
115
+ // That can happen because even though we load N rows, the pipeline might filter some of these rows out
105
116
  // causing the orderBy operator to receive less than N rows or even no rows at all.
106
117
  // So this callback would notice that it doesn't have enough rows and load some more.
107
- // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready.
118
+ // The callback returns a boolean, when it's true it's done loading data.
108
119
  maybeRunGraph(
109
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
120
+ config: SyncMethods<TResult>,
110
121
  syncState: FullSyncState,
111
122
  callback?: () => boolean
112
123
  ) {
@@ -120,13 +131,15 @@ export class CollectionConfigBuilder<
120
131
  this.isGraphRunning = true
121
132
 
122
133
  try {
123
- const { begin, commit, markReady } = config
134
+ const { begin, commit } = config
135
+
136
+ // Don't run if the live query is in an error state
137
+ if (this.isInErrorState) {
138
+ return
139
+ }
124
140
 
125
- // We only run the graph if all the collections are ready
126
- if (
127
- this.allCollectionsReadyOrInitialCommit() &&
128
- syncState.subscribedToAllCollections
129
- ) {
141
+ // Always run the graph if subscribed (eager execution)
142
+ if (syncState.subscribedToAllCollections) {
130
143
  while (syncState.graph.pendingWork()) {
131
144
  syncState.graph.run()
132
145
  callback?.()
@@ -137,10 +150,9 @@ export class CollectionConfigBuilder<
137
150
  if (syncState.messagesCount === 0) {
138
151
  begin()
139
152
  commit()
140
- }
141
- // Mark the collection as ready after the first successful run
142
- if (this.allCollectionsReady()) {
143
- markReady()
153
+ // After initial commit, check if we should mark ready
154
+ // (in case all sources were already ready before we subscribed)
155
+ this.updateLiveQueryStatus(config)
144
156
  }
145
157
  }
146
158
  } finally {
@@ -155,7 +167,10 @@ export class CollectionConfigBuilder<
155
167
  }
156
168
  }
157
169
 
158
- private syncFn(config: Parameters<SyncConfig<TResult>[`sync`]>[0]) {
170
+ private syncFn(config: SyncMethods<TResult>) {
171
+ // Store reference to the live query collection for error state transitions
172
+ this.liveQueryCollection = config.collection
173
+
159
174
  const syncState: SyncState = {
160
175
  messagesCount: 0,
161
176
  subscribedToAllCollections: false,
@@ -233,7 +248,7 @@ export class CollectionConfigBuilder<
233
248
  }
234
249
 
235
250
  private extendPipelineWithChangeProcessing(
236
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
251
+ config: SyncMethods<TResult>,
237
252
  syncState: SyncState
238
253
  ): FullSyncState {
239
254
  const { begin, commit } = config
@@ -266,7 +281,7 @@ export class CollectionConfigBuilder<
266
281
  }
267
282
 
268
283
  private applyChanges(
269
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
284
+ config: SyncMethods<TResult>,
270
285
  changes: {
271
286
  deletes: number
272
287
  inserts: number
@@ -317,21 +332,76 @@ export class CollectionConfigBuilder<
317
332
  }
318
333
  }
319
334
 
335
+ /**
336
+ * Handle status changes from source collections
337
+ */
338
+ private handleSourceStatusChange(
339
+ config: SyncMethods<TResult>,
340
+ collectionId: string,
341
+ event: AllCollectionEvents[`status:change`]
342
+ ) {
343
+ const { status } = event
344
+
345
+ // Handle error state - any source collection in error puts live query in error
346
+ if (status === `error`) {
347
+ this.transitionToError(
348
+ `Source collection '${collectionId}' entered error state`
349
+ )
350
+ return
351
+ }
352
+
353
+ // Handle manual cleanup - this should not happen due to GC prevention,
354
+ // but could happen if user manually calls cleanup()
355
+ if (status === `cleaned-up`) {
356
+ this.transitionToError(
357
+ `Source collection '${collectionId}' was manually cleaned up while live query '${this.id}' depends on it. ` +
358
+ `Live queries prevent automatic GC, so this was likely a manual cleanup() call.`
359
+ )
360
+ return
361
+ }
362
+
363
+ // Update ready status based on all source collections
364
+ this.updateLiveQueryStatus(config)
365
+ }
366
+
367
+ /**
368
+ * Update the live query status based on source collection statuses
369
+ */
370
+ private updateLiveQueryStatus(config: SyncMethods<TResult>) {
371
+ const { markReady } = config
372
+
373
+ // Don't update status if already in error
374
+ if (this.isInErrorState) {
375
+ return
376
+ }
377
+
378
+ // Mark ready when all source collections are ready
379
+ if (this.allCollectionsReady()) {
380
+ markReady()
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Transition the live query to error state
386
+ */
387
+ private transitionToError(message: string) {
388
+ this.isInErrorState = true
389
+
390
+ // Log error to console for debugging
391
+ console.error(`[Live Query Error] ${message}`)
392
+
393
+ // Transition live query collection to error state
394
+ this.liveQueryCollection?._lifecycle.setStatus(`error`)
395
+ }
396
+
320
397
  private allCollectionsReady() {
321
398
  return Object.values(this.collections).every((collection) =>
322
399
  collection.isReady()
323
400
  )
324
401
  }
325
402
 
326
- private allCollectionsReadyOrInitialCommit() {
327
- return Object.values(this.collections).every(
328
- (collection) =>
329
- collection.status === `ready` || collection.status === `initialCommit`
330
- )
331
- }
332
-
333
403
  private subscribeToAllCollections(
334
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
404
+ config: SyncMethods<TResult>,
335
405
  syncState: FullSyncState
336
406
  ) {
337
407
  const loaders = Object.entries(this.collections).map(
@@ -347,6 +417,12 @@ export class CollectionConfigBuilder<
347
417
  const subscription = collectionSubscriber.subscribe()
348
418
  this.subscriptions[collectionId] = subscription
349
419
 
420
+ // Subscribe to status changes for status flow
421
+ const statusUnsubscribe = collection.on(`status:change`, (event) => {
422
+ this.handleSourceStatusChange(config, collectionId, event)
423
+ })
424
+ syncState.unsubscribeCallbacks.add(statusUnsubscribe)
425
+
350
426
  const loadMore = collectionSubscriber.loadMoreIfNeeded.bind(
351
427
  collectionSubscriber,
352
428
  subscription
@@ -364,6 +440,9 @@ export class CollectionConfigBuilder<
364
440
  // Mark the collections as subscribed in the sync state
365
441
  syncState.subscribedToAllCollections = true
366
442
 
443
+ // Initial status check after all subscriptions are set up
444
+ this.updateLiveQueryStatus(config)
445
+
367
446
  return loadMoreDataCallback
368
447
  }
369
448
  }
@@ -103,9 +103,9 @@ export class CollectionSubscriber<
103
103
  // otherwise we end up in an infinite loop trying to load more data
104
104
  const dataLoader = sentChanges > 0 ? callback : undefined
105
105
 
106
- // We need to call `maybeRunGraph` even if there's no data to load
107
- // because we need to mark the collection as ready if it's not already
108
- // and that's only done in `maybeRunGraph`
106
+ // Always call maybeRunGraph to process changes eagerly.
107
+ // The graph will run unless the live query is in an error state.
108
+ // Status management is handled separately via status:change event listeners.
109
109
  this.collectionConfigBuilder.maybeRunGraph(
110
110
  this.config,
111
111
  this.syncState,
package/src/types.ts CHANGED
@@ -298,17 +298,15 @@ export type DeleteMutationFn<
298
298
  *
299
299
  * @example
300
300
  * // Status transitions
301
- * // idle → loading → initialCommit ready
301
+ * // idle → loading → ready (when markReady() is called)
302
302
  * // Any status can transition to → error or cleaned-up
303
303
  */
304
304
  export type CollectionStatus =
305
305
  /** Collection is created but sync hasn't started yet (when startSync config is false) */
306
306
  | `idle`
307
- /** Sync has started but hasn't received the first commit yet */
307
+ /** Sync has started and is loading data */
308
308
  | `loading`
309
- /** Collection is in the process of committing its first transaction */
310
- | `initialCommit`
311
- /** Collection has received at least one commit and is ready for use */
309
+ /** Collection has been explicitly marked ready via markReady() */
312
310
  | `ready`
313
311
  /** An error occurred during sync initialization */
314
312
  | `error`