@tanstack/db 0.5.15 → 0.5.17

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 (48) hide show
  1. package/dist/cjs/collection/changes.cjs +15 -1
  2. package/dist/cjs/collection/changes.cjs.map +1 -1
  3. package/dist/cjs/collection/changes.d.cts +1 -1
  4. package/dist/cjs/collection/index.cjs +8 -5
  5. package/dist/cjs/collection/index.cjs.map +1 -1
  6. package/dist/cjs/collection/index.d.cts +9 -6
  7. package/dist/cjs/collection/subscription.cjs +4 -1
  8. package/dist/cjs/collection/subscription.cjs.map +1 -1
  9. package/dist/cjs/errors.cjs +13 -0
  10. package/dist/cjs/errors.cjs.map +1 -1
  11. package/dist/cjs/errors.d.cts +3 -0
  12. package/dist/cjs/index.cjs +1 -0
  13. package/dist/cjs/index.cjs.map +1 -1
  14. package/dist/cjs/query/builder/index.cjs +12 -0
  15. package/dist/cjs/query/builder/index.cjs.map +1 -1
  16. package/dist/cjs/query/builder/index.d.cts +2 -1
  17. package/dist/cjs/query/index.d.cts +1 -1
  18. package/dist/cjs/types.d.cts +18 -2
  19. package/dist/cjs/utils.cjs +9 -0
  20. package/dist/cjs/utils.cjs.map +1 -1
  21. package/dist/esm/collection/changes.d.ts +1 -1
  22. package/dist/esm/collection/changes.js +15 -1
  23. package/dist/esm/collection/changes.js.map +1 -1
  24. package/dist/esm/collection/index.d.ts +9 -6
  25. package/dist/esm/collection/index.js +8 -5
  26. package/dist/esm/collection/index.js.map +1 -1
  27. package/dist/esm/collection/subscription.js +5 -2
  28. package/dist/esm/collection/subscription.js.map +1 -1
  29. package/dist/esm/errors.d.ts +3 -0
  30. package/dist/esm/errors.js +13 -0
  31. package/dist/esm/errors.js.map +1 -1
  32. package/dist/esm/index.js +2 -1
  33. package/dist/esm/query/builder/index.d.ts +2 -1
  34. package/dist/esm/query/builder/index.js +13 -1
  35. package/dist/esm/query/builder/index.js.map +1 -1
  36. package/dist/esm/query/index.d.ts +1 -1
  37. package/dist/esm/types.d.ts +18 -2
  38. package/dist/esm/utils.js +9 -0
  39. package/dist/esm/utils.js.map +1 -1
  40. package/package.json +4 -4
  41. package/src/collection/changes.ts +22 -2
  42. package/src/collection/index.ts +9 -6
  43. package/src/collection/subscription.ts +12 -2
  44. package/src/errors.ts +13 -0
  45. package/src/query/builder/index.ts +27 -0
  46. package/src/query/index.ts +2 -0
  47. package/src/types.ts +22 -5
  48. package/src/utils.ts +20 -0
@@ -1,4 +1,8 @@
1
1
  import { NegativeActiveSubscribersError } from '../errors'
2
+ import {
3
+ createSingleRowRefProxy,
4
+ toExpression,
5
+ } from '../query/builder/ref-proxy.js'
2
6
  import { CollectionSubscription } from './subscription.js'
3
7
  import type { StandardSchemaV1 } from '@standard-schema/spec'
4
8
  import type { ChangeMessage, SubscribeChangesOptions } from '../types'
@@ -94,13 +98,29 @@ export class CollectionChangesManager<
94
98
  */
