Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Nov 3, 2025

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:

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:

// Before (buggy):
singleResult: TContext['singleResult'] extends true ? true : false

// After (fixed):
type PreserveSingleResultFlag<TFlag> = [TFlag] extends [true]
  ? { singleResult: true }
  : {}

// Used as:
} & PreserveSingleResultFlag<TContext['singleResult']>

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)

## Problem
When using findOne() after join operations, the type of query.data
became 'never', breaking TypeScript type inference. This was reported
on Discord where users found that limit(1) worked but findOne() did not.

## Root Cause
In MergeContextWithJoinType, the singleResult property was being
explicitly set to false when not true:
  singleResult: TContext['singleResult'] extends true ? true : false

This caused a type conflict when findOne() later tried to intersect
with SingleResult:
  { singleResult: false } & { singleResult: true } = { singleResult: never }

## Solution
Changed to preserve the singleResult value as-is:
  singleResult: TContext['singleResult']

This allows:
- findOne() before join: singleResult stays true
- findOne() after join: singleResult is undefined, which properly
  intersects with { singleResult: true }

## Changes
- Fixed type definition in packages/db/src/query/builder/types.ts:577
- Added 8 comprehensive type tests for findOne() with all join types
- Added investigation documentation

## Test Coverage
- findOne() with leftJoin, innerJoin, rightJoin, fullJoin
- findOne() with multiple joins
- findOne() with select
- findOne() before vs after joins
- limit(1) vs findOne() type differences
@changeset-bot
Copy link

changeset-bot bot commented Nov 3, 2025

🦋 Changeset detected

Latest commit: e022e1d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch
todos Patch
@tanstack/db-example-paced-mutations-demo Patch
@tanstack/db-example-react-todo Patch
@tanstack/db-example-solid-todo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 3, 2025

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@749

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@749

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@749

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@749

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@749

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@749

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@749

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@749

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@749

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@749

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@749

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@749

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@749

commit: e022e1d

@github-actions
Copy link
Contributor

github-actions bot commented Nov 3, 2025

Size Change: 0 B

Total Size: 79.1 kB

ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.34 kB
./packages/db/dist/esm/collection/changes.js 977 B
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.12 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.67 kB
./packages/db/dist/esm/collection/mutations.js 2.26 kB
./packages/db/dist/esm/collection/state.js 3.43 kB
./packages/db/dist/esm/collection/subscription.js 2.42 kB
./packages/db/dist/esm/collection/sync.js 2.02 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 3.9 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.36 kB
./packages/db/dist/esm/indexes/auto-index.js 731 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 1.87 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 513 B
./packages/db/dist/esm/local-only.js 837 B
./packages/db/dist/esm/local-storage.js 2.04 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.22 kB
./packages/db/dist/esm/query/builder/functions.js 606 B
./packages/db/dist/esm/query/builder/index.js 3.85 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 917 B
./packages/db/dist/esm/query/compiler/evaluators.js 1.26 kB
./packages/db/dist/esm/query/compiler/expressions.js 674 B
./packages/db/dist/esm/query/compiler/group-by.js 1.78 kB
./packages/db/dist/esm/query/compiler/index.js 1.95 kB
./packages/db/dist/esm/query/compiler/joins.js 2 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.17 kB
./packages/db/dist/esm/query/compiler/select.js 1.07 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5 kB
./packages/db/dist/esm/query/live/collection-registry.js 214 B
./packages/db/dist/esm/query/live/collection-subscriber.js 1.77 kB
./packages/db/dist/esm/query/optimizer.js 2.6 kB
./packages/db/dist/esm/scheduler.js 1.21 kB
./packages/db/dist/esm/SortedMap.js 1.18 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 237 B
./packages/db/dist/esm/strategies/queueStrategy.js 418 B
./packages/db/dist/esm/strategies/throttleStrategy.js 236 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 881 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 660 B
./packages/db/dist/esm/utils/index-optimization.js 1.49 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Nov 3, 2025

