@tanstack/db 0.5.3 → 0.5.5

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 (49) hide show
  1. package/README.md +10 -10
  2. package/dist/cjs/collection/subscription.cjs +17 -6
  3. package/dist/cjs/collection/subscription.cjs.map +1 -1
  4. package/dist/cjs/collection/subscription.d.cts +5 -0
  5. package/dist/cjs/collection/sync.cjs +16 -0
  6. package/dist/cjs/collection/sync.cjs.map +1 -1
  7. package/dist/cjs/collection/sync.d.cts +6 -0
  8. package/dist/cjs/errors.cjs +8 -0
  9. package/dist/cjs/errors.cjs.map +1 -1
  10. package/dist/cjs/errors.d.cts +3 -0
  11. package/dist/cjs/index.cjs +1 -0
  12. package/dist/cjs/index.cjs.map +1 -1
  13. package/dist/cjs/query/builder/index.cjs +18 -2
  14. package/dist/cjs/query/builder/index.cjs.map +1 -1
  15. package/dist/cjs/strategies/debounceStrategy.cjs +4 -4
  16. package/dist/cjs/strategies/debounceStrategy.cjs.map +1 -1
  17. package/dist/cjs/strategies/queueStrategy.cjs +10 -8
  18. package/dist/cjs/strategies/queueStrategy.cjs.map +1 -1
  19. package/dist/cjs/strategies/throttleStrategy.cjs +4 -4
  20. package/dist/cjs/strategies/throttleStrategy.cjs.map +1 -1
  21. package/dist/cjs/types.d.cts +2 -0
  22. package/dist/esm/collection/subscription.d.ts +5 -0
  23. package/dist/esm/collection/subscription.js +17 -6
  24. package/dist/esm/collection/subscription.js.map +1 -1
  25. package/dist/esm/collection/sync.d.ts +6 -0
  26. package/dist/esm/collection/sync.js +16 -0
  27. package/dist/esm/collection/sync.js.map +1 -1
  28. package/dist/esm/errors.d.ts +3 -0
  29. package/dist/esm/errors.js +8 -0
  30. package/dist/esm/errors.js.map +1 -1
  31. package/dist/esm/index.js +2 -1
  32. package/dist/esm/query/builder/index.js +19 -3
  33. package/dist/esm/query/builder/index.js.map +1 -1
  34. package/dist/esm/strategies/debounceStrategy.js +2 -2
  35. package/dist/esm/strategies/debounceStrategy.js.map +1 -1
  36. package/dist/esm/strategies/queueStrategy.js +10 -8
  37. package/dist/esm/strategies/queueStrategy.js.map +1 -1
  38. package/dist/esm/strategies/throttleStrategy.js +2 -2
  39. package/dist/esm/strategies/throttleStrategy.js.map +1 -1
  40. package/dist/esm/types.d.ts +2 -0
  41. package/package.json +2 -2
  42. package/src/collection/subscription.ts +32 -6
  43. package/src/collection/sync.ts +25 -0
  44. package/src/errors.ts +9 -0
  45. package/src/query/builder/index.ts +28 -2
  46. package/src/strategies/debounceStrategy.ts +2 -2
  47. package/src/strategies/queueStrategy.ts +22 -9
  48. package/src/strategies/throttleStrategy.ts +2 -2
  49. package/src/types.ts +3 -0
@@ -198,10 +198,12 @@ export type LoadSubsetOptions = {
198
198
  subscription?: Subscription;
199
199
  };
200
200
  export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise<void>;
201
+ export type UnloadSubsetFn = (options: LoadSubsetOptions) => void;
201
202
  export type CleanupFn = () => void;
202
203
  export type SyncConfigRes = {
203
204
  cleanup?: CleanupFn;
204
205
  loadSubset?: LoadSubsetFn;
206
+ unloadSubset?: UnloadSubsetFn;
205
207
  };
206
208
  export interface SyncConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
