@tanstack/db 0.4.1 → 0.4.2

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 (60) hide show
  1. package/dist/cjs/collection/events.cjs +2 -1
  2. package/dist/cjs/collection/events.cjs.map +1 -1
  3. package/dist/cjs/collection/lifecycle.cjs +11 -5
  4. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  5. package/dist/cjs/collection/lifecycle.d.cts +1 -1
  6. package/dist/cjs/collection/state.cjs +1 -1
  7. package/dist/cjs/collection/state.cjs.map +1 -1
  8. package/dist/cjs/indexes/btree-index.cjs +19 -13
  9. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  10. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  11. package/dist/cjs/query/builder/functions.d.cts +1 -1
  12. package/dist/cjs/query/compiler/evaluators.cjs +3 -2
  13. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  14. package/dist/cjs/query/compiler/group-by.cjs +6 -2
  15. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  16. package/dist/cjs/query/compiler/joins.cjs +2 -1
  17. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  18. package/dist/cjs/query/optimizer.cjs +8 -3
  19. package/dist/cjs/query/optimizer.cjs.map +1 -1
  20. package/dist/cjs/query/optimizer.d.cts +2 -0
  21. package/dist/cjs/types.d.cts +8 -2
  22. package/dist/cjs/utils/comparison.cjs +7 -0
  23. package/dist/cjs/utils/comparison.cjs.map +1 -1
  24. package/dist/cjs/utils/comparison.d.cts +4 -0
  25. package/dist/esm/collection/events.js +2 -1
  26. package/dist/esm/collection/events.js.map +1 -1
  27. package/dist/esm/collection/lifecycle.d.ts +1 -1
  28. package/dist/esm/collection/lifecycle.js +12 -6
  29. package/dist/esm/collection/lifecycle.js.map +1 -1
  30. package/dist/esm/collection/state.js +1 -1
  31. package/dist/esm/collection/state.js.map +1 -1
  32. package/dist/esm/indexes/btree-index.js +20 -14
  33. package/dist/esm/indexes/btree-index.js.map +1 -1
  34. package/dist/esm/query/builder/functions.d.ts +1 -1
  35. package/dist/esm/query/builder/functions.js.map +1 -1
  36. package/dist/esm/query/compiler/evaluators.js +3 -2
  37. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  38. package/dist/esm/query/compiler/group-by.js +6 -2
  39. package/dist/esm/query/compiler/group-by.js.map +1 -1
  40. package/dist/esm/query/compiler/joins.js +2 -1
  41. package/dist/esm/query/compiler/joins.js.map +1 -1
  42. package/dist/esm/query/optimizer.d.ts +2 -0
  43. package/dist/esm/query/optimizer.js +8 -3
  44. package/dist/esm/query/optimizer.js.map +1 -1
  45. package/dist/esm/types.d.ts +8 -2
  46. package/dist/esm/utils/comparison.d.ts +4 -0
  47. package/dist/esm/utils/comparison.js +8 -1
  48. package/dist/esm/utils/comparison.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/collection/events.ts +1 -1
  51. package/src/collection/lifecycle.ts +20 -8
  52. package/src/collection/state.ts +1 -1
  53. package/src/indexes/btree-index.ts +24 -14
  54. package/src/query/builder/functions.ts +3 -3
  55. package/src/query/compiler/evaluators.ts +3 -2
  56. package/src/query/compiler/group-by.ts +15 -2
  57. package/src/query/compiler/joins.ts +6 -1
  58. package/src/query/optimizer.ts +28 -6
  59. package/src/types.ts +8 -2
  60. package/src/utils/comparison.ts +10 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CollectionInErrorStateError,
3
+ CollectionStateError,
3
4
  InvalidCollectionStatusTransitionError,
4
5
  } from "../errors"
