@tanstack/db 0.4.18 → 0.4.20

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 (50) hide show
  1. package/dist/cjs/collection/index.cjs +1 -1
  2. package/dist/cjs/collection/index.cjs.map +1 -1
  3. package/dist/cjs/collection/sync.cjs +7 -1
  4. package/dist/cjs/collection/sync.cjs.map +1 -1
  5. package/dist/cjs/errors.cjs +9 -4
  6. package/dist/cjs/errors.cjs.map +1 -1
  7. package/dist/cjs/errors.d.cts +4 -1
  8. package/dist/cjs/local-storage.cjs +15 -28
  9. package/dist/cjs/local-storage.cjs.map +1 -1
  10. package/dist/cjs/query/builder/types.d.cts +15 -2
  11. package/dist/cjs/query/live/collection-config-builder.cjs +21 -2
  12. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  13. package/dist/cjs/query/live/collection-config-builder.d.cts +6 -1
  14. package/dist/cjs/query/live/collection-registry.cjs +2 -1
  15. package/dist/cjs/query/live/collection-registry.cjs.map +1 -1
  16. package/dist/cjs/query/live/collection-registry.d.cts +1 -1
  17. package/dist/cjs/query/live/internal.cjs +5 -0
  18. package/dist/cjs/query/live/internal.cjs.map +1 -0
  19. package/dist/cjs/query/live/internal.d.cts +13 -0
  20. package/dist/cjs/types.d.cts +2 -2
  21. package/dist/esm/collection/index.js +1 -1
  22. package/dist/esm/collection/index.js.map +1 -1
  23. package/dist/esm/collection/sync.js +7 -1
  24. package/dist/esm/collection/sync.js.map +1 -1
  25. package/dist/esm/errors.d.ts +4 -1
  26. package/dist/esm/errors.js +9 -4
  27. package/dist/esm/errors.js.map +1 -1
  28. package/dist/esm/local-storage.js +15 -28
  29. package/dist/esm/local-storage.js.map +1 -1
  30. package/dist/esm/query/builder/types.d.ts +15 -2
  31. package/dist/esm/query/live/collection-config-builder.d.ts +6 -1
  32. package/dist/esm/query/live/collection-config-builder.js +21 -2
  33. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  34. package/dist/esm/query/live/collection-registry.d.ts +1 -1
  35. package/dist/esm/query/live/collection-registry.js +2 -1
  36. package/dist/esm/query/live/collection-registry.js.map +1 -1
  37. package/dist/esm/query/live/internal.d.ts +13 -0
  38. package/dist/esm/query/live/internal.js +5 -0
  39. package/dist/esm/query/live/internal.js.map +1 -0
  40. package/dist/esm/types.d.ts +2 -2
  41. package/package.json +1 -1
  42. package/src/collection/index.ts +2 -2
  43. package/src/collection/sync.ts +9 -1
  44. package/src/errors.ts +20 -4
  45. package/src/local-storage.ts +28 -45
  46. package/src/query/builder/types.ts +16 -2
  47. package/src/query/live/collection-config-builder.ts +27 -2
  48. package/src/query/live/collection-registry.ts +3 -2
  49. package/src/query/live/internal.ts +15 -0
  50. package/src/types.ts +2 -2
@@ -9,6 +9,7 @@ import {
9
9
  SyncTransactionAlreadyCommittedWriteError,
10
10
  } from "../errors"
11
11
  import { deepEquals } from "../utils"
12
+ import { LIVE_QUERY_INTERNAL } from "../query/live/internal.js"
12
13
  import type { StandardSchemaV1 } from "@standard-schema/spec"
