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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions