Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/automatic-static-querykey-ondemand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@tanstack/query-db-collection": patch
---

Automatically append predicates to static queryKey in on-demand mode.

When using a static `queryKey` with `syncMode: 'on-demand'`, the system now automatically appends serialized LoadSubsetOptions to create unique cache keys for different predicate combinations. This fixes an issue where all live queries with different predicates would share the same TanStack Query cache entry, causing data to be overwritten.

**Before:**

```typescript
// This would cause conflicts between different queries
queryCollectionOptions({
queryKey: ["products"], // Static key
syncMode: "on-demand",
queryFn: async (ctx) => {
const { where, limit } = ctx.meta.loadSubsetOptions
return fetch(`/api/products?...`).then((r) => r.json())
},
})
```

With different live queries filtering by `category='A'` and `category='B'`, both would share the same cache key `['products']`, causing the last query to overwrite the first.

**After:**
Static queryKeys now work correctly in on-demand mode! The system automatically creates unique cache keys:

- Query with `category='A'` → `['products', '{"where":{...A...}}']`
- Query with `category='B'` → `['products', '{"where":{...B...}}']`

**Key behaviors:**

- ✅ Static queryKeys now work correctly with on-demand mode (automatic serialization)
- ✅ Function-based queryKeys continue to work as before (no change)
- ✅ Eager mode with static queryKeys unchanged (no automatic serialization)
- ✅ Identical predicates correctly reuse the same cache entry

This makes the documentation example work correctly without requiring users to manually implement function-based queryKeys for predicate push-down.
15 changes: 14 additions & 1 deletion packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
QueryKeyRequiredError,
} from "./errors"
import { createWriteUtils } from "./manual-sync"
import { serializeLoadSubsetOptions } from "./serialization"
import type {
BaseCollectionConfig,
ChangeMessage,
Expand Down Expand Up @@ -627,7 +628,19 @@ export function queryCollectionOptions(
queryFunction: typeof queryFn = queryFn
): true | Promise<void> => {
// Push the predicates down to the queryKey and queryFn
const key = typeof queryKey === `function` ? queryKey(opts) : queryKey
let key: QueryKey
if (typeof queryKey === `function`) {
// Function-based queryKey: use it to build the key from opts
key = queryKey(opts)
} else if (syncMode === `on-demand`) {
// Static queryKey in on-demand mode: automatically append serialized predicates
// to create separate cache entries for different predicate combinations
const serialized = serializeLoadSubsetOptions(opts)
key = serialized !== undefined ? [...queryKey, serialized] : queryKey
} else {
// Static queryKey in eager mode: use as-is
key = queryKey
}
const hashedQueryKey = hashKey(key)
const extendedMeta = { ...meta, loadSubsetOptions: opts }

Expand Down
128 changes: 128 additions & 0 deletions packages/query-db-collection/src/serialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { IR, LoadSubsetOptions } from "@tanstack/db"

/**
* Serializes LoadSubsetOptions into a stable, hashable format for query keys
* @internal
*/
export function serializeLoadSubsetOptions(
options: LoadSubsetOptions | undefined
): string | undefined {
if (!options) {
return undefined
}

const result: Record<string, unknown> = {}

if (options.where) {
result.where = serializeExpression(options.where)
}

if (options.orderBy?.length) {
result.orderBy = options.orderBy.map((clause) => {
const baseOrderBy = {
expression: serializeExpression(clause.expression),
direction: clause.compareOptions.direction,
nulls: clause.compareOptions.nulls,
stringSort: clause.compareOptions.stringSort,
}

// Handle locale-specific options when stringSort is 'locale'
if (clause.compareOptions.stringSort === `locale`) {
return {
...baseOrderBy,
locale: clause.compareOptions.locale,
localeOptions: clause.compareOptions.localeOptions,
}
}

return baseOrderBy
})
}

if (options.limit !== undefined) {
result.limit = options.limit
}

return Object.keys(result).length === 0 ? undefined : JSON.stringify(result)
}

/**
* Recursively serializes an IR expression for stable hashing
* @internal
*/
function serializeExpression(expr: IR.BasicExpression | undefined): unknown {
if (!expr) {
return null
}

switch (expr.type) {
case `val`:
return {
type: `val`,
value: serializeValue(expr.value),
}
case `ref`:
return {
type: `ref`,
path: [...expr.path],
}
case `func`:
return {
type: `func`,
name: expr.name,
args: expr.args.map((arg) => serializeExpression(arg)),
}
default:
return null
}
}

/**
* Serializes special JavaScript values (undefined, NaN, Infinity, Date)
* @internal
*/
function serializeValue(value: unknown): unknown {
if (value === undefined) {
return { __type: `undefined` }
}

if (typeof value === `number`) {
if (Number.isNaN(value)) {
return { __type: `nan` }
}
if (value === Number.POSITIVE_INFINITY) {
return { __type: `infinity`, sign: 1 }
}
if (value === Number.NEGATIVE_INFINITY) {
return { __type: `infinity`, sign: -1 }
}
}

if (
value === null ||
typeof value === `string` ||
typeof value === `number` ||
typeof value === `boolean`
) {
return value
}

if (value instanceof Date) {
return { __type: `date`, value: value.toJSON() }
}

if (Array.isArray(value)) {
return value.map((item) => serializeValue(item))
}

if (typeof value === `object`) {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, val]) => [
key,
serializeValue(val),
])
)
}