5
6
  import {
@@ -90,7 +91,18 @@ export class CollectionLifecycleManager<
90
91
  * Safely update the collection status with validation
91
92
  * @private
92
93
  */
93
- public setStatus(newStatus: CollectionStatus): void {
94
+ public setStatus(
95
+ newStatus: CollectionStatus,
96
+ allowReady: boolean = false
97
+ ): void {
98
+ if (newStatus === `ready` && !allowReady) {
99
+ // setStatus('ready') is an internal method that should not be called directly
100
+ // Instead, use markReady to transition to ready triggering the necessary events
101
+ // and side effects.
102
+ throw new CollectionStateError(
103
+ `You can't directly call "setStatus('ready'). You must use markReady instead.`
104
+ )
105
+ }
94
106
  this.validateStatusTransition(this.status, newStatus)
95
107
  const previousStatus = this.status
96
108
  this.status = newStatus
@@ -129,9 +141,10 @@ export class CollectionLifecycleManager<
129
141
  * @private - Should only be called by sync implementations
130
142
  */
131
143
  public markReady(): void {
144
+ this.validateStatusTransition(this.status, `ready`)
132
145
  // Can transition to ready from loading or initialCommit states
133
146
  if (this.status === `loading` || this.status === `initialCommit`) {
134
- this.setStatus(`ready`)
147
+ this.setStatus(`ready`, true)
135
148
 
136
149
  // Call any registered first ready callbacks (only on first time becoming ready)
137
150
  if (!this.hasBeenReady) {
@@ -146,12 +159,11 @@ export class CollectionLifecycleManager<
146
159
  this.onFirstReadyCallbacks = []
147
160
  callbacks.forEach((callback) => callback())
148
161
  }
149
- }
150
-
151
- // Always notify dependents when markReady is called, after status is set
152
- // This ensures live queries get notified when their dependencies become ready
153
- if (this.changes.changeSubscriptions.size > 0) {
154
- this.changes.emitEmptyReadyEvent()
162
+ // Notify dependents when markReady is called, after status is set
163
+ // This ensures live queries get notified when their dependencies become ready
164
+ if (this.changes.changeSubscriptions.size > 0) {
165
+ this.changes.emitEmptyReadyEvent()
166
+ }
155
167
  }
156
168
  }
157
169
 
@@ -644,7 +644,7 @@ export class CollectionStateManager<
644
644
 
645
645
  // Ensure listeners are active before emitting this critical batch
646
646
  if (this.lifecycle.status !== `ready`) {
647
- this.lifecycle.setStatus(`ready`)
647
+ this.lifecycle.markReady()
648
648
  }
649
649
  }
650
650
 
@@ -1,5 +1,5 @@
1
1
  import { BTree } from "../utils/btree.js"
2
- import { defaultComparator } from "../utils/comparison.js"
2
+ import { defaultComparator, normalizeValue } from "../utils/comparison.js"
3
3
  import { BaseIndex } from "./base-index.js"
4
4
  import type { BasicExpression } from "../query/ir.js"
5
5
  import type { IndexOperation } from "./base-index.js"
@@ -71,15 +71,18 @@ export class BTreeIndex<
71
71
  )
72
72
  }
73
73
 
74
+ // Normalize the value for Map key usage
75
+ const normalizedValue = normalizeValue(indexedValue)
76
+
74
77
  // Check if this value already exists
75
- if (this.valueMap.has(indexedValue)) {
78
+ if (this.valueMap.has(normalizedValue)) {
76
79
  // Add to existing set
77
- this.valueMap.get(indexedValue)!.add(key)
80
+ this.valueMap.get(normalizedValue)!.add(key)
78
81
  } else {
79
82
  // Create new set for this value
80
83
  const keySet = new Set<TKey>([key])
81
- this.valueMap.set(indexedValue, keySet)
82
- this.orderedEntries.set(indexedValue, undefined)
84
+ this.valueMap.set(normalizedValue, keySet)
85
+ this.orderedEntries.set(normalizedValue, undefined)
83
86
  }
84
87
 
85
88
  this.indexedKeys.add(key)
@@ -101,16 +104,19 @@ export class BTreeIndex<
101
104
  return
102
105
  }
103
106
 
