Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/svelte-findone-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@tanstack/svelte-db': minor
---

Add `findOne()` / `SingleResult` support to `useLiveQuery` hook.

When using `.findOne()` in a query, the `data` property is now correctly typed as `T | undefined` instead of `Array<T>`, matching the React implementation.

**Example:**

```ts
const query = useLiveQuery((q) =>
q
.from({ users: usersCollection })
.where(({ users }) => eq(users.id, userId))
.findOne(),
)

// query.data is now typed as User | undefined (not User[])
```

This works with all query patterns:

- Query functions: `useLiveQuery((q) => q.from(...).findOne())`
- Config objects: `useLiveQuery({ query: (q) => q.from(...).findOne() })`
- Pre-created collections with `SingleResult`
53 changes: 43 additions & 10 deletions packages/svelte-db/src/useLiveQuery.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db'
import type {
ChangeMessage,
Collection,
CollectionConfigSingleRowOption,
CollectionStatus,
Context,
GetResult,
InferResultType,
InitialQueryBuilder,
LiveQueryCollectionConfig,
NonSingleResult,
QueryBuilder,
SingleResult,
} from '@tanstack/db'

/**
* Return type for useLiveQuery hook
* @property state - Reactive Map of query results (key → item)
* @property data - Reactive array of query results in order
* @property data - Reactive array of query results in order, or single item when using findOne()
* @property collection - The underlying query collection instance
* @property status - Current query status
* @property isLoading - True while initial query data is loading
Expand All @@ -26,9 +30,9 @@ import type {
* @property isError - True when query encountered an error
* @property isCleanedUp - True when query has been cleaned up
*/
export interface UseLiveQueryReturn<T extends object> {
export interface UseLiveQueryReturn<T extends object, TData = Array<T>> {
state: Map<string | number, T>
data: Array<T>
data: TData
collection: Collection<T, string | number, {}>
status: CollectionStatus
isLoading: boolean
Expand All @@ -42,9 +46,10 @@ export interface UseLiveQueryReturnWithCollection<
T extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
TData = Array<T>,
> {
state: Map<TKey, T>
data: Array<T>
data: TData
collection: Collection<T, TKey, TUtils>
status: CollectionStatus
isLoading: boolean
Expand Down Expand Up @@ -155,15 +160,18 @@ function toValue<T>(value: MaybeGetter<T>): T {
export function useLiveQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
deps?: Array<() => unknown>,
): UseLiveQueryReturn<GetResult<TContext>>
): UseLiveQueryReturn<GetResult<TContext>, InferResultType<TContext>>

// Overload 1b: Accept query function that can return undefined/null
export function useLiveQuery<TContext extends Context>(
queryFn: (
q: InitialQueryBuilder,
) => QueryBuilder<TContext> | undefined | null,
deps?: Array<() => unknown>,
): UseLiveQueryReturn<GetResult<TContext>>
): UseLiveQueryReturn<
GetResult<TContext>,
InferResultType<TContext> | undefined
>

/**
* Create a live query using configuration object
Expand Down Expand Up @@ -206,7 +214,7 @@ export function useLiveQuery<TContext extends Context>(
export function useLiveQuery<TContext extends Context>(
config: LiveQueryCollectionConfig<TContext>,
deps?: Array<() => unknown>,
): UseLiveQueryReturn<GetResult<TContext>>
): UseLiveQueryReturn<GetResult<TContext>, InferResultType<TContext>>

/**
* Subscribe to an existing query collection (can be reactive)
Expand Down Expand Up @@ -251,14 +259,27 @@ export function useLiveQuery<TContext extends Context>(
* // {/each}
* // {/if}
*/
// Overload 3: Accept pre-created live query collection (can be reactive)
// Overload 3: Accept pre-created live query collection WITHOUT SingleResult (returns array)
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: MaybeGetter<Collection<TResult, TKey, TUtils>>,
): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils>
liveQueryCollection: MaybeGetter<
Collection<TResult, TKey, TUtils> & NonSingleResult
>,
): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, Array<TResult>>

