From da9c97c0b293110e98f564e3cc630dc4dd6af3d6 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Sat, 23 Aug 2025 21:18:56 +0200 Subject: [PATCH 01/11] add findOne() to query builder --- docs/reference/classes/basequerybuilder.md | 26 ++++++++++++++ packages/db/src/collection/index.ts | 1 + packages/db/src/query/builder/index.ts | 21 ++++++++++++ packages/db/src/query/builder/types.ts | 3 ++ packages/db/src/query/compiler/index.ts | 5 ++- packages/db/src/query/ir.ts | 1 + .../query/live/collection-config-builder.ts | 1 + packages/db/src/query/live/types.ts | 5 +++ packages/db/src/types.ts | 5 +++ packages/react-db/src/useLiveQuery.ts | 15 ++++++-- packages/react-db/tests/useLiveQuery.test.tsx | 34 +++++++++++++++++++ 11 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs/reference/classes/basequerybuilder.md b/docs/reference/classes/basequerybuilder.md index bb440aaec..c519a8503 100644 --- a/docs/reference/classes/basequerybuilder.md +++ b/docs/reference/classes/basequerybuilder.md @@ -200,6 +200,32 @@ query *** +### findOne() + +```ts +findOne(): QueryBuilder +``` + +Specify that the query should return a single row as `data` and not an array. + +#### Returns + +[`QueryBuilder`](../../type-aliases/querybuilder.md)\<`TContext`\> + +A QueryBuilder with single return enabled + +#### Example + +```ts +// Get an user by ID +query + .from({ users: usersCollection }) + .where(({users}) => eq(users.id, 1)) + .findOne() +``` + +*** + ### from() ```ts diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 63e818799..1dcc60fe0 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -50,6 +50,7 @@ export interface Collection< TInsertInput extends object = T, > extends CollectionImpl { readonly utils: TUtils + readonly single?: true } /** diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 423f5be05..e80904b79 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -615,6 +615,27 @@ export class BaseQueryBuilder { }) as any } + /** + * Specify that the query should return a single result + * @returns A QueryBuilder with single result enforced + * + * @example + * ```ts + * // Get the user matching the query + * query + * .from({ users: usersCollection }) + * .where(({users}) => eq(users.id, 1)) + * .findOne() + *``` + */ + findOne(): QueryBuilder { + return new BaseQueryBuilder({ + ...this.query, + limit: 1, + single: true, + }) + } + // Helper methods private _getCurrentAliases(): Array { const aliases: Array = [] diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index bef1c2bed..865320790 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -47,6 +47,8 @@ export interface Context { > // The result type after select (if select has been called) result?: any + // Single result only (if findOne has been called) + single?: boolean } /** @@ -571,6 +573,7 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] + single: TContext[`single`] } /** diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index a79ace809..8a07862e2 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -290,7 +290,10 @@ export function compileQuery( cache.set(rawQuery, compilationResult) return compilationResult - } else if (query.limit !== undefined || query.offset !== undefined) { + } else if ( + !query.single && + (query.limit !== undefined || query.offset !== undefined) + ) { // If there's a limit or offset without orderBy, throw an error throw new LimitOffsetRequireOrderByError() } diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index add2b1cdc..bb4219fcb 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -17,6 +17,7 @@ export interface QueryIR { limit?: Limit offset?: Offset distinct?: true + single?: true // Functional variants fnSelect?: (row: NamespacedRow) => any diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 72d90f905..09e8f0153 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -93,6 +93,7 @@ export class CollectionConfigBuilder< onUpdate: this.config.onUpdate, onDelete: this.config.onDelete, startSync: this.config.startSync, + single: this.config.single, } } diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 995101aef..b85c9a1a6 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -90,4 +90,9 @@ export interface LiveQueryCollectionConfig< * GC time for the collection */ gcTime?: number + + /** + * If enabled the collection will return a single object instead of an array + */ + single?: true } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 38ae672ce..6ad45bcd4 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -487,6 +487,11 @@ export interface BaseCollectionConfig< * } */ onDelete?: DeleteMutationFn + + /** + * If enabled the collection will return a single object instead of an array + */ + single?: true } export interface CollectionConfig< diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index c6cf77cae..4782edeb1 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -31,6 +31,14 @@ export type UseLiveQueryStatus = CollectionStatus | `disabled` * .select(({ todos }) => ({ id: todos.id, text: todos.text })) * ) * + * @example + * // Single result query + * const { data } = useLiveQuery( + * (q) => q.from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.id, 1)) + * .findOne() + * ) + * * @example * // With dependencies that trigger re-execution * const { data, state } = useLiveQuery( @@ -74,7 +82,9 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: Array> + data: TContext extends { single: true } + ? GetResult + : Array> collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled if always returns QueryBuilder isLoading: boolean @@ -469,6 +479,7 @@ export function useLiveQuery( } else { // Capture a stable view of entries for this snapshot to avoid tearing const entries = Array.from(snapshot.collection.entries()) + const single = snapshot.collection.config.single let stateCache: Map | null = null let dataCache: Array | null = null @@ -483,7 +494,7 @@ export function useLiveQuery( if (!dataCache) { dataCache = entries.map(([, value]) => value) } - return dataCache + return single ? dataCache[0] : dataCache }, collection: snapshot.collection, status: snapshot.collection.status, diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 30374d1af..49e3caa5d 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -157,6 +157,40 @@ describe(`Query Collections`, () => { expect(data1).toBe(data2) }) + it(`should be able to return a single row`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne() + ) + }) + + // Wait for collection to sync + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + expect(result.current.data).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + }) + it(`should be able to query a collection with live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From e954b2988eb7a92c145302413feac0eff243188e Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Sun, 24 Aug 2025 09:59:03 +0200 Subject: [PATCH 02/11] move utility type to db/types --- packages/db/src/types.ts | 10 ++++++++++ packages/react-db/src/useLiveQuery.ts | 7 +++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6ad45bcd4..eff98d841 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -3,6 +3,7 @@ import type { Collection } from "./collection/index.js" import type { StandardSchemaV1 } from "@standard-schema/spec" import type { Transaction } from "./transactions" import type { BasicExpression, OrderBy } from "./query/ir.js" +import type { Context, GetResult } from "./query/builder/types" /** * Helper type to extract the output type from a standard schema @@ -677,3 +678,12 @@ export type WritableDeep = T extends BuiltIns : T extends object ? WritableObjectDeep : unknown + +/** + * Utility type to infer the query result size (single row or an array) + */ +export type WithResultSize = TContext extends { + single: true +} + ? GetResult + : Array> diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 4782edeb1..a2097c6f4 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -12,6 +12,7 @@ import type { InitialQueryBuilder, LiveQueryCollectionConfig, QueryBuilder, + WithResultSize, } from "@tanstack/db" const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) @@ -82,9 +83,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: TContext extends { single: true } - ? GetResult - : Array> + data: WithResultSize collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled if always returns QueryBuilder isLoading: boolean @@ -230,7 +229,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: Array> + data: WithResultSize collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled for config objects isLoading: boolean From 817afa4920649ff16fe2b17423c2a2039538d9c6 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Sun, 24 Aug 2025 14:29:45 +0200 Subject: [PATCH 03/11] fix utility type --- packages/db/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index eff98d841..9038d2872 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -685,5 +685,5 @@ export type WritableDeep = T extends BuiltIns export type WithResultSize = TContext extends { single: true } - ? GetResult + ? GetResult | undefined : Array> From 79ab356b51e945db1ef8f0a1c9a096734291029f Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 27 Sep 2025 13:42:17 +0100 Subject: [PATCH 04/11] add single support to aditional useLiveQuery overloads --- packages/db/src/query/live/collection-config-builder.ts | 2 +- packages/react-db/src/useLiveQuery.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 09e8f0153..5cc6fca3c 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -93,7 +93,7 @@ export class CollectionConfigBuilder< onUpdate: this.config.onUpdate, onDelete: this.config.onDelete, startSync: this.config.startSync, - single: this.config.single, + single: this.query.single, } } diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index a2097c6f4..3f08dfae3 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -102,7 +102,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> | undefined - data: Array> | undefined + data: WithResultSize | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus isLoading: boolean @@ -121,7 +121,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> | undefined - data: Array> | undefined + data: WithResultSize | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus isLoading: boolean @@ -176,7 +176,7 @@ export function useLiveQuery< | Map> | Map | undefined - data: Array> | Array | undefined + data: WithResultSize | Array | undefined collection: | Collection, string | number, {}> | Collection From 61b66a5f36490e058944d12b513b76d2ae5562ae Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 27 Sep 2025 17:33:00 +0100 Subject: [PATCH 05/11] Add support for specifying `.findOne()` outside of a query builder --- packages/db/src/collection/index.ts | 37 +++++- packages/db/src/query/builder/types.ts | 2 +- .../db/src/query/live-query-collection.ts | 34 +++-- .../query/live/collection-config-builder.ts | 4 +- packages/db/src/types.ts | 17 ++- packages/db/tests/collection-events.test.ts | 3 +- packages/react-db/src/useLiveQuery.ts | 27 +++- packages/react-db/tests/test-setup.ts | 4 + .../react-db/tests/useLiveQuery.test-d.tsx | 118 ++++++++++++++++++ packages/react-db/tests/useLiveQuery.test.tsx | 74 ++++++++++- 10 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 packages/react-db/tests/useLiveQuery.test-d.tsx diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 1dcc60fe0..0d1fd41e8 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -133,8 +133,26 @@ export function createCollection< options: CollectionConfig, TKey, T> & { schema: T utils?: TUtils + single?: never } -): Collection, TKey, TUtils, T, InferSchemaInput> +): Collection, TKey, TUtils, T, InferSchemaInput> & { + single?: never +} + +// Overload for when schema is provided and single is true +export function createCollection< + T extends StandardSchemaV1, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = {}, +>( + options: CollectionConfig, TKey, T> & { + schema: T + utils?: TUtils + single: true + } +): Collection, TKey, TUtils, T, InferSchemaInput> & { + single: true +} // Overload for when no schema is provided // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config @@ -146,8 +164,23 @@ export function createCollection< options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided utils?: TUtils + single?: never + } +): Collection & { single?: never } + +// Overload for when no schema is provided and single is true +// the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config +export function createCollection< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = {}, +>( + options: CollectionConfig & { + schema?: never // prohibit schema if an explicit type is provided + utils?: TUtils + single: true } -): Collection +): Collection & { single: true } // Implementation export function createCollection( diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 865320790..8ec508edc 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -573,7 +573,7 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] - single: TContext[`single`] + single: TContext[`single`] extends true ? true : false } /** diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index ec631cd8f..58294c1f7 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -35,12 +35,20 @@ export function liveQueryCollectionOptions< TResult extends object = GetResult, >( config: LiveQueryCollectionConfig -): CollectionConfig { +): TContext extends { + single: true +} + ? CollectionConfig & { single: true } + : CollectionConfig & { single?: never } { const collectionConfigBuilder = new CollectionConfigBuilder< TContext, TResult >(config) - return collectionConfigBuilder.getConfig() + return collectionConfigBuilder.getConfig() as TContext extends { + single: true + } + ? CollectionConfig & { single: true } + : CollectionConfig & { single?: never } } /** @@ -83,7 +91,9 @@ export function createLiveQueryCollection< TResult extends object = GetResult, >( query: (q: InitialQueryBuilder) => QueryBuilder -): Collection +): TContext extends { single: true } + ? Collection & { single: true } + : Collection & { single?: never } // Overload 2: Accept full config object with optional utilities export function createLiveQueryCollection< @@ -92,7 +102,9 @@ export function createLiveQueryCollection< TUtils extends UtilsRecord = {}, >( config: LiveQueryCollectionConfig & { utils?: TUtils } -): Collection +): TContext extends { single: true } + ? Collection & { single: true } + : Collection & { single?: never } // Implementation export function createLiveQueryCollection< @@ -103,7 +115,9 @@ export function createLiveQueryCollection< configOrQuery: | (LiveQueryCollectionConfig & { utils?: TUtils }) | ((q: InitialQueryBuilder) => QueryBuilder) -): Collection { +): TContext extends { single: true } + ? Collection & { single: true } + : Collection & { single?: never } { // Determine if the argument is a function (query) or a config object if (typeof configOrQuery === `function`) { // Simple query function case @@ -113,7 +127,11 @@ export function createLiveQueryCollection< ) => QueryBuilder, } const options = liveQueryCollectionOptions(config) - return bridgeToCreateCollection(options) + return bridgeToCreateCollection(options) as TContext extends { + single: true + } + ? Collection & { single: true } + : Collection & { single?: never } } else { // Config object case const config = configOrQuery as LiveQueryCollectionConfig< @@ -124,7 +142,9 @@ export function createLiveQueryCollection< return bridgeToCreateCollection({ ...options, utils: config.utils, - }) + }) as TContext extends { single: true } + ? Collection & { single: true } + : Collection & { single?: never } } } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 5cc6fca3c..0ff7a0250 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -7,7 +7,7 @@ import type { RootStreamBuilder } from "@tanstack/db-ivm" import type { OrderByOptimizationInfo } from "../compiler/order-by.js" import type { Collection } from "../../collection/index.js" import type { - CollectionConfig, + CollectionConfigSingleRowOption, KeyedStream, ResultStream, SyncConfig, @@ -79,7 +79,7 @@ export class CollectionConfigBuilder< this.compileBasePipeline() } - getConfig(): CollectionConfig { + getConfig(): CollectionConfigSingleRowOption { return { id: this.id, getKey: diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 9038d2872..37c515588 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -488,11 +488,6 @@ export interface BaseCollectionConfig< * } */ onDelete?: DeleteMutationFn - - /** - * If enabled the collection will return a single object instead of an array - */ - single?: true } export interface CollectionConfig< @@ -503,6 +498,18 @@ export interface CollectionConfig< sync: SyncConfig } +// Only used for live query collections +export interface CollectionConfigSingleRowOption< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +> extends CollectionConfig { + /** + * If enabled the collection will return a single object instead of an array + */ + single?: true +} + export type ChangesPayload> = Array< ChangeMessage > diff --git a/packages/db/tests/collection-events.test.ts b/packages/db/tests/collection-events.test.ts index 04a31b621..494d5a3e8 100644 --- a/packages/db/tests/collection-events.test.ts +++ b/packages/db/tests/collection-events.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { createCollection } from "../src/collection/index.js" +import type { Collection } from "../src/collection/index.js" describe(`Collection Events System`, () => { - let collection: ReturnType + let collection: Collection let mockSync: ReturnType beforeEach(() => { diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 3f08dfae3..137554a65 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -11,6 +11,7 @@ import type { GetResult, InitialQueryBuilder, LiveQueryCollectionConfig, + CollectionConfigSingleRowOption, QueryBuilder, WithResultSize, } from "@tanstack/db" @@ -275,7 +276,7 @@ export function useLiveQuery< TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection + liveQueryCollection: Collection & { single?: never } ): { state: Map data: Array @@ -289,6 +290,26 @@ export function useLiveQuery< isEnabled: true // Always true for pre-created live query collections } +// Overload 8: Accept pre-created live query collection with single: true +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & { single: true } +): { + state: Map + data: TResult | undefined + collection: Collection & { single: true } + status: CollectionStatus // Can't be disabled for pre-created live query collections + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: true // Always true for pre-created live query collections +} + // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( configOrQueryOrCollection: any, @@ -478,7 +499,9 @@ export function useLiveQuery( } else { // Capture a stable view of entries for this snapshot to avoid tearing const entries = Array.from(snapshot.collection.entries()) - const single = snapshot.collection.config.single + const config: CollectionConfigSingleRowOption = + snapshot.collection.config + const single = config.single let stateCache: Map | null = null let dataCache: Array | null = null diff --git a/packages/react-db/tests/test-setup.ts b/packages/react-db/tests/test-setup.ts index 84b267ed1..b686eeaaf 100644 --- a/packages/react-db/tests/test-setup.ts +++ b/packages/react-db/tests/test-setup.ts @@ -2,6 +2,10 @@ import "@testing-library/jest-dom/vitest" import { cleanup } from "@testing-library/react" import { afterEach } from "vitest" +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean +} + global.IS_REACT_ACT_ENVIRONMENT = true // https://testing-library.com/docs/react-testing-library/api#cleanup afterEach(() => cleanup()) diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx new file mode 100644 index 000000000..e99ff57d9 --- /dev/null +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -0,0 +1,118 @@ +import { expectTypeOf, describe, it } from "vitest" +import { createCollection } from "../../db/src/collection/index" +import { mockSyncCollectionOptions } from "../../db/tests/utils" +import { + createLiveQueryCollection, + eq, + liveQueryCollectionOptions, +} from "../../db/src/query/index" +import { renderHook } from "@testing-library/react" +import { useLiveQuery } from "../src/useLiveQuery" + +type Person = { + id: string + name: string + age: number + email: string + isActive: boolean + team: string +} + +describe(`useLiveQuery type assertions`, () => { + it(`should type findOne query builder to return a single row`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: [], + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne() + ) + }) + + expectTypeOf(result.current.data).toEqualTypeOf() + }) + + it(`should type findOne config object to return a single row`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: [], + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + }) + + expectTypeOf(result.current.data).toEqualTypeOf() + }) + + it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: [], + }) + ) + + const options = liveQueryCollectionOptions({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + + const liveQueryCollection = createCollection(options) + + expectTypeOf(liveQueryCollection).toExtend<{ single: true }>() + + const { result } = renderHook(() => { + return useLiveQuery(liveQueryCollection) + }) + + expectTypeOf(result.current.data).toEqualTypeOf() + }) + + it(`should type findOne collection using createLiveQueryCollection to return a single row`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: [], + }) + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + + expectTypeOf(liveQueryCollection).toExtend<{ single: true }>() + + const { result } = renderHook(() => { + return useLiveQuery(liveQueryCollection) + }) + + expectTypeOf(result.current.data).toEqualTypeOf() + }) +}) diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 49e3caa5d..cd57cbd37 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -157,7 +157,7 @@ describe(`Query Collections`, () => { expect(data1).toBe(data2) }) - it(`should be able to return a single row`, async () => { + it(`should be able to return a single row with query builder`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-2`, @@ -191,6 +191,78 @@ describe(`Query Collections`, () => { }) }) + it(`should be able to return a single row with config object`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result } = renderHook(() => { + return useLiveQuery({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + }) + + // Wait for collection to sync + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + expect(result.current.data).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + }) + + it(`should be able to return a single row with collection`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-2`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.id, `3`)) + .findOne(), + }) + + const { result } = renderHook(() => { + return useLiveQuery(liveQueryCollection) + }) + + // Wait for collection to sync + await waitFor(() => { + expect(result.current.state.size).toBe(1) + }) + + expect(result.current.state.get(`3`)).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + + expect(result.current.data).toMatchObject({ + id: `3`, + name: `John Smith`, + }) + }) + it(`should be able to query a collection with live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From 594cc936888aa1d3ea7fe25335ea87ff024995ed Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 29 Sep 2025 17:50:30 +0100 Subject: [PATCH 06/11] WIP addressing of the review --- packages/db/src/collection/index.ts | 22 +++---- packages/db/src/query/builder/index.ts | 6 +- packages/db/src/query/builder/types.ts | 4 +- packages/db/src/query/compiler/index.ts | 5 +- packages/db/src/query/ir.ts | 2 +- .../db/src/query/live-query-collection.ts | 64 +++++++++++-------- .../query/live/collection-config-builder.ts | 2 +- packages/db/src/query/live/types.ts | 2 +- packages/db/src/types.ts | 4 +- packages/react-db/src/useLiveQuery.ts | 18 ++++-- .../react-db/tests/useLiveQuery.test-d.tsx | 8 +-- 11 files changed, 73 insertions(+), 64 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 0d1fd41e8..76829240f 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -50,7 +50,7 @@ export interface Collection< TInsertInput extends object = T, > extends CollectionImpl { readonly utils: TUtils - readonly single?: true + readonly singleResult?: true } /** @@ -133,13 +133,13 @@ export function createCollection< options: CollectionConfig, TKey, T> & { schema: T utils?: TUtils - single?: never + singleResult?: never } ): Collection, TKey, TUtils, T, InferSchemaInput> & { - single?: never + singleResult?: never } -// Overload for when schema is provided and single is true +// Overload for when schema is provided and singleResult is true export function createCollection< T extends StandardSchemaV1, TKey extends string | number = string | number, @@ -148,10 +148,10 @@ export function createCollection< options: CollectionConfig, TKey, T> & { schema: T utils?: TUtils - single: true + singleResult: true } ): Collection, TKey, TUtils, T, InferSchemaInput> & { - single: true + singleResult: true } // Overload for when no schema is provided @@ -164,11 +164,11 @@ export function createCollection< options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided utils?: TUtils - single?: never + singleResult?: never } -): Collection & { single?: never } +): Collection & { singleResult?: never } -// Overload for when no schema is provided and single is true +// Overload for when no schema is provided and singleResult is true // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config export function createCollection< T extends object, @@ -178,9 +178,9 @@ export function createCollection< options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided utils?: TUtils - single: true + singleResult: true } -): Collection & { single: true } +): Collection & { singleResult: true } // Implementation export function createCollection( diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index e80904b79..b71db0eee 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -617,7 +617,7 @@ export class BaseQueryBuilder { /** * Specify that the query should return a single result - * @returns A QueryBuilder with single result enforced + * @returns A QueryBuilder that returns the first result * * @example * ```ts @@ -628,11 +628,11 @@ export class BaseQueryBuilder { * .findOne() *``` */ - findOne(): QueryBuilder { + findOne(): QueryBuilder { return new BaseQueryBuilder({ ...this.query, limit: 1, - single: true, + singleResult: true, }) } diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 8ec508edc..2aaa34662 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -48,7 +48,7 @@ export interface Context { // The result type after select (if select has been called) result?: any // Single result only (if findOne has been called) - single?: boolean + singleResult?: boolean } /** @@ -573,7 +573,7 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] - single: TContext[`single`] extends true ? true : false + singleResult: TContext[`singleResult`] extends true ? true : false } /** diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 8a07862e2..a79ace809 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -290,10 +290,7 @@ export function compileQuery( cache.set(rawQuery, compilationResult) return compilationResult - } else if ( - !query.single && - (query.limit !== undefined || query.offset !== undefined) - ) { + } else if (query.limit !== undefined || query.offset !== undefined) { // If there's a limit or offset without orderBy, throw an error throw new LimitOffsetRequireOrderByError() } diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index bb4219fcb..ef0be542e 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -17,7 +17,7 @@ export interface QueryIR { limit?: Limit offset?: Offset distinct?: true - single?: true + singleResult?: true // Functional variants fnSelect?: (row: NamespacedRow) => any diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 58294c1f7..dce67af82 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -3,9 +3,31 @@ import { CollectionConfigBuilder } from "./live/collection-config-builder.js" import type { LiveQueryCollectionConfig } from "./live/types.js" import type { InitialQueryBuilder, QueryBuilder } from "./builder/index.js" import type { Collection } from "../collection/index.js" -import type { CollectionConfig, UtilsRecord } from "../types.js" +import type { + CollectionConfig, + CollectionConfigSingleRowOption, + UtilsRecord, +} from "../types.js" import type { Context, GetResult } from "./builder/types.js" +type CollectionConfigForContext< + TContext extends Context, + TResult extends object, +> = TContext extends { + singleResult: true +} + ? CollectionConfigSingleRowOption & { singleResult: true } + : CollectionConfigSingleRowOption & { singleResult?: never } + +type CollectionForContext< + TContext extends Context, + TResult extends object, +> = TContext extends { + singleResult: true +} + ? Collection & { singleResult: true } + : Collection & { singleResult?: never } + /** * Creates live query collection options for use with createCollection * @@ -35,20 +57,15 @@ export function liveQueryCollectionOptions< TResult extends object = GetResult, >( config: LiveQueryCollectionConfig -): TContext extends { - single: true -} - ? CollectionConfig & { single: true } - : CollectionConfig & { single?: never } { +): CollectionConfigForContext { const collectionConfigBuilder = new CollectionConfigBuilder< TContext, TResult >(config) - return collectionConfigBuilder.getConfig() as TContext extends { - single: true - } - ? CollectionConfig & { single: true } - : CollectionConfig & { single?: never } + return collectionConfigBuilder.getConfig() as CollectionConfigForContext< + TContext, + TResult + > } /** @@ -91,9 +108,7 @@ export function createLiveQueryCollection< TResult extends object = GetResult, >( query: (q: InitialQueryBuilder) => QueryBuilder -): TContext extends { single: true } - ? Collection & { single: true } - : Collection & { single?: never } +): CollectionForContext // Overload 2: Accept full config object with optional utilities export function createLiveQueryCollection< @@ -102,9 +117,7 @@ export function createLiveQueryCollection< TUtils extends UtilsRecord = {}, >( config: LiveQueryCollectionConfig & { utils?: TUtils } -): TContext extends { single: true } - ? Collection & { single: true } - : Collection & { single?: never } +): CollectionForContext // Implementation export function createLiveQueryCollection< @@ -115,9 +128,7 @@ export function createLiveQueryCollection< configOrQuery: | (LiveQueryCollectionConfig & { utils?: TUtils }) | ((q: InitialQueryBuilder) => QueryBuilder) -): TContext extends { single: true } - ? Collection & { single: true } - : Collection & { single?: never } { +): CollectionForContext { // Determine if the argument is a function (query) or a config object if (typeof configOrQuery === `function`) { // Simple query function case @@ -127,11 +138,10 @@ export function createLiveQueryCollection< ) => QueryBuilder, } const options = liveQueryCollectionOptions(config) - return bridgeToCreateCollection(options) as TContext extends { - single: true - } - ? Collection & { single: true } - : Collection & { single?: never } + return bridgeToCreateCollection(options) as CollectionForContext< + TContext, + TResult + > } else { // Config object case const config = configOrQuery as LiveQueryCollectionConfig< @@ -142,9 +152,7 @@ export function createLiveQueryCollection< return bridgeToCreateCollection({ ...options, utils: config.utils, - }) as TContext extends { single: true } - ? Collection & { single: true } - : Collection & { single?: never } + }) as CollectionForContext } } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 0ff7a0250..4572b345a 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -93,7 +93,7 @@ export class CollectionConfigBuilder< onUpdate: this.config.onUpdate, onDelete: this.config.onDelete, startSync: this.config.startSync, - single: this.query.single, + singleResult: this.query.singleResult, } } diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index b85c9a1a6..3149b3a66 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -94,5 +94,5 @@ export interface LiveQueryCollectionConfig< /** * If enabled the collection will return a single object instead of an array */ - single?: true + singleResult?: true } diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 37c515588..e0c06eb14 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -507,7 +507,7 @@ export interface CollectionConfigSingleRowOption< /** * If enabled the collection will return a single object instead of an array */ - single?: true + singleResult?: true } export type ChangesPayload> = Array< @@ -690,7 +690,7 @@ export type WritableDeep = T extends BuiltIns * Utility type to infer the query result size (single row or an array) */ export type WithResultSize = TContext extends { - single: true + singleResult: true } ? GetResult | undefined : Array> diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 137554a65..f18509d79 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -6,12 +6,12 @@ import { } from "@tanstack/db" import type { Collection, + CollectionConfigSingleRowOption, CollectionStatus, Context, GetResult, InitialQueryBuilder, LiveQueryCollectionConfig, - CollectionConfigSingleRowOption, QueryBuilder, WithResultSize, } from "@tanstack/db" @@ -276,7 +276,9 @@ export function useLiveQuery< TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection & { single?: never } + liveQueryCollection: Collection & { + singleResult?: never + } ): { state: Map data: Array @@ -290,17 +292,19 @@ export function useLiveQuery< isEnabled: true // Always true for pre-created live query collections } -// Overload 8: Accept pre-created live query collection with single: true +// Overload 8: Accept pre-created live query collection with singleResult: true export function useLiveQuery< TResult extends object, TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection & { single: true } + liveQueryCollection: Collection & { + singleResult: true + } ): { state: Map data: TResult | undefined - collection: Collection & { single: true } + collection: Collection & { singleResult: true } status: CollectionStatus // Can't be disabled for pre-created live query collections isLoading: boolean isReady: boolean @@ -501,7 +505,7 @@ export function useLiveQuery( const entries = Array.from(snapshot.collection.entries()) const config: CollectionConfigSingleRowOption = snapshot.collection.config - const single = config.single + const singleResult = config.singleResult let stateCache: Map | null = null let dataCache: Array | null = null @@ -516,7 +520,7 @@ export function useLiveQuery( if (!dataCache) { dataCache = entries.map(([, value]) => value) } - return single ? dataCache[0] : dataCache + return singleResult ? dataCache[0] : dataCache }, collection: snapshot.collection, status: snapshot.collection.status, diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index e99ff57d9..297ed9580 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -1,4 +1,5 @@ -import { expectTypeOf, describe, it } from "vitest" +import { describe, expectTypeOf, it } from "vitest" +import { renderHook } from "@testing-library/react" import { createCollection } from "../../db/src/collection/index" import { mockSyncCollectionOptions } from "../../db/tests/utils" import { @@ -6,7 +7,6 @@ import { eq, liveQueryCollectionOptions, } from "../../db/src/query/index" -import { renderHook } from "@testing-library/react" import { useLiveQuery } from "../src/useLiveQuery" type Person = { @@ -81,7 +81,7 @@ describe(`useLiveQuery type assertions`, () => { const liveQueryCollection = createCollection(options) - expectTypeOf(liveQueryCollection).toExtend<{ single: true }>() + expectTypeOf(liveQueryCollection).toExtend<{ singleResult: true }>() const { result } = renderHook(() => { return useLiveQuery(liveQueryCollection) @@ -107,7 +107,7 @@ describe(`useLiveQuery type assertions`, () => { .findOne(), }) - expectTypeOf(liveQueryCollection).toExtend<{ single: true }>() + expectTypeOf(liveQueryCollection).toExtend<{ singleResult: true }>() const { result } = renderHook(() => { return useLiveQuery(liveQueryCollection) From ea94c4e192f9d6ebfafb74ca7f1b08e101757607 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Mon, 29 Sep 2025 20:05:16 +0200 Subject: [PATCH 07/11] fix for review --- packages/db/src/collection/index.ts | 28 +++++------- packages/db/src/query/builder/index.ts | 15 +++++-- packages/db/src/query/builder/types.ts | 9 ++++ packages/db/src/query/compiler/index.ts | 44 +++++++++---------- packages/db/src/query/index.ts | 1 + .../db/src/query/live-query-collection.ts | 18 ++++---- packages/db/src/types.ts | 32 +++++++------- packages/react-db/src/useLiveQuery.ts | 24 +++++----- .../react-db/tests/useLiveQuery.test-d.tsx | 13 +++--- 9 files changed, 98 insertions(+), 86 deletions(-) diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 76829240f..56baab03c 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -24,7 +24,9 @@ import type { InferSchemaInput, InferSchemaOutput, InsertConfig, + NonSingleResult, OperationConfig, + SingleResult, SubscribeChangesOptions, Transaction as TransactionType, UtilsRecord, @@ -133,11 +135,9 @@ export function createCollection< options: CollectionConfig, TKey, T> & { schema: T utils?: TUtils - singleResult?: never - } -): Collection, TKey, TUtils, T, InferSchemaInput> & { - singleResult?: never -} + } & NonSingleResult +): Collection, TKey, TUtils, T, InferSchemaInput> & + NonSingleResult // Overload for when schema is provided and singleResult is true export function createCollection< @@ -148,11 +148,9 @@ export function createCollection< options: CollectionConfig, TKey, T> & { schema: T utils?: TUtils - singleResult: true - } -): Collection, TKey, TUtils, T, InferSchemaInput> & { - singleResult: true -} + } & SingleResult +): Collection, TKey, TUtils, T, InferSchemaInput> & + SingleResult // Overload for when no schema is provided // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config @@ -164,9 +162,8 @@ export function createCollection< options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided utils?: TUtils - singleResult?: never - } -): Collection & { singleResult?: never } + } & NonSingleResult +): Collection & NonSingleResult // Overload for when no schema is provided and singleResult is true // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config @@ -178,9 +175,8 @@ export function createCollection< options: CollectionConfig & { schema?: never // prohibit schema if an explicit type is provided utils?: TUtils - singleResult: true - } -): Collection & { singleResult: true } + } & SingleResult +): Collection & SingleResult // Implementation export function createCollection( diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index b71db0eee..916a92e19 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -16,7 +16,7 @@ import { SubQueryMustHaveFromClauseError, } from "../../errors.js" import { createRefProxy, toExpression } from "./ref-proxy.js" -import type { NamespacedRow } from "../../types.js" +import type { NamespacedRow, SingleResult } from "../../types.js" import type { Aggregate, BasicExpression, @@ -628,10 +628,13 @@ export class BaseQueryBuilder { * .findOne() *``` */ - findOne(): QueryBuilder { + findOne(): QueryBuilder { return new BaseQueryBuilder({ ...this.query, + // force to get only one result limit: 1, + // and the first result + offset: 0, singleResult: true, }) } @@ -838,4 +841,10 @@ export type ExtractContext = : never // Export the types from types.ts for convenience -export type { Context, Source, GetResult, RefLeaf as Ref } from "./types.js" +export type { + Context, + Source, + GetResult, + RefLeaf as Ref, + InferResultType, +} from "./types.js" diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 2aaa34662..437202cd9 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -1,4 +1,5 @@ import type { CollectionImpl } from "../../collection/index.js" +import type { SingleResult } from "../../types.js" import type { Aggregate, BasicExpression, @@ -624,6 +625,14 @@ export type ApplyJoinOptionalityToMergedSchema< TNewSchema[K] } +/** + * Utility type to infer the query result size (single row or an array) + */ +export type InferResultType = + TContext extends SingleResult + ? GetResult | undefined + : Array> + /** * GetResult - Determines the final result type of a query * diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index a79ace809..60d61c345 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -291,11 +291,11 @@ export function compileQuery( return compilationResult } else if (query.limit !== undefined || query.offset !== undefined) { - // If there's a limit or offset without orderBy, throw an error + // If there's a limit or offset w ithout orderBy, throw an error throw new LimitOffsetRequireOrderByError() } - // Final step: extract the __select_results and return tuple format (no orderBy) + // Final step: extract the __sele ct_results and return tuple format (no orderBy) const resultPipeline: ResultStream = pipeline.pipe( map(([key, row]) => { // Extract the final results from __select_results and return [key, [results, undefined]] @@ -309,7 +309,7 @@ export function compileQuery( ) const result = resultPipeline - // Cache the result before returning (use original query as key) + // Cache the result before return ing (use original query as key) const compilationResult = { collectionId: mainCollectionId, pipeline: result, @@ -321,7 +321,7 @@ export function compileQuery( } /** - * Processes the FROM clause to extract the main table alias and input stream + * Processes the FROM clause to extract the main table alias and input stream */ function processFrom( from: CollectionRef | QueryRef, @@ -343,10 +343,10 @@ function processFrom( return { alias: from.alias, input, collectionId: from.collection.id } } case `queryRef`: { - // Find the original query for caching purposes + // Find the original query for ca ching purposes const originalQuery = queryMapping.get(from.query) || from.query - // Recursively compile the sub-query with cache + // Recursively compile the sub-qu ery with cache const subQueryResult = compileQuery( originalQuery, allInputs, @@ -359,15 +359,15 @@ function processFrom( queryMapping ) - // Extract the pipeline from the compilation result + // Extract the pipeline from the compilation result const subQueryInput = subQueryResult.pipeline - // Subqueries may return [key, [value, orderByIndex]] (with ORDER BY) or [key, value] (without ORDER BY) - // We need to extract just the value for use in parent queries + // Subqueries may return [key, [v alue, orderByIndex]] (with ORDER BY) or [key, value] (without ORDER BY) + // We need to extract just the va lue for use in parent queries const extractedInput = subQueryInput.pipe( map((data: any) => { const [key, [value, _orderByIndex]] = data - // Unwrap Value expressions that might have leaked through as the entire row + // Unwrap Value expressions that might have leaked through as the entire row const unwrapped = unwrapValue(value) return [key, unwrapped] as [unknown, any] }) @@ -384,7 +384,7 @@ function processFrom( } } -// Helper to check if a value is a Value expression +// Helper to check if a value is a Value expression function isValue(raw: any): boolean { return ( raw instanceof ValClass || @@ -392,7 +392,7 @@ function isValue(raw: any): boolean { ) } -// Helper to unwrap a Value expression or return the value itself +// Helper to unwrap a Value expre ssion or return the value itself function unwrapValue(value: any): any { return isValue(value) ? value.value : value } @@ -407,7 +407,7 @@ function mapNestedQueries( originalQuery: QueryIR, queryMapping: QueryMapping ): void { - // Map the FROM clause if it's a QueryRef + // Map the FROM clause if it's a QueryRef if ( optimizedQuery.from.type === `queryRef` && originalQuery.from.type === `queryRef` @@ -463,7 +463,7 @@ function getRefFromAlias( } /** - * Follows the given reference in a query + * Follows the given referenc e in a query * until its finds the root field the reference points to. * @returns The collection, its alias, and the path to the root field in this collection */ @@ -477,9 +477,9 @@ export function followRef( } if (ref.path.length === 1) { - // This field should be part of this collection + // This field should be part of t his collection const field = ref.path[0]! - // is it part of the select clause? + // is it part of the select claus e? if (query.select) { const selectedField = query.select[field] if (selectedField && selectedField.type === `ref`) { @@ -487,10 +487,10 @@ export function followRef( } } - // Either this field is not part of the select clause - // and thus it must be part of the collection itself - // or it is part of the select but is not a reference - // so we can stop here and don't have to follow it + // Either this field is not part of the select clause + // and thus it must be part of th e collection itself + // or it is part of the select bu t is not a reference + // so we can stop here and don't have to follow it return { collection, path: [field] } } @@ -505,9 +505,9 @@ export function followRef( if (aliasRef.type === `queryRef`) { return followRef(aliasRef.query, new PropRef(rest), collection) } else { - // This is a reference to a collection + // This is a reference to a colle ction // we can't follow it further - // so the field must be on the collection itself + // so the field must be on the co llection itself return { collection: aliasRef.collection, path: rest } } } diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index f53d96503..c5e5873cc 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -9,6 +9,7 @@ export { type Context, type Source, type GetResult, + type InferResultType, } from "./builder/index.js" // Expression functions exports diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index dce67af82..c73b12b37 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -6,6 +6,8 @@ import type { Collection } from "../collection/index.js" import type { CollectionConfig, CollectionConfigSingleRowOption, + NonSingleResult, + SingleResult, UtilsRecord, } from "../types.js" import type { Context, GetResult } from "./builder/types.js" @@ -13,20 +15,16 @@ import type { Context, GetResult } from "./builder/types.js" type CollectionConfigForContext< TContext extends Context, TResult extends object, -> = TContext extends { - singleResult: true -} - ? CollectionConfigSingleRowOption & { singleResult: true } - : CollectionConfigSingleRowOption & { singleResult?: never } +> = TContext extends SingleResult + ? CollectionConfigSingleRowOption & SingleResult + : CollectionConfigSingleRowOption & NonSingleResult type CollectionForContext< TContext extends Context, TResult extends object, -> = TContext extends { - singleResult: true -} - ? Collection & { singleResult: true } - : Collection & { singleResult?: never } +> = TContext extends SingleResult + ? Collection & SingleResult + : Collection & NonSingleResult /** * Creates live query collection options for use with createCollection diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index e0c06eb14..b42962c84 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -3,7 +3,6 @@ import type { Collection } from "./collection/index.js" import type { StandardSchemaV1 } from "@standard-schema/spec" import type { Transaction } from "./transactions" import type { BasicExpression, OrderBy } from "./query/ir.js" -import type { Context, GetResult } from "./query/builder/types" /** * Helper type to extract the output type from a standard schema @@ -498,18 +497,28 @@ export interface CollectionConfig< sync: SyncConfig } -// Only used for live query collections -export interface CollectionConfigSingleRowOption< - T extends object = Record, - TKey extends string | number = string | number, - TSchema extends StandardSchemaV1 = never, -> extends CollectionConfig { +export type SingleResult = { + singleResult: true +} + +export type NonSingleResult = { + singleResult?: never +} + +export type MaybeSingleResult = { /** * If enabled the collection will return a single object instead of an array */ singleResult?: true } +// Only used for live query collections +export type CollectionConfigSingleRowOption< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, +> = CollectionConfig & MaybeSingleResult + export type ChangesPayload> = Array< ChangeMessage > @@ -685,12 +694,3 @@ export type WritableDeep = T extends BuiltIns : T extends object ? WritableObjectDeep : unknown - -/** - * Utility type to infer the query result size (single row or an array) - */ -export type WithResultSize = TContext extends { - singleResult: true -} - ? GetResult | undefined - : Array> diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index f18509d79..e1ae4d04b 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -10,10 +10,12 @@ import type { CollectionStatus, Context, GetResult, + InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + NonSingleResult, QueryBuilder, - WithResultSize, + SingleResult, } from "@tanstack/db" const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) @@ -84,7 +86,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: WithResultSize + data: InferResultType collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled if always returns QueryBuilder isLoading: boolean @@ -103,7 +105,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> | undefined - data: WithResultSize | undefined + data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus isLoading: boolean @@ -122,7 +124,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> | undefined - data: WithResultSize | undefined + data: InferResultType | undefined collection: Collection, string | number, {}> | undefined status: UseLiveQueryStatus isLoading: boolean @@ -177,7 +179,7 @@ export function useLiveQuery< | Map> | Map | undefined - data: WithResultSize | Array | undefined + data: InferResultType | Array | undefined collection: | Collection, string | number, {}> | Collection @@ -230,7 +232,7 @@ export function useLiveQuery( deps?: Array ): { state: Map> - data: WithResultSize + data: InferResultType collection: Collection, string | number, {}> status: CollectionStatus // Can't be disabled for config objects isLoading: boolean @@ -276,9 +278,7 @@ export function useLiveQuery< TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection & { - singleResult?: never - } + liveQueryCollection: Collection & NonSingleResult ): { state: Map data: Array @@ -298,13 +298,11 @@ export function useLiveQuery< TKey extends string | number, TUtils extends Record, >( - liveQueryCollection: Collection & { - singleResult: true - } + liveQueryCollection: Collection & SingleResult ): { state: Map data: TResult | undefined - collection: Collection & { singleResult: true } + collection: Collection & SingleResult status: CollectionStatus // Can't be disabled for pre-created live query collections isLoading: boolean isReady: boolean diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 297ed9580..df012a021 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -8,6 +8,7 @@ import { liveQueryCollectionOptions, } from "../../db/src/query/index" import { useLiveQuery } from "../src/useLiveQuery" +import type { SingleResult } from "../../db/src/types" type Person = { id: string @@ -19,7 +20,7 @@ type Person = { } describe(`useLiveQuery type assertions`, () => { - it(`should type findOne query builder to return a single row`, async () => { + it(`should type findOne query builder to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-2`, @@ -40,7 +41,7 @@ describe(`useLiveQuery type assertions`, () => { expectTypeOf(result.current.data).toEqualTypeOf() }) - it(`should type findOne config object to return a single row`, async () => { + it(`should type findOne config object to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-2`, @@ -62,7 +63,7 @@ describe(`useLiveQuery type assertions`, () => { expectTypeOf(result.current.data).toEqualTypeOf() }) - it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, async () => { + it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-2`, @@ -81,7 +82,7 @@ describe(`useLiveQuery type assertions`, () => { const liveQueryCollection = createCollection(options) - expectTypeOf(liveQueryCollection).toExtend<{ singleResult: true }>() + expectTypeOf(liveQueryCollection).toExtend() const { result } = renderHook(() => { return useLiveQuery(liveQueryCollection) @@ -90,7 +91,7 @@ describe(`useLiveQuery type assertions`, () => { expectTypeOf(result.current.data).toEqualTypeOf() }) - it(`should type findOne collection using createLiveQueryCollection to return a single row`, async () => { + it(`should type findOne collection using createLiveQueryCollection to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-2`, @@ -107,7 +108,7 @@ describe(`useLiveQuery type assertions`, () => { .findOne(), }) - expectTypeOf(liveQueryCollection).toExtend<{ singleResult: true }>() + expectTypeOf(liveQueryCollection).toExtend() const { result } = renderHook(() => { return useLiveQuery(liveQueryCollection) From f1ea36f0508ed210221dae76773022af29564c43 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Mon, 29 Sep 2025 22:22:22 +0200 Subject: [PATCH 08/11] remove unused offset --- packages/db/src/query/builder/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 916a92e19..445105025 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -631,10 +631,7 @@ export class BaseQueryBuilder { findOne(): QueryBuilder { return new BaseQueryBuilder({ ...this.query, - // force to get only one result limit: 1, - // and the first result - offset: 0, singleResult: true, }) } From 3e9512f8b242bef1979f15684a6475da35ef0b09 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Tue, 30 Sep 2025 10:49:01 +0200 Subject: [PATCH 09/11] restore text comments --- packages/db/src/query/compiler/index.ts | 38 ++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 60d61c345..90bbf6084 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -291,11 +291,11 @@ export function compileQuery( return compilationResult } else if (query.limit !== undefined || query.offset !== undefined) { - // If there's a limit or offset w ithout orderBy, throw an error + // If there's a limit or offset without orderBy, throw an error throw new LimitOffsetRequireOrderByError() } - // Final step: extract the __sele ct_results and return tuple format (no orderBy) + // Final step: extract the __select_results and return tuple format (no orderBy) const resultPipeline: ResultStream = pipeline.pipe( map(([key, row]) => { // Extract the final results from __select_results and return [key, [results, undefined]] @@ -309,7 +309,7 @@ export function compileQuery( ) const result = resultPipeline - // Cache the result before return ing (use original query as key) + // Cache the result before returning (use original query as key) const compilationResult = { collectionId: mainCollectionId, pipeline: result, @@ -343,10 +343,10 @@ function processFrom( return { alias: from.alias, input, collectionId: from.collection.id } } case `queryRef`: { - // Find the original query for ca ching purposes + // Find the original query for caching purposes const originalQuery = queryMapping.get(from.query) || from.query - // Recursively compile the sub-qu ery with cache + // Recursively compile the sub-query with cache const subQueryResult = compileQuery( originalQuery, allInputs, @@ -362,8 +362,8 @@ function processFrom( // Extract the pipeline from the compilation result const subQueryInput = subQueryResult.pipeline - // Subqueries may return [key, [v alue, orderByIndex]] (with ORDER BY) or [key, value] (without ORDER BY) - // We need to extract just the va lue for use in parent queries + // Subqueries may return [key, [value, orderByIndex]] (with ORDER BY) or [key, value] (without ORDER BY) + // We need to extract just the value for use in parent queries const extractedInput = subQueryInput.pipe( map((data: any) => { const [key, [value, _orderByIndex]] = data @@ -384,7 +384,7 @@ function processFrom( } } -// Helper to check if a value is a Value expression +// Helper to check if a value is a Value expression function isValue(raw: any): boolean { return ( raw instanceof ValClass || @@ -392,7 +392,7 @@ function isValue(raw: any): boolean { ) } -// Helper to unwrap a Value expre ssion or return the value itself +// Helper to unwrap a Value expression or return the value itself function unwrapValue(value: any): any { return isValue(value) ? value.value : value } @@ -407,7 +407,7 @@ function mapNestedQueries( originalQuery: QueryIR, queryMapping: QueryMapping ): void { - // Map the FROM clause if it's a QueryRef + // Map the FROM clause if it's a QueryRef if ( optimizedQuery.from.type === `queryRef` && originalQuery.from.type === `queryRef` @@ -463,7 +463,7 @@ function getRefFromAlias( } /** - * Follows the given referenc e in a query + * Follows the given reference in a query * until its finds the root field the reference points to. * @returns The collection, its alias, and the path to the root field in this collection */ @@ -477,9 +477,9 @@ export function followRef( } if (ref.path.length === 1) { - // This field should be part of t his collection + // This field should be part of this collection const field = ref.path[0]! - // is it part of the select claus e? + // is it part of the select clause? if (query.select) { const selectedField = query.select[field] if (selectedField && selectedField.type === `ref`) { @@ -487,10 +487,10 @@ export function followRef( } } - // Either this field is not part of the select clause - // and thus it must be part of th e collection itself - // or it is part of the select bu t is not a reference - // so we can stop here and don't have to follow it + // Either this field is not part of the select clause + // and thus it must be part of the collection itself + // or it is part of the select but is not a reference + // so we can stop here and don't have to follow it return { collection, path: [field] } } @@ -505,9 +505,9 @@ export function followRef( if (aliasRef.type === `queryRef`) { return followRef(aliasRef.query, new PropRef(rest), collection) } else { - // This is a reference to a colle ction + // This is a reference to a collection // we can't follow it further - // so the field must be on the co llection itself + // so the field must be on the collection itself return { collection: aliasRef.collection, path: rest } } } From f9c46a89f5bc5fab1e0d1eb030646c65906fde65 Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Tue, 30 Sep 2025 10:50:24 +0200 Subject: [PATCH 10/11] restore text comments --- packages/db/src/query/compiler/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 90bbf6084..a79ace809 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -321,7 +321,7 @@ export function compileQuery( } /** - * Processes the FROM clause to extract the main table alias and input stream + * Processes the FROM clause to extract the main table alias and input stream */ function processFrom( from: CollectionRef | QueryRef, @@ -359,7 +359,7 @@ function processFrom( queryMapping ) - // Extract the pipeline from the compilation result + // Extract the pipeline from the compilation result const subQueryInput = subQueryResult.pipeline // Subqueries may return [key, [value, orderByIndex]] (with ORDER BY) or [key, value] (without ORDER BY) @@ -367,7 +367,7 @@ function processFrom( const extractedInput = subQueryInput.pipe( map((data: any) => { const [key, [value, _orderByIndex]] = data - // Unwrap Value expressions that might have leaked through as the entire row + // Unwrap Value expressions that might have leaked through as the entire row const unwrapped = unwrapValue(value) return [key, unwrapped] as [unknown, any] }) From 40e1e7a3a6574ef9ae1e9eb6886385d7bd5be7cc Mon Sep 17 00:00:00 2001 From: Francesco Saverio Cannizzaro Date: Tue, 30 Sep 2025 14:15:44 +0200 Subject: [PATCH 11/11] disable limit=1 for future enforcing --- packages/db/src/query/builder/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 445105025..f6b52e5ed 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -631,7 +631,8 @@ export class BaseQueryBuilder { findOne(): QueryBuilder { return new BaseQueryBuilder({ ...this.query, - limit: 1, + // TODO: enforcing return only one result with also a default orderBy if none is specified + // limit: 1, singleResult: true, }) }