@tanstack/db 0.5.20 → 0.5.22

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 (55) hide show
  1. package/dist/cjs/collection/lifecycle.cjs +1 -1
  2. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  3. package/dist/cjs/collection/state.cjs +9 -4
  4. package/dist/cjs/collection/state.cjs.map +1 -1
  5. package/dist/cjs/collection/state.d.cts +6 -0
  6. package/dist/cjs/collection/sync.cjs +5 -3
  7. package/dist/cjs/collection/sync.cjs.map +1 -1
  8. package/dist/cjs/errors.cjs +5 -1
  9. package/dist/cjs/errors.cjs.map +1 -1
  10. package/dist/cjs/errors.d.cts +1 -0
  11. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  12. package/dist/cjs/query/builder/functions.d.cts +1 -1
  13. package/dist/cjs/query/compiler/group-by.cjs +12 -23
  14. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  15. package/dist/cjs/query/compiler/group-by.d.cts +5 -10
  16. package/dist/cjs/query/live/collection-config-builder.cjs +2 -1
  17. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  18. package/dist/cjs/query/live/internal.cjs.map +1 -1
  19. package/dist/cjs/query/live/internal.d.cts +1 -0
  20. package/dist/cjs/strategies/queueStrategy.cjs.map +1 -1
  21. package/dist/cjs/strategies/queueStrategy.d.cts +8 -2
  22. package/dist/cjs/types.d.cts +8 -1
  23. package/dist/esm/collection/lifecycle.js +1 -1
  24. package/dist/esm/collection/lifecycle.js.map +1 -1
  25. package/dist/esm/collection/state.d.ts +6 -0
  26. package/dist/esm/collection/state.js +9 -4
  27. package/dist/esm/collection/state.js.map +1 -1
  28. package/dist/esm/collection/sync.js +5 -3
  29. package/dist/esm/collection/sync.js.map +1 -1
  30. package/dist/esm/errors.d.ts +1 -0
  31. package/dist/esm/errors.js +5 -1
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/query/builder/functions.d.ts +1 -1
  34. package/dist/esm/query/builder/functions.js.map +1 -1
  35. package/dist/esm/query/compiler/group-by.d.ts +5 -10
  36. package/dist/esm/query/compiler/group-by.js +12 -23
  37. package/dist/esm/query/compiler/group-by.js.map +1 -1
  38. package/dist/esm/query/live/collection-config-builder.js +2 -1
  39. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  40. package/dist/esm/query/live/internal.d.ts +1 -0
  41. package/dist/esm/query/live/internal.js.map +1 -1
  42. package/dist/esm/strategies/queueStrategy.d.ts +8 -2
  43. package/dist/esm/strategies/queueStrategy.js.map +1 -1
  44. package/dist/esm/types.d.ts +8 -1
  45. package/package.json +2 -2
  46. package/src/collection/lifecycle.ts +4 -2
  47. package/src/collection/state.ts +23 -2
  48. package/src/collection/sync.ts +3 -1
  49. package/src/errors.ts +19 -3
  50. package/src/query/builder/functions.ts +3 -3
  51. package/src/query/compiler/group-by.ts +27 -54
  52. package/src/query/live/collection-config-builder.ts +1 -0
  53. package/src/query/live/internal.ts +1 -0
  54. package/src/strategies/queueStrategy.ts +8 -2
  55. package/src/types.ts +6 -1
@@ -247,7 +247,14 @@ export type SyncConfigRes = {
247
247
  export interface SyncConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
248
248
  sync: (params: {
249
249
  collection: Collection<T, TKey, any, any, any>;
250
- begin: () => void;
250
+ /**
251
+ * Begin a new sync transaction.
252
+ * @param options.immediate - When true, the transaction will be processed immediately
253
+ * even if there are persisting user transactions. Used by manual write operations.
254
+ */
255
+ begin: (options?: {
256
+ immediate?: boolean;
257
+ }) => void;
251
258
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void;
252
259
  commit: () => void;
253
260
  markReady: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "A reactive client store for building super fast apps on sync",
5
5
  "author": "Kyle Mathews",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@standard-schema/spec": "^1.1.0",
41
41
  "@tanstack/pacer-lite": "^0.2.0",
42
- "@tanstack/db-ivm": "0.1.16"
42
+ "@tanstack/db-ivm": "0.1.17"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "typescript": ">=4.7"
@@ -180,8 +180,10 @@ export class CollectionLifecycleManager<
180
180
 
181
181
  const gcTime = this.config.gcTime ?? 300000 // 5 minutes default
182
182
 
183
- // If gcTime is 0, GC is disabled
184
- if (gcTime === 0) {
183
+ // If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled.
184
+ // Note: setTimeout with Infinity coerces to 0 via ToInt32, causing immediate GC,
185
+ // so we must explicitly check for non-finite values here.
186
+ if (gcTime <= 0 || !Number.isFinite(gcTime)) {
185
187
  return
186
188
  }
187
189
 
@@ -25,6 +25,12 @@ interface PendingSyncedTransaction<
25
25
  upserts: Map<TKey, T>
26
26
  deletes: Set<TKey>
27
27
  }
28
+ /**
29
+ * When true, this transaction should be processed immediately even if there
30
+ * are persisting user transactions. Used by manual write operations (writeInsert,
31
+ * writeUpdate, writeDelete, writeUpsert) which need synchronous updates to syncedData.
32
+ */
33
+ immediate?: boolean
28
34
  }
29
35
 
30
36
  export class CollectionStateManager<
@@ -437,13 +443,17 @@ export class CollectionStateManager<
437
443
  committedSyncedTransactions,
438
444
  uncommittedSyncedTransactions,
439
445
  hasTruncateSync,
446
+ hasImmediateSync,
440
447
  } = this.pendingSyncedTransactions.reduce(
441
448
  (acc, t) => {
442
449
  if (t.committed) {
443
450
  acc.committedSyncedTransactions.push(t)
444
- if (t.truncate === true) {
451
+ if (t.truncate) {
445
452
  acc.hasTruncateSync = true
446
453
  }
454
+ if (t.immediate) {
455
+ acc.hasImmediateSync = true
456
+ }
447
457
  } else {
448
458
  acc.uncommittedSyncedTransactions.push(t)
449
459
  }
@@ -457,10 +467,21 @@ export class CollectionStateManager<
457
467
  PendingSyncedTransaction<TOutput, TKey>
458
468
  >,
459
469
  hasTruncateSync: false,
470
+ hasImmediateSync: false,
460
471
  },
461
472
  )
462
473
 
463
- if (!hasPersistingTransaction || hasTruncateSync) {
474
+ // Process committed transactions if:
475
+ // 1. No persisting user transaction (normal sync flow), OR
476
+ // 2. There's a truncate operation (must be processed immediately), OR
477
+ // 3. There's an immediate transaction (manual writes must be processed synchronously)
478
+ //
479
+ // Note: When hasImmediateSync or hasTruncateSync is true, we process ALL committed
480
+ // sync transactions (not just the immediate/truncate ones). This is intentional for
481
+ // ordering correctness: if we only processed the immediate transaction, earlier
482
+ // non-immediate transactions would be applied later and could overwrite newer state.
483
+ // Processing all committed transactions together preserves causal ordering.
484
+ if (!hasPersistingTransaction || hasTruncateSync || hasImmediateSync) {
464
485
  // Set flag to prevent redundant optimistic state recalculations
465
486
  this.isCommittingSyncTransactions = true
466
487
 
@@ -88,11 +88,12 @@ export class CollectionSyncManager<
88
88
  const syncRes = normalizeSyncFnResult(
89
89
  this.config.sync.sync({
90
90
  collection: this.collection,
91
- begin: () => {
91
+ begin: (options?: { immediate?: boolean }) => {
92
92
  this.state.pendingSyncedTransactions.push({
93
93
  committed: false,
94
94
  operations: [],
95
95
  deletedKeys: new Set(),
96
+ immediate: options?.immediate,
96
97
  })
97
98
  },
98
99
  write: (
@@ -149,6 +150,7 @@ export class CollectionSyncManager<
149
150
  throw new DuplicateKeySyncError(key, this.id, {
150
151
  hasCustomGetKey: internal?.hasCustomGetKey ?? false,
151
152
  hasJoins: internal?.hasJoins ?? false,
153
+ hasDistinct: internal?.hasDistinct ?? false,
152
154
  })
153
155
  }
154
156
  }
package/src/errors.ts CHANGED
@@ -172,12 +172,28 @@ export class DuplicateKeySyncError extends CollectionOperationError {
172
172
  constructor(
173
173
  key: string | number,
174
174
  collectionId: string,
175
- options?: { hasCustomGetKey?: boolean; hasJoins?: boolean },
175
+ options?: {
176
+ hasCustomGetKey?: boolean
177
+ hasJoins?: boolean
178
+ hasDistinct?: boolean
179
+ },
176
180
  ) {
177
181
  const baseMessage = `Cannot insert document with key "${key}" from sync because it already exists in the collection "${collectionId}"`
178
182
 
179
- // Provide enhanced guidance when custom getKey is used with joins
180
- if (options?.hasCustomGetKey && options.hasJoins) {
183
+ // Provide enhanced guidance when custom getKey is used with distinct
184
+ if (options?.hasCustomGetKey && options.hasDistinct) {
185
+ super(
186
+ `${baseMessage}. ` +
187
+ `This collection uses a custom getKey with .distinct(). ` +
188
+ `The .distinct() operator deduplicates by the ENTIRE selected object (standard SQL behavior), ` +
189
+ `but your custom getKey extracts only a subset of fields. This causes multiple distinct rows ` +
190
+ `(with different values in non-key fields) to receive the same key. ` +
191
+ `To fix this, either: (1) ensure your SELECT only includes fields that uniquely identify each row, ` +
192
+ `(2) use .groupBy() with min()/max() aggregates to select one value per group, or ` +
193
+ `(3) remove the custom getKey to use the default key behavior.`,
194
+ )
195
+ } else if (options?.hasCustomGetKey && options.hasJoins) {
196
+ // Provide enhanced guidance when custom getKey is used with joins
181
197
  super(
182
198
  `${baseMessage}. ` +
183
199
  `This collection uses a custom getKey with joined queries. ` +
@@ -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 | Date | bigint
56
+ ? U extends number | undefined | null | Date | bigint | string
57
57
  ? Aggregate<U>
58
- : Aggregate<number | undefined | null | Date | bigint>
59
- : Aggregate<number | undefined | null | Date | bigint>
58
+ : Aggregate<number | undefined | null | Date | bigint | string>
59
+ : Aggregate<number | undefined | null | Date | bigint | string>
60
60
 
61
61
  // Helper type to determine string function return type based on input nullability
62
62
  type StringFunctionReturnType<T> =
@@ -353,20 +353,28 @@ function getAggregateFunction(aggExpr: Aggregate) {
353
353
  const valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
354
354
  const value = compiledExpr(namespacedRow)
355
355
  // Ensure we return a number for numeric aggregate functions
356
- return typeof value === `number` ? value : value != null ? Number(value) : 0
356
+ if (typeof value === `number`) {
357
+ return value
358
+ }
359
+ return value != null ? Number(value) : 0
357
360
  }
358
361
 
359
- // Create a value extractor function for the expression to aggregate
360
- const valueExtractorWithDate = ([, namespacedRow]: [
362
+ // Create a value extractor function for min/max that preserves comparable types
363
+ const valueExtractorForMinMax = ([, namespacedRow]: [
361
364
  string,
362
365
  NamespacedRow,
363
366
  ]) => {
364
367
  const value = compiledExpr(namespacedRow)
365
- return typeof value === `number` || value instanceof Date
366
- ? value
367
- : value != null
368
- ? Number(value)
369
- : 0
368
+ // Preserve strings, numbers, Dates, and bigints for comparison
369
+ if (
370
+ typeof value === `number` ||
371
+ typeof value === `string` ||
372
+ typeof value === `bigint` ||
373
+ value instanceof Date
374
+ ) {
375
+ return value
376
+ }
377
+ return value != null ? Number(value) : 0
370
378
  }
371
379
 
372
380
  // Create a raw value extractor function for the expression to aggregate
@@ -383,9 +391,9 @@ function getAggregateFunction(aggExpr: Aggregate) {
383
391
  case `avg`:
384
392
  return avg(valueExtractor)
385
393
  case `min`:
386
- return min(valueExtractorWithDate)
394
+ return min(valueExtractorForMinMax)
387
395
  case `max`:
388
- return max(valueExtractorWithDate)
396
+ return max(valueExtractorForMinMax)
389
397
  default:
390
398
  throw new UnsupportedAggregateFunctionError(aggExpr.name)
391
399
  }
@@ -394,20 +402,15 @@ function getAggregateFunction(aggExpr: Aggregate) {
394
402
  /**
395
403
  * Transforms expressions to replace aggregate functions with references to computed values.
396
404
  *
397
- * This function is used in both ORDER BY and HAVING clauses to transform expressions that reference:
398
- * 1. Aggregate functions (e.g., `max()`, `count()`) - replaces with references to computed aggregates in SELECT
399
- * 2. SELECT field references via $selected namespace (e.g., `$selected.latestActivity`) - validates and passes through unchanged
400
- *
401
- * For aggregate expressions, it finds matching aggregates in the SELECT clause and replaces them with
402
- * PropRef([resultAlias, alias]) to reference the computed aggregate value.
405
+ * For aggregate expressions, finds matching aggregates in the SELECT clause and replaces them
406
+ * with PropRef([resultAlias, alias]) to reference the computed aggregate value.
403
407
  *
404
- * For ref expressions using the $selected namespace, it validates that the field exists in the SELECT clause
405
- * and passes them through unchanged (since $selected is already the correct namespace). All other ref expressions
406
- * are passed through unchanged (treating them as table column references).
408
+ * Ref expressions (table columns and $selected fields) and value expressions are passed through unchanged.
409
+ * Function expressions are recursively transformed.
407
410
  *
408
411
  * @param havingExpr - The expression to transform (can be aggregate, ref, func, or val)
409
412
  * @param selectClause - The SELECT clause containing aliases and aggregate definitions
410
- * @param resultAlias - The namespace alias for SELECT results (default: '$selected', used for aggregate references)
413
+ * @param resultAlias - The namespace alias for SELECT results (default: '$selected')
411
414
  * @returns A transformed BasicExpression that references computed values instead of raw expressions
412
415
  */
413
416
  export function replaceAggregatesByRefs(
@@ -439,41 +442,11 @@ export function replaceAggregatesByRefs(
439
442
  return new Func(funcExpr.name, transformedArgs)
440
443
  }
441
444
 
442
- case `ref`: {
443
- const refExpr = havingExpr
444
- const path = refExpr.path
445
-
446
- if (path.length === 0) {
447
- // Empty path - pass through
448
- return havingExpr as BasicExpression
449
- }
450
-
451
- // Check if this is a $selected reference
452
- if (path.length > 0 && path[0] === `$selected`) {
453
- // Extract the field path after $selected
454
- const fieldPath = path.slice(1)
455
-
456
- if (fieldPath.length === 0) {
457
- // Just $selected without a field - pass through unchanged
458
- return havingExpr as BasicExpression
459
- }
460
-
461
- // Verify the field exists in SELECT clause
462
- const alias = fieldPath.join(`.`)
463
- if (alias in selectClause) {
464
- // Pass through unchanged - $selected is already the correct namespace
465
- return havingExpr as BasicExpression
466
- }
467
-
468
- // Field doesn't exist in SELECT - this is an error, but we'll pass through for now
469
- // (Could throw an error here in the future)
470
- return havingExpr as BasicExpression
471
- }
472
-
473
- // Not a $selected reference - this is a table column reference, pass through unchanged
474
- // SELECT fields should only be accessed via $selected namespace
445
+ case `ref`:
446
+ // Ref expressions are passed through unchanged - they reference either:
447
+ // - $selected fields (which are already in the correct namespace)
448
+ // - Table column references (which remain valid)
475
449
  return havingExpr as BasicExpression
476
- }
477
450
 
478
451
  case `val`:
479
452
  // Return as-is
@@ -227,6 +227,7 @@ export class CollectionConfigBuilder<
227
227
  getBuilder: () => this,
228
228
  hasCustomGetKey: !!this.config.getKey,
229
229
  hasJoins: this.hasJoins(this.query),
230
+ hasDistinct: !!this.query.distinct,
230
231
  },
231
232
  },
232
233
  }
@@ -12,4 +12,5 @@ export type LiveQueryInternalUtils = {
12
12
  getBuilder: () => CollectionConfigBuilder<any, any>
13
13
  hasCustomGetKey: boolean
14
14
  hasJoins: boolean
15
+ hasDistinct: boolean
15
16
  }
@@ -6,9 +6,15 @@ import type { Transaction } from '../transactions'
6
6
  * Creates a queue strategy that processes all mutations in order with proper serialization.
7
7
  *
8
8
  * Unlike other strategies that may drop executions, queue ensures every
9
- * mutation is processed sequentially. Each transaction commit completes before
9
+ * mutation is attempted sequentially. Each transaction commit completes before
10
10
  * the next one starts. Useful when data consistency is critical and
11
- * every operation must complete in order.
11
+ * every operation must be attempted in order.
12
+ *
13
+ * **Error handling behavior:**
14
+ * - If a mutation fails, it is NOT automatically retried - the transaction transitions to "failed" state
15
+ * - Failed mutations surface their error via `transaction.isPersisted.promise` (which will reject)
16
+ * - Subsequent mutations continue processing - a single failure does not block the queue
17
+ * - Each mutation is independent; there is no all-or-nothing transaction semantics
12
18
  *
13
19
  * @param options - Configuration for queue behavior (FIFO/LIFO, timing, size limits)
14
20
  * @returns A queue strategy instance
package/src/types.ts CHANGED
@@ -328,7 +328,12 @@ export interface SyncConfig<
328
328
  > {
329
329
  sync: (params: {
330
330
  collection: Collection<T, TKey, any, any, any>
331
- begin: () => void
331
+ /**
332
+ * Begin a new sync transaction.
333
+ * @param options.immediate - When true, the transaction will be processed immediately
334
+ * even if there are persisting user transactions. Used by manual write operations.
335
+ */
336
+ begin: (options?: { immediate?: boolean }) => void
332
337
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => void
333
338
  commit: () => void
334
339
  markReady: () => void