|
| 1 | +# Investigation: findOne() with joins Type Inference Bug |
| 2 | + |
| 3 | +## Problem Summary |
| 4 | + |
| 5 | +When using `findOne()` after join operations in `useLiveQuery`, the type of `query.data` becomes `never`, causing TypeScript errors. This issue was reported on Discord. |
| 6 | + |
| 7 | +### Example Code (Bug) |
| 8 | + |
| 9 | +```typescript |
| 10 | +const query = useLiveQuery( |
| 11 | + (q) => |
| 12 | + q |
| 13 | + .from({ todo: todoCollection }) |
| 14 | + .where(({ todo }) => eq(todo.id, id)) |
| 15 | + .leftJoin({ todoOptions: todoOptionsCollection }, ({ todo, todoOptions }) => |
| 16 | + eq(todo.id, todoOptions.todoId) |
| 17 | + ) |
| 18 | + .findOne() // ❌ Causes type of query.data to become never |
| 19 | +); |
| 20 | +``` |
| 21 | + |
| 22 | +### Workaround |
| 23 | + |
| 24 | +Using `limit(1)` instead of `findOne()` worked correctly: |
| 25 | + |
| 26 | +```typescript |
| 27 | +const query = useLiveQuery( |
| 28 | + (q) => |
| 29 | + q |
| 30 | + .from({ todo: todoCollection }) |
| 31 | + .where(({ todo }) => eq(todo.id, id)) |
| 32 | + .leftJoin({ todoOptions: todoOptionsCollection }, ({ todo, todoOptions }) => |
| 33 | + eq(todo.id, todoOptions.todoId) |
| 34 | + ) |
| 35 | + .limit(1) // ✅ Works correctly |
| 36 | +); |
| 37 | +``` |
| 38 | + |
| 39 | +## Root Cause Analysis |
| 40 | + |
| 41 | +### Type Flow Investigation |
| 42 | + |
| 43 | +1. **After `from()`**: |
| 44 | + - Context has `singleResult: undefined` (property not set) |
| 45 | + |
| 46 | +2. **After `leftJoin()`**: |
| 47 | + - `MergeContextWithJoinType` was explicitly setting `singleResult: false` |
| 48 | + - Line 577 in `/packages/db/src/query/builder/types.ts`: |
| 49 | + ```typescript |
| 50 | + singleResult: TContext['singleResult'] extends true ? true : false |
| 51 | + ``` |
| 52 | + - This forces `singleResult` to `false` when it's not explicitly `true` |
| 53 | + |
| 54 | +3. **After `findOne()`**: |
| 55 | + - `findOne()` returns `QueryBuilder<TContext & SingleResult>` |
| 56 | + - This creates an intersection: `{ singleResult: false } & { singleResult: true }` |
| 57 | + - TypeScript resolves this conflict as `{ singleResult: never }` |
| 58 | + - The `never` type propagates through the type system, breaking type inference |
| 59 | + |
| 60 | +### The Bug |
| 61 | + |
| 62 | +The issue was in the `MergeContextWithJoinType` type definition, which was forcing `singleResult` to be explicitly `false` instead of preserving its original value. |
| 63 | + |
| 64 | +**Before (Buggy)**: |
| 65 | +```typescript |
| 66 | +export type MergeContextWithJoinType< |
| 67 | + TContext extends Context, |
| 68 | + TNewSchema extends ContextSchema, |
| 69 | + TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, |
| 70 | +> = { |
| 71 | + baseSchema: TContext[`baseSchema`] |
| 72 | + schema: ApplyJoinOptionalityToMergedSchema<...> |
| 73 | + fromSourceName: TContext[`fromSourceName`] |
| 74 | + hasJoins: true |
| 75 | + joinTypes: (TContext[`joinTypes`] extends Record<string, any> |
| 76 | + ? TContext[`joinTypes`] |
| 77 | + : {}) & { |
| 78 | + [K in keyof TNewSchema & string]: TJoinType |
| 79 | + } |
| 80 | + result: TContext[`result`] |
| 81 | + singleResult: TContext[`singleResult`] extends true ? true : false // ❌ BUG HERE |
| 82 | +} |
| 83 | +``` |
| 84 | + |
| 85 | +## The Fix |
| 86 | + |
| 87 | +Changed line 577 in `/packages/db/src/query/builder/types.ts` to preserve the `singleResult` value as-is: |
| 88 | + |
| 89 | +**After (Fixed)**: |
| 90 | +```typescript |
| 91 | +export type MergeContextWithJoinType< |
| 92 | + TContext extends Context, |
| 93 | + TNewSchema extends ContextSchema, |
| 94 | + TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, |
| 95 | +> = { |
| 96 | + baseSchema: TContext[`baseSchema`] |
| 97 | + schema: ApplyJoinOptionalityToMergedSchema<...> |
| 98 | + fromSourceName: TContext[`fromSourceName`] |
| 99 | + hasJoins: true |
| 100 | + joinTypes: (TContext[`joinTypes`] extends Record<string, any> |
| 101 | + ? TContext[`joinTypes`] |
| 102 | + : {}) & { |
| 103 | + [K in keyof TNewSchema & string]: TJoinType |
| 104 | + } |
| 105 | + result: TContext[`result`] |
| 106 | + singleResult: TContext[`singleResult`] // ✅ FIXED: Preserve value as-is |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +### Why This Works |
| 111 | + |
| 112 | +By preserving `singleResult` as-is: |
| 113 | +- If `findOne()` is called **before** join: `singleResult` is `true` and stays `true` |
| 114 | +- If `findOne()` is called **after** join: `singleResult` is `undefined` and the intersection `undefined & { singleResult: true }` properly resolves to `{ singleResult: true }` |
| 115 | +- No type conflict occurs |
| 116 | + |
| 117 | +## Test Coverage Added |
| 118 | + |
| 119 | +Added comprehensive type tests in `/packages/db/tests/query/join.test-d.ts`: |
| 120 | + |
| 121 | +1. `findOne()` with `leftJoin` - returns single result with optional right table |
| 122 | +2. `findOne()` with `innerJoin` - returns single result with both tables required |
| 123 | +3. `findOne()` with `rightJoin` - returns single result with optional left table |
| 124 | +4. `findOne()` with `fullJoin` - returns single result with both tables optional |
| 125 | +5. `findOne()` with multiple joins - handles complex optionality correctly |
| 126 | +6. `findOne()` with join and select - projects correctly |
| 127 | +7. `findOne()` before join - works correctly in reverse order |
| 128 | +8. `limit(1)` vs `findOne()` - confirms different return types |
| 129 | + |
| 130 | +## Impact |
| 131 | + |
| 132 | +This fix ensures that: |
| 133 | +- ✅ `findOne()` works correctly with all join types (left, right, inner, full) |
| 134 | +- ✅ Type inference works correctly for `query.data` in `useLiveQuery` |
| 135 | +- ✅ No breaking changes to existing code |
| 136 | +- ✅ Both `findOne()` before and after joins work correctly |
| 137 | + |
| 138 | +## Files Modified |
| 139 | + |
| 140 | +1. `/packages/db/src/query/builder/types.ts` (line 577) - Fixed type definition |
| 141 | +2. `/packages/db/tests/query/join.test-d.ts` - Added 8 new type tests for findOne with joins |
| 142 | + |
| 143 | +## Related Types |
| 144 | + |
| 145 | +- `SingleResult`: `{ singleResult: true }` |
| 146 | +- `NonSingleResult`: `{ singleResult?: never }` |
| 147 | +- `Context`: Interface with optional `singleResult?: boolean` |
| 148 | +- `InferResultType<TContext>`: Determines if result is `T | undefined` or `Array<T>` |
0 commit comments