// Overload 4: Accept pre-created live query collection WITH SingleResult (returns single item)
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: MaybeGetter<
Collection<TResult, TKey, TUtils> & SingleResult
>,
): UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, TResult | undefined>

// Implementation
export function useLiveQuery(
Expand Down Expand Up @@ -438,6 +459,18 @@ export function useLiveQuery(
return state
},
get data() {
const currentCollection = collection
if (currentCollection) {
const config =
currentCollection.config as CollectionConfigSingleRowOption<
any,
any,
any
>
if (config.singleResult) {
return internalData[0]
}
}
return internalData
},
get collection() {
Expand Down
217 changes: 217 additions & 0 deletions packages/svelte-db/tests/useLiveQuery.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1852,4 +1852,221 @@ describe(`Query Collections`, () => {
})
})
})

describe(`findOne() - single result queries`, () => {
it(`should return a single row when using findOne() with query function`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-1`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const query = useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
)

flushSync()

// State should still contain the item as a Map entry
expect(query.state.size).toBe(1)
expect(query.state.get(`3`)).toMatchObject({
id: `3`,
name: `John Smith`,
})

// Data should be a single object, not an array
expect(query.data).toMatchObject({
id: `3`,
name: `John Smith`,
})
expect(Array.isArray(query.data)).toBe(false)
})
})

it(`should return a single row when using findOne() with config object`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-2`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const query = useLiveQuery({
query: (q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
})

flushSync()

expect(query.state.size).toBe(1)
expect(query.data).toMatchObject({
id: `3`,
name: `John Smith`,
})
expect(Array.isArray(query.data)).toBe(false)
})
})

it(`should return a single row with pre-created collection using findOne()`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-3`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const liveQueryCollection = createLiveQueryCollection({
query: (q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
})

const query = useLiveQuery(liveQueryCollection)

flushSync()

expect(query.state.size).toBe(1)
expect(query.data).toMatchObject({
id: `3`,
name: `John Smith`,
})
expect(Array.isArray(query.data)).toBe(false)
})
})

it(`should return undefined when findOne() matches no rows`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-empty`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const query = useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `999`)) // Non-existent ID
.findOne(),
)

flushSync()

expect(query.state.size).toBe(0)
expect(query.data).toBeUndefined()
})
})

it(`should reactively update single result when data changes`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-reactive`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const query = useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
)

flushSync()

expect(query.data).toMatchObject({
id: `3`,
name: `John Smith`,
})

// Update the person
collection.utils.begin()
collection.utils.write({
type: `update`,
value: {
id: `3`,
name: `John Smith Updated`,
age: 36,
email: `john.smith@example.com`,
isActive: true,
team: `team1`,
},
})
collection.utils.commit()

flushSync()

expect(query.data).toMatchObject({
id: `3`,
name: `John Smith Updated`,
age: 36,
})
expect(Array.isArray(query.data)).toBe(false)
})
})

it(`should transition from single result to undefined when item is deleted`, () => {
const collection = createCollection(
mockSyncCollectionOptions<Person>({
id: `test-persons-findone-delete`,
getKey: (person: Person) => person.id,
initialData: initialPersons,
}),
)

cleanup = $effect.root(() => {
const query = useLiveQuery((q) =>
q
.from({ collection })
.where(({ collection: c }) => eq(c.id, `3`))
.findOne(),
)

flushSync()

expect(query.data).toMatchObject({
id: `3`,
name: `John Smith`,
})

// Delete the person
collection.utils.begin()
collection.utils.write({
type: `delete`,
value: {
id: `3`,
name: `John Smith`,
age: 35,
email: `john.smith@example.com`,
isActive: true,
team: `team1`,
},
})
collection.utils.commit()

flushSync()

expect(query.data).toBeUndefined()
expect(query.state.size).toBe(0)
})
})
})
})