diff --git a/.changeset/fix-findone-joins-type-inference.md b/.changeset/fix-findone-joins-type-inference.md new file mode 100644 index 000000000..f89e970a8 --- /dev/null +++ b/.changeset/fix-findone-joins-type-inference.md @@ -0,0 +1,45 @@ +--- +"@tanstack/db": patch +--- + +Fix type inference for findOne() when used with join operations + +Previously, using `findOne()` with join operations (leftJoin, innerJoin, etc.) resulted in the query type being inferred as `never`, breaking TypeScript type checking: + +```typescript +const query = useLiveQuery( + (q) => + q + .from({ todo: todoCollection }) + .leftJoin({ todoOptions: todoOptionsCollection }, ...) + .findOne() // Type became 'never' +) +``` + +**The Fix:** + +Fixed the `MergeContextWithJoinType` type definition to conditionally include the `singleResult` property only when it's explicitly `true`, avoiding type conflicts when `findOne()` is called after joins: + +```typescript +// Before (buggy): +singleResult: TContext['singleResult'] extends true ? true : false + +// After (fixed): +type PreserveSingleResultFlag = [TFlag] extends [true] + ? { singleResult: true } + : {} + +// Used as: +} & PreserveSingleResultFlag +``` + +**Why This Works:** + +By using a conditional intersection that omits the property entirely when not needed, we avoid type conflicts. Intersecting `{} & { singleResult: true }` cleanly results in `{ singleResult: true }`, whereas the previous approach created conflicting property types resulting in `never`. The tuple wrapper (`[TFlag]`) ensures robust behavior even if the flag type becomes a union in the future. + +**Impact:** + +- ✅ `findOne()` now works correctly with all join types +- ✅ Type inference works properly in `useLiveQuery` and other contexts +- ✅ Both `findOne()` before and after joins work correctly +- ✅ All tests pass with no breaking changes (8 new type tests added) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 53f365318..2780293be 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -106,7 +106,13 @@ jobs: run: | if [ -n "$(git status --porcelain)" ]; then echo "Error: Generated docs are out of sync!" - echo "Please run 'pnpm docs:generate' locally and commit the changes." + echo "" + echo "Please run the following commands locally and commit the changes:" + echo " 1. pnpm install" + echo " 2. pnpm build" + echo " 3. pnpm docs:generate" + echo " 4. git add docs/" + echo " 5. git commit -m 'docs: regenerate API documentation'" echo "" echo "Files that need to be updated:" git status --short diff --git a/docs/reference/type-aliases/GetResult.md b/docs/reference/type-aliases/GetResult.md index 390412903..c0c631fab 100644 --- a/docs/reference/type-aliases/GetResult.md +++ b/docs/reference/type-aliases/GetResult.md @@ -9,7 +9,7 @@ title: GetResult type GetResult = Prettify; ``` -Defined in: [packages/db/src/query/builder/types.ts:661](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L661) +Defined in: [packages/db/src/query/builder/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L675) GetResult - Determines the final result type of a query diff --git a/docs/reference/type-aliases/InferResultType.md b/docs/reference/type-aliases/InferResultType.md index cb1dceaa3..49cc53887 100644 --- a/docs/reference/type-aliases/InferResultType.md +++ b/docs/reference/type-aliases/InferResultType.md @@ -9,7 +9,7 @@ title: InferResultType type InferResultType = TContext extends SingleResult ? GetResult | undefined : GetResult[]; ``` -Defined in: [packages/db/src/query/builder/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L631) +Defined in: [packages/db/src/query/builder/types.ts:645](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/types.ts#L645) Utility type to infer the query result size (single row or an array) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index de01f4be5..2cd3a4872 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -530,6 +530,20 @@ export type RefLeaf = { readonly [RefBrand]?: T } type WithoutRefBrand = T extends Record ? Omit : T +/** + * PreserveSingleResultFlag - Conditionally includes the singleResult flag + * + * This helper type ensures the singleResult flag is only added to the context when it's + * explicitly true. It uses a non-distributive conditional (tuple wrapper) to prevent + * unexpected behavior when TFlag is a union type. + * + * @template TFlag - The singleResult flag value to check + * @returns { singleResult: true } if TFlag is true, otherwise {} + */ +type PreserveSingleResultFlag = [TFlag] extends [true] + ? { singleResult: true } + : {} + /** * MergeContextWithJoinType - Creates a new context after a join operation * @@ -551,6 +565,7 @@ type WithoutRefBrand = * - `hasJoins`: Set to true * - `joinTypes`: Updated to track this join type * - `result`: Preserved from previous operations + * - `singleResult`: Preserved only if already true (via PreserveSingleResultFlag) */ export type MergeContextWithJoinType< TContext extends Context, @@ -574,8 +589,7 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] - singleResult: TContext[`singleResult`] extends true ? true : false -} +} & PreserveSingleResultFlag /** * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas diff --git a/packages/db/tests/query/findone-joins.test-d.ts b/packages/db/tests/query/findone-joins.test-d.ts new file mode 100644 index 000000000..7d76a686f --- /dev/null +++ b/packages/db/tests/query/findone-joins.test-d.ts @@ -0,0 +1,248 @@ +import { describe, expectTypeOf, test } from "vitest" +import { createLiveQueryCollection, eq } from "../../src/query/index.js" +import { createCollection } from "../../src/collection/index.js" +import { mockSyncCollectionOptions } from "../utils.js" + +type Todo = { + id: string + text: string + order: number +} + +type TodoOption = { + id: string + todoId: string + optionText: string +} + +const todoCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-todos-findone-joins`, + getKey: (todo) => todo.id, + initialData: [], + }) +) + +const todoOptionsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-todo-options-findone-joins`, + getKey: (opt) => opt.id, + initialData: [], + }) +) + +describe(`findOne() with joins`, () => { + test(`findOne() after leftJoin should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .orderBy(({ todo }) => todo.order, `asc`) + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo + todoOptions: TodoOption | undefined + }> + >() + }) + + test(`findOne() with innerJoin should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .innerJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo + todoOptions: TodoOption + }> + >() + }) + + test(`findOne() before join should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .findOne() + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo + todoOptions: TodoOption | undefined + }> + >() + }) + + test(`findOne() with rightJoin should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .rightJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo | undefined + todoOptions: TodoOption + }> + >() + }) + + test(`findOne() with fullJoin should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .fullJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo | undefined + todoOptions: TodoOption | undefined + }> + >() + }) + + test(`findOne() with multiple joins should infer correct types`, () => { + type TodoTag = { + id: string + todoId: string + tag: string + } + + const todoTagsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-todo-tags-findone-multi`, + getKey: (tag) => tag.id, + initialData: [], + }) + ) + + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .innerJoin({ tag: todoTagsCollection }, ({ todo, tag }) => + eq(todo.id, tag.todoId) + ) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo + todoOptions: TodoOption | undefined + tag: TodoTag + }> + >() + }) + + test(`findOne() with select() and joins should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .select(({ todo, todoOptions }) => ({ + todoText: todo.text, + optionText: todoOptions?.optionText, + })) + .findOne(), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todoText: string + optionText: string | undefined + }> + >() + }) + + test(`findOne() before select() with joins should infer correct types`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .findOne() + .select(({ todo, todoOptions }) => ({ + todoText: todo.text, + optionText: todoOptions?.optionText, + })), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todoText: string + optionText: string | undefined + }> + >() + }) + + test(`limit(1) should infer array type`, () => { + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.id, `test-id`)) + .orderBy(({ todo }) => todo.order, `asc`) + .leftJoin( + { todoOptions: todoOptionsCollection }, + ({ todo, todoOptions }) => eq(todo.id, todoOptions.todoId) + ) + .limit(1), + }) + + expectTypeOf(query.toArray).toEqualTypeOf< + Array<{ + todo: Todo + todoOptions: TodoOption | undefined + }> + >() + }) +})