Size Change: 0 B

Total Size: 3.34 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.17 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.11 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 431 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

## Problem
When using findOne() after join operations, the type became 'never'.
The original fix (commit 5613b5a) used `singleResult: TContext['singleResult']`
but this still had edge case issues with type intersection.

## Root Cause
The MergeContextWithJoinType was setting singleResult to an explicit value
(true or false), which created conflicts when findOne() tried to intersect
with { singleResult: true }.

## Solution
Use a conditional intersection to either include { singleResult: true } or
an empty object {}:

```typescript
} & (TContext['singleResult'] extends true ? { singleResult: true } : {})
```

This ensures:
- When singleResult is true: it's preserved
- When singleResult is not set: the property is completely absent (not undefined/false)
- When findOne() is called after joins: {} & { singleResult: true } = { singleResult: true } ✅

## Changes
- Refined type definition in packages/db/src/query/builder/types.ts:577
- Simplified join.test-d.ts tests to use .not.toBeNever() assertions
- Added direct Discord bug reproduction test
- Updated investigation documentation

## Test Results
✅ All 1413 tests pass (74 test files)
✅ No type errors
✅ Verified with direct reproduction of Discord bug report
@KyleAMathews KyleAMathews requested a review from kevin-dp November 3, 2025 14:24
Renamed test file and updated descriptions to focus on functionality
rather than the source of the bug report. These tests will live in the
codebase long-term and should describe what they test, not where the
bug was originally reported.

Changes:
- Renamed findone-joins-discord-bug.test-d.ts -> findone-joins.test-d.ts
- Updated describe block: 'Discord Bug: findOne() with joins' -> 'findOne() with joins'
- Updated test names to describe functionality being tested
- Updated collection IDs to not reference discord
- Updated comments to be more descriptive
Per technical review feedback:

1. Apply non-distributive conditional pattern
   - Wrap conditional in tuple [TFlag] extends [true] to prevent
     unexpected behavior with union types
   - More robust against future type system changes

2. Extract named utility type (PreserveSingleResultFlag)
   - Improves readability and makes intent crystal clear
   - Easier for future maintainers to understand the pattern

3. Add comprehensive edge case tests
   - rightJoin + findOne()
   - fullJoin + findOne()
   - Multiple joins + findOne()
   - select() + findOne() + joins (both orders)
   - Total: 5 new type tests added

All 1418 tests pass with no type errors.
Updated changeset to show:
- The actual PreserveSingleResultFlag helper type
- Non-distributive conditional pattern [TFlag] extends [true]
- Accurate test count (8 new tests added)

The changeset now matches the actual implementation in the codebase.
@KyleAMathews KyleAMathews moved this to Ready for review in 1.0.0 release Nov 4, 2025
Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot, and I think the implementation is correct. However I don't like that the tests are just asserting that its not never and not that it's of the correct type.

Comment on lines 49 to 55
// Verify query result is properly typed (not never)
type QueryData = typeof query.toArray
type IsNever<T> = [T] extends [never] ? true : false
type DataIsNever = IsNever<QueryData>

expectTypeOf<DataIsNever>().toEqualTypeOf<false>()
expectTypeOf(query.toArray).not.toBeNever()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we assert that the data is exactly the correct type, not that it's not never.

Replace .not.toBeNever() assertions with precise type checks that verify
the exact structure of query results. Now asserting that findOne() with
leftJoin returns Array<{ todo: Todo, todoOptions: TodoOption | undefined }>,
ensuring proper optionality is tested.

Addresses feedback from samwillis on PR #749 requesting tests assert
correct types rather than just checking they're not never.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
KyleAMathews and others added 2 commits November 5, 2025 08:52
Run docs:generate to sync generated documentation with current codebase.
Removes old doc files as part of the regeneration process.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Run pnpm install && pnpm build (db package) && tsx scripts/generateDocs.ts
to properly generate docs from built TypeScript declaration files.