13
14
  import type {
14
15
  ChangeMessage,
@@ -21,6 +22,7 @@ import type { CollectionImpl } from "./index.js"
21
22
  import type { CollectionStateManager } from "./state"
22
23
  import type { CollectionLifecycleManager } from "./lifecycle"
23
24
  import type { CollectionEventsManager } from "./events.js"
25
+ import type { LiveQueryCollectionUtils } from "../query/live/collection-config-builder.js"
24
26
 
25
27
  export class CollectionSyncManager<
26
28
  TOutput extends object = Record<string, unknown>,
@@ -127,7 +129,13 @@ export class CollectionSyncManager<
127
129
  // throwing a duplicate-key error during reconciliation.
128
130
  messageType = `update`
129
131
  } else {
130
- throw new DuplicateKeySyncError(key, this.id)
132
+ const utils = this.config
133
+ .utils as Partial<LiveQueryCollectionUtils>
134
+ const internal = utils[LIVE_QUERY_INTERNAL]
135
+ throw new DuplicateKeySyncError(key, this.id, {
136
+ hasCustomGetKey: internal?.hasCustomGetKey ?? false,
137
+ hasJoins: internal?.hasJoins ?? false,
138
+ })
131
139
  }
132
140
  }
133
141
  }
package/src/errors.ts CHANGED
@@ -160,10 +160,26 @@ export class DuplicateKeyError extends CollectionOperationError {
160
160
  }
161
161
 