95
99
  public subscribeChanges(
96
100
  callback: (changes: Array<ChangeMessage<TOutput>>) => void,
97
- options: SubscribeChangesOptions = {},
101
+ options: SubscribeChangesOptions<TOutput> = {},
98
102
  ): CollectionSubscription {
99
103
  // Start sync and track subscriber
100
104
  this.addSubscriber()
101
105
 
106
+ // Compile where callback to whereExpression if provided
107
+ if (options.where && options.whereExpression) {
108
+ throw new Error(
109
+ `Cannot specify both 'where' and 'whereExpression' options. Use one or the other.`,
110
+ )
111
+ }
112
+
113
+ const { where, ...opts } = options
114
+ let whereExpression = opts.whereExpression
115
+ if (where) {
116
+ const proxy = createSingleRowRefProxy<TOutput>()
117
+ const result = where(proxy)
118
+ whereExpression = toExpression(result)
119
+ }
120
+
102
121
  const subscription = new CollectionSubscription(this.collection, callback, {
103
- ...options,
122
+ ...opts,
123
+ whereExpression,
104
124
  onUnsubscribe: () => {
105
125
  this.removeSubscriber()
106
126
  this.changeSubscriptions.delete(subscription)
@@ -849,26 +849,29 @@ export class CollectionImpl<
849
849
  * }, { includeInitialState: true })
850
850
  *
851
851
  * @example
852
- * // Subscribe only to changes matching a condition
852
+ * // Subscribe only to changes matching a condition using where callback
853
+ * import { eq } from "@tanstack/db"
854
+ *
853
855
  * const subscription = collection.subscribeChanges((changes) => {
854
856
  * updateUI(changes)
855
857
  * }, {
856
858
  * includeInitialState: true,
857
- * where: (row) => row.status === 'active'
859
+ * where: (row) => eq(row.status, "active")
858
860
  * })
859
861
  *
860
862
  * @example
861
- * // Subscribe using a pre-compiled expression
863
+ * // Using multiple conditions with and()
864
+ * import { and, eq, gt } from "@tanstack/db"
865
+ *
862
866
  * const subscription = collection.subscribeChanges((changes) => {
863
867
  * updateUI(changes)
864
868
  * }, {
865
- * includeInitialState: true,
866
- * whereExpression: eq(row.status, 'active')
869
+ * where: (row) => and(eq(row.status, "active"), gt(row.priority, 5))
867
870
  * })
868
871
  */
869
872
  public subscribeChanges(
870
873
  callback: (changes: Array<ChangeMessage<TOutput>>) => void,
871
- options: SubscribeChangesOptions = {},
874
+ options: SubscribeChangesOptions<TOutput> = {},
872
875
  ): CollectionSubscription {
873
876
  return this._changes.subscribeChanges(callback, options)
874
877
  }
@@ -1,7 +1,8 @@
1
1
  import { ensureIndexForExpression } from '../indexes/auto-index.js'
2
2
  import { and, eq, gte, lt } from '../query/builder/functions.js'
3
- import { Value } from '../query/ir.js'
3
+ import { PropRef, Value } from '../query/ir.js'
4
4
  import { EventEmitter } from '../event-emitter.js'
5
+ import { compileExpression } from '../query/compiler/evaluators.js'
5
6
  import { buildCursor } from '../utils/cursor.js'
6
7
  import {
7
8
  createFilterFunctionFromExpression,
@@ -494,6 +495,13 @@ export class CollectionSubscription
494
495
  const valuesNeeded = () => Math.max(limit - changes.length, 0)
495
496
  const collectionExhausted = () => keys.length === 0
496
497
 
498
+ // Create a value extractor for the orderBy field to properly track the biggest indexed value
499
+ const orderByExpression = orderBy[0]!.expression
500
+ const valueExtractor =
501
+ orderByExpression.type === `ref`
502
+ ? compileExpression(new PropRef(orderByExpression.path), true)
503
+ : null
504
+
497
505
  while (valuesNeeded() > 0 && !collectionExhausted()) {
498
506
  const insertedKeys = new Set<string | number>() // Track keys we add to `changes` in this iteration
499
507
 
@@ -504,7 +512,9 @@ export class CollectionSubscription
504
512
  key,
505
513
  value,
506
514
  })
507
- biggestObservedValue = value
515
+ // Extract the indexed value (e.g., salary) from the row, not the full row
516
+ // This is needed for index.take() to work correctly with the BTree comparator
517
+ biggestObservedValue = valueExtractor ? valueExtractor(value) : value
508
518
  insertedKeys.add(key) // Track this key
509
519
  }
510
520
 
package/src/errors.ts CHANGED
@@ -390,6 +390,19 @@ export class QueryMustHaveFromClauseError extends QueryBuilderError {
390
390
  }
391
391
  }
392
392
 
393
+ export class InvalidWhereExpressionError extends QueryBuilderError {
394
+ constructor(valueType: string) {
395
+ super(
396
+ `Invalid where() expression: Expected a query expression, but received a ${valueType}. ` +
397
+ `This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. ` +
398
+ `Instead, use the query builder functions:\n\n` +
399
+ ` ❌ .where(({ user }) => user.id === 'abc')\n` +
400
+ ` ✅ .where(({ user }) => eq(user.id, 'abc'))\n\n` +
401
+ `Available comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined`,
402
+ )
403
+ }
404
+ }
405
+
393
406
  // Query Compilation Errors
394
407
  export class QueryCompilationError extends TanStackDBError {
395
408
  constructor(message: string) {
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  InvalidSourceError,
13
13
  InvalidSourceTypeError,
14
+ InvalidWhereExpressionError,
14
15
  JoinConditionMustBeEqualityError,
15
16
  OnlyOneSourceAllowedError,
16
17
  QueryMustHaveFromClauseError,
@@ -29,6 +30,7 @@ import type {
29
30
  import type {
30
31
  CompareOptions,
31
32
  Context,
33
+ GetResult,
32
34
  GroupByCallback,
33
35
  JoinOnCallback,
34
36
  MergeContextForJoinCallback,
@@ -361,6 +363,13 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
361
363
  const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
362
364
  const expression = callback(refProxy)
363
365
 
366
+ // Validate that the callback returned a valid expression
367
+ // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
368
+ // which return boolean primitives instead of expression objects
369
+ if (!isExpressionLike(expression)) {
370
+ throw new InvalidWhereExpressionError(getValueTypeName(expression))
371
+ }
372
+
364
373
  const existingWhere = this.query.where || []
365
374
 
366
375
  return new BaseQueryBuilder({
@@ -402,6 +411,13 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
402
411
  const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
403
412
  const expression = callback(refProxy)
404
413
 
414
+ // Validate that the callback returned a valid expression
415
+ // This catches common mistakes like using JavaScript comparison operators (===, !==, etc.)
416
+ // which return boolean primitives instead of expression objects
417
+ if (!isExpressionLike(expression)) {
418
+ throw new InvalidWhereExpressionError(getValueTypeName(expression))
419
+ }
420
+
405
421
  const existingHaving = this.query.having || []
406
422
 
407
423
  return new BaseQueryBuilder({
@@ -789,6 +805,14 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
789
805
  }
790
806
  }
791
807
 
808
+ // Helper to get a descriptive type name for error messages
809
+ function getValueTypeName(value: unknown): string {
810
+ if (value === null) return `null`
811
+ if (value === undefined) return `undefined`
812
+ if (typeof value === `object`) return `object`
813
+ return typeof value
814
+ }
815
+
792
816
  // Helper to ensure we have a BasicExpression/Aggregate for a value
793
817
  function toExpr(value: any): BasicExpression | Aggregate {
794
818
  if (value === undefined) return toExpression(null)
@@ -864,6 +888,9 @@ export type ExtractContext<T> =
864
888
  ? TContext
865
889
  : never
866
890
 
891
+ // Helper type to extract the result type from a QueryBuilder (similar to Zod's z.infer)
892
+ export type QueryResult<T> = GetResult<ExtractContext<T>>
893
+
867
894
  // Export the types from types.ts for convenience
868
895
  export type {
869
896
  Context,
@@ -10,6 +10,8 @@ export {
10
10
  type Source,
11
11
  type GetResult,
12
12
  type InferResultType,
13
+ type ExtractContext,
14
+ type QueryResult,
13
15
  } from './builder/index.js'
14
16
 
15
17
  // Expression functions exports
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
4
4
  import type { Transaction } from './transactions'
5
5
  import type { BasicExpression, OrderBy } from './query/ir.js'
6
6
  import type { EventEmitter } from './event-emitter.js'
7
+ import type { SingleRowRefProxy } from './query/builder/ref-proxy.js'
7
8
 
8
9
  /**
9
10
  * Interface for a collection-like object that provides the necessary methods
@@ -775,17 +776,33 @@ export type NamespacedAndKeyedStream = IStreamBuilder<KeyedNamespacedRow>
775
776
  /**
776
777
  * Options for subscribing to collection changes
777
778
  */
778
- export interface SubscribeChangesOptions {
779
+ export interface SubscribeChangesOptions<
780
+ T extends object = Record<string, unknown>,
781
+ > {
779
782
  /** Whether to include the current state as initial changes */
780
783
  includeInitialState?: boolean
784
+ /**
785
+ * Callback function for filtering changes using a row proxy.
786
+ * The callback receives a proxy object that records property access,
787
+ * allowing you to use query builder functions like `eq`, `gt`, etc.
788
+ *
789
+ * @example
790
+ * ```ts
791
+ * import { eq } from "@tanstack/db"
792
+ *
793
+ * collection.subscribeChanges(callback, {
794
+ * where: (row) => eq(row.status, "active")
795
+ * })
796
+ * ```
797
+ */
798
+ where?: (row: SingleRowRefProxy<T>) => any
781
799
  /** Pre-compiled expression for filtering changes */
782
800
  whereExpression?: BasicExpression<boolean>
783
801
  }
784
802
 
785
- export interface SubscribeChangesSnapshotOptions extends Omit<
786
- SubscribeChangesOptions,
787
- `includeInitialState`
788
- > {
803
+ export interface SubscribeChangesSnapshotOptions<
804
+ T extends object = Record<string, unknown>,
805
+ > extends Omit<SubscribeChangesOptions<T>, `includeInitialState`> {
789
806
  orderBy?: OrderBy
790
807
  limit?: number
791
808
  }
package/src/utils.ts CHANGED
@@ -52,12 +52,16 @@ function deepEqualsInternal(
52
52
  if (!(b instanceof Date)) return false
53
53
  return a.getTime() === b.getTime()
54
54
  }
55
+ // Symmetric check: if b is Date but a is not, they're not equal
56
+ if (b instanceof Date) return false
55
57
 
56
58
  // Handle RegExp objects
57
59
  if (a instanceof RegExp) {
58
60
  if (!(b instanceof RegExp)) return false
59
61
  return a.source === b.source && a.flags === b.flags
60
62
  }
63
+ // Symmetric check: if b is RegExp but a is not, they're not equal
64
+ if (b instanceof RegExp) return false
61
65
 
62
66
  // Handle Map objects - only if both are Maps
63
67
  if (a instanceof Map) {
@@ -78,6 +82,8 @@ function deepEqualsInternal(
78
82
  visited.delete(a)
79
83
  return result
80
84
  }
85
+ // Symmetric check: if b is Map but a is not, they're not equal
86
+ if (b instanceof Map) return false
81
87
 
82
88
  // Handle Set objects - only if both are Sets
83
89
  if (a instanceof Set) {
@@ -106,6 +112,8 @@ function deepEqualsInternal(
106
112
  visited.delete(a)
107
113
  return result
108
114
  }
115
+ // Symmetric check: if b is Set but a is not, they're not equal
116
+ if (b instanceof Set) return false
109
117
 
110
118
  // Handle TypedArrays
111
119
  if (
@@ -124,6 +132,14 @@ function deepEqualsInternal(
124
132
 
125
133
  return true
126
134
  }
135
+ // Symmetric check: if b is TypedArray but a is not, they're not equal
136
+ if (
137
+ ArrayBuffer.isView(b) &&
138
+ !(b instanceof DataView) &&
139
+ !ArrayBuffer.isView(a)
140
+ ) {
141
+ return false
142
+ }
127
143
 
128
144
  // Handle Temporal objects
129
145
  // Check if both are Temporal objects of the same type
@@ -142,6 +158,8 @@ function deepEqualsInternal(
142
158
  // Fallback to toString comparison for other types
143
159
  return a.toString() === b.toString()
144
160
  }
161
+ // Symmetric check: if b is Temporal but a is not, they're not equal
162
+ if (isTemporal(b)) return false
145
163
 
146
164
  // Handle arrays
147
165
  if (Array.isArray(a)) {
@@ -159,6 +177,8 @@ function deepEqualsInternal(
159
177
  visited.delete(a)
160
178
  return result
161
179
  }
180
+ // Symmetric check: if b is array but a is not, they're not equal
181
+ if (Array.isArray(b)) return false
162
182
 
163
183
  // Handle objects
164
184
  if (typeof a === `object`) {