Skip to content

Commit 5e69941

Browse files
Fix type of findOne queries in Vue (#1134)
* test(vue-db): add type tests for findOne returning incorrect type Add type tests reproducing issue #1095 where useLiveQuery with findOne() returns `ComputedRef<Array<T>>` instead of `ComputedRef<T | undefined>`. This differs from the React implementation which correctly types findOne() queries to return a single object or undefined. The tests currently fail, demonstrating the bug. * fix(vue-db): properly type findOne() queries to return single result Update useLiveQuery types to use InferResultType<TContext> which correctly handles the SingleResult type marker, returning `T | undefined` for findOne() queries instead of always returning `Array<T>`. Changes: - Import InferResultType, SingleResult, NonSingleResult from @tanstack/db - Update UseLiveQueryReturn interface to use InferResultType<TContext> - Add UseLiveQueryReturnWithSingleResultCollection for singleResult collections - Add separate overloads for pre-created collections (NonSingleResult vs SingleResult) - Update runtime implementation to return first element for singleResult collections This aligns Vue adapter behavior with the React adapter. Fixes #1095 * Address optional persons in tests * Fix comments * Changeset --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 9dadf0e commit 5e69941

File tree

4 files changed

+199
-15
lines changed

4 files changed

+199
-15
lines changed

.changeset/ninety-cooks-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/vue-db': patch
3+
---
4+
5+
Fix type of findOne queries in Vue such that they type to a singular result instead of an array of results.

packages/vue-db/src/useLiveQuery.ts

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,23 @@ import { createLiveQueryCollection } from '@tanstack/db'
1212
import type {
1313
ChangeMessage,
1414
Collection,
15+
CollectionConfigSingleRowOption,
1516
CollectionStatus,
1617
Context,
1718
GetResult,
19+
InferResultType,
1820
InitialQueryBuilder,
1921
LiveQueryCollectionConfig,
22+
NonSingleResult,
2023
QueryBuilder,
24+
SingleResult,
2125
} from '@tanstack/db'
2226
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
2327

2428
/**
2529
* Return type for useLiveQuery hook
2630
* @property state - Reactive Map of query results (key → item)
27-
* @property data - Reactive array of query results in order
31+
* @property data - Reactive array of query results in order, or single result for findOne queries
2832
* @property collection - The underlying query collection instance
2933
* @property status - Current query status
3034
* @property isLoading - True while initial query data is loading
@@ -33,10 +37,10 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'
3337
* @property isError - True when query encountered an error
3438
* @property isCleanedUp - True when query has been cleaned up
3539
*/
36-
export interface UseLiveQueryReturn<T extends object> {
37-
state: ComputedRef<Map<string | number, T>>
38-
data: ComputedRef<Array<T>>
39-
collection: ComputedRef<Collection<T, string | number, {}>>
40+
export interface UseLiveQueryReturn<TContext extends Context> {
41+
state: ComputedRef<Map<string | number, GetResult<TContext>>>
42+
data: ComputedRef<InferResultType<TContext>>
43+
collection: ComputedRef<Collection<GetResult<TContext>, string | number, {}>>
4044
status: ComputedRef<CollectionStatus>
4145
isLoading: ComputedRef<boolean>
4246
isReady: ComputedRef<boolean>
@@ -61,6 +65,22 @@ export interface UseLiveQueryReturnWithCollection<
6165
isCleanedUp: ComputedRef<boolean>
6266
}
6367

68+
export interface UseLiveQueryReturnWithSingleResultCollection<
69+
T extends object,
70+
TKey extends string | number,
71+
TUtils extends Record<string, any>,
72+
> {
73+
state: ComputedRef<Map<TKey, T>>
74+
data: ComputedRef<T | undefined>
75+
collection: ComputedRef<Collection<T, TKey, TUtils> & SingleResult>
76+
status: ComputedRef<CollectionStatus>
77+
isLoading: ComputedRef<boolean>
78+
isReady: ComputedRef<boolean>
79+
isIdle: ComputedRef<boolean>
80+
isError: ComputedRef<boolean>
81+
isCleanedUp: ComputedRef<boolean>
82+
}
83+
6484
/**
6585
* Create a live query using a query function
6686
* @param queryFn - Query function that defines what data to fetch
@@ -114,15 +134,15 @@ export interface UseLiveQueryReturnWithCollection<
114134
export function useLiveQuery<TContext extends Context>(
115135
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
116136
deps?: Array<MaybeRefOrGetter<unknown>>,
117-
): UseLiveQueryReturn<GetResult<TContext>>
137+
): UseLiveQueryReturn<TContext>
118138

119139
// Overload 1b: Accept query function that can return undefined/null
120140
export function useLiveQuery<TContext extends Context>(
121141
queryFn: (
122142
q: InitialQueryBuilder,
123143
) => QueryBuilder<TContext> | undefined | null,
124144
deps?: Array<MaybeRefOrGetter<unknown>>,
125-
): UseLiveQueryReturn<GetResult<TContext>>
145+
): UseLiveQueryReturn<TContext>
126146

127147
/**
128148
* Create a live query using configuration object
@@ -160,7 +180,7 @@ export function useLiveQuery<TContext extends Context>(
160180
export function useLiveQuery<TContext extends Context>(
161181
config: LiveQueryCollectionConfig<TContext>,
162182
deps?: Array<MaybeRefOrGetter<unknown>>,
163-
): UseLiveQueryReturn<GetResult<TContext>>
183+
): UseLiveQueryReturn<TContext>
164184

165185
/**
166186
* Subscribe to an existing query collection (can be reactive)
@@ -201,15 +221,28 @@ export function useLiveQuery<TContext extends Context>(
201221
* // <Item v-for="item in data" :key="item.id" v-bind="item" />
202222
* // </div>
203223
*/
204-
// Overload 3: Accept pre-created live query collection (can be reactive)
224+
// Overload 3: Accept pre-created live query collection (can be reactive) - non-single result
205225
export function useLiveQuery<
206226
TResult extends object,
207227
TKey extends string | number,
208228
TUtils extends Record<string, any>,
209229
>(
210-
liveQueryCollection: MaybeRefOrGetter<Collection<TResult, TKey, TUtils>>,
230+
liveQueryCollection: MaybeRefOrGetter<
231+
Collection<TResult, TKey, TUtils> & NonSingleResult
232+
>,
211233
): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils>
212234

235+
// Overload 4: Accept pre-created live query collection with singleResult: true
236+
export function useLiveQuery<
237+
TResult extends object,
238+
TKey extends string | number,
239+
TUtils extends Record<string, any>,
240+
>(
241+
liveQueryCollection: MaybeRefOrGetter<
242+
Collection<TResult, TKey, TUtils> & SingleResult
243+
>,
244+
): UseLiveQueryReturnWithSingleResultCollection<TResult, TKey, TUtils>
245+
213246
// Implementation
214247
export function useLiveQuery(
215248
configOrQueryOrCollection: any,
@@ -294,7 +327,16 @@ export function useLiveQuery(
294327
const internalData = reactive<Array<any>>([])
295328

296329
// Computed wrapper for the data to match expected return type
297-
const data = computed(() => internalData)
330+
// Returns single item for singleResult collections, array otherwise
331+
const data = computed(() => {
332+
const currentCollection = collection.value
333+
if (!currentCollection) {
334+
return internalData
335+
}
336+
const config: CollectionConfigSingleRowOption<any, any, any> =
337+
currentCollection.config
338+
return config.singleResult ? internalData[0] : internalData
339+
})
298340

299341
// Track collection status reactively
300342
const status = ref(
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { createCollection } from '../../db/src/collection/index'
3+
import { mockSyncCollectionOptions } from '../../db/tests/utils'
4+
import {
5+
createLiveQueryCollection,
6+
eq,
7+
liveQueryCollectionOptions,
8+
} from '../../db/src/query/index'
9+
import { useLiveQuery } from '../src/useLiveQuery'
10+
import type { SingleResult } from '../../db/src/types'
11+
12+
type Person = {
13+
id: string
14+
name: string
15+
age: number
16+
email: string
17+
isActive: boolean
18+
team: string
19+
}
20+
21+
describe(`useLiveQuery type assertions`, () => {
22+
it(`should type findOne query builder to return a single row`, () => {
23+
const collection = createCollection(
24+
mockSyncCollectionOptions<Person>({
25+
id: `test-persons-findone-vue`,
26+
getKey: (person: Person) => person.id,
27+
initialData: [],
28+
}),
29+
)
30+
31+
const { data } = useLiveQuery((q) =>
32+
q
33+
.from({ collection })
34+
.where(({ collection: c }) => eq(c.id, `3`))
35+
.findOne(),
36+
)
37+
38+
// BUG: Currently returns ComputedRef<Array<Person>> but should be ComputedRef<Person | undefined>
39+
expectTypeOf(data.value).toEqualTypeOf<Person | undefined>()
40+
})
41+
42+
it(`should type findOne config object to return a single row`, () => {
43+
const collection = createCollection(
44+
mockSyncCollectionOptions<Person>({
45+
id: `test-persons-findone-config-vue`,
46+
getKey: (person: Person) => person.id,
47+
initialData: [],
48+
}),
49+
)
50+
51+
const { data } = useLiveQuery({
52+
query: (q) =>
53+
q
54+
.from({ collection })
55+
.where(({ collection: c }) => eq(c.id, `3`))
56+
.findOne(),
57+
})
58+
59+
// BUG: Currently returns ComputedRef<Array<Person>> but should be ComputedRef<Person | undefined>
60+
expectTypeOf(data.value).toEqualTypeOf<Person | undefined>()
61+
})
62+
63+
it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => {
64+
const collection = createCollection(
65+
mockSyncCollectionOptions<Person>({
66+
id: `test-persons-findone-options-vue`,
67+
getKey: (person: Person) => person.id,
68+
initialData: [],
69+
}),
70+
)
71+
72+
const options = liveQueryCollectionOptions({
73+
query: (q) =>
74+
q
75+
.from({ collection })
76+
.where(({ collection: c }) => eq(c.id, `3`))
77+
.findOne(),
78+
})
79+
80+
const liveQueryCollection = createCollection(options)
81+
82+
expectTypeOf(liveQueryCollection).toExtend<SingleResult>()
83+
84+
const { data } = useLiveQuery(liveQueryCollection)
85+
86+
// BUG: Currently returns ComputedRef<Array<Person>> but should be ComputedRef<Person | undefined>
87+
expectTypeOf(data.value).toEqualTypeOf<Person | undefined>()
88+
})
89+
90+
it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => {
91+
const collection = createCollection(
92+
mockSyncCollectionOptions<Person>({
93+
id: `test-persons-findone-create-vue`,
94+
getKey: (person: Person) => person.id,
95+
initialData: [],
96+
}),
97+
)
98+
99+
const liveQueryCollection = createLiveQueryCollection({
100+
query: (q) =>
101+
q
102+
.from({ collection })
103+
.where(({ collection: c }) => eq(c.id, `3`))
104+
.findOne(),
105+
})
106+
107+
expectTypeOf(liveQueryCollection).toExtend<SingleResult>()
108+
109+
const { data } = useLiveQuery(liveQueryCollection)
110+
111+
// BUG: Currently returns ComputedRef<Array<Person>> but should be ComputedRef<Person | undefined>
112+
expectTypeOf(data.value).toEqualTypeOf<Person | undefined>()
113+
})
114+
115+
it(`should type regular query to return an array`, () => {
116+
const collection = createCollection(
117+
mockSyncCollectionOptions<Person>({
118+
id: `test-persons-array-vue`,
119+
getKey: (person: Person) => person.id,
120+
initialData: [],
121+
}),
122+
)
123+
124+
const { data } = useLiveQuery((q) =>
125+
q
126+
.from({ collection })
127+
.where(({ collection: c }) => eq(c.isActive, true))
128+
.select(({ collection: c }) => ({
129+
id: c.id,
130+
name: c.name,
131+
})),
132+
)
133+
134+
// Regular queries should return an array
135+
expectTypeOf(data.value).toEqualTypeOf<Array<{ id: string; name: string }>>()
136+
})
137+
})

packages/vue-db/tests/useLiveQuery.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe(`Query Collections`, () => {
301301
.select(({ issues, persons }) => ({
302302
id: issues.id,
303303
title: issues.title,
304-
name: persons.name,
304+
name: persons?.name,
305305
})),
306306
)
307307

@@ -590,7 +590,7 @@ describe(`Query Collections`, () => {
590590
.select(({ issues, persons }) => ({
591591
id: issues.id,
592592
title: issues.title,
593-
name: persons.name,
593+
name: persons?.name,
594594
})),
595595
)
596596

@@ -1162,7 +1162,7 @@ describe(`Query Collections`, () => {
11621162
.select(({ issues, persons }) => ({
11631163
id: issues.id,
11641164
title: issues.title,
1165-
name: persons.name,
1165+
name: persons?.name,
11661166
})),
11671167
)
11681168

@@ -1689,7 +1689,7 @@ describe(`Query Collections`, () => {
16891689
.select(({ issues, persons }) => ({
16901690
id: issues.id,
16911691
title: issues.title,
1692-
userName: persons.name,
1692+
userName: persons?.name,
16931693
})),
16941694
)
16951695

0 commit comments

Comments
 (0)