return value
}
177 changes: 177 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
Collection,
DeleteMutationFnParams,
InsertMutationFnParams,
LoadSubsetOptions,
TransactionWithMutations,
UpdateMutationFnParams,
} from "@tanstack/db"
Expand Down Expand Up @@ -3538,4 +3539,180 @@ describe(`QueryCollection`, () => {
expect(collection.size).toBe(0)
})
})

describe(`Static queryKey with on-demand mode`, () => {
it(`should automatically append serialized predicates to static queryKey in on-demand mode`, async () => {
const items: Array<CategorisedItem> = [
{ id: `1`, name: `Item 1`, category: `A` },
{ id: `2`, name: `Item 2`, category: `A` },
{ id: `3`, name: `Item 3`, category: `B` },
{ id: `4`, name: `Item 4`, category: `B` },
]

const queryFn = vi.fn((ctx: QueryFunctionContext) => {
const loadSubsetOptions = ctx.meta?.loadSubsetOptions as
| LoadSubsetOptions
| undefined
// Filter items based on the where clause if present
if (loadSubsetOptions?.where) {
// Simple mock filtering - in real use, you'd use parseLoadSubsetOptions
return Promise.resolve(items)
}
return Promise.resolve(items)
})

const staticQueryKey = [`static-on-demand-test`]

const config: QueryCollectionConfig<CategorisedItem> = {
id: `static-on-demand-test`,
queryClient,
queryKey: staticQueryKey, // Static queryKey (not a function)
queryFn,
getKey: (item: CategorisedItem) => item.id,
syncMode: `on-demand`,
startSync: true,
}

const options = queryCollectionOptions(config)
const collection = createCollection(options)

// Collection should start empty with on-demand sync mode
expect(collection.size).toBe(0)

// Create first live query with category A filter
const queryA = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `A`))
.select(({ item }) => item),
})

await queryA.preload()

// Wait for first query to load
await vi.waitFor(() => {
expect(collection.size).toBeGreaterThan(0)
})

// Verify queryFn was called
expect(queryFn).toHaveBeenCalledTimes(1)
const firstCall = queryFn.mock.calls[0]?.[0]
expect(firstCall?.meta?.loadSubsetOptions).toBeDefined()

// Create second live query with category B filter
const queryB = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `B`))
.select(({ item }) => item),
})

await queryB.preload()

// Wait for second query to trigger another queryFn call
await vi.waitFor(() => {
expect(queryFn).toHaveBeenCalledTimes(2)
})

// Verify the second call has different loadSubsetOptions
const secondCall = queryFn.mock.calls[1]?.[0]
expect(secondCall?.meta?.loadSubsetOptions).toBeDefined()

// The two queries should have triggered separate cache entries
// because the static queryKey was automatically extended with serialized predicates
expect(queryFn).toHaveBeenCalledTimes(2)

// Cleanup
await queryA.cleanup()
await queryB.cleanup()
})

it(`should create same cache key for identical predicates with static queryKey`, async () => {
const items: Array<CategorisedItem> = [
{ id: `1`, name: `Item 1`, category: `A` },
{ id: `2`, name: `Item 2`, category: `A` },
]

const queryFn = vi.fn().mockResolvedValue(items)

const config: QueryCollectionConfig<CategorisedItem> = {
id: `static-identical-predicates-test`,
queryClient,
queryKey: [`identical-test`],
queryFn,
getKey: (item: CategorisedItem) => item.id,
syncMode: `on-demand`,
startSync: true,
}

const options = queryCollectionOptions(config)
const collection = createCollection(options)

// Create two live queries with identical predicates
const query1 = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `A`))
.select(({ item }) => item),
})

const query2 = createLiveQueryCollection({
query: (q) =>
q
.from({ item: collection })
.where(({ item }) => eq(item.category, `A`))
.select(({ item }) => item),
})

await query1.preload()
await query2.preload()

await vi.waitFor(() => {
expect(collection.size).toBeGreaterThan(0)
})

// Should only call queryFn once because identical predicates
// should produce the same serialized cache key
expect(queryFn).toHaveBeenCalledTimes(1)

// Cleanup
await query1.cleanup()
await query2.cleanup()
})

it(`should work correctly in eager mode with static queryKey (no automatic serialization)`, async () => {
const items: Array<TestItem> = [
{ id: `1`, name: `Item 1` },
{ id: `2`, name: `Item 2` },
]

const queryFn = vi.fn().mockResolvedValue(items)

const config: QueryCollectionConfig<TestItem> = {
id: `static-eager-test`,
queryClient,
queryKey: [`eager-test`],
queryFn,
getKey,
syncMode: `eager`, // Eager mode should NOT append predicates
startSync: true,
}

const options = queryCollectionOptions(config)
const collection = createCollection(options)

// Wait for initial load
await vi.waitFor(() => {
expect(collection.size).toBe(items.length)
})

// Should call queryFn once with empty predicates
expect(queryFn).toHaveBeenCalledTimes(1)
const call = queryFn.mock.calls[0]?.[0]
expect(call?.meta?.loadSubsetOptions).toEqual({})
})
})
})
Loading