Previous commit incorrectly deleted docs because build step was skipped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@@ -918,3 +918,189 @@ describe(`Join with ArkType Schemas`, () => {
>()
})
})

describe(`findOne() with joins - Type Safety`, () => {
test(`findOne() with leftJoin should not result in never type`, () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the new tests in this file are still asserting not-never and have that in the name.

We also have the new findone-joins.test-d.ts file with what look like sensible tests, these may be redundant?

Update check-docs job to provide clear step-by-step instructions
when generated docs are out of sync. The updated error message now
explicitly shows that builds must happen before doc generation:

1. pnpm install
2. pnpm build (generates .d.ts files needed for docs)
3. pnpm docs:generate (uses built declaration files)

This makes it clear that docs are generated from built TypeScript
declaration files, not directly from source.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@KyleAMathews KyleAMathews force-pushed the claude/investigate-findone-joins-bug-011CUm3DZSa7GZAoQ2LmysZE branch from 15036b3 to 75fd5bd Compare November 5, 2025 16:07
KyleAMathews and others added 5 commits November 5, 2025 09:10
# Conflicts:
#	.github/workflows/pr.yml
#	docs/reference/classes/AggregateFunctionNotInSelectError.md
#	docs/reference/classes/AggregateNotSupportedError.md
#	docs/reference/classes/CannotCombineEmptyExpressionListError.md
#	docs/reference/classes/CollectionConfigurationError.md
#	docs/reference/classes/CollectionInErrorStateError.md
#	docs/reference/classes/CollectionInputNotFoundError.md
#	docs/reference/classes/CollectionIsInErrorStateError.md
#	docs/reference/classes/CollectionOperationError.md
#	docs/reference/classes/CollectionRequiresConfigError.md
#	docs/reference/classes/CollectionRequiresSyncConfigError.md
#	docs/reference/classes/CollectionStateError.md
#	docs/reference/classes/DeleteKeyNotFoundError.md
#	docs/reference/classes/DistinctRequiresSelectError.md
#	docs/reference/classes/DuplicateKeyError.md
#	docs/reference/classes/DuplicateKeySyncError.md
#	docs/reference/classes/EmptyReferencePathError.md
#	docs/reference/classes/GroupByError.md
#	docs/reference/classes/HavingRequiresGroupByError.md
#	docs/reference/classes/InvalidCollectionStatusTransitionError.md
#	docs/reference/classes/InvalidJoinCondition.md
#	docs/reference/classes/InvalidJoinConditionLeftSourceError.md
#	docs/reference/classes/InvalidJoinConditionRightSourceError.md
#	docs/reference/classes/InvalidJoinConditionSameSourceError.md
#	docs/reference/classes/InvalidJoinConditionSourceMismatchError.md
#	docs/reference/classes/InvalidSchemaError.md
#	docs/reference/classes/InvalidSourceError.md
#	docs/reference/classes/InvalidStorageDataFormatError.md
#	docs/reference/classes/InvalidStorageObjectFormatError.md
#	docs/reference/classes/JoinCollectionNotFoundError.md
#	docs/reference/classes/JoinConditionMustBeEqualityError.md
#	docs/reference/classes/JoinError.md
#	docs/reference/classes/KeyUpdateNotAllowedError.md
#	docs/reference/classes/LimitOffsetRequireOrderByError.md
#	docs/reference/classes/LocalStorageCollectionError.md
#	docs/reference/classes/MissingAliasInputsError.md
#	docs/reference/classes/MissingDeleteHandlerError.md
#	docs/reference/classes/MissingHandlerError.md
#	docs/reference/classes/MissingInsertHandlerError.md
#	docs/reference/classes/MissingMutationFunctionError.md
#	docs/reference/classes/MissingUpdateArgumentError.md
#	docs/reference/classes/MissingUpdateHandlerError.md
#	docs/reference/classes/NegativeActiveSubscribersError.md
#	docs/reference/classes/NoKeysPassedToDeleteError.md
#	docs/reference/classes/NoKeysPassedToUpdateError.md
#	docs/reference/classes/NoPendingSyncTransactionCommitError.md
#	docs/reference/classes/NoPendingSyncTransactionWriteError.md
#	docs/reference/classes/NonAggregateExpressionNotInGroupByError.md
#	docs/reference/classes/OnlyOneSourceAllowedError.md
#	docs/reference/classes/QueryBuilderError.md
#	docs/reference/classes/QueryCompilationError.md
#	docs/reference/classes/QueryMustHaveFromClauseError.md
#	docs/reference/classes/QueryOptimizerError.md
#	docs/reference/classes/SchemaMustBeSynchronousError.md
#	docs/reference/classes/SerializationError.md
#	docs/reference/classes/SetWindowRequiresOrderByError.md
#	docs/reference/classes/StorageError.md
#	docs/reference/classes/StorageKeyRequiredError.md
#	docs/reference/classes/SubQueryMustHaveFromClauseError.md
#	docs/reference/classes/SubscriptionNotFoundError.md
#	docs/reference/classes/SyncCleanupError.md
#	docs/reference/classes/SyncTransactionAlreadyCommittedError.md
#	docs/reference/classes/SyncTransactionAlreadyCommittedWriteError.md
#	docs/reference/classes/TanStackDBError.md
#	docs/reference/classes/TransactionAlreadyCompletedRollbackError.md
#	docs/reference/classes/TransactionError.md
#	docs/reference/classes/TransactionNotPendingCommitError.md
#	docs/reference/classes/TransactionNotPendingMutateError.md
#	docs/reference/classes/UndefinedKeyError.md
#	docs/reference/classes/UnknownExpressionTypeError.md
#	docs/reference/classes/UnknownFunctionError.md
#	docs/reference/classes/UnknownHavingExpressionTypeError.md
#	docs/reference/classes/UnsupportedAggregateFunctionError.md
#	docs/reference/classes/UnsupportedFromTypeError.md
#	docs/reference/classes/UnsupportedJoinSourceTypeError.md
#	docs/reference/classes/UnsupportedJoinTypeError.md
#	docs/reference/classes/UpdateKeyNotFoundError.md
#	docs/reference/classes/WhereClauseConversionError.md
#	docs/reference/functions/compileQuery.md
#	docs/reference/functions/createOptimisticAction.md
#	docs/reference/functions/createPacedMutations.md
#	docs/reference/functions/createTransaction.md
#	docs/reference/functions/debounceStrategy.md
#	docs/reference/functions/getActiveTransaction.md
#	docs/reference/functions/throttleStrategy.md
#	docs/reference/index.md
#	docs/reference/interfaces/Transaction.md
#	docs/reference/type-aliases/GetResult.md
#	docs/reference/type-aliases/InferResultType.md
Regenerate docs after merging with main to reflect:
- New useLiveSuspenseQuery hook from React Suspense support
- Updated line numbers in GetResult and InferResultType from type changes
- Removed PowerSync docs (package has build errors)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
- Expand findone-joins.test-d.ts with all findOne() + joins scenarios
  using exact type assertions (innerJoin, rightJoin, fullJoin, multiple
  joins, select combinations)
- Remove redundant "findOne() with joins" section from join.test-d.ts
  that used weak .not.toBeNever() assertions
- All findOne() join tests now verify exact type structures with proper
  optionality checks

Addresses samwillis feedback on PR #749 about redundant tests and
weak type assertions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
PowerSync package builds successfully - the earlier deletion was due to
a transient build failure. Regenerated docs after clean install/build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@KyleAMathews KyleAMathews merged commit 6c55e16 into main Nov 5, 2025
7 checks passed
@KyleAMathews KyleAMathews deleted the claude/investigate-findone-joins-bug-011CUm3DZSa7GZAoQ2LmysZE branch November 5, 2025 17:26
@github-actions github-actions bot mentioned this pull request Nov 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Ready for review

Development

Successfully merging this pull request may close these issues.

4 participants