diff --git a/.changeset/svelte-findone-support.md b/.changeset/svelte-findone-support.md new file mode 100644 index 000000000..0e1782c97 --- /dev/null +++ b/.changeset/svelte-findone-support.md @@ -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`, 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` diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 436112b5f..cd82a59fa 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -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 @@ -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 { +export interface UseLiveQueryReturn> { state: Map - data: Array + data: TData collection: Collection status: CollectionStatus isLoading: boolean @@ -42,9 +46,10 @@ export interface UseLiveQueryReturnWithCollection< T extends object, TKey extends string | number, TUtils extends Record, + TData = Array, > { state: Map - data: Array + data: TData collection: Collection status: CollectionStatus isLoading: boolean @@ -155,7 +160,7 @@ function toValue(value: MaybeGetter): T { export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, deps?: Array<() => unknown>, -): UseLiveQueryReturn> +): UseLiveQueryReturn, InferResultType> // Overload 1b: Accept query function that can return undefined/null export function useLiveQuery( @@ -163,7 +168,10 @@ export function useLiveQuery( q: InitialQueryBuilder, ) => QueryBuilder | undefined | null, deps?: Array<() => unknown>, -): UseLiveQueryReturn> +): UseLiveQueryReturn< + GetResult, + InferResultType | undefined +> /** * Create a live query using configuration object @@ -206,7 +214,7 @@ export function useLiveQuery( export function useLiveQuery( config: LiveQueryCollectionConfig, deps?: Array<() => unknown>, -): UseLiveQueryReturn> +): UseLiveQueryReturn, InferResultType> /** * Subscribe to an existing query collection (can be reactive) @@ -251,14 +259,27 @@ export function useLiveQuery( * // {/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, >( - liveQueryCollection: MaybeGetter>, -): UseLiveQueryReturnWithCollection + liveQueryCollection: MaybeGetter< + Collection & NonSingleResult + >, +): UseLiveQueryReturnWithCollection> + +// 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, +>( + liveQueryCollection: MaybeGetter< + Collection & SingleResult + >, +): UseLiveQueryReturnWithCollection // Implementation export function useLiveQuery( @@ -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() { diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index edf7eb79c..cb16e8579 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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) + }) + }) + }) })