From e93ad71d22d2d283a10535214775c22ca008a34b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 21:06:26 +0000 Subject: [PATCH 1/5] docs: add investigation for passing join info to queryFn in on-demand mode Investigates the pagination issue when using queryCollection with joins in on-demand syncMode. Documents the root cause (LoadSubsetOptions lacks join information) and proposes extending the type to include join details so backends can perform server-side joins before pagination. --- INVESTIGATION-join-pagination-queryFn.md | 249 +++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 INVESTIGATION-join-pagination-queryFn.md diff --git a/INVESTIGATION-join-pagination-queryFn.md b/INVESTIGATION-join-pagination-queryFn.md new file mode 100644 index 000000000..80fcdd372 --- /dev/null +++ b/INVESTIGATION-join-pagination-queryFn.md @@ -0,0 +1,249 @@ +# Investigation: Passing Join Information to queryFn in On-Demand Mode + +## Problem Statement + +When using `queryCollection` in on-demand mode with joins and pagination, the pagination is applied **before** join filters are evaluated, leading to inconsistent page sizes or empty results. + +**User's scenario:** +- `tasksCollection` (queryCollection, on-demand) with `limit: 10` +- Joined with `accountsCollection` +- Filter on `account.name = 'example'` + +**What happens:** +1. `tasksCollection.queryFn` receives `{ limit: 10, where: }` +2. Backend returns 10 tasks +3. Client-side join with `accountsCollection` +4. Account filter applied client-side +5. Result: Only 6 tasks match (page size inconsistent) + +## Root Cause + +### Current `LoadSubsetOptions` Structure + +```typescript +// packages/db/src/types.ts:285-312 +export type LoadSubsetOptions = { + where?: BasicExpression // Filters for THIS collection only + orderBy?: OrderBy // OrderBy for THIS collection only + limit?: number + cursor?: CursorExpressions + offset?: number + subscription?: Subscription +} +``` + +**Key limitation:** No fields for join information, cross-collection filters, or cross-collection ordering. + +### How Filters Are Currently Partitioned + +The optimizer (`packages/db/src/query/optimizer.ts`) already partitions WHERE clauses: + +```typescript +interface GroupedWhereClauses { + singleSource: Map> // Per-collection filters + multiSource?: BasicExpression // Cross-collection filters (e.g., joins) +} +``` + +- **Single-source clauses** (e.g., `eq(task.status, 'active')`) → passed to that collection's subscription +- **Multi-source clauses** (e.g., `eq(task.account_id, account.id)`) → applied in the D2 pipeline after join + +The problem: When `tasksCollection` calls `loadSubset`, it has no knowledge of: +1. The join with `accountsCollection` +2. Filters that apply to `accountsCollection` (e.g., `eq(account.name, 'example')`) +3. That results will be filtered after the join + +### Code Flow + +``` +CollectionSubscriber.subscribe() + ↓ +getWhereClauseForAlias() → only returns single-source filters for THIS alias + ↓ +subscribeToOrderedChanges() + ↓ +requestLimitedSnapshot({ limit: 10, where: }) + ↓ +loadSubset({ limit: 10, where: }) // No join info! + ↓ +queryFn receives: { limit: 10, where: } +``` + +**Key files:** +- `packages/db/src/collection/subscription.ts:587-595` - constructs LoadSubsetOptions +- `packages/db/src/query/live/collection-subscriber.ts:380-387` - getWhereClauseForAlias +- `packages/db/src/query/optimizer.ts:228-258` - extractSourceWhereClauses + +## Proposed Solution + +### Extend `LoadSubsetOptions` with Join Information + +```typescript +export type LoadSubsetOptions = { + // Existing fields + where?: BasicExpression + orderBy?: OrderBy + limit?: number + cursor?: CursorExpressions + offset?: number + subscription?: Subscription + + // NEW: Join information for server-side query construction + joins?: Array +} + +export type JoinInfo = { + /** The collection being joined */ + collectionId: string + + /** Join type */ + type: 'inner' | 'left' | 'right' | 'full' | 'cross' + + /** Join key from this collection (e.g., task.account_id) */ + localKey: BasicExpression + + /** Join key from joined collection (e.g., account.id) */ + foreignKey: BasicExpression + + /** Filters that apply to the joined collection */ + where?: BasicExpression + + /** OrderBy expressions from the joined collection (if ordering by joined fields) */ + orderBy?: OrderBy +} +``` + +### Implementation Changes Required + +#### 1. Extract Join Info During Query Compilation + +In `packages/db/src/query/compiler/index.ts`, when processing joins: + +```typescript +// After processJoins(), collect join info for each collection +const joinInfoByCollection = new Map() + +for (const joinClause of query.join) { + const mainCollectionId = getCollectionId(query.from) + const joinedCollectionId = getCollectionId(joinClause.from) + + // Get filters that apply to the joined collection + const joinedFilters = sourceWhereClauses.get(joinedAlias) + + const joinInfo: JoinInfo = { + collectionId: joinedCollectionId, + type: joinClause.type, + localKey: joinClause.left, // e.g., task.account_id + foreignKey: joinClause.right, // e.g., account.id + where: joinedFilters, + } + + // Store for the main collection + if (!joinInfoByCollection.has(mainCollectionId)) { + joinInfoByCollection.set(mainCollectionId, []) + } + joinInfoByCollection.get(mainCollectionId)!.push(joinInfo) +} +``` + +#### 2. Pass Join Info to CollectionSubscriber + +In `packages/db/src/query/live/collection-config-builder.ts`: + +```typescript +// Store join info alongside sourceWhereClauses +this.joinInfoCache = compilation.joinInfoByCollection +``` + +#### 3. Include Join Info in loadSubset Call + +In `packages/db/src/collection/subscription.ts:587-595`: + +```typescript +const loadOptions: LoadSubsetOptions = { + where, + limit, + orderBy, + cursor: cursorExpressions, + offset: offset ?? currentOffset, + subscription: this, + // NEW: Include join info if available + joins: this.joinInfo, +} +``` + +#### 4. Update queryCollection to Use Join Info + +In `packages/query-db-collection/src/query.ts`, the `queryFn` would receive join info via `context.meta.loadSubsetOptions.joins` and can construct a proper server-side query: + +```typescript +queryFn: async (context) => { + const opts = context.meta.loadSubsetOptions + + if (opts.joins?.length) { + // Construct a query that joins server-side + // e.g., for Drizzle: + // db.select().from(tasks) + // .innerJoin(accounts, eq(tasks.accountId, accounts.id)) + // .where(and(taskFilters, accountFilters)) + // .orderBy(...) + // .limit(opts.limit) + } else { + // Simple query without joins + } +} +``` + +## Alternative Approaches + +### 1. Deoptimize Joins with Pagination + +When the main collection has `limit/offset` AND there are joins with filters, load the entire lazy collection state instead of using lazy loading. This ensures all data is available client-side before filtering. + +**Pros:** Simpler implementation, no changes to LoadSubsetOptions +**Cons:** Poor performance for large collections, defeats purpose of on-demand mode + +### 2. Iterative Loading + +When the result set is smaller than `limit` after join filtering, automatically request more data. + +**Pros:** Works with existing API +**Cons:** Multiple round trips, poor UX (results "growing"), hard to implement correctly + +### 3. Estimated Overfetch + +Pass an overfetch factor based on estimated join selectivity. + +**Pros:** Simple +**Cons:** Unreliable, wastes bandwidth, still may not return enough results + +## Recommendation + +Implement **Option 1: Extend LoadSubsetOptions with Join Information** + +This is the most robust solution because: + +1. **Server-side efficiency** - The server can perform the join and filter before pagination, returning exactly `limit` matching results +2. **Works with existing backends** - SQL databases, ORMs like Drizzle/Prisma, and GraphQL all support server-side joins +3. **Preserves on-demand semantics** - Only loads data that's actually needed +4. **Future-proof** - Can be extended for more complex scenarios (nested joins, aggregates) + +## Implementation Effort + +- **Types:** Add `JoinInfo` type and extend `LoadSubsetOptions` (~20 lines) +- **Compiler:** Extract join info during compilation (~50 lines) +- **Subscription:** Pass join info to loadSubset (~30 lines) +- **queryCollection:** Document how to use join info in queryFn (docs) +- **Tests:** Add tests for join info extraction and passing (~100 lines) + +Estimated: ~200 lines of core implementation + tests + documentation + +## Questions for Discussion + +1. Should `JoinInfo.where` include only single-source filters for the joined collection, or all filters that touch it? + +2. How should multi-level joins be represented? (e.g., tasks → accounts → organizations) + +3. Should there be a way to opt-out of join info passing for simple use cases? + +4. How should this interact with subqueries? (e.g., `.from({ user: subquery })`) From b4c80e10796ffb196ee8c1e8d01bcb136a28e144 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 21:27:17 +0000 Subject: [PATCH 2/5] feat: pass join information to queryFn in on-demand mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enables server-side joins before pagination for queryCollection, solving the issue where pagination was applied before join filters, leading to inconsistent page sizes. Changes: - Add JoinInfo type with collection ID, alias, type, keys, where, orderBy - Extend LoadSubsetOptions with optional joins array - Extract join info during query optimization (extractJoinInfo) - Pass join info through CompilationResult → CollectionConfigBuilder - Include join info when creating subscriptions - Pass joins in loadSubset calls from CollectionSubscription - Serialize joins in query keys for queryCollection The queryFn now receives join information in context.meta.loadSubsetOptions.joins, allowing construction of server-side join queries that filter before pagination. Closes: Investigation for passing join info to queryFn in on-demand mode --- INVESTIGATION-join-pagination-queryFn.md | 289 ++++++------------ packages/db/src/collection/subscription.ts | 9 + packages/db/src/query/compiler/index.ts | 13 +- .../query/live/collection-config-builder.ts | 8 + .../src/query/live/collection-subscriber.ts | 24 ++ packages/db/src/query/optimizer.ts | 159 +++++++++- packages/db/src/types.ts | 59 ++++ packages/db/tests/query/optimizer.test.ts | 201 ++++++++++++ .../query-db-collection/src/serialization.ts | 22 ++ 9 files changed, 591 insertions(+), 193 deletions(-) diff --git a/INVESTIGATION-join-pagination-queryFn.md b/INVESTIGATION-join-pagination-queryFn.md index 80fcdd372..14fd9f147 100644 --- a/INVESTIGATION-join-pagination-queryFn.md +++ b/INVESTIGATION-join-pagination-queryFn.md @@ -1,249 +1,156 @@ # Investigation: Passing Join Information to queryFn in On-Demand Mode +**Status: IMPLEMENTED** + ## Problem Statement -When using `queryCollection` in on-demand mode with joins and pagination, the pagination is applied **before** join filters are evaluated, leading to inconsistent page sizes or empty results. +When using `queryCollection` in on-demand mode with joins and pagination, the pagination was applied **before** join filters were evaluated, leading to inconsistent page sizes or empty results. **User's scenario:** - `tasksCollection` (queryCollection, on-demand) with `limit: 10` - Joined with `accountsCollection` - Filter on `account.name = 'example'` -**What happens:** -1. `tasksCollection.queryFn` receives `{ limit: 10, where: }` -2. Backend returns 10 tasks +**What was happening:** +1. `tasksCollection.queryFn` received `{ limit: 10, where: }` +2. Backend returned 10 tasks 3. Client-side join with `accountsCollection` 4. Account filter applied client-side 5. Result: Only 6 tasks match (page size inconsistent) -## Root Cause - -### Current `LoadSubsetOptions` Structure +## Solution Implemented -```typescript -// packages/db/src/types.ts:285-312 -export type LoadSubsetOptions = { - where?: BasicExpression // Filters for THIS collection only - orderBy?: OrderBy // OrderBy for THIS collection only - limit?: number - cursor?: CursorExpressions - offset?: number - subscription?: Subscription -} -``` +We extended `LoadSubsetOptions` to include join information, allowing the sync layer to construct server-side join queries that filter before pagination. -**Key limitation:** No fields for join information, cross-collection filters, or cross-collection ordering. - -### How Filters Are Currently Partitioned - -The optimizer (`packages/db/src/query/optimizer.ts`) already partitions WHERE clauses: +### New `JoinInfo` Type ```typescript -interface GroupedWhereClauses { - singleSource: Map> // Per-collection filters - multiSource?: BasicExpression // Cross-collection filters (e.g., joins) +// packages/db/src/types.ts +export type JoinInfo = { + /** The ID of the collection being joined */ + collectionId: string + /** The alias used for the joined collection in the query */ + alias: string + /** The type of join to perform */ + type: `inner` | `left` | `right` | `full` | `cross` + /** The join key expression from the main collection (e.g., task.account_id) */ + localKey: BasicExpression + /** The join key expression from the joined collection (e.g., account.id) */ + foreignKey: BasicExpression + /** Filters that apply to the joined collection */ + where?: BasicExpression + /** OrderBy expressions that reference the joined collection */ + orderBy?: OrderBy } ``` -- **Single-source clauses** (e.g., `eq(task.status, 'active')`) → passed to that collection's subscription -- **Multi-source clauses** (e.g., `eq(task.account_id, account.id)`) → applied in the D2 pipeline after join - -The problem: When `tasksCollection` calls `loadSubset`, it has no knowledge of: -1. The join with `accountsCollection` -2. Filters that apply to `accountsCollection` (e.g., `eq(account.name, 'example')`) -3. That results will be filtered after the join - -### Code Flow - -``` -CollectionSubscriber.subscribe() - ↓ -getWhereClauseForAlias() → only returns single-source filters for THIS alias - ↓ -subscribeToOrderedChanges() - ↓ -requestLimitedSnapshot({ limit: 10, where: }) - ↓ -loadSubset({ limit: 10, where: }) // No join info! - ↓ -queryFn receives: { limit: 10, where: } -``` - -**Key files:** -- `packages/db/src/collection/subscription.ts:587-595` - constructs LoadSubsetOptions -- `packages/db/src/query/live/collection-subscriber.ts:380-387` - getWhereClauseForAlias -- `packages/db/src/query/optimizer.ts:228-258` - extractSourceWhereClauses - -## Proposed Solution - -### Extend `LoadSubsetOptions` with Join Information +### Extended `LoadSubsetOptions` ```typescript export type LoadSubsetOptions = { - // Existing fields where?: BasicExpression orderBy?: OrderBy limit?: number cursor?: CursorExpressions offset?: number subscription?: Subscription - // NEW: Join information for server-side query construction joins?: Array } +``` -export type JoinInfo = { - /** The collection being joined */ - collectionId: string - - /** Join type */ - type: 'inner' | 'left' | 'right' | 'full' | 'cross' - - /** Join key from this collection (e.g., task.account_id) */ - localKey: BasicExpression - - /** Join key from joined collection (e.g., account.id) */ - foreignKey: BasicExpression - - /** Filters that apply to the joined collection */ - where?: BasicExpression +## Implementation Details - /** OrderBy expressions from the joined collection (if ordering by joined fields) */ - orderBy?: OrderBy -} -``` +### 1. Join Info Extraction (`packages/db/src/query/optimizer.ts`) -### Implementation Changes Required +Added `extractJoinInfo()` function that: +- Analyzes query IR to extract join clause information +- Associates filters from `sourceWhereClauses` with their respective joins +- Associates orderBy expressions with their respective joins +- Returns a map of main source alias → Array -#### 1. Extract Join Info During Query Compilation +### 2. Compilation Pipeline (`packages/db/src/query/compiler/index.ts`) -In `packages/db/src/query/compiler/index.ts`, when processing joins: +- `CompilationResult` now includes `joinInfoBySource` +- `compileQuery()` passes through join info from optimizer -```typescript -// After processJoins(), collect join info for each collection -const joinInfoByCollection = new Map() - -for (const joinClause of query.join) { - const mainCollectionId = getCollectionId(query.from) - const joinedCollectionId = getCollectionId(joinClause.from) - - // Get filters that apply to the joined collection - const joinedFilters = sourceWhereClauses.get(joinedAlias) - - const joinInfo: JoinInfo = { - collectionId: joinedCollectionId, - type: joinClause.type, - localKey: joinClause.left, // e.g., task.account_id - foreignKey: joinClause.right, // e.g., account.id - where: joinedFilters, - } +### 3. Live Query Builder (`packages/db/src/query/live/collection-config-builder.ts`) - // Store for the main collection - if (!joinInfoByCollection.has(mainCollectionId)) { - joinInfoByCollection.set(mainCollectionId, []) - } - joinInfoByCollection.get(mainCollectionId)!.push(joinInfo) -} -``` +- Added `joinInfoBySourceCache` to store join info +- Populated during compilation -#### 2. Pass Join Info to CollectionSubscriber +### 4. Collection Subscriber (`packages/db/src/query/live/collection-subscriber.ts`) -In `packages/db/src/query/live/collection-config-builder.ts`: +- Added `getJoinInfoForAlias()` method +- Passes join info when creating subscriptions -```typescript -// Store join info alongside sourceWhereClauses -this.joinInfoCache = compilation.joinInfoByCollection -``` +### 5. Collection Subscription (`packages/db/src/collection/subscription.ts`) -#### 3. Include Join Info in loadSubset Call +- `CollectionSubscriptionOptions` now accepts `joinInfo` +- `requestLimitedSnapshot()` includes `joins` in `LoadSubsetOptions` -In `packages/db/src/collection/subscription.ts:587-595`: +### 6. Serialization (`packages/query-db-collection/src/serialization.ts`) -```typescript -const loadOptions: LoadSubsetOptions = { - where, - limit, - orderBy, - cursor: cursorExpressions, - offset: offset ?? currentOffset, - subscription: this, - // NEW: Include join info if available - joins: this.joinInfo, -} -``` +- `serializeLoadSubsetOptions()` now serializes join info for query key generation -#### 4. Update queryCollection to Use Join Info +## Usage in queryFn -In `packages/query-db-collection/src/query.ts`, the `queryFn` would receive join info via `context.meta.loadSubsetOptions.joins` and can construct a proper server-side query: +Now `queryFn` can access join information: ```typescript queryFn: async (context) => { const opts = context.meta.loadSubsetOptions if (opts.joins?.length) { - // Construct a query that joins server-side - // e.g., for Drizzle: - // db.select().from(tasks) - // .innerJoin(accounts, eq(tasks.accountId, accounts.id)) - // .where(and(taskFilters, accountFilters)) - // .orderBy(...) - // .limit(opts.limit) - } else { - // Simple query without joins + // Construct a query with server-side joins + // Example with Drizzle: + let query = db.select().from(tasks) + + for (const join of opts.joins) { + // Add join based on join.type, join.localKey, join.foreignKey + query = query.innerJoin( + accounts, + eq(tasks.accountId, accounts.id) + ) + + // Apply joined collection's filter + if (join.where) { + query = query.where(/* translate join.where to Drizzle */) + } + } + + // Apply main collection's filter + if (opts.where) { + query = query.where(/* translate opts.where to Drizzle */) + } + + return query.limit(opts.limit).offset(opts.offset) } + + // Simple query without joins (existing behavior) + return db.select().from(tasks) + .where(/* opts.where */) + .limit(opts.limit) } ``` -## Alternative Approaches - -### 1. Deoptimize Joins with Pagination - -When the main collection has `limit/offset` AND there are joins with filters, load the entire lazy collection state instead of using lazy loading. This ensures all data is available client-side before filtering. - -**Pros:** Simpler implementation, no changes to LoadSubsetOptions -**Cons:** Poor performance for large collections, defeats purpose of on-demand mode - -### 2. Iterative Loading - -When the result set is smaller than `limit` after join filtering, automatically request more data. - -**Pros:** Works with existing API -**Cons:** Multiple round trips, poor UX (results "growing"), hard to implement correctly - -### 3. Estimated Overfetch - -Pass an overfetch factor based on estimated join selectivity. - -**Pros:** Simple -**Cons:** Unreliable, wastes bandwidth, still may not return enough results - -## Recommendation - -Implement **Option 1: Extend LoadSubsetOptions with Join Information** - -This is the most robust solution because: - -1. **Server-side efficiency** - The server can perform the join and filter before pagination, returning exactly `limit` matching results -2. **Works with existing backends** - SQL databases, ORMs like Drizzle/Prisma, and GraphQL all support server-side joins -3. **Preserves on-demand semantics** - Only loads data that's actually needed -4. **Future-proof** - Can be extended for more complex scenarios (nested joins, aggregates) - -## Implementation Effort - -- **Types:** Add `JoinInfo` type and extend `LoadSubsetOptions` (~20 lines) -- **Compiler:** Extract join info during compilation (~50 lines) -- **Subscription:** Pass join info to loadSubset (~30 lines) -- **queryCollection:** Document how to use join info in queryFn (docs) -- **Tests:** Add tests for join info extraction and passing (~100 lines) - -Estimated: ~200 lines of core implementation + tests + documentation - -## Questions for Discussion - -1. Should `JoinInfo.where` include only single-source filters for the joined collection, or all filters that touch it? - -2. How should multi-level joins be represented? (e.g., tasks → accounts → organizations) - -3. Should there be a way to opt-out of join info passing for simple use cases? - -4. How should this interact with subqueries? (e.g., `.from({ user: subquery })`) +## Files Changed + +- `packages/db/src/types.ts` - Added `JoinInfo` type, extended `LoadSubsetOptions` and `SubscribeChangesOptions` +- `packages/db/src/query/optimizer.ts` - Added `extractJoinInfo()`, updated `OptimizationResult` +- `packages/db/src/query/compiler/index.ts` - Added `joinInfoBySource` to `CompilationResult` +- `packages/db/src/query/live/collection-config-builder.ts` - Added `joinInfoBySourceCache` +- `packages/db/src/query/live/collection-subscriber.ts` - Added `getJoinInfoForAlias()` +- `packages/db/src/collection/subscription.ts` - Pass join info in `loadSubset` calls +- `packages/query-db-collection/src/serialization.ts` - Serialize joins in query keys +- `packages/db/tests/query/optimizer.test.ts` - Added tests for join info extraction + +## Test Coverage + +Added tests in `packages/db/tests/query/optimizer.test.ts`: +- Empty map for queries without joins +- Basic inner join extraction +- WHERE clause inclusion for joined collections +- OrderBy inclusion for joined collections +- Multiple joins handling +- Swapped key expression handling diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 44c9af49f..73bb4a085 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -12,6 +12,7 @@ import type { BasicExpression, OrderBy } from '../query/ir.js' import type { IndexInterface } from '../indexes/base-index.js' import type { ChangeMessage, + JoinInfo, LoadSubsetOptions, Subscription, SubscriptionEvents, @@ -45,6 +46,12 @@ type CollectionSubscriptionOptions = { whereExpression?: BasicExpression /** Callback to call when the subscription is unsubscribed */ onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void + /** + * Join information for server-side query construction. + * When present, this is included in loadSubset calls so the sync layer + * can perform joins before pagination. + */ + joinInfo?: Array } export class CollectionSubscription @@ -591,6 +598,8 @@ export class CollectionSubscription cursor: cursorExpressions, // Cursor expressions passed separately offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this, + // Include join info for server-side query construction + joins: this.options.joinInfo, } const syncResult = this.collection._sync.loadSubset(loadOptions) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 30513ba85..6ba03e8fa 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -25,6 +25,7 @@ import type { import type { LazyCollectionCallbacks } from './joins.js' import type { Collection } from '../../collection/index.js' import type { + JoinInfo, KeyedStream, NamespacedAndKeyedStream, ResultStream, @@ -67,6 +68,13 @@ export interface CompilationResult { * the inner aliases where collection subscriptions were created. */ aliasRemapping: Record + + /** + * Map of main source aliases to their join information for server-side query construction. + * This enables the sync layer to perform joins before pagination, ensuring consistent page sizes + * when filtering by joined collection properties. + */ + joinInfoBySource: Map> } /** @@ -106,7 +114,8 @@ export function compileQuery( validateQueryStructure(rawQuery) // Optimize the query before compilation - const { optimizedQuery: query, sourceWhereClauses } = optimizeQuery(rawQuery) + const { optimizedQuery: query, sourceWhereClauses, joinInfoBySource } = + optimizeQuery(rawQuery) // Create mapping from optimized query to original for caching queryMapping.set(query, rawQuery) @@ -345,6 +354,7 @@ export function compileQuery( sourceWhereClauses, aliasToCollectionId, aliasRemapping, + joinInfoBySource, } cache.set(rawQuery, compilationResult) @@ -375,6 +385,7 @@ export function compileQuery( sourceWhereClauses, aliasToCollectionId, aliasRemapping, + joinInfoBySource, } cache.set(rawQuery, compilationResult) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 53f6d13a9..ad6d840ee 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -19,6 +19,7 @@ import type { OrderByOptimizationInfo } from '../compiler/order-by.js' import type { Collection } from '../../collection/index.js' import type { CollectionConfigSingleRowOption, + JoinInfo, KeyedStream, ResultStream, StringCollationConfig, @@ -136,6 +137,11 @@ export class CollectionConfigBuilder< | Map> | undefined + // Map of source aliases to their join info for server-side query construction + public joinInfoBySourceCache: + | Map> + | undefined + // Map of source alias to subscription readonly subscriptions: Record = {} // Map of source aliases to functions that load keys for that lazy source @@ -617,6 +623,7 @@ export class CollectionConfigBuilder< this.inputsCache = undefined this.pipelineCache = undefined this.sourceWhereClausesCache = undefined + this.joinInfoBySourceCache = undefined // Reset lazy source alias state this.lazySources.clear() @@ -664,6 +671,7 @@ export class CollectionConfigBuilder< this.pipelineCache = compilation.pipeline this.sourceWhereClausesCache = compilation.sourceWhereClauses + this.joinInfoBySourceCache = compilation.joinInfoBySource this.compiledAliasToCollectionId = compilation.aliasToCollectionId // Defensive check: verify all compiled aliases have corresponding inputs diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ec4876b74..db6d49e92 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -7,6 +7,7 @@ import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm' import type { Collection } from '../../collection/index.js' import type { ChangeMessage, + JoinInfo, SubscriptionStatusChangeEvent, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' @@ -197,6 +198,9 @@ export class CollectionSubscriber< this.sendChangesToPipeline(changes) } + // Get join info if this is the main source in a join query + const joinInfo = this.getJoinInfoForAlias() + // Create subscription with onStatusChange - listener is registered before snapshot // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false // which is the default behavior in subscribeChanges @@ -204,6 +208,7 @@ export class CollectionSubscriber< ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, + joinInfo, }) return subscription @@ -230,11 +235,15 @@ export class CollectionSubscriber< ) } + // Get join info if this is the main source in a join query + const joinInfo = this.getJoinInfoForAlias() + // Subscribe to changes with onStatusChange - listener is registered before any snapshot // values bigger than what we've sent don't need to be sent because they can't affect the topK const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, onStatusChange, + joinInfo, }) subscriptionHolder.current = subscription @@ -386,6 +395,21 @@ export class CollectionSubscriber< return sourceWhereClausesCache.get(this.alias) } + /** + * Gets join info for this alias if it's the main source in a join query. + * Join info is only returned for the main collection (FROM clause), + * not for joined collections, since only the main collection needs + * to know about joins for server-side query construction. + */ + private getJoinInfoForAlias(): Array | undefined { + const joinInfoBySourceCache = + this.collectionConfigBuilder.joinInfoBySourceCache + if (!joinInfoBySourceCache) { + return undefined + } + return joinInfoBySourceCache.get(this.alias) + } + private getOrderByInfo(): OrderByOptimizationInfo | undefined { const info = this.collectionConfigBuilder.optimizableOrderByCollections[ diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index e1020c284..1142171ef 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -131,7 +131,15 @@ import { getWhereExpression, isResidualWhere, } from './ir.js' -import type { BasicExpression, From, QueryIR, Select, Where } from './ir.js' +import type { + BasicExpression, + From, + OrderBy, + QueryIR, + Select, + Where, +} from './ir.js' +import type { JoinInfo } from '../types.js' /** * Represents a WHERE clause after source analysis @@ -163,6 +171,11 @@ export interface OptimizationResult { optimizedQuery: QueryIR /** Map of source aliases to their extracted WHERE clauses for index optimization */ sourceWhereClauses: Map> + /** + * Map of main source aliases to their join information for server-side query construction. + * This enables the sync layer to perform joins before pagination, ensuring consistent page sizes. + */ + joinInfoBySource: Map> } /** @@ -192,6 +205,10 @@ export function optimizeQuery(query: QueryIR): OptimizationResult { // First, extract source WHERE clauses before optimization const sourceWhereClauses = extractSourceWhereClauses(query) + // Extract join info for server-side query construction + // This must be done on the original query before optimization + const joinInfoBySource = extractJoinInfo(query, sourceWhereClauses) + // Apply multi-level predicate pushdown with iterative convergence let optimized = query let previousOptimized: QueryIR | undefined @@ -214,6 +231,7 @@ export function optimizeQuery(query: QueryIR): OptimizationResult { return { optimizedQuery: cleaned, sourceWhereClauses, + joinInfoBySource, } } @@ -257,6 +275,145 @@ function extractSourceWhereClauses( return sourceWhereClauses } +/** + * Extracts join information from a query for server-side query construction. + * This allows the sync layer to perform joins before pagination, + * ensuring consistent page sizes when filtering by joined collection properties. + * + * @param query - The original QueryIR to analyze + * @param sourceWhereClauses - Pre-computed WHERE clauses by source alias + * @returns Map of main source alias to array of JoinInfo for that source's joins + */ +export function extractJoinInfo( + query: QueryIR, + sourceWhereClauses: Map>, +): Map> { + const joinInfoBySource = new Map>() + + // No joins means no join info to extract + if (!query.join || query.join.length === 0) { + return joinInfoBySource + } + + const mainAlias = query.from.alias + + // Extract orderBy expressions that reference each alias + const orderByByAlias = extractOrderByByAlias(query.orderBy) + + for (const joinClause of query.join) { + const joinedFrom = joinClause.from + const joinedAlias = joinedFrom.alias + + // Get the collection ID - for queryRef, we need to traverse to find the innermost collection + let collectionId: string + if (joinedFrom.type === `collectionRef`) { + collectionId = joinedFrom.collection.id + } else { + // For subqueries, get the innermost collection ID + collectionId = getInnermostCollectionId(joinedFrom.query) + } + + // Determine which expression belongs to which side of the join + const { localKey, foreignKey } = analyzeJoinKeys( + joinClause.left, + joinClause.right, + mainAlias, + joinedAlias, + ) + + // Map join type to JoinInfo type (handle 'outer' as 'full') + const joinType = joinClause.type === `outer` ? `full` : joinClause.type + + const joinInfo: JoinInfo = { + collectionId, + alias: joinedAlias, + type: joinType, + localKey, + foreignKey, + // Include any WHERE clause that applies to the joined collection + where: sourceWhereClauses.get(joinedAlias), + // Include any orderBy that references the joined collection + orderBy: orderByByAlias.get(joinedAlias), + } + + // Store join info keyed by the main source alias + if (!joinInfoBySource.has(mainAlias)) { + joinInfoBySource.set(mainAlias, []) + } + joinInfoBySource.get(mainAlias)!.push(joinInfo) + } + + return joinInfoBySource +} + +/** + * Extracts orderBy expressions grouped by the alias they reference. + */ +function extractOrderByByAlias( + orderBy: OrderBy | undefined, +): Map { + const result = new Map() + + if (!orderBy || orderBy.length === 0) { + return result + } + + for (const clause of orderBy) { + const alias = getExpressionAlias(clause.expression) + if (alias) { + if (!result.has(alias)) { + result.set(alias, []) + } + result.get(alias)!.push(clause) + } + } + + return result +} + +/** + * Gets the alias (first path element) from an expression if it's a PropRef. + */ +function getExpressionAlias(expr: BasicExpression): string | undefined { + if (expr.type === `ref` && expr.path.length > 0) { + return expr.path[0] + } + return undefined +} + +/** + * Analyzes join key expressions to determine which is local (main) and which is foreign (joined). + */ +function analyzeJoinKeys( + left: BasicExpression, + right: BasicExpression, + mainAlias: string, + joinedAlias: string, +): { localKey: BasicExpression; foreignKey: BasicExpression } { + const leftAlias = getExpressionAlias(left) + const rightAlias = getExpressionAlias(right) + + // If left references the joined alias, swap them + if (leftAlias === joinedAlias || rightAlias === mainAlias) { + return { localKey: right, foreignKey: left } + } + + // Default: left is local, right is foreign + return { localKey: left, foreignKey: right } +} + +/** + * Gets the collection ID from the innermost FROM clause of a query. + * Used for subqueries to find the actual collection being queried. + */ +function getInnermostCollectionId(query: QueryIR): string { + if (query.from.type === `collectionRef`) { + return query.from.collection.id + } + // Recurse into subquery + return getInnermostCollectionId(query.from.query) +} + /** * Determines if a source alias refers to a collection reference (not a subquery). * This is used to identify WHERE clauses that can be pushed down to collection subscriptions. diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 29bfce622..fd50fe7ac 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -282,6 +282,48 @@ export type CursorExpressions = { lastKey?: string | number } +/** + * Information about a join that should be performed server-side. + * This allows the sync layer to construct queries that join and filter + * before pagination, ensuring consistent page sizes. + */ +export type JoinInfo = { + /** The ID of the collection being joined */ + collectionId: string + + /** The alias used for the joined collection in the query */ + alias: string + + /** The type of join to perform */ + type: `inner` | `left` | `right` | `full` | `cross` + + /** + * The join key expression from the main collection. + * For example, in `eq(task.account_id, account.id)`, this would be `task.account_id`. + */ + localKey: BasicExpression + + /** + * The join key expression from the joined collection. + * For example, in `eq(task.account_id, account.id)`, this would be `account.id`. + */ + foreignKey: BasicExpression + + /** + * Filters that apply to the joined collection. + * These should be applied as part of the join condition or WHERE clause + * on the server side. + */ + where?: BasicExpression + + /** + * OrderBy expressions that reference the joined collection. + * If the query orders by a field from the joined collection, + * this information is needed for the server-side query. + */ + orderBy?: OrderBy +} + export type LoadSubsetOptions = { /** The where expression to filter the data (does NOT include cursor expressions) */ where?: BasicExpression @@ -309,6 +351,16 @@ export type LoadSubsetOptions = { * @optional Available when called from CollectionSubscription, may be undefined for direct calls */ subscription?: Subscription + /** + * Information about joins that should be performed server-side. + * When present, the sync layer can construct queries that join and filter + * before applying pagination, ensuring consistent page sizes. + * + * This is particularly important for on-demand mode where pagination + * would otherwise be applied before join filters, leading to + * inconsistent result counts. + */ + joins?: Array } export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise @@ -804,6 +856,13 @@ export interface SubscribeChangesOptions< * @internal */ onStatusChange?: (event: SubscriptionStatusChangeEvent) => void + /** + * Join information for server-side query construction. + * When present, this is included in loadSubset calls so the sync layer + * can perform joins before pagination. + * @internal + */ + joinInfo?: Array } export interface SubscribeChangesSnapshotOptions< diff --git a/packages/db/tests/query/optimizer.test.ts b/packages/db/tests/query/optimizer.test.ts index 77ff8cbed..007867b11 100644 --- a/packages/db/tests/query/optimizer.test.ts +++ b/packages/db/tests/query/optimizer.test.ts @@ -1856,4 +1856,205 @@ describe(`Query Optimizer`, () => { } }) }) + + describe(`Join Info Extraction`, () => { + test(`should return empty map for queries without joins`, () => { + const query: QueryIR = { + from: new CollectionRef(mockCollection, `u`), + where: [createEq(createPropRef(`u`, `status`), createValue(`active`))], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.size).toBe(0) + }) + + test(`should extract join info for simple inner join`, () => { + const usersCollection = { id: `users-collection` } as any + const postsCollection = { id: `posts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + from: new CollectionRef(postsCollection, `post`), + type: `inner`, + left: createPropRef(`user`, `id`), + right: createPropRef(`post`, `user_id`), + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + // Join info should be keyed by main source alias + expect(joinInfoBySource.has(`user`)).toBe(true) + const userJoins = joinInfoBySource.get(`user`)! + expect(userJoins).toHaveLength(1) + + const joinInfo = userJoins[0]! + expect(joinInfo.collectionId).toBe(`posts-collection`) + expect(joinInfo.alias).toBe(`post`) + expect(joinInfo.type).toBe(`inner`) + expect(joinInfo.localKey).toEqual(createPropRef(`user`, `id`)) + expect(joinInfo.foreignKey).toEqual(createPropRef(`post`, `user_id`)) + expect(joinInfo.where).toBeUndefined() + expect(joinInfo.orderBy).toBeUndefined() + }) + + test(`should include WHERE clause for joined collection in join info`, () => { + const tasksCollection = { id: `tasks-collection` } as any + const accountsCollection = { id: `accounts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(tasksCollection, `task`), + join: [ + { + from: new CollectionRef(accountsCollection, `account`), + type: `inner`, + left: createPropRef(`task`, `account_id`), + right: createPropRef(`account`, `id`), + }, + ], + where: [ + // Filter on task (main collection) + createEq(createPropRef(`task`, `status`), createValue(`active`)), + // Filter on account (joined collection) + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ], + } + + const { joinInfoBySource, sourceWhereClauses } = optimizeQuery(query) + + // sourceWhereClauses should have the account filter + expect(sourceWhereClauses.has(`account`)).toBe(true) + expect(sourceWhereClauses.get(`account`)).toEqual( + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ) + + // joinInfoBySource should include the account filter in the join info + expect(joinInfoBySource.has(`task`)).toBe(true) + const taskJoins = joinInfoBySource.get(`task`)! + expect(taskJoins).toHaveLength(1) + + const joinInfo = taskJoins[0]! + expect(joinInfo.where).toEqual( + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ) + }) + + test(`should include orderBy for joined collection in join info`, () => { + const tasksCollection = { id: `tasks-collection` } as any + const accountsCollection = { id: `accounts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(tasksCollection, `task`), + join: [ + { + from: new CollectionRef(accountsCollection, `account`), + type: `inner`, + left: createPropRef(`task`, `account_id`), + right: createPropRef(`account`, `id`), + }, + ], + orderBy: [ + { + expression: createPropRef(`account`, `name`), + compareOptions: { direction: `asc` }, + }, + { + expression: createPropRef(`task`, `created_at`), + compareOptions: { direction: `desc` }, + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.has(`task`)).toBe(true) + const taskJoins = joinInfoBySource.get(`task`)! + const joinInfo = taskJoins[0]! + + // Only the account orderBy should be in the join info + expect(joinInfo.orderBy).toHaveLength(1) + expect(joinInfo.orderBy![0]!.expression).toEqual( + createPropRef(`account`, `name`), + ) + }) + + test(`should handle multiple joins`, () => { + const ordersCollection = { id: `orders-collection` } as any + const customersCollection = { id: `customers-collection` } as any + const productsCollection = { id: `products-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(ordersCollection, `order`), + join: [ + { + from: new CollectionRef(customersCollection, `customer`), + type: `inner`, + left: createPropRef(`order`, `customer_id`), + right: createPropRef(`customer`, `id`), + }, + { + from: new CollectionRef(productsCollection, `product`), + type: `left`, + left: createPropRef(`order`, `product_id`), + right: createPropRef(`product`, `id`), + }, + ], + where: [ + createEq(createPropRef(`customer`, `tier`), createValue(`premium`)), + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.has(`order`)).toBe(true) + const orderJoins = joinInfoBySource.get(`order`)! + expect(orderJoins).toHaveLength(2) + + // First join: customer + const customerJoin = orderJoins.find((j) => j.alias === `customer`)! + expect(customerJoin.collectionId).toBe(`customers-collection`) + expect(customerJoin.type).toBe(`inner`) + expect(customerJoin.where).toEqual( + createEq(createPropRef(`customer`, `tier`), createValue(`premium`)), + ) + + // Second join: product + const productJoin = orderJoins.find((j) => j.alias === `product`)! + expect(productJoin.collectionId).toBe(`products-collection`) + expect(productJoin.type).toBe(`left`) + expect(productJoin.where).toBeUndefined() + }) + + test(`should handle join with swapped key expressions`, () => { + const usersCollection = { id: `users-collection` } as any + const postsCollection = { id: `posts-collection` } as any + + // Join with right side referencing main, left side referencing joined + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + from: new CollectionRef(postsCollection, `post`), + type: `inner`, + // Swapped: left is post (joined), right is user (main) + left: createPropRef(`post`, `user_id`), + right: createPropRef(`user`, `id`), + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + const userJoins = joinInfoBySource.get(`user`)! + const joinInfo = userJoins[0]! + + // The extractor should correctly identify which is local vs foreign + expect(joinInfo.localKey).toEqual(createPropRef(`user`, `id`)) + expect(joinInfo.foreignKey).toEqual(createPropRef(`post`, `user_id`)) + }) + }) }) diff --git a/packages/query-db-collection/src/serialization.ts b/packages/query-db-collection/src/serialization.ts index 9849c4bd3..3d6e9cc1d 100644 --- a/packages/query-db-collection/src/serialization.ts +++ b/packages/query-db-collection/src/serialization.ts @@ -50,6 +50,28 @@ export function serializeLoadSubsetOptions( result.offset = options.offset } + // Include joins for server-side query construction + if (options.joins?.length) { + result.joins = options.joins.map((join) => ({ + collectionId: join.collectionId, + alias: join.alias, + type: join.type, + localKey: serializeExpression(join.localKey), + foreignKey: serializeExpression(join.foreignKey), + where: join.where ? serializeExpression(join.where) : undefined, + orderBy: join.orderBy?.map((clause) => ({ + expression: serializeExpression(clause.expression), + direction: clause.compareOptions.direction, + nulls: clause.compareOptions.nulls, + stringSort: clause.compareOptions.stringSort, + ...(clause.compareOptions.stringSort === `locale` && { + locale: clause.compareOptions.locale, + localeOptions: clause.compareOptions.localeOptions, + }), + })), + })) + } + return Object.keys(result).length === 0 ? undefined : JSON.stringify(result) } From b6c1c42d732942f275d4532201390ce2803b6117 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:47:43 +0000 Subject: [PATCH 3/5] ci: apply automated fixes --- INVESTIGATION-join-pagination-queryFn.md | 13 ++++++------- packages/db/src/query/compiler/index.ts | 7 +++++-- .../db/src/query/live/collection-config-builder.ts | 4 +--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/INVESTIGATION-join-pagination-queryFn.md b/INVESTIGATION-join-pagination-queryFn.md index 14fd9f147..e46e5e1f0 100644 --- a/INVESTIGATION-join-pagination-queryFn.md +++ b/INVESTIGATION-join-pagination-queryFn.md @@ -7,11 +7,13 @@ When using `queryCollection` in on-demand mode with joins and pagination, the pagination was applied **before** join filters were evaluated, leading to inconsistent page sizes or empty results. **User's scenario:** + - `tasksCollection` (queryCollection, on-demand) with `limit: 10` - Joined with `accountsCollection` - Filter on `account.name = 'example'` **What was happening:** + 1. `tasksCollection.queryFn` received `{ limit: 10, where: }` 2. Backend returned 10 tasks 3. Client-side join with `accountsCollection` @@ -64,6 +66,7 @@ export type LoadSubsetOptions = { ### 1. Join Info Extraction (`packages/db/src/query/optimizer.ts`) Added `extractJoinInfo()` function that: + - Analyzes query IR to extract join clause information - Associates filters from `sourceWhereClauses` with their respective joins - Associates orderBy expressions with their respective joins @@ -108,10 +111,7 @@ queryFn: async (context) => { for (const join of opts.joins) { // Add join based on join.type, join.localKey, join.foreignKey - query = query.innerJoin( - accounts, - eq(tasks.accountId, accounts.id) - ) + query = query.innerJoin(accounts, eq(tasks.accountId, accounts.id)) // Apply joined collection's filter if (join.where) { @@ -128,9 +128,7 @@ queryFn: async (context) => { } // Simple query without joins (existing behavior) - return db.select().from(tasks) - .where(/* opts.where */) - .limit(opts.limit) + return db.select().from(tasks).where(/* opts.where */).limit(opts.limit) } ``` @@ -148,6 +146,7 @@ queryFn: async (context) => { ## Test Coverage Added tests in `packages/db/tests/query/optimizer.test.ts`: + - Empty map for queries without joins - Basic inner join extraction - WHERE clause inclusion for joined collections diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 6ba03e8fa..3ff161bec 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -114,8 +114,11 @@ export function compileQuery( validateQueryStructure(rawQuery) // Optimize the query before compilation - const { optimizedQuery: query, sourceWhereClauses, joinInfoBySource } = - optimizeQuery(rawQuery) + const { + optimizedQuery: query, + sourceWhereClauses, + joinInfoBySource, + } = optimizeQuery(rawQuery) // Create mapping from optimized query to original for caching queryMapping.set(query, rawQuery) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ad6d840ee..34c600cf7 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -138,9 +138,7 @@ export class CollectionConfigBuilder< | undefined // Map of source aliases to their join info for server-side query construction - public joinInfoBySourceCache: - | Map> - | undefined + public joinInfoBySourceCache: Map> | undefined // Map of source alias to subscription readonly subscriptions: Record = {} From ec860ab00a4c9a7e2d9783f48bd8a536dbe62594 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 22:18:07 +0000 Subject: [PATCH 4/5] fix: add missing nulls property to compareOptions in tests --- packages/db/tests/query/optimizer.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/db/tests/query/optimizer.test.ts b/packages/db/tests/query/optimizer.test.ts index 007867b11..c33cfad4d 100644 --- a/packages/db/tests/query/optimizer.test.ts +++ b/packages/db/tests/query/optimizer.test.ts @@ -1960,11 +1960,11 @@ describe(`Query Optimizer`, () => { orderBy: [ { expression: createPropRef(`account`, `name`), - compareOptions: { direction: `asc` }, + compareOptions: { direction: `asc`, nulls: `last` }, }, { expression: createPropRef(`task`, `created_at`), - compareOptions: { direction: `desc` }, + compareOptions: { direction: `desc`, nulls: `last` }, }, ], } From f529c55a4adce39ce1d2c57ff6aa9e404a872bdd Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Jan 2026 16:00:36 -0700 Subject: [PATCH 5/5] chore: add changeset for join info feature Co-Authored-By: Claude Opus 4.5 --- .changeset/pass-join-info-to-queryfn.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pass-join-info-to-queryfn.md diff --git a/.changeset/pass-join-info-to-queryfn.md b/.changeset/pass-join-info-to-queryfn.md new file mode 100644 index 000000000..c71a9abc5 --- /dev/null +++ b/.changeset/pass-join-info-to-queryfn.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/query-db-collection': patch +--- + +Pass join information to queryFn in on-demand mode. This enables server-side joins before pagination, fixing inconsistent page sizes when queries combine pagination with filters on joined collections. The new `joins` array in `LoadSubsetOptions` contains collection ID, alias, join type, key expressions, and associated filters.