@tanstack/db 0.4.19 → 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 (45) 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/query/builder/types.d.cts +15 -2
  9. package/dist/cjs/query/live/collection-config-builder.cjs +21 -2
  10. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  11. package/dist/cjs/query/live/collection-config-builder.d.cts +6 -1
  12. package/dist/cjs/query/live/collection-registry.cjs +2 -1
  13. package/dist/cjs/query/live/collection-registry.cjs.map +1 -1
  14. package/dist/cjs/query/live/collection-registry.d.cts +1 -1
  15. package/dist/cjs/query/live/internal.cjs +5 -0
  16. package/dist/cjs/query/live/internal.cjs.map +1 -0
  17. package/dist/cjs/query/live/internal.d.cts +13 -0
  18. package/dist/cjs/types.d.cts +2 -2
  19. package/dist/esm/collection/index.js +1 -1
  20. package/dist/esm/collection/index.js.map +1 -1
  21. package/dist/esm/collection/sync.js +7 -1
  22. package/dist/esm/collection/sync.js.map +1 -1
  23. package/dist/esm/errors.d.ts +4 -1
  24. package/dist/esm/errors.js +9 -4
  25. package/dist/esm/errors.js.map +1 -1
  26. package/dist/esm/query/builder/types.d.ts +15 -2
  27. package/dist/esm/query/live/collection-config-builder.d.ts +6 -1
  28. package/dist/esm/query/live/collection-config-builder.js +21 -2
  29. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  30. package/dist/esm/query/live/collection-registry.d.ts +1 -1
  31. package/dist/esm/query/live/collection-registry.js +2 -1
  32. package/dist/esm/query/live/collection-registry.js.map +1 -1
  33. package/dist/esm/query/live/internal.d.ts +13 -0
  34. package/dist/esm/query/live/internal.js +5 -0
  35. package/dist/esm/query/live/internal.js.map +1 -0
  36. package/dist/esm/types.d.ts +2 -2
  37. package/package.json +1 -1
  38. package/src/collection/index.ts +2 -2
  39. package/src/collection/sync.ts +9 -1
  40. package/src/errors.ts +20 -4
  41. package/src/query/builder/types.ts +16 -2
  42. package/src/query/live/collection-config-builder.ts +27 -2
  43. package/src/query/live/collection-registry.ts +3 -2
  44. package/src/query/live/internal.ts +15 -0
  45. package/src/types.ts +2 -2
@@ -1,7 +1,7 @@
1
1
  import { Collection } from '../../collection/index.js';
2
2
  import { CollectionConfigBuilder } from './collection-config-builder.js';
3
3
  /**
4
- * Retrieves the builder attached to a config object via its utils.getBuilder() method.
4
+ * Retrieves the builder attached to a config object via its internal utils.
5
5
  *
6
6
  * @param config - The collection config object
7
7
  * @returns The attached builder, or `undefined` if none exists
@@ -1,6 +1,7 @@
1
+ import { LIVE_QUERY_INTERNAL } from "./internal.js";
1
2
  const collectionBuilderRegistry = /* @__PURE__ */ new WeakMap();
2
3
  function getBuilderFromConfig(config) {
3
- return config.utils?.getBuilder?.();
4
+ return config.utils?.[LIVE_QUERY_INTERNAL]?.getBuilder?.();
4
5
  }
