@tanstack/db 0.4.16 → 0.4.18

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 (75) hide show
  1. package/dist/cjs/collection/changes.cjs +1 -1
  2. package/dist/cjs/collection/changes.cjs.map +1 -1
  3. package/dist/cjs/collection/subscription.cjs +55 -5
  4. package/dist/cjs/collection/subscription.cjs.map +1 -1
  5. package/dist/cjs/collection/subscription.d.cts +5 -2
  6. package/dist/cjs/duplicate-instance-check.d.cts +1 -0
  7. package/dist/cjs/errors.cjs +46 -0
  8. package/dist/cjs/errors.cjs.map +1 -1
  9. package/dist/cjs/errors.d.cts +14 -0
  10. package/dist/cjs/index.cjs +3 -0
  11. package/dist/cjs/index.cjs.map +1 -1
  12. package/dist/cjs/optimistic-action.cjs +6 -1
  13. package/dist/cjs/optimistic-action.cjs.map +1 -1
  14. package/dist/cjs/paced-mutations.cjs.map +1 -1
  15. package/dist/cjs/paced-mutations.d.cts +2 -2
  16. package/dist/cjs/query/compiler/index.cjs +40 -0
  17. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  18. package/dist/cjs/query/compiler/order-by.cjs +3 -6
  19. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  20. package/dist/cjs/query/live/collection-subscriber.cjs +20 -34
  21. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  22. package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -1
  23. package/dist/cjs/strategies/debounceStrategy.d.cts +4 -1
  24. package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -1
  25. package/dist/cjs/strategies/throttleStrategy.d.cts +8 -2
  26. package/dist/cjs/transactions.cjs +3 -1
  27. package/dist/cjs/transactions.cjs.map +1 -1
  28. package/dist/cjs/transactions.d.cts +3 -1
  29. package/dist/cjs/utils/type-guards.cjs +7 -0
  30. package/dist/cjs/utils/type-guards.cjs.map +1 -0
  31. package/dist/cjs/utils/type-guards.d.cts +6 -0
  32. package/dist/esm/collection/changes.js +1 -1
  33. package/dist/esm/collection/changes.js.map +1 -1
  34. package/dist/esm/collection/subscription.d.ts +5 -2
  35. package/dist/esm/collection/subscription.js +56 -6
  36. package/dist/esm/collection/subscription.js.map +1 -1
  37. package/dist/esm/duplicate-instance-check.d.ts +1 -0
  38. package/dist/esm/errors.d.ts +14 -0
  39. package/dist/esm/errors.js +46 -0
  40. package/dist/esm/errors.js.map +1 -1
  41. package/dist/esm/index.js +4 -1
  42. package/dist/esm/optimistic-action.js +6 -1
  43. package/dist/esm/optimistic-action.js.map +1 -1
  44. package/dist/esm/paced-mutations.d.ts +2 -2
  45. package/dist/esm/paced-mutations.js.map +1 -1
  46. package/dist/esm/query/compiler/index.js +41 -1
  47. package/dist/esm/query/compiler/index.js.map +1 -1
  48. package/dist/esm/query/compiler/order-by.js +3 -6
  49. package/dist/esm/query/compiler/order-by.js.map +1 -1
  50. package/dist/esm/query/live/collection-subscriber.js +20 -34
  51. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  52. package/dist/esm/strategies/debounceStrategy.d.ts +4 -1
  53. package/dist/esm/strategies/debounceStrategy.js.map +1 -1
  54. package/dist/esm/strategies/throttleStrategy.d.ts +8 -2
  55. package/dist/esm/strategies/throttleStrategy.js.map +1 -1
  56. package/dist/esm/transactions.d.ts +3 -1
  57. package/dist/esm/transactions.js +3 -1
  58. package/dist/esm/transactions.js.map +1 -1
  59. package/dist/esm/utils/type-guards.d.ts +6 -0
  60. package/dist/esm/utils/type-guards.js +7 -0
  61. package/dist/esm/utils/type-guards.js.map +1 -0
  62. package/package.json +1 -1
  63. package/src/collection/changes.ts +1 -1
  64. package/src/collection/subscription.ts +82 -6
  65. package/src/duplicate-instance-check.ts +32 -0
  66. package/src/errors.ts +51 -0
  67. package/src/optimistic-action.ts +7 -2
  68. package/src/paced-mutations.ts +2 -2
  69. package/src/query/compiler/index.ts +74 -0
  70. package/src/query/compiler/order-by.ts +3 -5
  71. package/src/query/live/collection-subscriber.ts +26 -72
  72. package/src/strategies/debounceStrategy.ts +4 -1
  73. package/src/strategies/throttleStrategy.ts +8 -2
  74. package/src/transactions.ts +5 -1
  75. package/src/utils/type-guards.ts +12 -0