104
- if (this.valueMap.has(indexedValue)) {
105
- const keySet = this.valueMap.get(indexedValue)!
107
+ // Normalize the value for Map key usage
108
+ const normalizedValue = normalizeValue(indexedValue)
109
+
110
+ if (this.valueMap.has(normalizedValue)) {
111
+ const keySet = this.valueMap.get(normalizedValue)!
106
112
  keySet.delete(key)
107
113
 
108
114
  // If set is now empty, remove the entry entirely
109
115
  if (keySet.size === 0) {
110
- this.valueMap.delete(indexedValue)
116
+ this.valueMap.delete(normalizedValue)
111
117
 
112
118
  // Remove from ordered entries
113
- this.orderedEntries.delete(indexedValue)
119
+ this.orderedEntries.delete(normalizedValue)
114
120
  }
115
121
  }
116
122
 
@@ -195,7 +201,8 @@ export class BTreeIndex<
195
201
  * Performs an equality lookup
196
202
  */
197
203
  equalityLookup(value: any): Set<TKey> {
198
- return new Set(this.valueMap.get(value) ?? [])
204
+ const normalizedValue = normalizeValue(value)
205
+ return new Set(this.valueMap.get(normalizedValue) ?? [])
199
206
  }
200
207
 
201
208
  /**
@@ -206,8 +213,10 @@ export class BTreeIndex<
206
213
  const { from, to, fromInclusive = true, toInclusive = true } = options
207
214
  const result = new Set<TKey>()
208
215
 
209
- const fromKey = from ?? this.orderedEntries.minKey()
210
- const toKey = to ?? this.orderedEntries.maxKey()
216
+ const normalizedFrom = normalizeValue(from)
217
+ const normalizedTo = normalizeValue(to)
218
+ const fromKey = normalizedFrom ?? this.orderedEntries.minKey()
219
+ const toKey = normalizedTo ?? this.orderedEntries.maxKey()
211
220
 
212
221
  this.orderedEntries.forRange(
213
222
  fromKey,
@@ -240,7 +249,7 @@ export class BTreeIndex<
240
249
  const keysInResult: Set<TKey> = new Set()
241
250
  const result: Array<TKey> = []
242
251
  const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
243
- let key = from
252
+ let key = normalizeValue(from)
244
253
 
245
254
  while ((key = nextKey(key)) && result.length < n) {
246
255
  const keys = this.valueMap.get(key)
@@ -266,7 +275,8 @@ export class BTreeIndex<
266
275
  const result = new Set<TKey>()
267
276
 
268
277
  for (const value of values) {
269
- const keys = this.valueMap.get(value)
278
+ const normalizedValue = normalizeValue(value)
279
+ const keys = this.valueMap.get(normalizedValue)
270
280
  if (keys) {
271
281
  keys.forEach((key) => result.add(key))
272
282
  }
@@ -53,10 +53,10 @@ type ExtractType<T> =
53
53
  // Helper type to determine aggregate return type based on input nullability
54
54
  type AggregateReturnType<T> =
55
55
  ExtractType<T> extends infer U
56
- ? U extends number | undefined | null
56
+ ? U extends number | undefined | null | Date | bigint
57
57
  ? Aggregate<U>
58
- : Aggregate<number | undefined | null>
59
- : Aggregate<number | undefined | null>
58
+ : Aggregate<number | undefined | null | Date | bigint>
59
+ : Aggregate<number | undefined | null | Date | bigint>
60
60
 
61
61
  // Helper type to determine string function return type based on input nullability
62
62
  type StringFunctionReturnType<T> =
@@ -3,6 +3,7 @@ import {
3
3
  UnknownExpressionTypeError,
4
4
  UnknownFunctionError,
5
5
  } from "../../errors.js"
6
+ import { normalizeValue } from "../../utils/comparison.js"
6
7
  import type { BasicExpression, Func, PropRef } from "../ir.js"
7
8
  import type { NamespacedRow } from "../../types.js"
8
9
 
@@ -142,8 +143,8 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
142
143
  const argA = compiledArgs[0]!
143
144
  const argB = compiledArgs[1]!
144
145
  return (data) => {
145
- const a = argA(data)
146
- const b = argB(data)
146
+ const a = normalizeValue(argA(data))
147
+ const b = normalizeValue(argB(data))
147
148
  return a === b
148
149
  }
149
150
  }
@@ -349,6 +349,19 @@ function getAggregateFunction(aggExpr: Aggregate) {
349
349
  return typeof value === `number` ? value : value != null ? Number(value) : 0
350
350
  }
351
351
 
352
+ // Create a value extractor function for the expression to aggregate
353
+ const valueExtractorWithDate = ([, namespacedRow]: [
354
+ string,
355
+ NamespacedRow,
356
+ ]) => {
357
+ const value = compiledExpr(namespacedRow)
358
+ return typeof value === `number` || value instanceof Date
359
+ ? value
360
+ : value != null
361
+ ? Number(value)
362
+ : 0
363
+ }
364
+
352
365
  // Create a raw value extractor function for the expression to aggregate
353
366
  const rawValueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
354
367
  return compiledExpr(namespacedRow)
@@ -363,9 +376,9 @@ function getAggregateFunction(aggExpr: Aggregate) {
363
376
  case `avg`:
364
377
  return avg(valueExtractor)
365
378
  case `min`:
366
- return min(valueExtractor)
379
+ return min(valueExtractorWithDate)
367
380
  case `max`:
368
- return max(valueExtractor)
381
+ return max(valueExtractorWithDate)
369
382
  default:
370
383
  throw new UnsupportedAggregateFunctionError(aggExpr.name)
371
384
  }
@@ -204,7 +204,12 @@ function processJoin(
204
204
  lazyFrom.type === `queryRef` &&
205
205
  (lazyFrom.query.limit || lazyFrom.query.offset)
206
206
 
207
- if (!limitedSubquery) {
207
+ // If join expressions contain computed values (like concat functions)
208
+ // we don't optimize the join because we don't have an index over the computed values
209
+ const hasComputedJoinExpr =
210
+ mainExpr.type === `func` || joinedExpr.type === `func`
211
+
212
+ if (!limitedSubquery && !hasComputedJoinExpr) {
208
213
  // This join can be optimized by having the active collection
209
214
  // dynamically load keys into the lazy collection
210
215
  // based on the value of the joinKey and by looking up
@@ -142,6 +142,8 @@ export interface AnalyzedWhereClause {
142
142
  expression: BasicExpression<boolean>
143
143
  /** Set of table/source aliases that this WHERE clause touches */
144
144
  touchedSources: Set<string>
145
+ /** Whether this clause contains namespace-only references that prevent pushdown */
146
+ hasNamespaceOnlyRef: boolean
145
147
  }