162
162
  export class DuplicateKeySyncError extends CollectionOperationError {
163
- constructor(key: string | number, collectionId: string) {
164
- super(
165
- `Cannot insert document with key "${key}" from sync because it already exists in the collection "${collectionId}"`
166
- )
163
+ constructor(
164
+ key: string | number,
165
+ collectionId: string,
166
+ options?: { hasCustomGetKey?: boolean; hasJoins?: boolean }
167
+ ) {
168
+ const baseMessage = `Cannot insert document with key "${key}" from sync because it already exists in the collection "${collectionId}"`
169
+
170
+ // Provide enhanced guidance when custom getKey is used with joins
171
+ if (options?.hasCustomGetKey && options.hasJoins) {
172
+ super(
173
+ `${baseMessage}. ` +
174
+ `This collection uses a custom getKey with joined queries. ` +
175
+ `Joined queries can produce multiple rows with the same key when relationships are not 1:1. ` +
176
+ `Consider: (1) using a composite key in your getKey function (e.g., \`\${item.key1}-\${item.key2}\`), ` +
177
+ `(2) ensuring your join produces unique rows per key, or (3) removing the custom getKey ` +
178
+ `to use the default composite key behavior.`
179
+ )
180
+ } else {
181
+ super(baseMessage)
182
+ }
167
183
  }
168
184
  }
169
185
 
@@ -346,16 +346,6 @@ export function localStorageCollectionOptions(
346
346
  lastKnownData
347
347
  )
348
348
 
349
- /**
350
- * Manual trigger function for local sync updates
351
- * Forces a check for storage changes and updates the collection if needed
352
- */
353
- const triggerLocalSync = () => {
354
- if (sync.manualTrigger) {
355
- sync.manualTrigger()
356
- }
357
- }
358
-
359
349
  /**
360
350
  * Save data to storage
361
351
  * @param dataMap - Map of items with version tracking to save to storage
@@ -413,24 +403,24 @@ export function localStorageCollectionOptions(
413
403
  }
414
404
 
415
405
  // Always persist to storage
416
- // Load current data from storage
417
- const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
418
-
406
+ // Use lastKnownData (in-memory cache) instead of reading from storage
419
407
  // Add new items with version keys
420
408
  params.transaction.mutations.forEach((mutation) => {
421
- const key = config.getKey(mutation.modified)
409
+ // Use the engine's pre-computed key for consistency
410
+ const key = mutation.key
422
411
  const storedItem: StoredItem<any> = {
423
412
  versionKey: generateUuid(),
424
413
  data: mutation.modified,
425
414
  }
426
- currentData.set(key, storedItem)
415
+ lastKnownData.set(key, storedItem)
427
416
  })
428
417
 
429
418
  // Save to storage
430
- saveToStorage(currentData)
419
+ saveToStorage(lastKnownData)
431
420
 
432
- // Manually trigger local sync since storage events don't fire for current tab
433
- triggerLocalSync()
421
+ // Confirm mutations through sync interface (moves from optimistic to synced state)
422
+ // without reloading from storage
423
+ sync.confirmOperationsSync(params.transaction.mutations)
434
424
 
435
425
  return handlerResult
436
426
  }
@@ -448,24 +438,24 @@ export function localStorageCollectionOptions(
448
438
  }
449
439
 
450
440
  // Always persist to storage
451
- // Load current data from storage
452
- const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
453
-
441
+ // Use lastKnownData (in-memory cache) instead of reading from storage
454
442
  // Update items with new version keys
455
443
  params.transaction.mutations.forEach((mutation) => {
456
- const key = config.getKey(mutation.modified)
444
+ // Use the engine's pre-computed key for consistency
445
+ const key = mutation.key
457
446
  const storedItem: StoredItem<any> = {
458
447
  versionKey: generateUuid(),
459
448
  data: mutation.modified,
460
449
  }
461
- currentData.set(key, storedItem)
450
+ lastKnownData.set(key, storedItem)
462
451
  })
463
452
 
464
453
  // Save to storage
465
- saveToStorage(currentData)
454
+ saveToStorage(lastKnownData)
466
455
 
467
- // Manually trigger local sync since storage events don't fire for current tab
468
- triggerLocalSync()
456
+ // Confirm mutations through sync interface (moves from optimistic to synced state)
457
+ // without reloading from storage
458
+ sync.confirmOperationsSync(params.transaction.mutations)
469
459
 
470
460
  return handlerResult
471
461
  }
@@ -478,21 +468,20 @@ export function localStorageCollectionOptions(
478
468
  }
479
469
 
480
470
  // Always persist to storage
481
- // Load current data from storage
482
- const currentData = loadFromStorage<any>(config.storageKey, storage, parser)
483
-
471
+ // Use lastKnownData (in-memory cache) instead of reading from storage
484
472
  // Remove items
485
473
  params.transaction.mutations.forEach((mutation) => {
486
- // For delete operations, mutation.original contains the full object
487
- const key = config.getKey(mutation.original)
488
- currentData.delete(key)
474
+ // Use the engine's pre-computed key for consistency
475
+ const key = mutation.key
476
+ lastKnownData.delete(key)
489
477
  })
490
478
 
491
479
  // Save to storage
492
- saveToStorage(currentData)
480
+ saveToStorage(lastKnownData)
493
481
 
494
- // Manually trigger local sync since storage events don't fire for current tab
495
- triggerLocalSync()
482
+ // Confirm mutations through sync interface (moves from optimistic to synced state)
483
+ // without reloading from storage
484
+ sync.confirmOperationsSync(params.transaction.mutations)
496
485
 
497
486
  return handlerResult
498
487
  }
@@ -546,13 +535,7 @@ export function localStorageCollectionOptions(
546
535
  }
547
536
  }
548
537
 
549
- // Load current data from storage
550
- const currentData = loadFromStorage<Record<string, unknown>>(
551
- config.storageKey,
552
- storage,
553
- parser
554
- )
555
-
538
+ // Use lastKnownData (in-memory cache) instead of reading from storage
556
539
  // Apply each mutation
557
540
  for (const mutation of collectionMutations) {
558
541
  // Use the engine's pre-computed key to avoid key derivation issues
@@ -565,18 +548,18 @@ export function localStorageCollectionOptions(
565
548
  versionKey: generateUuid(),
566
549
  data: mutation.modified,
567
550
  }
568
- currentData.set(key, storedItem)
551
+ lastKnownData.set(key, storedItem)
569
552
  break
570
553
  }
571
554
  case `delete`: {
572
- currentData.delete(key)
555
+ lastKnownData.delete(key)
573
556
  break
574
557
  }
575
558
  }
576
559
  }
577
560
 
578
561
  // Save to storage
579
- saveToStorage(currentData)
562
+ saveToStorage(lastKnownData)
580
563
 
581
564
  // Confirm the mutations in the collection to move them from optimistic to synced state
582
565
  // This writes them through the sync interface to make them "synced" instead of "optimistic"
@@ -530,6 +530,20 @@ export type RefLeaf<T = any> = { readonly [RefBrand]?: T }
530
530
  type WithoutRefBrand<T> =
531
531
  T extends Record<string, any> ? Omit<T, typeof RefBrand> : T
532
532
 
533
+ /**
534
+ * PreserveSingleResultFlag - Conditionally includes the singleResult flag
535
+ *
536
+ * This helper type ensures the singleResult flag is only added to the context when it's
537
+ * explicitly true. It uses a non-distributive conditional (tuple wrapper) to prevent
538
+ * unexpected behavior when TFlag is a union type.
539
+ *
540
+ * @template TFlag - The singleResult flag value to check
541
+ * @returns { singleResult: true } if TFlag is true, otherwise {}
542
+ */
543
+ type PreserveSingleResultFlag<TFlag> = [TFlag] extends [true]
544
+ ? { singleResult: true }
545
+ : {}
546
+
533
547
  /**
534
548
  * MergeContextWithJoinType - Creates a new context after a join operation
535
549
  *
@@ -551,6 +565,7 @@ type WithoutRefBrand<T> =
551
565
  * - `hasJoins`: Set to true
552
566
  * - `joinTypes`: Updated to track this join type
553
567
  * - `result`: Preserved from previous operations
568
+ * - `singleResult`: Preserved only if already true (via PreserveSingleResultFlag)
554
569
  */
555
570
  export type MergeContextWithJoinType<
556
571
  TContext extends Context,
@@ -574,8 +589,7 @@ export type MergeContextWithJoinType<
574
589
  [K in keyof TNewSchema & string]: TJoinType
575
590
  }
