@tanstack/db 0.5.30 → 0.5.32

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/subscription.cjs +6 -6
  2. package/dist/cjs/collection/subscription.cjs.map +1 -1
  3. package/dist/cjs/errors.cjs +8 -0
  4. package/dist/cjs/errors.cjs.map +1 -1
  5. package/dist/cjs/errors.d.cts +3 -0
  6. package/dist/cjs/index.cjs +13 -10
  7. package/dist/cjs/index.cjs.map +1 -1
  8. package/dist/cjs/query/builder/types.d.cts +28 -31
  9. package/dist/cjs/query/compiler/index.cjs +3 -0
  10. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  11. package/dist/cjs/query/index.d.cts +1 -0
  12. package/dist/cjs/query/query-once.cjs +28 -0
  13. package/dist/cjs/query/query-once.cjs.map +1 -0
  14. package/dist/cjs/query/query-once.d.cts +57 -0
  15. package/dist/cjs/query/subset-dedupe.cjs +8 -7
  16. package/dist/cjs/query/subset-dedupe.cjs.map +1 -1
  17. package/dist/esm/collection/subscription.js +6 -6
  18. package/dist/esm/collection/subscription.js.map +1 -1
  19. package/dist/esm/errors.d.ts +3 -0
  20. package/dist/esm/errors.js +8 -0
  21. package/dist/esm/errors.js.map +1 -1
  22. package/dist/esm/index.js +6 -3
  23. package/dist/esm/index.js.map +1 -1
  24. package/dist/esm/query/builder/types.d.ts +28 -31
  25. package/dist/esm/query/compiler/index.js +4 -1
  26. package/dist/esm/query/compiler/index.js.map +1 -1
  27. package/dist/esm/query/index.d.ts +1 -0
  28. package/dist/esm/query/query-once.d.ts +57 -0
  29. package/dist/esm/query/query-once.js +28 -0
  30. package/dist/esm/query/query-once.js.map +1 -0
  31. package/dist/esm/query/subset-dedupe.js +8 -7
  32. package/dist/esm/query/subset-dedupe.js.map +1 -1
  33. package/package.json +3 -2
  34. package/skills/db-core/SKILL.md +61 -0
  35. package/skills/db-core/collection-setup/SKILL.md +427 -0
  36. package/skills/db-core/collection-setup/references/electric-adapter.md +238 -0
  37. package/skills/db-core/collection-setup/references/local-adapters.md +220 -0
  38. package/skills/db-core/collection-setup/references/powersync-adapter.md +241 -0
  39. package/skills/db-core/collection-setup/references/query-adapter.md +183 -0
  40. package/skills/db-core/collection-setup/references/rxdb-adapter.md +152 -0
  41. package/skills/db-core/collection-setup/references/schema-patterns.md +215 -0
  42. package/skills/db-core/collection-setup/references/trailbase-adapter.md +147 -0
  43. package/skills/db-core/custom-adapter/SKILL.md +285 -0
  44. package/skills/db-core/live-queries/SKILL.md +332 -0
  45. package/skills/db-core/live-queries/references/operators.md +302 -0
  46. package/skills/db-core/mutations-optimistic/SKILL.md +375 -0
  47. package/skills/db-core/mutations-optimistic/references/transaction-api.md +207 -0
  48. package/skills/meta-framework/SKILL.md +361 -0
  49. package/src/collection/subscription.ts +6 -6
  50. package/src/errors.ts +11 -0
  51. package/src/query/builder/types.ts +64 -50
  52. package/src/query/compiler/index.ts +5 -0
  53. package/src/query/index.ts +3 -0
  54. package/src/query/query-once.ts +115 -0
  55. package/src/query/subset-dedupe.ts +14 -15