@@ -3,6 +3,7 @@ import { optimizeQuery } from "../optimizer.js"
3
3
  import {
4
4
  CollectionInputNotFoundError,
5
5
  DistinctRequiresSelectError,
6
+ DuplicateAliasInSubqueryError,
6
7
  HavingRequiresGroupByError,
7
8
  LimitOffsetRequireOrderByError,
8
9
  UnsupportedFromTypeError,
@@ -99,6 +100,11 @@ export function compileQuery(
99
100
  return cachedResult
100
101
  }
101
102
 
103
+ // Validate the raw query BEFORE optimization to check user's original structure.
104
+ // This must happen before optimization because the optimizer may create internal
105
+ // subqueries (e.g., for predicate pushdown) that reuse aliases, which is fine.
106
+ validateQueryStructure(rawQuery)
107
+
102
108
  // Optimize the query before compilation
103
109
  const { optimizedQuery: query, sourceWhereClauses } = optimizeQuery(rawQuery)
104
110
 
@@ -375,6 +381,74 @@ export function compileQuery(
375
381
  return compilationResult
376
382
  }
377
383
 
384
+ /**
385
+ * Collects aliases used for DIRECT collection references (not subqueries).
386
+ * Used to validate that subqueries don't reuse parent query collection aliases.
387
+ * Only direct CollectionRef aliases matter - QueryRef aliases don't cause conflicts.
388
+ */
389
+ function collectDirectCollectionAliases(query: QueryIR): Set<string> {
390
+ const aliases = new Set<string>()
391
+
392
+ // Collect FROM alias only if it's a direct collection reference
393
+ if (query.from.type === `collectionRef`) {
394
+ aliases.add(query.from.alias)
395
+ }
396
+
397
+ // Collect JOIN aliases only for direct collection references
398
+ if (query.join) {
399
+ for (const joinClause of query.join) {
400
+ if (joinClause.from.type === `collectionRef`) {
401
+ aliases.add(joinClause.from.alias)
402
+ }
403
+ }
404
+ }
405
+
406
+ return aliases
407
+ }
408
+
409
+ /**
410
+ * Validates the structure of a query and its subqueries.
411
+ * Checks that subqueries don't reuse collection aliases from parent queries.
412
+ * This must be called on the RAW query before optimization.
413
+ */
414
+ function validateQueryStructure(
415
+ query: QueryIR,
416
+ parentCollectionAliases: Set<string> = new Set()
417
+ ): void {
418
+ // Collect direct collection aliases from this query level
419
+ const currentLevelAliases = collectDirectCollectionAliases(query)
420
+
421
+ // Check if any current alias conflicts with parent aliases
422
+ for (const alias of currentLevelAliases) {
423
+ if (parentCollectionAliases.has(alias)) {
424
+ throw new DuplicateAliasInSubqueryError(
425
+ alias,
426
+ Array.from(parentCollectionAliases)
427
+ )
428
+ }
429
+ }
430
+
431
+ // Combine parent and current aliases for checking nested subqueries
432
+ const combinedAliases = new Set([
433
+ ...parentCollectionAliases,
434
+ ...currentLevelAliases,
435
+ ])
436
+
437
+ // Recursively validate FROM subquery
438
+ if (query.from.type === `queryRef`) {
439
+ validateQueryStructure(query.from.query, combinedAliases)
440
+ }
441
+
442
+ // Recursively validate JOIN subqueries
443
+ if (query.join) {
444
+ for (const joinClause of query.join) {
445
+ if (joinClause.from.type === `queryRef`) {
446
+ validateQueryStructure(joinClause.from.query, combinedAliases)
447
+ }
448
+ }
449
+ }
450
+ }
451
+
378
452
  /**
379
453
  * Processes the FROM clause, handling direct collection references and subqueries.
380
454
  * Populates `aliasToCollectionId` and `aliasRemapping` for per-alias subscription tracking.
@@ -179,13 +179,11 @@ export function processOrderBy(
179
179
  orderByOptimizationInfo
180
180
 
181
181
  setSizeCallback = (getSize: () => number) => {
182
- optimizableOrderByCollections[followRefCollection.id] = {
183
- ...optimizableOrderByCollections[followRefCollection.id]!,
184
- dataNeeded: () => {
182
+ optimizableOrderByCollections[followRefCollection.id]![`dataNeeded`] =
183
+ () => {
185
184
  const size = getSize()
186
185
  return Math.max(0, orderByOptimizationInfo!.limit - size)
187
- },
188
- }
186
+ }
189
187
  }
190
188
  }
191
189
  }
@@ -73,29 +73,33 @@ export class CollectionSubscriber<
73
73
  )
74
74
  }
75
75
 
76
+ const trackLoadPromise = () => {
77
+ // Guard against duplicate transitions
78
+ if (!this.subscriptionLoadingPromises.has(subscription)) {
79
+ let resolve: () => void
80
+ const promise = new Promise<void>((res) => {
81
+ resolve = res
82
+ })
83
+
84
+ this.subscriptionLoadingPromises.set(subscription, {
85
+ resolve: resolve!,
86
+ })
87
+ this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
88
+ promise
89
+ )
90
+ }
91
+ }
92
+
93
+ // It can be that we are not yet subscribed when the first `loadSubset` call happens (i.e. the initial query).
94
+ // So we also check the status here and if it's `loadingSubset` then we track the load promise
95
+ if (subscription.status === `loadingSubset`) {
96
+ trackLoadPromise()
97
+ }
98
+
76
99
  // Subscribe to subscription status changes to propagate loading state
77
100
  const statusUnsubscribe = subscription.on(`status:change`, (event) => {
78
- // TODO: For now we are setting this loading state whenever the subscription
79
- // status changes to 'loadingSubset'. But we have discussed it only happening
80
- // when the the live query has it's offset/limit changed, and that triggers the
81
- // subscription to request a snapshot. This will require more work to implement,
82
- // and builds on https://github.com/TanStack/db/pull/663 which this PR
83
- // does not yet depend on.
84
101
  if (event.status === `loadingSubset`) {
85
- // Guard against duplicate transitions
86
- if (!this.subscriptionLoadingPromises.has(subscription)) {
87
- let resolve: () => void
88
- const promise = new Promise<void>((res) => {
89
- resolve = res
90
- })
91
-
92
- this.subscriptionLoadingPromises.set(subscription, {
93
- resolve: resolve!,
94
- })
95
- this.collectionConfigBuilder.liveQueryCollection!._sync.trackLoadPromise(
96
- promise
97
- )
98
- }
102
+ trackLoadPromise()
99
103
  } else {
100
104
  // status is 'ready'
101
105
  const deferred = this.subscriptionLoadingPromises.get(subscription)
@@ -176,30 +180,14 @@ export class CollectionSubscriber<
176
180
  whereExpression: BasicExpression<boolean> | undefined,
177
181
  orderByInfo: OrderByOptimizationInfo
178
182
  ) {
179
- const { orderBy, offset, limit, comparator, dataNeeded, index } =
180
- orderByInfo
183
+ const { orderBy, offset, limit, index } = orderByInfo
181
184
 
182
185
  const sendChangesInRange = (
183
186
  changes: Iterable<ChangeMessage<any, string | number>>
184
187
  ) => {
185
188
  // Split live updates into a delete of the old value and an insert of the new value
186
- // and filter out changes that are bigger than the biggest value we've sent so far
187
- // because they can't affect the topK (and if later we need more data, we will dynamically load more data)
188
189
  const splittedChanges = splitUpdates(changes)
189
- let filteredChanges = splittedChanges
190
- if (dataNeeded && dataNeeded() === 0) {
191
- // If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue
192
- // because they can never make it into the topK.
193
- // However, if the topK isn't full yet, we need to also send changes > maxSentValue
194
- // because they will make it into the topK
195
- filteredChanges = filterChangesSmallerOrEqualToMax(
196
- splittedChanges,
197
- comparator,
198
- this.biggest
199
- )
200
- }
201
-
202
- this.sendChangesToPipelineWithTracking(filteredChanges, subscription)
190
+ this.sendChangesToPipelineWithTracking(splittedChanges, subscription)
203
191
  }
204
192
 
205
193
  // Subscribe to changes and only send changes that are smaller than the biggest value we've sent so far
@@ -395,37 +383,3 @@ function* splitUpdates<
395
383
  }
396
384
  }
397
385
  }
398
-
399
- function* filterChanges<
400
- T extends object = Record<string, unknown>,
401
- TKey extends string | number = string | number,
402
- >(
403
- changes: Iterable<ChangeMessage<T, TKey>>,
404
- f: (change: ChangeMessage<T, TKey>) => boolean
405
- ): Generator<ChangeMessage<T, TKey>> {
406
- for (const change of changes) {
407
- if (f(change)) {
408
- yield change
409
- }
410
- }
411
- }
412
-
413
- /**
414
- * Filters changes to only include those that are smaller or equal to the provided max value
415
- * @param changes - Iterable of changes to filter
416
- * @param comparator - Comparator function to use for filtering
417
- * @param maxValue - Range to filter changes within (range boundaries are exclusive)
418
- * @returns Iterable of changes that fall within the range
419
- */
420
- function* filterChangesSmallerOrEqualToMax<
421
- T extends object = Record<string, unknown>,
422
- TKey extends string | number = string | number,
423
- >(
424
- changes: Iterable<ChangeMessage<T, TKey>>,
425
- comparator: (a: any, b: any) => number,
426
- maxValue: any
427
- ): Generator<ChangeMessage<T, TKey>> {
428
- yield* filterChanges(changes, (change) => {
429
- return !maxValue || comparator(change.value, maxValue) <= 0
430
- })
431
- }
@@ -14,7 +14,10 @@ import type { Transaction } from "../transactions"
14
14
  *
15
15
  * @example
16
16
  * ```ts
17
- * const mutate = useSerializedTransaction({
17
+ * const mutate = usePacedMutations({
18
+ * onMutate: (value) => {
19
+ * collection.update(id, draft => { draft.value = value })
20
+ * },
18
21
  * mutationFn: async ({ transaction }) => {
19
22
  * await api.save(transaction.mutations)
20
23
  * },
@@ -16,7 +16,10 @@ import type { Transaction } from "../transactions"
16
16
  * @example
17
17
  * ```ts
18
18
  * // Throttle slider updates to every 200ms
19
- * const mutate = useSerializedTransaction({
19
+ * const mutate = usePacedMutations({
20
+ * onMutate: (volume) => {
21
+ * settingsCollection.update('volume', draft => { draft.value = volume })
22
+ * },
20
23
  * mutationFn: async ({ transaction }) => {
21
24
  * await api.updateVolume(transaction.mutations)
22
25
  * },
@@ -27,7 +30,10 @@ import type { Transaction } from "../transactions"
27
30
  * @example
28
31
  * ```ts
29
32
  * // Throttle with leading and trailing execution
30
- * const mutate = useSerializedTransaction({
33
+ * const mutate = usePacedMutations({
34
+ * onMutate: (data) => {
35
+ * collection.update(id, draft => { Object.assign(draft, data) })
36
+ * },
31
37
  * mutationFn: async ({ transaction }) => {
32
38
  * await api.save(transaction.mutations)
33
39
  * },
@@ -1,4 +1,5 @@
1
1
  import { createDeferred } from "./deferred"
2
+ import "./duplicate-instance-check"
2
3
  import {
3
4
  MissingMutationFunctionError,
4
5
  TransactionAlreadyCompletedRollbackError,
@@ -244,7 +245,9 @@ class Transaction<T extends object = Record<string, unknown>> {
244
245
 
245
246
  /**
246
247
  * Execute collection operations within this transaction
247
- * @param callback - Function containing collection operations to group together
248
+ * @param callback - Function containing collection operations to group together. If the
249
+ * callback returns a Promise, the transaction context will remain active until the promise
250
+ * settles, allowing optimistic writes after `await` boundaries.
248
251
  * @returns This transaction for chaining
249
252
  * @example
250
253
  * // Group multiple operations
@@ -287,6 +290,7 @@ class Transaction<T extends object = Record<string, unknown>> {
287
290
  }
288
291
 
289
292
  registerTransaction(this)
293
+
290
294
  try {
291
295
  callback()
292
296
  } finally {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Type guard to check if a value is promise-like (has a `.then` method)
3
+ * @param value - The value to check
4
+ * @returns True if the value is promise-like, false otherwise
5
+ */
6
+ export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
7
+ return (
8
+ !!value &&
9
+ (typeof value === `object` || typeof value === `function`) &&
10
+ typeof (value as { then?: unknown }).then === `function`
11
+ )
12
+ }