207
209
  sync: (params: {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.5.3",
4
+ "version": "0.5.5",
5
5
  "dependencies": {
6
6
  "@standard-schema/spec": "^1.0.0",
7
- "@tanstack/pacer": "^0.16.3",
7
+ "@tanstack/pacer-lite": "^0.1.0",
8
8
  "@tanstack/db-ivm": "0.1.13"
9
9
  },
10
10
  "devDependencies": {
@@ -10,6 +10,7 @@ import type { BasicExpression, OrderBy } from "../query/ir.js"
10
10
  import type { IndexInterface } from "../indexes/base-index.js"
11
11
  import type {
12
12
  ChangeMessage,
13
+ LoadSubsetOptions,
13
14
  Subscription,
14
15
  SubscriptionEvents,
15
16
  SubscriptionStatus,
@@ -47,6 +48,12 @@ export class CollectionSubscription
47
48
  // While `snapshotSent` is false we filter out all changes from subscription to the collection.
48
49
  private snapshotSent = false
49
50
 
51
+ /**
52
+ * Track all loadSubset calls made by this subscription so we can unload them on cleanup.
53
+ * We store the exact LoadSubsetOptions we passed to loadSubset to ensure symmetric unload.
54
+ */
55
+ private loadedSubsets: Array<LoadSubsetOptions> = []
56
+
50
57
  // Keep track of the keys we've sent (needed for join and orderBy optimizations)
51
58
  private sentKeys = new Set<string | number>()
52
59
 
@@ -193,10 +200,14 @@ export class CollectionSubscription
193
200
 
194
201
  // Request the sync layer to load more data
195
202
  // don't await it, we will load the data into the collection when it comes in
196
- const syncResult = this.collection._sync.loadSubset({
203
+ const loadOptions: LoadSubsetOptions = {
197
204
  where: stateOpts.where,
198
205
  subscription: this,
199
- })
206
+ }
207
+ const syncResult = this.collection._sync.loadSubset(loadOptions)
208
+
209
+ // Track this loadSubset call so we can unload it later
210
+ this.loadedSubsets.push(loadOptions)
200
211
 
201
212
  const trackLoadSubsetPromise = opts?.trackLoadSubsetPromise ?? true
202
213
  if (trackLoadSubsetPromise) {
@@ -333,12 +344,16 @@ export class CollectionSubscription
333
344
 
334
345
  // Request the sync layer to load more data
335
346
  // don't await it, we will load the data into the collection when it comes in
336
- const syncResult = this.collection._sync.loadSubset({
347
+ const loadOptions1: LoadSubsetOptions = {
337
348
  where: whereWithValueFilter,
338
349
  limit,
339
350
  orderBy,
340
351
  subscription: this,
341
- })
352
+ }
353
+ const syncResult = this.collection._sync.loadSubset(loadOptions1)
354
+
355
+ // Track this loadSubset call
356
+ this.loadedSubsets.push(loadOptions1)
342
357
 
343
358
  // Make parallel loadSubset calls for values equal to minValue and values greater than minValue
344
359
  const promises: Array<Promise<void>> = []
@@ -348,10 +363,14 @@ export class CollectionSubscription
348
363
  const { expression } = orderBy[0]!
349
364
  const exactValueFilter = eq(expression, new Value(minValue))
350
365
 
351
- const equalValueResult = this.collection._sync.loadSubset({
366
+ const loadOptions2: LoadSubsetOptions = {
352
367
  where: exactValueFilter,
353
368
  subscription: this,
354
- })
369
+ }
370
+ const equalValueResult = this.collection._sync.loadSubset(loadOptions2)
371
+
372
+ // Track this loadSubset call
373
+ this.loadedSubsets.push(loadOptions2)
355
374
 
356
375
  if (equalValueResult instanceof Promise) {
357
376
  promises.push(equalValueResult)
@@ -417,6 +436,13 @@ export class CollectionSubscription
417
436
  }
418
437
 
419
438
  unsubscribe() {
439
+ // Unload all subsets that this subscription loaded
440
+ // We pass the exact same LoadSubsetOptions we used for loadSubset
441
+ for (const options of this.loadedSubsets) {
442
+ this.collection._sync.unloadSubset(options)
443
+ }
444
+ this.loadedSubsets = []
445
+
420
446
  this.emitInner(`unsubscribed`, {
421
447
  type: `unsubscribed`,
422
448
  subscription: this,
@@ -43,6 +43,8 @@ export class CollectionSyncManager<
43
43
  public syncLoadSubsetFn:
44
44
  | ((options: LoadSubsetOptions) => true | Promise<void>)
45
45
  | null = null
46
+ public syncUnloadSubsetFn: ((options: LoadSubsetOptions) => void) | null =
47
+ null
46
48
 
47
49
  private pendingLoadSubsetPromises: Set<Promise<void>> = new Set()
48
50
 
@@ -209,6 +211,9 @@ export class CollectionSyncManager<
209
211
  // Store loadSubset function if provided
210
212
  this.syncLoadSubsetFn = syncRes?.loadSubset ?? null
211
213
 
214
+ // Store unloadSubset function if provided
215
+ this.syncUnloadSubsetFn = syncRes?.unloadSubset ?? null
216
+
212
217
  // Validate: on-demand mode requires a loadSubset function
213
218
  if (this.syncMode === `on-demand` && !this.syncLoadSubsetFn) {
214
219
  throw new CollectionConfigurationError(
@@ -231,6 +236,16 @@ export class CollectionSyncManager<
231
236
  return this.preloadPromise
232
237
  }
233
238
 
239
+ // Warn when calling preload on an on-demand collection
240
+ if (this.syncMode === `on-demand`) {
241
+ console.warn(
242
+ `${this.id ? `[${this.id}] ` : ``}Calling .preload() on a collection with syncMode "on-demand" is a no-op. ` +
243
+ `In on-demand mode, data is only loaded when queries request it. ` +
244
+ `Instead, create a live query and call .preload() on that to load the specific data you need. ` +
245
+ `See https://tanstack.com/blog/tanstack-db-0.5-query-driven-sync for more details.`
246
+ )
247
+ }
248
+
234
249
  this.preloadPromise = new Promise<void>((resolve, reject) => {
235
250
  if (this.lifecycle.status === `ready`) {
236
251
  resolve()
@@ -331,6 +346,16 @@ export class CollectionSyncManager<
331
346
  return true
332
347
  }
333
348
 
349
+ /**
350
+ * Notifies the sync layer that a subset is no longer needed.
351
+ * @param options Options that identify what data is being unloaded
352
+ */
353
+ public unloadSubset(options: LoadSubsetOptions): void {
354
+ if (this.syncUnloadSubsetFn) {
355
+ this.syncUnloadSubsetFn(options)
356
+ }
357
+ }
358
+
334
359
  public cleanup(): void {
335
360
  try {
336
361
  if (this.syncCleanupFn) {
package/src/errors.ts CHANGED
@@ -360,6 +360,15 @@ export class InvalidSourceError extends QueryBuilderError {
360
360
  }
361
361
  }
362
362
 
363
+ export class InvalidSourceTypeError extends QueryBuilderError {
364
+ constructor(context: string, type: string) {
365
+ super(
366
+ `Invalid source for ${context}: Expected an object with a single key-value pair like { alias: collection }. ` +
367
+ `For example: .from({ todos: todosCollection }). Got: ${type}`
368
+ )
369
+ }
370
+ }
371
+
363
372
  export class JoinConditionMustBeEqualityError extends QueryBuilderError {
364
373
  constructor() {
365
374
  super(`Join condition must be an equality expression`)
@@ -10,6 +10,7 @@ import {
10
10
  } from "../ir.js"
11
11
  import {
12
12
  InvalidSourceError,
13
+ InvalidSourceTypeError,
13
14
  JoinConditionMustBeEqualityError,
14
15
  OnlyOneSourceAllowedError,
15
16
  QueryMustHaveFromClauseError,
@@ -60,13 +61,38 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
60
61
  source: TSource,
61
62
  context: string
62
63
  ): [string, CollectionRef | QueryRef] {
63
- if (Object.keys(source).length !== 1) {
64
+ // Validate source is a plain object (not null, array, string, etc.)
65
+ // We use try-catch to handle null/undefined gracefully
66
+ let keys: Array<string>
67
+ try {
68
+ keys = Object.keys(source)
69
+ } catch {
70
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
71
+ const type = source === null ? `null` : `undefined`
72
+ throw new InvalidSourceTypeError(context, type)
73
+ }
74
+
75
+ // Check if it's an array (arrays pass Object.keys but aren't valid sources)
76
+ if (Array.isArray(source)) {
77
+ throw new InvalidSourceTypeError(context, `array`)
78
+ }
79
+
80
+ // Validate exactly one key
81
+ if (keys.length !== 1) {
82
+ if (keys.length === 0) {
83
+ throw new InvalidSourceTypeError(context, `empty object`)
84
+ }
85
+ // Check if it looks like a string was passed (has numeric keys)
86
+ if (keys.every((k) => !isNaN(Number(k)))) {
87
+ throw new InvalidSourceTypeError(context, `string`)
88
+ }
64
89
  throw new OnlyOneSourceAllowedError(context)
65
90
  }
66
91
 
67
- const alias = Object.keys(source)[0]!
92
+ const alias = keys[0]!
68
93
  const sourceValue = source[alias]
69
94
 
95
+ // Validate the value is a Collection or QueryBuilder
70
96
  let ref: CollectionRef | QueryRef
71
97
 
72
98
  if (sourceValue instanceof CollectionImpl) {
@@ -1,4 +1,4 @@
1
- import { Debouncer } from "@tanstack/pacer/debouncer"
1
+ import { LiteDebouncer } from "@tanstack/pacer-lite/lite-debouncer"
2
2
  import type { DebounceStrategy, DebounceStrategyOptions } from "./types"
3
3
  import type { Transaction } from "../transactions"
4
4
 
@@ -28,7 +28,7 @@ import type { Transaction } from "../transactions"
28
28
  export function debounceStrategy(
29
29
  options: DebounceStrategyOptions
30
30
  ): DebounceStrategy {
31
- const debouncer = new Debouncer(
31
+ const debouncer = new LiteDebouncer(
32
32
  (callback: () => Transaction) => callback(),
33
33
  options
34
34
  )
@@ -1,4 +1,4 @@
1
- import { AsyncQueuer } from "@tanstack/pacer/async-queuer"
1
+ import { LiteQueuer } from "@tanstack/pacer-lite/lite-queuer"
2
2
  import type { QueueStrategy, QueueStrategyOptions } from "./types"
3
3
  import type { Transaction } from "../transactions"
4
4
 
@@ -44,16 +44,29 @@ import type { Transaction } from "../transactions"
44
44
  * ```
45
45
  */
46
46
  export function queueStrategy(options?: QueueStrategyOptions): QueueStrategy {
47
- const queuer = new AsyncQueuer<() => Transaction>(
48
- async (fn) => {
49
- const transaction = fn()
50
- // Wait for the transaction to be persisted before processing next item
51
- // Note: fn() already calls commit(), we just wait for it to complete
52
- await transaction.isPersisted.promise
47
+ // Manual promise chaining to ensure async serialization
48
+ // LiteQueuer (unlike AsyncQueuer from @tanstack/pacer) lacks built-in async queue
49
+ // primitives and concurrency control. We compensate by manually chaining promises
50
+ // to ensure each transaction completes before the next one starts.
51
+ let processingChain = Promise.resolve()
52
+
53
+ const queuer = new LiteQueuer<() => Transaction>(
54
+ (fn) => {
55
+ // Chain each transaction to the previous one's completion
56
+ processingChain = processingChain
57
+ .then(async () => {
58
+ const transaction = fn()
59
+ // Wait for the transaction to be persisted before processing next item
60
+ await transaction.isPersisted.promise
61
+ })
62
+ .catch(() => {
63
+ // Errors are handled via transaction.isPersisted.promise and surfaced there.
64
+ // This catch prevents unhandled promise rejections from breaking the chain,
65
+ // ensuring subsequent transactions can still execute even if one fails.
66
+ })
53
67
  },
54
68
  {
55
- concurrency: 1, // Process one at a time to ensure serialization
56
- wait: options?.wait,
69
+ wait: options?.wait ?? 0,
57
70
  maxSize: options?.maxSize,
58
71
  addItemsTo: options?.addItemsTo ?? `back`, // Default FIFO: add to back
59
72
  getItemsFrom: options?.getItemsFrom ?? `front`, // Default FIFO: get from front
@@ -1,4 +1,4 @@
1
- import { Throttler } from "@tanstack/pacer/throttler"
1
+ import { LiteThrottler } from "@tanstack/pacer-lite/lite-throttler"
2
2
  import type { ThrottleStrategy, ThrottleStrategyOptions } from "./types"
3
3
  import type { Transaction } from "../transactions"
4
4
 
@@ -48,7 +48,7 @@ import type { Transaction } from "../transactions"
48
48
  export function throttleStrategy(
49
49
  options: ThrottleStrategyOptions
50
50
  ): ThrottleStrategy {
51
- const throttler = new Throttler(
51
+ const throttler = new LiteThrottler(
52
52
  (callback: () => Transaction) => callback(),
53
53
  options
54
54
  )
package/src/types.ts CHANGED
@@ -273,11 +273,14 @@ export type LoadSubsetOptions = {
273
273
 
274
274
  export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise<void>
275
275
 
276
+ export type UnloadSubsetFn = (options: LoadSubsetOptions) => void
277
+
276
278
  export type CleanupFn = () => void
277
279
 
278
280
  export type SyncConfigRes = {
279
281
  cleanup?: CleanupFn
280
282
  loadSubset?: LoadSubsetFn
283
+ unloadSubset?: UnloadSubsetFn
281
284
  }
282
285
  export interface SyncConfig<
283
286
  T extends object = Record<string, unknown>,