576
591
  result: TContext[`result`]
577
- singleResult: TContext[`singleResult`] extends true ? true : false
578
- }
592
+ } & PreserveSingleResultFlag<TContext[`singleResult`]>
579
593
 
580
594
  /**
581
595
  * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas
@@ -9,6 +9,8 @@ import { transactionScopedScheduler } from "../../scheduler.js"
9
9
  import { getActiveTransaction } from "../../transactions.js"
10
10
  import { CollectionSubscriber } from "./collection-subscriber.js"
11
11
  import { getCollectionBuilder } from "./collection-registry.js"
12
+ import { LIVE_QUERY_INTERNAL } from "./internal.js"
13
+ import type { LiveQueryInternalUtils } from "./internal.js"
12
14
  import type { WindowOptions } from "../compiler/index.js"
13
15
  import type { SchedulerContextId } from "../../scheduler.js"
14
16
  import type { CollectionSubscription } from "../../collection/subscription.js"
@@ -35,7 +37,6 @@ import type { AllCollectionEvents } from "../../collection/events.js"
35
37
 
36
38
  export type LiveQueryCollectionUtils = UtilsRecord & {
37
39
  getRunCount: () => number
38
- getBuilder: () => CollectionConfigBuilder<any, any>
39
40
  /**
40
41
  * Sets the offset and limit of an ordered query.
41
42
  * Is a no-op if the query is not ordered.
@@ -49,6 +50,7 @@ export type LiveQueryCollectionUtils = UtilsRecord & {
49
50
  * @returns The current window settings, or `undefined` if the query is not windowed
50
51
  */
51
52
  getWindow: () => { offset: number; limit: number } | undefined
53
+ [LIVE_QUERY_INTERNAL]: LiveQueryInternalUtils
52
54
  }
53
55
 
54
56
  type PendingGraphRun = {
@@ -173,6 +175,25 @@ export class CollectionConfigBuilder<
173
175
  this.compileBasePipeline()
174
176
  }
175
177
 
178
+ /**
179
+ * Recursively checks if a query or any of its subqueries contains joins
180
+ */
181
+ private hasJoins(query: QueryIR): boolean {
182
+ // Check if this query has joins
183
+ if (query.join && query.join.length > 0) {
184
+ return true
185
+ }
186
+
187
+ // Recursively check subqueries in the from clause
188
+ if (query.from.type === `queryRef`) {
189
+ if (this.hasJoins(query.from.query)) {
190
+ return true
191
+ }
192
+ }
193
+
194
+ return false
195
+ }
196
+
176
197
  getConfig(): CollectionConfigSingleRowOption<TResult> & {
177
198
  utils: LiveQueryCollectionUtils
178
199
  } {
@@ -192,9 +213,13 @@ export class CollectionConfigBuilder<
192
213
  singleResult: this.query.singleResult,
193
214
  utils: {
194
215
  getRunCount: this.getRunCount.bind(this),
195
- getBuilder: () => this,
196
216
  setWindow: this.setWindow.bind(this),
197
217
  getWindow: this.getWindow.bind(this),
218
+ [LIVE_QUERY_INTERNAL]: {
219
+ getBuilder: () => this,
220
+ hasCustomGetKey: !!this.config.getKey,
221
+ hasJoins: this.hasJoins(this.query),
222
+ },
198
223
  },
199
224
  }
200
225
  }
@@ -1,3 +1,4 @@
1
+ import { LIVE_QUERY_INTERNAL } from "./internal.js"
1
2
  import type { Collection } from "../../collection/index.js"
2
3
  import type { CollectionConfigBuilder } from "./collection-config-builder.js"
3
4
 
@@ -7,7 +8,7 @@ const collectionBuilderRegistry = new WeakMap<
7
8
  >()
8
9
 
9
10
  /**
10
- * Retrieves the builder attached to a config object via its utils.getBuilder() method.
11
+ * Retrieves the builder attached to a config object via its internal utils.
11
12
  *
12
13
  * @param config - The collection config object
13
14
  * @returns The attached builder, or `undefined` if none exists
@@ -15,7 +16,7 @@ const collectionBuilderRegistry = new WeakMap<
15
16
  export function getBuilderFromConfig(
16
17
  config: object
17
18
  ): CollectionConfigBuilder<any, any> | undefined {
18
- return (config as any).utils?.getBuilder?.()
19
+ return (config as any).utils?.[LIVE_QUERY_INTERNAL]?.getBuilder?.()
19
20
  }
20
21
 
21
22
  /**
@@ -0,0 +1,15 @@
1
+ import type { CollectionConfigBuilder } from "./collection-config-builder.js"
2
+
3
+ /**
4
+ * Symbol for accessing internal utilities that should not be part of the public API
5
+ */
6
+ export const LIVE_QUERY_INTERNAL = Symbol(`liveQueryInternal`)
7
+
8
+ /**
9
+ * Internal utilities for live queries, accessible via Symbol
10
+ */
11
+ export type LiveQueryInternalUtils = {
12
+ getBuilder: () => CollectionConfigBuilder<any, any>
13
+ hasCustomGetKey: boolean
14
+ hasJoins: boolean
15
+ }
package/src/types.ts CHANGED
@@ -35,9 +35,9 @@ export type TransactionState = `pending` | `persisting` | `completed` | `failed`
35
35
  export type Fn = (...args: Array<any>) => any
36
36
 
37
37
  /**
38
- * A record of utility functions that can be attached to a collection
38
+ * A record of utilities (functions or getters) that can be attached to a collection
39
39
  */
40
- export type UtilsRecord = Record<string, Fn>
40
+ export type UtilsRecord = Record<string, any>
41
41
 
42
42
  /**
43
43
  *