@@ -227,18 +227,28 @@ export type ResultTypeFromSelect<TSelectObject> = WithoutRefBrand<
227
227
  Prettify<{
228
228
  [K in keyof TSelectObject]: NeedsExtraction<TSelectObject[K]> extends true
229
229
  ? ExtractExpressionType<TSelectObject[K]>
230
- : TSelectObject[K] extends Ref<infer _T>
230
+ : // Ref (full object ref or spread with RefBrand) - recursively process properties
231
+ TSelectObject[K] extends Ref<infer _T>
231
232
  ? ExtractRef<TSelectObject[K]>
232
- : TSelectObject[K] extends RefLeaf<infer T>
233
- ? T
234
- : TSelectObject[K] extends RefLeaf<infer T> | undefined
233
+ : // RefLeaf (simple property ref like user.name)
234
+ TSelectObject[K] extends RefLeaf<infer T>
235
+ ? IsNullableRef<TSelectObject[K]> extends true
235
236
  ? T | undefined
236
- : TSelectObject[K] extends RefLeaf<infer T> | null
237
- ? T | null
238
- : TSelectObject[K] extends Ref<infer _T> | undefined
239
- ? ExtractRef<TSelectObject[K]> | undefined
240
- : TSelectObject[K] extends Ref<infer _T> | null
241
- ? ExtractRef<TSelectObject[K]> | null
237
+ : T
238
+ : // RefLeaf | undefined (schema-optional field)
239
+ TSelectObject[K] extends RefLeaf<infer T> | undefined
240
+ ? T | undefined
241
+ : // RefLeaf | null (schema-nullable field)
242
+ TSelectObject[K] extends RefLeaf<infer T> | null
243
+ ? IsNullableRef<Exclude<TSelectObject[K], null>> extends true
244
+ ? T | null | undefined
245
+ : T | null
246
+ : // Ref | undefined (optional object-type schema field)
247
+ TSelectObject[K] extends Ref<infer _T> | undefined
248
+ ? ExtractRef<Exclude<TSelectObject[K], undefined>> | undefined
249
+ : // Ref | null (nullable object-type schema field)
250
+ TSelectObject[K] extends Ref<infer _T> | null
251
+ ? ExtractRef<Exclude<TSelectObject[K], null>> | null
242
252
  : TSelectObject[K] extends Aggregate<infer T>
243
253
  ? T
244
254
  : TSelectObject[K] extends
@@ -366,24 +376,17 @@ export type FunctionalHavingRow<TContext extends Context> = TContext[`schema`] &
366
376
  (TContext[`result`] extends object ? { $selected: TContext[`result`] } : {})
367
377
 
368
378
  /**
369
- * RefProxyForContext - Creates ref proxies for all tables/collections in a query context
379
+ * RefsForContext - Creates ref proxies for all tables/collections in a query context
370
380
  *
371
381
  * This is the main entry point for creating ref objects in query builder callbacks.
372
- * It handles optionality by placing undefined/null OUTSIDE the RefProxy to enable
373
- * JavaScript's optional chaining operator (?.):
382
+ * For nullable join sides (left/right/full joins), it produces `Ref<T, true>` instead
383
+ * of `Ref<T> | undefined`. This accurately reflects that the proxy object is always
384
+ * present at build time (it's a truthy proxy that records property access paths),
385
+ * while the `Nullable` flag ensures the result type correctly includes `| undefined`.
374
386
  *
375
387
  * Examples:
376
- * - Required field: `RefProxy<User>` → user.name works
377
- * - Optional field: `RefProxy<User> | undefined` → user?.name works
378
- * - Nullable field: `RefProxy<User> | null` → user?.name works
379
- * - Both optional and nullable: `RefProxy<User> | undefined` → user?.name works
380
- *
381
- * The key insight is that `RefProxy<User | undefined>` would NOT allow `user?.name`
382
- * because the undefined is "inside" the proxy, but `RefProxy<User> | undefined`
383
- * does allow it because the undefined is "outside" the proxy.
384
- *
385
- * The logic prioritizes optional chaining by always placing `undefined` outside when
386
- * a type is both optional and nullable (e.g., `string | null | undefined`).
388
+ * - Required field: `Ref<User>` → user.name works, result is T
389
+ * - Nullable join side: `Ref<User, true>` → user.name works, result is T | undefined
387
390
  *
388
391
  * After `select()` is called, this type also includes `$selected` which provides access
389
392
  * to the SELECT result fields via `$selected.fieldName` syntax.
@@ -394,17 +397,17 @@ export type RefsForContext<TContext extends Context> = {
394
397
  > extends true
395
398
  ? IsNonExactNullable<TContext[`schema`][K]> extends true
396
399
  ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined)
397
- // Extract the non-undefined and non-null part and place undefined outside
398
- Ref<NonNullable<TContext[`schema`][K]>> | undefined
400
+ // Extract the non-undefined and non-null part, mark as nullable ref
401
+ Ref<NonNullable<TContext[`schema`][K]>, true>
399
402
  : // T is optional (T | undefined) but not exactly undefined, and not nullable
400
- // Extract the non-undefined part and place undefined outside
401
- Ref<NonUndefined<TContext[`schema`][K]>> | undefined
403
+ // Extract the non-undefined part, mark as nullable ref
404
+ Ref<NonUndefined<TContext[`schema`][K]>, true>
402
405
  : IsNonExactNullable<TContext[`schema`][K]> extends true
403
406
  ? // T is nullable (T | null) but not exactly null, and not optional
404
- // Extract the non-null part and place null outside
405
- Ref<NonNull<TContext[`schema`][K]>> | null
407
+ // Extract the non-null part, mark as nullable ref
408
+ Ref<NonNull<TContext[`schema`][K]>, true>
406
409
  : // T is exactly undefined, exactly null, or neither optional nor nullable
407
- // Wrap in RefProxy as-is (includes exact undefined, exact null, and normal types)
410
+ // Wrap in Ref as-is (includes exact undefined, exact null, and normal types)
408
411
  Ref<TContext[`schema`][K]>
409
412
  } & (TContext[`result`] extends object
410
413
  ? { $selected: Ref<TContext[`result`]> }
@@ -479,41 +482,44 @@ type NonNull<T> = T extends null ? never : T
479
482
  * It provides a recursive interface that allows nested property access while
480
483
  * preserving optionality and nullability correctly.
481
484
  *
482
- * When spread in select clauses, it correctly produces the underlying data type
483
- * without Ref wrappers, enabling clean spread operations.
485
+ * The `Nullable` parameter indicates whether this ref comes from a nullable
486
+ * join side (left/right/full). When `true`, the `Nullable` flag propagates
487
+ * through all nested property accesses, ensuring the result type includes
488
+ * `| undefined` for all fields accessed through this ref.
484
489
  *
485
490
  * Example usage:
486
491
  * ```typescript
487
- * // Clean interface - no internal properties visible
488
- * const users: Ref<{ id: number; profile?: { bio: string } }> = { ... }
489
- * users.id // Ref<number> - clean display
490
- * users.profile?.bio // Ref<string> - nested optional access works
492
+ * // Non-nullable ref (inner join or from table):
493
+ * select(({ user }) => ({ name: user.name })) // result: string
494
+ *
495
+ * // Nullable ref (left join right side):
496
+ * select(({ dept }) => ({ name: dept.name })) // result: string | undefined
491
497
  *
492
498
  * // Spread operations work cleanly:
493
499
  * select(({ user }) => ({ ...user })) // Returns User type, not Ref types
494
500
  * ```
495
501
  */
496
- export type Ref<T = any> = {
502
+ export type Ref<T = any, Nullable extends boolean = false> = {
497
503
  [K in keyof T]: IsNonExactOptional<T[K]> extends true
498
504
  ? IsNonExactNullable<T[K]> extends true
499
505
  ? // Both optional and nullable
500
506
  IsPlainObject<NonNullable<T[K]>> extends true
501
- ? Ref<NonNullable<T[K]>> | undefined
502
- : RefLeaf<NonNullable<T[K]>> | undefined
507
+ ? Ref<NonNullable<T[K]>, Nullable> | undefined
508
+ : RefLeaf<NonNullable<T[K]>, Nullable> | undefined
503
509
  : // Optional only
504
510
  IsPlainObject<NonUndefined<T[K]>> extends true
505
- ? Ref<NonUndefined<T[K]>> | undefined
506
- : RefLeaf<NonUndefined<T[K]>> | undefined
511
+ ? Ref<NonUndefined<T[K]>, Nullable> | undefined
512
+ : RefLeaf<NonUndefined<T[K]>, Nullable> | undefined
507
513
  : IsNonExactNullable<T[K]> extends true
508
514
  ? // Nullable only
509
515
  IsPlainObject<NonNull<T[K]>> extends true
510
- ? Ref<NonNull<T[K]>> | null
511
- : RefLeaf<NonNull<T[K]>> | null
516
+ ? Ref<NonNull<T[K]>, Nullable> | null
517
+ : RefLeaf<NonNull<T[K]>, Nullable> | null
512
518
  : // Required
513
519
  IsPlainObject<T[K]> extends true
514
- ? Ref<T[K]>
515
- : RefLeaf<T[K]>
516
- } & RefLeaf<T>
520
+ ? Ref<T[K], Nullable>
521
+ : RefLeaf<T[K], Nullable>
522
+ } & RefLeaf<T, Nullable>
517
523
 
518
524
  /**
519
525
  * Ref - The user-facing ref type with clean IDE display
@@ -527,11 +533,19 @@ export type Ref<T = any> = {
527
533
  * - No internal properties like __refProxy, __path, __type are visible
528
534
  */
529
535
  declare const RefBrand: unique symbol
530
- export type RefLeaf<T = any> = { readonly [RefBrand]?: T }
536
+ declare const NullableBrand: unique symbol
537
+ export type RefLeaf<T = any, Nullable extends boolean = false> = {
538
+ readonly [RefBrand]?: T
539
+ } & ([Nullable] extends [true] ? { readonly [NullableBrand]?: true } : {})
540
+
541
+ // Detect NullableBrand by checking for the key's presence
542
+ type IsNullableRef<T> = typeof NullableBrand extends keyof T ? true : false
531
543
 
532
- // Helper type to remove RefBrand from objects
544
+ // Helper type to remove RefBrand and NullableBrand from objects
533
545
  type WithoutRefBrand<T> =
534
- T extends Record<string, any> ? Omit<T, typeof RefBrand> : T
546
+ T extends Record<string, any>
547
+ ? Omit<T, typeof RefBrand | typeof NullableBrand>
548
+ : T
535
549
 
536
550
  /**
537
551
  * PreserveSingleResultFlag - Conditionally includes the singleResult flag
@@ -4,6 +4,7 @@ import {
4
4
  CollectionInputNotFoundError,
5
5
  DistinctRequiresSelectError,
6
6
  DuplicateAliasInSubqueryError,
7
+ FnSelectWithGroupByError,
7
8
  HavingRequiresGroupByError,
8
9
  LimitOffsetRequireOrderByError,
9
10
  UnsupportedFromTypeError,
@@ -218,6 +219,10 @@ export function compileQuery(
218
219
  throw new DistinctRequiresSelectError()
219
220
  }
220
221
 
222
+ if (query.fnSelect && query.groupBy && query.groupBy.length > 0) {
223
+ throw new FnSelectWithGroupByError()
224
+ }
225
+
221
226
  // Process the SELECT clause early - always create $selected
222
227
  // This eliminates duplication and allows for DISTINCT implementation
223
228
  if (query.fnSelect) {
@@ -74,6 +74,9 @@ export {
74
74
  liveQueryCollectionOptions,
75
75
  } from './live-query-collection.js'
76
76
 
77
+ // One-shot query execution
78
+ export { queryOnce, type QueryOnceConfig } from './query-once.js'
79
+
77
80
  export { type LiveQueryCollectionConfig } from './live/types.js'
78
81
  export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js'
79
82
 
@@ -0,0 +1,115 @@
1
+ import { createLiveQueryCollection } from './live-query-collection.js'
2
+ import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
3
+ import type { Context, InferResultType } from './builder/types.js'
4
+
5
+ /**
6
+ * Configuration options for queryOnce
7
+ */
8
+ export interface QueryOnceConfig<TContext extends Context> {
9
+ /**
10
+ * Query builder function that defines the query
11
+ */
12
+ query:
13
+ | ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
14
+ | QueryBuilder<TContext>
15
+ // Future: timeout, signal, etc.
16
+ }
17
+
18
+ // Overload 1: Simple query function returning array (non-single result)
19
+ /**
20
+ * Executes a one-shot query and returns the results as an array.
21
+ *
22
+ * This function creates a live query collection, preloads it, extracts the results,
23
+ * and automatically cleans up the collection. It's ideal for:
24
+ * - AI/LLM context building
25
+ * - Data export
26
+ * - Background processing
27
+ * - Testing
28
+ *
29
+ * @param queryFn - A function that receives the query builder and returns a query
30
+ * @returns A promise that resolves to an array of query results
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * // Basic query
35
+ * const users = await queryOnce((q) =>
36
+ * q.from({ user: usersCollection })
37
+ * )
38
+ *
39
+ * // With filtering and projection
40
+ * const activeUserNames = await queryOnce((q) =>
41
+ * q.from({ user: usersCollection })
42
+ * .where(({ user }) => eq(user.active, true))
43
+ * .select(({ user }) => ({ name: user.name }))
44
+ * )
45
+ * ```
46
+ */
47
+ export function queryOnce<TContext extends Context>(
48
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
49
+ ): Promise<InferResultType<TContext>>
50
+
51
+ // Overload 2: Config object form returning array (non-single result)
52
+ /**
53
+ * Executes a one-shot query using a configuration object.
54
+ *
55
+ * @param config - Configuration object with the query function
56
+ * @returns A promise that resolves to an array of query results
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const recentOrders = await queryOnce({
61
+ * query: (q) =>
62
+ * q.from({ order: ordersCollection })
63
+ * .orderBy(({ order }) => desc(order.createdAt))
64
+ * .limit(100),
65
+ * })
66
+ * ```
67
+ */
68
+ export function queryOnce<TContext extends Context>(
69
+ config: QueryOnceConfig<TContext>,
70
+ ): Promise<InferResultType<TContext>>
71
+
72
+ // Implementation
73
+ export async function queryOnce<TContext extends Context>(
74
+ configOrQuery:
75
+ | QueryOnceConfig<TContext>
76
+ | ((q: InitialQueryBuilder) => QueryBuilder<TContext>),
77
+ ): Promise<InferResultType<TContext>> {
78
+ // Normalize input
79
+ const config: QueryOnceConfig<TContext> =
80
+ typeof configOrQuery === `function`
81
+ ? { query: configOrQuery }
82
+ : configOrQuery
83
+
84
+ const query = (q: InitialQueryBuilder) => {
85
+ const queryConfig = config.query
86
+ return typeof queryConfig === `function` ? queryConfig(q) : queryConfig
87
+ }
88
+
89
+ // Create collection with minimal GC time; preload handles sync start
90
+ const collection = createLiveQueryCollection({
91
+ query,
92
+ gcTime: 1, // Cleanup in next tick when no subscribers (0 disables GC)
93
+ })
94
+
95
+ try {
96
+ // Wait for initial data load
97
+ await collection.preload()
98
+
99
+ // Check if this is a single-result query (findOne was called)
100
+ const isSingleResult =
101
+ (collection.config as { singleResult?: boolean }).singleResult === true
102
+
103
+ // Extract and return results
104
+ if (isSingleResult) {
105
+ const first = collection.values().next().value as
106
+ | InferResultType<TContext>
107
+ | undefined
108
+ return first as InferResultType<TContext>
109
+ }
110
+ return collection.toArray as InferResultType<TContext>
111
+ } finally {
112
+ // Always cleanup, even on error
113
+ await collection.cleanup()
114
+ }
115
+ }
@@ -126,28 +126,29 @@ export class DeduplicatedLoadSubset {
126
126
  return prom
127
127
  }
128
128
 
129
- // Not fully covered by existing data
130
- // Compute the subset of data that is not covered by the existing data
131
- // such that we only have to load that subset of missing data
132
- const clonedOptions = cloneOptions(options)
129
+ // Not fully covered by existing data — load the missing subset.
130
+ // We need two clones: trackingOptions preserves the original predicate for
131
+ // accurate tracking (e.g., where=undefined means "all data"), while loadOptions
132
+ // may be narrowed with a difference expression for the actual backend request.
133
+ const trackingOptions = cloneOptions(options)
134
+ const loadOptions = cloneOptions(options)
133
135
  if (this.unlimitedWhere !== undefined && options.limit === undefined) {
134
136
  // Compute difference to get only the missing data
135
137
  // We can only do this for unlimited queries
136
138
  // and we can only remove data that was loaded from unlimited queries
137
139
  // because with limited queries we have no way to express that we already loaded part of the matching data
138
- clonedOptions.where =
139
- minusWherePredicates(clonedOptions.where, this.unlimitedWhere) ??
140
- clonedOptions.where
140
+ loadOptions.where =
141
+ minusWherePredicates(loadOptions.where, this.unlimitedWhere) ??
142
+ loadOptions.where
141
143
  }
142
144
 
143
145
  // Call underlying loadSubset to load the missing data
144
- const resultPromise = this._loadSubset(clonedOptions)
146
+ const resultPromise = this._loadSubset(loadOptions)
145
147
 
146
148
  // Handle both sync (true) and async (Promise<void>) return values
147
149
  if (resultPromise === true) {
148
- // Sync return - update tracking synchronously
149
- // Clone options before storing to protect against caller mutation
150
- this.updateTracking(clonedOptions)
150
+ // Sync return - update tracking with the original predicate
151
+ this.updateTracking(trackingOptions)
151
152
  return true
152
153
  } else {
153
154
  // Async return - track the promise and update tracking after it resolves
@@ -158,16 +159,14 @@ export class DeduplicatedLoadSubset {
158
159
 
159
160
  // We need to create a reference to the in-flight entry so we can remove it later
160
161
  const inflightEntry = {
161
- options: clonedOptions, // Store cloned options for subset matching
162
+ options: loadOptions, // Store load options for subset matching of in-flight requests
162
163
  promise: resultPromise
163
164
  .then((result) => {
164
165
  // Only update tracking if this request is still from the current generation
165
166
  // If reset() was called, the generation will have incremented and we should
166
167
  // not repopulate the state that was just cleared
167
168
  if (capturedGeneration === this.generation) {
168
- // Use the cloned options that we captured before any caller mutations
169
- // This ensures we track exactly what was loaded, not what the caller changed
170
- this.updateTracking(clonedOptions)
169
+ this.updateTracking(trackingOptions)
171
170
  }
172
171
  return result
173
172
  })