5
6
  function registerCollectionBuilder(collection, builder) {
6
7
  collectionBuilderRegistry.set(collection, builder);
@@ -1 +1 @@
1
- {"version":3,"file":"collection-registry.js","sources":["../../../../src/query/live/collection-registry.ts"],"sourcesContent":["import type { Collection } from \"../../collection/index.js\"\nimport type { CollectionConfigBuilder } from \"./collection-config-builder.js\"\n\nconst collectionBuilderRegistry = new WeakMap<\n Collection<any, any, any>,\n CollectionConfigBuilder<any, any>\n>()\n\n/**\n * Retrieves the builder attached to a config object via its utils.getBuilder() method.\n *\n * @param config - The collection config object\n * @returns The attached builder, or `undefined` if none exists\n */\nexport function getBuilderFromConfig(\n config: object\n): CollectionConfigBuilder<any, any> | undefined {\n return (config as any).utils?.getBuilder?.()\n}\n\n/**\n * Registers a builder for a collection in the global registry.\n * Used to detect when a live query depends on another live query,\n * enabling the scheduler to ensure parent queries run first.\n *\n * @param collection - The collection to register the builder for\n * @param builder - The builder that produces this collection\n */\nexport function registerCollectionBuilder(\n collection: Collection<any, any, any>,\n builder: CollectionConfigBuilder<any, any>\n): void {\n collectionBuilderRegistry.set(collection, builder)\n}\n\n/**\n * Retrieves the builder registered for a collection.\n * Used to discover dependencies when a live query subscribes to another live query.\n *\n * @param collection - The collection to look up\n * @returns The registered builder, or `undefined` if none exists\n */\nexport function getCollectionBuilder(\n collection: Collection<any, any, any>\n): CollectionConfigBuilder<any, any> | undefined {\n return collectionBuilderRegistry.get(collection)\n}\n"],"names":[],"mappings":"AAGA,MAAM,gDAAgC,QAAA;AAW/B,SAAS,qBACd,QAC+C;AAC/C,SAAQ,OAAe,OAAO,aAAA;AAChC;AAUO,SAAS,0BACd,YACA,SACM;AACN,4BAA0B,IAAI,YAAY,OAAO;AACnD;AASO,SAAS,qBACd,YAC+C;AAC/C,SAAO,0BAA0B,IAAI,UAAU;AACjD;"}
1
+ {"version":3,"file":"collection-registry.js","sources":["../../../../src/query/live/collection-registry.ts"],"sourcesContent":["import { LIVE_QUERY_INTERNAL } from \"./internal.js\"\nimport type { Collection } from \"../../collection/index.js\"\nimport type { CollectionConfigBuilder } from \"./collection-config-builder.js\"\n\nconst collectionBuilderRegistry = new WeakMap<\n Collection<any, any, any>,\n CollectionConfigBuilder<any, any>\n>()\n\n/**\n * Retrieves the builder attached to a config object via its internal utils.\n *\n * @param config - The collection config object\n * @returns The attached builder, or `undefined` if none exists\n */\nexport function getBuilderFromConfig(\n config: object\n): CollectionConfigBuilder<any, any> | undefined {\n return (config as any).utils?.[LIVE_QUERY_INTERNAL]?.getBuilder?.()\n}\n\n/**\n * Registers a builder for a collection in the global registry.\n * Used to detect when a live query depends on another live query,\n * enabling the scheduler to ensure parent queries run first.\n *\n * @param collection - The collection to register the builder for\n * @param builder - The builder that produces this collection\n */\nexport function registerCollectionBuilder(\n collection: Collection<any, any, any>,\n builder: CollectionConfigBuilder<any, any>\n): void {\n collectionBuilderRegistry.set(collection, builder)\n}\n\n/**\n * Retrieves the builder registered for a collection.\n * Used to discover dependencies when a live query subscribes to another live query.\n *\n * @param collection - The collection to look up\n * @returns The registered builder, or `undefined` if none exists\n */\nexport function getCollectionBuilder(\n collection: Collection<any, any, any>\n): CollectionConfigBuilder<any, any> | undefined {\n return collectionBuilderRegistry.get(collection)\n}\n"],"names":[],"mappings":";AAIA,MAAM,gDAAgC,QAAA;AAW/B,SAAS,qBACd,QAC+C;AAC/C,SAAQ,OAAe,QAAQ,mBAAmB,GAAG,aAAA;AACvD;AAUO,SAAS,0BACd,YACA,SACM;AACN,4BAA0B,IAAI,YAAY,OAAO;AACnD;AASO,SAAS,qBACd,YAC+C;AAC/C,SAAO,0BAA0B,IAAI,UAAU;AACjD;"}
@@ -0,0 +1,13 @@
1
+ import { CollectionConfigBuilder } from './collection-config-builder.js';
2
+ /**
3
+ * Symbol for accessing internal utilities that should not be part of the public API
4
+ */
5
+ export declare const LIVE_QUERY_INTERNAL: unique symbol;
6
+ /**
7
+ * Internal utilities for live queries, accessible via Symbol
8
+ */
9
+ export type LiveQueryInternalUtils = {
10
+ getBuilder: () => CollectionConfigBuilder<any, any>;
11
+ hasCustomGetKey: boolean;
12
+ hasJoins: boolean;
13
+ };
@@ -0,0 +1,5 @@
1
+ const LIVE_QUERY_INTERNAL = Symbol(`liveQueryInternal`);
2
+ export {
3
+ LIVE_QUERY_INTERNAL
4
+ };
5
+ //# sourceMappingURL=internal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal.js","sources":["../../../../src/query/live/internal.ts"],"sourcesContent":["import type { CollectionConfigBuilder } from \"./collection-config-builder.js\"\n\n/**\n * Symbol for accessing internal utilities that should not be part of the public API\n */\nexport const LIVE_QUERY_INTERNAL = Symbol(`liveQueryInternal`)\n\n/**\n * Internal utilities for live queries, accessible via Symbol\n */\nexport type LiveQueryInternalUtils = {\n getBuilder: () => CollectionConfigBuilder<any, any>\n hasCustomGetKey: boolean\n hasJoins: boolean\n}\n"],"names":[],"mappings":"AAKO,MAAM,sBAAsB,OAAO,mBAAmB;"}
@@ -22,9 +22,9 @@ export type TransactionState = `pending` | `persisting` | `completed` | `failed`
22
22
  */
23
23
  export type Fn = (...args: Array<any>) => any;
24
24
  /**
25
- * A record of utility functions that can be attached to a collection
25
+ * A record of utilities (functions or getters) that can be attached to a collection
26
26
  */
27
- export type UtilsRecord = Record<string, Fn>;
27
+ export type UtilsRecord = Record<string, any>;
28
28
  /**
29
29
  *
30
30
  * @remarks `update` and `insert` are both represented as `Partial<T>`, but changes for `insert` could me made more precise by inferring the schema input type. In practice, this has almost 0 real world impact so it's not worth the added type complexity.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.4.19",
4
+ "version": "0.4.20",
5
5
  "dependencies": {
6
6
  "@standard-schema/spec": "^1.0.0",
7
7
  "@tanstack/pacer": "^0.1.0",
@@ -189,9 +189,9 @@ export function createCollection(
189
189
  options
190
190
  )
191
191
 
192
- // Copy utils to both top level and .utils namespace
192
+ // Attach utils to collection
193
193
  if (options.utils) {
194
- collection.utils = { ...options.utils }
194
+ collection.utils = options.utils
195
195
  } else {
196
196
  collection.utils = {}
197
197
  }
@@ -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
 
@@ -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
  *