From 147895e8aa620a3d3c809924c294faf5b1ad2340 Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Mon, 25 May 2026 10:10:07 +0300 Subject: [PATCH 1/3] fix(db): boundary expansion for multi-column orderBy pagination --- .../fix-boundary-expansion-multi-orderby.md | 5 + packages/db/src/collection/subscription.ts | 20 +++ .../boundary-expansion-multi-orderby.test.ts | 154 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 .changeset/fix-boundary-expansion-multi-orderby.md create mode 100644 packages/db/tests/boundary-expansion-multi-orderby.test.ts diff --git a/.changeset/fix-boundary-expansion-multi-orderby.md b/.changeset/fix-boundary-expansion-multi-orderby.md new file mode 100644 index 000000000..4f36dfddc --- /dev/null +++ b/.changeset/fix-boundary-expansion-multi-orderby.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +fix(db): expand boundary items for multi-column orderBy in loadNextItems diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 2d48add4b..9ebe03643 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -543,6 +543,26 @@ export class CollectionSubscription keys = index.take(valuesNeeded(), biggestObservedValue!, filterFn) } + // Boundary expansion for multi-column orderBy: + // BTree orders by (first_col, _id) only. Items sharing the same first-column + // value as biggestObservedValue but not selected by BTree may rank higher by + // the full comparator. Send all of them so D2 picks the correct top-K. + if (orderBy.length > 1 && biggestObservedValue !== undefined && valueExtractor) { + const alreadyAddedKeys = new Set(changes.map((c) => c.key)) + const boundaryChanges = this.collection.currentStateAsChanges({ + where: eq(orderByExpression, new Value(biggestObservedValue)), + }) + if (boundaryChanges) { + for (const { key, value } of boundaryChanges) { + if (!alreadyAddedKeys.has(key) && !this.sentKeys.has(key)) { + if (value !== undefined && (whereFilterFn?.(value) ?? true)) { + changes.push({ type: `insert`, key, value }) + } + } + } + } + } + // Track row count for offset-based pagination (before sending to callback) // Use the current count as the offset for this load const currentOffset = this.limitedSnapshotRowCount diff --git a/packages/db/tests/boundary-expansion-multi-orderby.test.ts b/packages/db/tests/boundary-expansion-multi-orderby.test.ts new file mode 100644 index 000000000..eece8a802 --- /dev/null +++ b/packages/db/tests/boundary-expansion-multi-orderby.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection, eq } from '../src/query/index.js' +import { flushPromises, mockSyncCollectionOptions } from './utils.js' + +type Item = { + id: string + category: string + name: string +} + +describe(`Boundary expansion for multi-column orderBy`, () => { + it(`should include all boundary items when paginating with multi-column orderBy`, async () => { + // Items with same category (first orderBy col) but different names (second orderBy col). + // BTree indexes by (category, _id) only. When paginating, items sharing the + // boundary category but with different _id ordering may be missed by BTree's take(). + // The boundary expansion step should send them so D2 picks the correct top-K. + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta` }, + { id: `a2`, category: `A`, name: `Alpha` }, + { id: `a3`, category: `A`, name: `Beta` }, + { id: `b1`, category: `B`, name: `Gamma` }, + { id: `b2`, category: `B`, name: `Delta` }, + { id: `c1`, category: `C`, name: `Epsilon` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-multi-orderby-source`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(4) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // Full multi-column sort (category asc, name asc): + // A-Alpha (a2), A-Beta (a3), A-Zeta (a1), B-Delta (b2), B-Gamma (b1), C-Epsilon (c1) + // Top 4 should be: A-Alpha, A-Beta, A-Zeta, B-Delta + expect(results).toHaveLength(4) + expect(results.map((r) => r.id)).toEqual([`a2`, `a3`, `a1`, `b2`]) + }) + + it(`should return correct results when all items share the same first orderBy value`, async () => { + const initialData: Array = [ + { id: `x3`, category: `X`, name: `Cherry` }, + { id: `x1`, category: `X`, name: `Apple` }, + { id: `x2`, category: `X`, name: `Banana` }, + { id: `x4`, category: `X`, name: `Date` }, + { id: `x5`, category: `X`, name: `Elderberry` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-same-first-col`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // All share category X, sorted by name: Apple (x1), Banana (x2), Cherry (x3), Date (x4), Elderberry (x5) + // Top 3: Apple, Banana, Cherry + expect(results).toHaveLength(3) + expect(results.map((r) => r.id)).toEqual([`x1`, `x2`, `x3`]) + }) + + it(`should handle multi-column orderBy with where filter correctly`, async () => { + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta`, active: true }, + { id: `a2`, category: `A`, name: `Alpha`, active: false }, + { id: `a3`, category: `A`, name: `Beta`, active: true }, + { id: `b1`, category: `B`, name: `Gamma`, active: true }, + { id: `b2`, category: `B`, name: `Delta`, active: true }, + { id: `c1`, category: `C`, name: `Epsilon`, active: true }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-multi-orderby-where`, + getKey: (item: (typeof initialData)[0]) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => eq(items.active, true)) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // Active items sorted by (category asc, name asc): + // A-Beta (a3), A-Zeta (a1), B-Delta (b2), B-Gamma (b1), C-Epsilon (c1) + // (a2 Alpha is inactive, filtered out) + // Top 3: A-Beta, A-Zeta, B-Delta + expect(results).toHaveLength(3) + expect(results.map((r) => r.id)).toEqual([`a3`, `a1`, `b2`]) + }) +}) From 227feab4efc62d23306cac8ed95e2729fa847fc2 Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Mon, 25 May 2026 10:39:35 +0300 Subject: [PATCH 2/3] test: add limit edge cases and dynamic insert test --- .../boundary-expansion-multi-orderby.test.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/db/tests/boundary-expansion-multi-orderby.test.ts b/packages/db/tests/boundary-expansion-multi-orderby.test.ts index eece8a802..ae6a5c4c3 100644 --- a/packages/db/tests/boundary-expansion-multi-orderby.test.ts +++ b/packages/db/tests/boundary-expansion-multi-orderby.test.ts @@ -151,4 +151,143 @@ describe(`Boundary expansion for multi-column orderBy`, () => { expect(results).toHaveLength(3) expect(results.map((r) => r.id)).toEqual([`a3`, `a1`, `b2`]) }) + + it(`should return only the first item when limit is 1`, async () => { + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta` }, + { id: `a2`, category: `A`, name: `Alpha` }, + { id: `b1`, category: `B`, name: `Gamma` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-limit-1`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(1) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // Full sort (category asc, name asc): A-Alpha (a2), A-Zeta (a1), B-Gamma (b1) + // limit(1) → only A-Alpha + expect(results).toHaveLength(1) + expect(results.map((r) => r.id)).toEqual([`a2`]) + }) + + it(`should return all rows when limit equals total row count`, async () => { + const initialData: Array = [ + { id: `b1`, category: `B`, name: `Gamma` }, + { id: `a2`, category: `A`, name: `Alpha` }, + { id: `a1`, category: `A`, name: `Zeta` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-limit-equals-total`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // All 3 rows in sorted order: A-Alpha (a2), A-Zeta (a1), B-Gamma (b1) + expect(results).toHaveLength(3) + expect(results.map((r) => r.id)).toEqual([`a2`, `a1`, `b1`]) + }) + + it(`should update top-K after dynamic insert via sync`, async () => { + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta` }, + { id: `b1`, category: `B`, name: `Gamma` }, + { id: `c1`, category: `C`, name: `Epsilon` }, + ] + + const options = mockSyncCollectionOptions({ + id: `boundary-dynamic-insert`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }) + + const sourceCollection = createCollection(options) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(2) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + // Before insert — sorted: A-Zeta (a1), B-Gamma (b1), C-Epsilon (c1) + // Top 2: A-Zeta, B-Gamma + let results = Array.from(liveQuery.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.id)).toEqual([`a1`, `b1`]) + + // Insert a new item that should land in the top-2 + options.utils.begin() + options.utils.write({ + type: `insert`, + value: { id: `a2`, category: `A`, name: `Alpha` }, + }) + options.utils.commit() + await flushPromises() + + // After insert — sorted: A-Alpha (a2), A-Zeta (a1), B-Gamma (b1), C-Epsilon (c1) + // Top 2: A-Alpha, A-Zeta + results = Array.from(liveQuery.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.id)).toEqual([`a2`, `a1`]) + }) }) From a6ac5b366f21dd965260f33b22b8750a6ed43c3f Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Mon, 25 May 2026 11:14:19 +0300 Subject: [PATCH 3/3] test: add limit(0) and offset edge cases --- .../boundary-expansion-multi-orderby.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/db/tests/boundary-expansion-multi-orderby.test.ts b/packages/db/tests/boundary-expansion-multi-orderby.test.ts index ae6a5c4c3..efeec0a90 100644 --- a/packages/db/tests/boundary-expansion-multi-orderby.test.ts +++ b/packages/db/tests/boundary-expansion-multi-orderby.test.ts @@ -290,4 +290,84 @@ describe(`Boundary expansion for multi-column orderBy`, () => { expect(results).toHaveLength(2) expect(results.map((r) => r.id)).toEqual([`a2`, `a1`]) }) + + it(`should return empty results when limit is 0`, async () => { + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta` }, + { id: `a2`, category: `A`, name: `Alpha` }, + { id: `b1`, category: `B`, name: `Gamma` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-limit-0`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .limit(0) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + expect(results).toHaveLength(0) + }) + + it(`should return empty results when offset exceeds available rows`, async () => { + const initialData: Array = [ + { id: `a1`, category: `A`, name: `Zeta` }, + { id: `a2`, category: `A`, name: `Alpha` }, + { id: `b1`, category: `B`, name: `Gamma` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `boundary-offset-beyond`, + getKey: (item: Item) => item.id, + initialData, + autoIndex: `eager`, + }), + ) + + await sourceCollection.preload() + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.category, `asc`) + .orderBy(({ items }) => items.name, `asc`) + .offset(100) + .limit(3) + .select(({ items }) => ({ + id: items.id, + category: items.category, + name: items.name, + })), + ) + + await liveQuery.preload() + await flushPromises() + + const results = Array.from(liveQuery.values()) + + // Only 3 items exist; offset(100) skips past all of them + expect(results).toHaveLength(0) + }) })