146
148
 
147
149
  /**
@@ -486,19 +488,31 @@ function splitAndClausesRecursive(
486
488
  * This determines whether a clause can be pushed down to a specific table
487
489
  * or must remain in the main query (for multi-source clauses like join conditions).
488
490
  *
491
+ * Special handling for namespace-only references in outer joins:
492
+ * WHERE clauses that reference only a table namespace (e.g., isUndefined(special), eq(special, value))
493
+ * rather than specific properties (e.g., isUndefined(special.id), eq(special.id, value)) are treated as
494
+ * multi-source to prevent incorrect predicate pushdown that would change join semantics.
495
+ *
489
496
  * @param clause - The WHERE expression to analyze
490
497
  * @returns Analysis result with the expression and touched source aliases
491
498
  *
492
499
  * @example
493
500
  * ```typescript
494
- * // eq(users.department_id, 1) -> touches ['users']
495
- * // eq(users.id, posts.user_id) -> touches ['users', 'posts']
501
+ * // eq(users.department_id, 1) -> touches ['users'], hasNamespaceOnlyRef: false
502
+ * // eq(users.id, posts.user_id) -> touches ['users', 'posts'], hasNamespaceOnlyRef: false
503
+ * // isUndefined(special) -> touches ['special'], hasNamespaceOnlyRef: true (prevents pushdown)
504
+ * // eq(special, someValue) -> touches ['special'], hasNamespaceOnlyRef: true (prevents pushdown)
505
+ * // isUndefined(special.id) -> touches ['special'], hasNamespaceOnlyRef: false (allows pushdown)
506
+ * // eq(special.id, 5) -> touches ['special'], hasNamespaceOnlyRef: false (allows pushdown)
496
507
  * ```
497
508
  */
498
509
  function analyzeWhereClause(
499
510
  clause: BasicExpression<boolean>
500
511
  ): AnalyzedWhereClause {
512
+ // Track which table aliases this WHERE clause touches
501
513
  const touchedSources = new Set<string>()
514
+ // Track whether this clause contains namespace-only references that prevent pushdown
515
+ let hasNamespaceOnlyRef = false
502
516
 
503
517
  /**
504
518
  * Recursively collect all table aliases referenced in an expression
@@ -511,6 +525,13 @@ function analyzeWhereClause(
511
525
  const firstElement = expr.path[0]
512
526
  if (firstElement) {
513
527
  touchedSources.add(firstElement)
528
+
529
+ // If the path has only one element (just the namespace),
530
+ // this is a namespace-only reference that should not be pushed down
531
+ // This applies to ANY function, not just existence-checking functions
532
+ if (expr.path.length === 1) {
533
+ hasNamespaceOnlyRef = true
534
+ }
514
535
  }
515
536
  }
516
537
  break
@@ -537,6 +558,7 @@ function analyzeWhereClause(
537
558
  return {
538
559
  expression: clause,
539
560
  touchedSources,
561
+ hasNamespaceOnlyRef,
540
562
  }
541
563
  }
542
564
 
@@ -557,15 +579,15 @@ function groupWhereClauses(
557
579
 
558
580
  // Categorize each clause based on how many sources it touches
559
581
  for (const clause of analyzedClauses) {
560
- if (clause.touchedSources.size === 1) {
561
- // Single source clause - can be optimized
582
+ if (clause.touchedSources.size === 1 && !clause.hasNamespaceOnlyRef) {
583
+ // Single source clause without namespace-only references - can be optimized
562
584
  const source = Array.from(clause.touchedSources)[0]!
563
585
  if (!singleSource.has(source)) {
564
586
  singleSource.set(source, [])
565
587
  }
566
588
  singleSource.get(source)!.push(clause.expression)
567
- } else if (clause.touchedSources.size > 1) {
568
- // Multi-source clause - must stay in main query
589
+ } else if (clause.touchedSources.size > 1 || clause.hasNamespaceOnlyRef) {
590
+ // Multi-source clause or namespace-only reference - must stay in main query
569
591
  multiSource.push(clause.expression)
570
592
  }
571
593
  // Skip clauses that touch no sources (constants) - they don't need optimization
package/src/types.ts CHANGED
@@ -334,8 +334,14 @@ export interface BaseCollectionConfig<
334
334
  */
335
335
  gcTime?: number
336
336
  /**
337
- * Whether to start syncing immediately when the collection is created.
338
- * Defaults to false for lazy loading. Set to true to immediately sync.
337
+ * Whether to eagerly start syncing on collection creation.
338
+ * When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches.
339
+ *
340
+ * Note: Even with startSync=true, collections will pause syncing when there are no active
341
+ * subscribers (typically when components querying the collection unmount), resuming when new
342
+ * subscribers attach. This preserves normal staleTime/gcTime behavior.
343
+ *
344
+ * @default false
339
345
  */
340
346
  startSync?: boolean
341
347
  /**
@@ -110,3 +110,13 @@ export const defaultComparator = makeComparator({
110
110
  nulls: `first`,
111
111
  stringSort: `locale`,
112
112
  })
113
+
114
+ /**
115
+ * Normalize a value for comparison
116
+ */
117
+ export function normalizeValue(value: any): any {
118
+ if (value instanceof Date) {
119
+ return value.getTime()
120
+ }
121
+ return value
122
+ }