Skip to content

Lazy-join Join requires an index warning blames the wrong collection when a subquery's select field traces across an inner join #1494

@vibl

Description

@vibl
  • Validated against @tanstack/db@0.6.5

Describe the bug

In packages/db/src/query/compiler/joins.ts, the lazy-join path emits:

[TanStack DB] [<collectionId>] Join requires an index on "<fieldPath>" for efficient loading. Falling back to loading all data. ...

When the joined side is a subquery, the collectionId in the warning comes from followRefCollection.id — the ultimate source of the join expression traced through the subquery's select. But the subscription that actually failed to optimize is looked up via aliasRemapping[lazyAlias], which resolves to the subquery's innermost .from alias.

When these resolve to different collections (because the subquery's select field comes from a joined side rather than the .from side), the warning names a collection whose advice is typically already satisfied, and hides the real failing subscription.

Data still flows via the fallback full-load, so this is a warning-quality bug, not a correctness bug.

To Reproduce

// packages/db/tests/lazy-join-wrong-collection-id.test.ts
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { createLiveQueryCollection, eq } from '../src/query/index.js'
import { BTreeIndex, createCollection } from '../src/collection/index.js'
import { flushPromises, mockSyncCollectionOptions } from './utils.js'

type A = { id: string }
type B = { id: string; aId: string }

describe('lazy-join warning misattributed when followRef crosses a subquery join', () => {
  let a: ReturnType<typeof createCollection<A>>
  let b: ReturnType<typeof createCollection<B>>
  let warnSpy: ReturnType<typeof vi.spyOn>

  beforeEach(() => {
    a = createCollection(mockSyncCollectionOptions<A>({
      id: 'a', getKey: (r) => r.id,
      autoIndex: 'eager', defaultIndexType: BTreeIndex,
      initialData: [{ id: 'a1' }],
    }))
    b = createCollection(mockSyncCollectionOptions<B>({
      id: 'b', getKey: (r) => r.id,
      initialData: [{ id: 'b1', aId: 'a1' }],
    }))
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
  })

  test('warning blames followRefCollection instead of the failing subscription', async () => {
    const live = createLiveQueryCollection({
      id: 'outer', startSync: true,
      query: (q) => {
        const sub = q
          .from({ innerB: b })
          .leftJoin({ innerA: a }, ({ innerA, innerB }) => eq(innerA.id, innerB.aId))
          .select(({ innerA }) => ({ aId: innerA?.id }))

        return q
          .from({ outerB: b })
          .leftJoin({ sub }, ({ outerB, sub }) => eq(sub.aId, outerB.aId))
          .select(({ outerB }) => ({ id: outerB.id }))
      },
    })

    await flushPromises()

    const warnings = warnSpy.mock.calls.map((c) => String(c[0]))
      .filter((m) => m.includes('Join requires an index'))

    expect(warnings).toHaveLength(1)
    // Warning names `[a]`. But `a` has an auto-indexed `id`; the advice
    // "create an index on a.id" is already satisfied. The actually failing
    // subscription is on `b` (aliasRemapping['sub'] = 'innerB').
    expect(warnings[0]).toContain('[a]')
    expect(warnings[0]).toContain('"id"')
  })
})

Passes against @tanstack/db@0.6.5.

Expected behavior

Either:

  • No warning — the lazy-loader should reach the correct collection's subscription (whichever one can actually satisfy the index lookup).
  • Or a warning whose collection ID names the subscription that actually failed — so the "consider creating an index" advice is actionable.

Additional context

Trace through the compiler for the outer .leftJoin({ sub }, eq(sub.aId, outerB.aId)):

  • followRef(rawQuery, sub.aId, lazySource) walks sub → subquery → select.aId = innerA.idinnerA → collection A. Returns { collection: a, path: ['id'] }. So followRefCollection = a.
  • subQueryResult.collectionId = b (subquery's innermost .from), so aliasRemapping['sub'] = 'innerB', and subscriptions['innerB'] is the b subscription.
  • The tap runs bSubscription.requestSnapshot({ where: inArray(PropRef(['id']), joinKeys), optimizedOnly: true }). b has an id field but no index → findIndexForField(b, ['id']) returns undefinedcanOptimize: falsecurrentStateAsChanges returns undefinedloaded = false → warning with collectionId = followRefCollection.id = 'a'.

I instrumented the warning site to log directly and confirmed the mismatch:

[VIBL-VERIFY] subscription.collection.id=b resolvedAlias=innerB followRefCollection.id=a fieldPath=id

Workaround: swap the subquery's .from so the innermost alias maps to followRefCollection. E.g., q.from({ innerA: a }).leftJoin({ innerB: b }, ...).select(({ innerA }) => ({ aId: innerA.id })). Tested — no warning in that shape.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions