From 28270fa23f1c735c76162d40587f517e89ecf184 Mon Sep 17 00:00:00 2001 From: Karol Date: Sat, 14 Mar 2026 12:41:05 +0100 Subject: [PATCH 1/2] Add meta in queries --- docs/collections/query-collection.md | 51 ++++++ docs/guides/live-queries.md | 163 ++++++++++++++++++ packages/db/src/collection/subscription.ts | 15 ++ packages/db/src/query/builder/index.ts | 27 +++ packages/db/src/query/ir.ts | 1 + .../src/query/live/collection-subscriber.ts | 2 + packages/db/src/query/predicate-utils.ts | 7 + packages/db/src/query/subset-dedupe.ts | 52 +++--- packages/db/src/types.ts | 10 ++ packages/db/src/utils.ts | 85 +++++++++ .../db/tests/query/builder/buildQuery.test.ts | 28 +++ .../tests/query/live-query-collection.test.ts | 41 +++++ packages/db/tests/query/subset-dedupe.test.ts | 56 ++++++ packages/query-db-collection/src/query.ts | 6 +- .../query-db-collection/src/serialization.ts | 6 + .../query-db-collection/tests/query.test.ts | 129 ++++++++++++++ .../tests/serialization.test.ts | 29 ++++ 17 files changed, 686 insertions(+), 22 deletions(-) create mode 100644 packages/query-db-collection/tests/serialization.test.ts diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 38fecd4b4..8b8b89147 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -229,6 +229,57 @@ const productsCollection = createCollection( ) ``` +## Query-Level Dynamic Meta + +In addition to static collection-level `meta`, you can pass dynamic metadata at the query level using the `.meta()` chainable method. This is useful when the metadata depends on runtime conditions or user-specific context that varies between different queries. + +### When to Use Query-Level Meta + +Query-level meta is useful for: +- **Multi-tenancy**: Different queries for different tenants +- **User-specific context**: User ID, permissions, or roles that vary per query +- **Request-scoped parameters**: API flags or options specific to a query invocation +- **Authorization scopes**: Different authorization contexts for different queries + +### Basic Usage + +Use `.meta()` when building a live query to attach dynamic metadata: + +```typescript +import { createLiveQueryCollection, eq } from "@tanstack/db" + +// Tenant-specific query +const tenantProducts = createLiveQueryCollection((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.active, true)) + .meta({ tenantId: "tenant-123" }) +) +``` + +The metadata passed to `.meta()` is merged with the collection-level meta and passed to your query function via `ctx.meta`: + +```typescript +const productsCollection = createCollection( + queryCollectionOptions({ + queryKey: ["products"], + queryFn: async (ctx) => { + // Both collection-level and query-level meta are available + const { loadSubsetOptions, tenantId } = ctx.meta + + return api.getProducts({ + ...parseLoadSubsetOptions(loadSubsetOptions), + tenantId, // From query-level .meta() + }) + }, + queryClient, + getKey: (item) => item.id, + }) +) +``` + +For detailed information about meta merging, precedence rules, cache key isolation, and more examples, see the [Query Metadata section in the Live Queries guide](../guides/live-queries.md#query-metadata). + ## Persistence Handlers You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index a4371f888..c7af2a207 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -42,6 +42,7 @@ The result types are automatically inferred from your query structure, providing - [findOne](#findone) - [Distinct](#distinct) - [Order By, Limit, and Offset](#order-by-limit-and-offset) +- [Query Metadata](#query-metadata) - [Composable Queries](#composable-queries) - [Expression Functions Reference](#expression-functions-reference) - [Functional Variants](#functional-variants) @@ -1486,6 +1487,168 @@ const page2Users = createLiveQueryCollection((q) => ) ``` +## Query Metadata + +Query metadata allows you to pass dynamic context and parameters to your query function at runtime. This is useful for multi-tenancy, authorization, API-specific parameters, and other request-scoped data that can't be determined at query definition time. + +The `.meta()` chainable method lets you attach metadata to a query, which gets merged with any collection-level metadata and passed to your query function via `ctx.meta`. + +### Basic Usage + +Use `.meta()` to pass context data with your query: + +```ts +import { createLiveQueryCollection, eq } from '@tanstack/db' + +const userPosts = createLiveQueryCollection((q) => + q + .from({ post: postsCollection }) + .where(({ post }) => eq(post.userId, userId)) + .meta({ tenantId: 'tenant-abc' }) +) +``` + +### Real-World Use Cases + +#### Multi-Tenancy + +Pass tenant context to ensure data isolation: + +```ts +const tenantUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .meta({ tenantId: 'tenant-123' }) +) +``` + +The `tenantId` is available in your query function: + +```ts +const collection = createCollection( + queryCollectionOptions({ + queryKey: ['tenant-users'], + queryFn: async (ctx) => { + const { tenantId } = ctx.meta + const response = await fetch(`/api/users?tenantId=${tenantId}`) + return response.json() + }, + getKey: (user) => user.id, + }) +) +``` + +#### Authorization Context + +Pass authorization headers or user context: + +```ts +const userProfile = createLiveQueryCollection((q) => + q + .from({ profile: profilesCollection }) + .where(({ profile }) => eq(profile.userId, currentUserId)) + .meta({ userId: currentUserId, roles: userRoles }) +) +``` + +#### API Feature Flags + +Control API behavior with query-specific parameters: + +```ts +const detailedUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .meta({ includeDetails: true, includeAuditLog: false }) +) +``` + +In your query function: + +```ts +queryFn: async (ctx) => { + const { includeDetails, includeAuditLog } = ctx.meta + const params = new URLSearchParams() + if (includeDetails) params.append('details', 'true') + if (includeAuditLog) params.append('audit', 'true') + + const response = await fetch(`/api/users?${params}`) + return response.json() +} +``` + +### Metadata Merging + +When you combine collection-level `meta` with query-level `.meta()`, they merge together. Query-level metadata takes precedence when the same key exists in both: + +```ts +// Collection has base metadata +const collection = createCollection( + queryCollectionOptions({ + queryKey: ['items'], + queryFn: async (ctx) => { + // ctx.meta includes both collection and query-level meta + console.log(ctx.meta) + }, + meta: { apiVersion: 'v2', environment: 'production' }, + getKey: (item) => item.id, + }) +) + +// Query adds or overrides metadata +const items = createLiveQueryCollection((q) => + q + .from({ item: collection }) + .meta({ tenantId: 'tenant-a', apiVersion: 'v3' }) +) + +// Result in queryFn: +// { apiVersion: 'v3', environment: 'production', tenantId: 'tenant-a' } +``` + +Notice that `apiVersion` was overridden to `'v3'` because the query-level `.meta()` takes precedence. + +### Multiple Meta Calls + +You can chain multiple `.meta()` calls, and they will merge together: + +```ts +const items = createLiveQueryCollection((q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.active, true)) + .meta({ tenantId: 'tenant-a' }) + .meta({ includeArchived: false }) + .meta({ userId: 'user-123' }) +) + +// Result: { tenantId: 'tenant-a', includeArchived: false, userId: 'user-123' } +``` + +### Caching Implications + +Different metadata values create different cache entries. This ensures that queries with the same predicates but different metadata (like different tenants) don't accidentally share cached data: + +```ts +// These are cached separately +const tenantAUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .meta({ tenantId: 'tenant-a' }) +) + +const tenantBUsers = createLiveQueryCollection((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .meta({ tenantId: 'tenant-b' }) +) + +// Different metadata = different cache keys in TanStack Query +// Tenant A's data won't be served to Tenant B +``` + ## Composable Queries Build complex queries by composing smaller, reusable parts. This approach makes your queries more maintainable and allows for better performance through caching. diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index ae13e1295..402b798f8 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -28,6 +28,8 @@ type RequestSnapshotOptions = { orderBy?: OrderBy /** Optional limit to pass to loadSubset for backend optimization */ limit?: number + /** Optional metadata to include in loadSubset */ + meta?: Record /** Callback that receives the raw loadSubset result for external tracking */ onLoadSubsetResult?: (result: Promise | true) => void } @@ -39,6 +41,8 @@ type RequestLimitedSnapshotOptions = { minValues?: Array /** Row offset for offset-based pagination (passed to sync layer) */ offset?: number + /** Optional metadata to include in loadSubset */ + meta?: Record /** Whether to track the loadSubset promise on this subscription (default: true) */ trackLoadSubsetPromise?: boolean /** Callback that receives the raw loadSubset result for external tracking */ @@ -49,6 +53,8 @@ type CollectionSubscriptionOptions = { includeInitialState?: boolean /** Pre-compiled expression for filtering changes */ whereExpression?: BasicExpression + /** Optional metadata included in all loadSubset requests for this subscription */ + meta?: Record /** Callback to call when the subscription is unsubscribed */ onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void } @@ -368,6 +374,10 @@ export class CollectionSubscription // Include orderBy and limit if provided so sync layer can optimize the query orderBy: opts?.orderBy, limit: opts?.limit, + meta: { + ...(this.options.meta ?? {}), + ...(opts?.meta ?? {}), + }, } const syncResult = this.collection._sync.loadSubset(loadOptions) @@ -425,6 +435,7 @@ export class CollectionSubscription limit, minValues, offset, + meta, trackLoadSubsetPromise: shouldTrackLoadSubsetPromise = true, onLoadSubsetResult, }: RequestLimitedSnapshotOptions) { @@ -606,6 +617,10 @@ export class CollectionSubscription cursor: cursorExpressions, // Cursor expressions passed separately offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this, + meta: { + ...(this.options.meta ?? {}), + ...(meta ?? {}), + }, } const syncResult = this.collection._sync.loadSubset(loadOptions) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index c6b129c53..ad1bdbba8 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -666,6 +666,33 @@ export class BaseQueryBuilder { }) as any } + /** + * Attach runtime metadata to the query. + * This metadata is forwarded to sync layer `loadSubset` calls and downstream adapters. + * + * @param data - The metadata to attach to the query + * @returns A QueryBuilder with the metadata applied + * + * Multiple calls merge values, with later calls overriding earlier keys. + * + * @example + * ```ts + * query + * .from({ products: productsCollection }) + * .meta({ scope: 'tenant-1' }) + * .meta({ includeClients: true }) + * ``` + */ + meta(data: Record): QueryBuilder { + return new BaseQueryBuilder({ + ...this.query, + meta: { + ...(this.query.meta ?? {}), + ...data, + }, + }) as any + } + /** * Specify that the query should return distinct rows. * Deduplicates rows based on the selected columns. diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index b1e3d1e07..fe84818bf 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -16,6 +16,7 @@ export interface QueryIR { orderBy?: OrderBy limit?: Limit offset?: Offset + meta?: Record distinct?: true singleResult?: true diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 4ff265220..b11450be6 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -241,6 +241,7 @@ export class CollectionSubscriber< ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, + meta: this.collectionConfigBuilder.query.meta, orderBy: orderByForSubscription, limit: limitForSubscription, onLoadSubsetResult, @@ -297,6 +298,7 @@ export class CollectionSubscriber< const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, onStatusChange, + meta: this.collectionConfigBuilder.query.meta, }) subscriptionHolder.current = subscription diff --git a/packages/db/src/query/predicate-utils.ts b/packages/db/src/query/predicate-utils.ts index 4483d44ae..9979fe154 100644 --- a/packages/db/src/query/predicate-utils.ts +++ b/packages/db/src/query/predicate-utils.ts @@ -1,3 +1,4 @@ +import { deepEquals } from '../utils.js' import { Func, Value } from './ir.js' import type { BasicExpression, OrderBy, PropRef } from './ir.js' import type { LoadSubsetOptions } from '../types.js' @@ -857,6 +858,12 @@ export function isPredicateSubset( subset: LoadSubsetOptions, superset: LoadSubsetOptions, ): boolean { + // Dynamic metadata must match exactly; otherwise requests may target + // different backend scopes despite sharing predicates. + if (!deepEquals(subset.meta, superset.meta)) { + return false + } + // When the superset has a limit, we can only determine subset relationship // if the where clauses are equal (not just subset relationship). // diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index 87d703b74..d53965cac 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -1,3 +1,4 @@ +import { hashKey } from '../utils.js' import { isPredicateSubset, isWhereSubset, @@ -42,11 +43,14 @@ export class DeduplicatedLoadSubset { | ((options: LoadSubsetOptions) => void) | undefined - // Combined where predicate for all unlimited calls (no limit) - private unlimitedWhere: BasicExpression | undefined = undefined + // Combined where predicate for unlimited calls, scoped by metadata context. + private unlimitedWhereByMeta = new Map< + string, + BasicExpression | undefined + >() - // Flag to track if we've loaded all data (unlimited call with no where clause) - private hasLoadedAllData = false + // Tracks if all data has been loaded for a metadata context. + private hasLoadedAllDataByMeta = new Set() // List of all limited calls (with limit, possibly with orderBy) // We clone options before storing to prevent mutation of stored predicates @@ -83,16 +87,19 @@ export class DeduplicatedLoadSubset { * @returns true if data is already loaded, or a Promise that resolves when data is loaded */ loadSubset = (options: LoadSubsetOptions): true | Promise => { + const metaKey = hashKey(options.meta ?? null) + // If we've loaded all data, everything is covered - if (this.hasLoadedAllData) { + if (this.hasLoadedAllDataByMeta.has(metaKey)) { this.onDeduplicate?.(options) return true } // Check against unlimited combined predicate // If we've loaded all data matching a where clause, we don't need to refetch subsets - if (this.unlimitedWhere !== undefined && options.where !== undefined) { - if (isWhereSubset(options.where, this.unlimitedWhere)) { + const unlimitedWhere = this.unlimitedWhereByMeta.get(metaKey) + if (unlimitedWhere !== undefined && options.where !== undefined) { + if (isWhereSubset(options.where, unlimitedWhere)) { this.onDeduplicate?.(options) return true // Data already loaded via unlimited call } @@ -132,13 +139,13 @@ export class DeduplicatedLoadSubset { // may be narrowed with a difference expression for the actual backend request. const trackingOptions = cloneOptions(options) const loadOptions = cloneOptions(options) - if (this.unlimitedWhere !== undefined && options.limit === undefined) { + if (unlimitedWhere !== undefined && options.limit === undefined) { // Compute difference to get only the missing data // We can only do this for unlimited queries // and we can only remove data that was loaded from unlimited queries // because with limited queries we have no way to express that we already loaded part of the matching data loadOptions.where = - minusWherePredicates(loadOptions.where, this.unlimitedWhere) ?? + minusWherePredicates(loadOptions.where, unlimitedWhere) ?? loadOptions.where } @@ -195,8 +202,8 @@ export class DeduplicatedLoadSubset { * state after the reset. This prevents old requests from repopulating cleared state. */ reset(): void { - this.unlimitedWhere = undefined - this.hasLoadedAllData = false + this.unlimitedWhereByMeta.clear() + this.hasLoadedAllDataByMeta.clear() this.limitedCalls = [] this.inflightCalls = [] // Increment generation to invalidate any in-flight completion handlers @@ -205,23 +212,26 @@ export class DeduplicatedLoadSubset { } private updateTracking(options: LoadSubsetOptions): void { + const metaKey = hashKey(options.meta ?? null) + // Update tracking based on whether this was a limited or unlimited call if (options.limit === undefined) { // Unlimited call - update combined where predicate // We ignore orderBy for unlimited calls as mentioned in requirements if (options.where === undefined) { // No where clause = all data loaded - this.hasLoadedAllData = true - this.unlimitedWhere = undefined - this.limitedCalls = [] - this.inflightCalls = [] - } else if (this.unlimitedWhere === undefined) { - this.unlimitedWhere = options.where + this.hasLoadedAllDataByMeta.add(metaKey) + this.unlimitedWhereByMeta.delete(metaKey) + } else if (this.unlimitedWhereByMeta.get(metaKey) === undefined) { + this.unlimitedWhereByMeta.set(metaKey, options.where) } else { - this.unlimitedWhere = unionWherePredicates([ - this.unlimitedWhere, - options.where, - ]) + this.unlimitedWhereByMeta.set( + metaKey, + unionWherePredicates([ + this.unlimitedWhereByMeta.get(metaKey)!, + options.where, + ]), + ) } } else { // Limited call - add to list for future subset checks diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 1598382d8..ec9fe5ccb 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -309,6 +309,11 @@ export type LoadSubsetOptions = { * @optional Available when called from CollectionSubscription, may be undefined for direct calls */ subscription?: Subscription + /** + * Query-level metadata passed via the query builder `.meta(...)`. + * This allows sync adapters to receive additional dynamic context. + */ + meta?: Record } export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise @@ -825,6 +830,11 @@ export interface SubscribeChangesOptions< * @internal */ onLoadSubsetResult?: (result: Promise | true) => void + /** + * Optional metadata to include in downstream loadSubset requests. + * @internal + */ + meta?: Record } export interface SubscribeChangesSnapshotOptions< diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 00292e37a..4d2a0e59a 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -237,3 +237,88 @@ export const DEFAULT_COMPARE_OPTIONS: CompareOptions = { nulls: `first`, stringSort: `locale`, } + +/** + * Check if a value is a plain object (not a class instance, Date, RegExp, etc). + * Handles edge cases like Object.create() and objects with modified prototypes. + * + * Adapted from: https://github.com/jonschlinkert/is-plain-object + * + * @param o - The value to check + * @returns True if the value is a plain object, false otherwise + * + * @example + * ```typescript + * isPlainObject({}) // true + * isPlainObject({ a: 1 }) // true + * isPlainObject(Object.create(null)) // true + * isPlainObject(new Date()) // false + * isPlainObject([]) // false + * isPlainObject(new MyClass()) // false + * ``` + */ +export function isPlainObject(o: any): o is Record { + if (!hasObjectPrototype(o)) { + return false + } + + // If has no constructor + const ctor = o.constructor + if (ctor === undefined) { + return true + } + + // If has modified prototype + const prot = ctor.prototype + if (!hasObjectPrototype(prot)) { + return false + } + + // If constructor does not have an Object-specific method + if (!prot.hasOwnProperty('isPrototypeOf')) { + return false + } + + // Handles Objects created by Object.create() + if (Object.getPrototypeOf(o) !== Object.prototype) { + return false + } + + // Most likely a plain Object + return true +} + +function hasObjectPrototype(o: any): boolean { + return Object.prototype.toString.call(o) === '[object Object]' +} + +/** + * Hash function for objects that creates a stable, order-agnostic hash. + * This is the same algorithm used by @tanstack/query-core's hashKey function. + * + * Object keys are sorted alphabetically before stringifying, ensuring that + * { a: 1, b: 2 } and { b: 2, a: 1 } produce identical hashes. + * + * @param value - The value to hash + * @returns A stable string hash of the value + * + * @example + * ```typescript + * hashKey({ scope: 'tenant-a', includeClients: true }) + * // Same as: + * hashKey({ includeClients: true, scope: 'tenant-a' }) + * ``` + */ +export function hashKey(value: unknown): string { + return JSON.stringify(value, (_, val) => + isPlainObject(val) + ? Object.keys(val) + .sort() + .reduce((result, key) => { + result[key] = val[key] + return result + }, {} as any) + : val, + ) +} + diff --git a/packages/db/tests/query/builder/buildQuery.test.ts b/packages/db/tests/query/builder/buildQuery.test.ts index 292d6f892..d7ec5941b 100644 --- a/packages/db/tests/query/builder/buildQuery.test.ts +++ b/packages/db/tests/query/builder/buildQuery.test.ts @@ -144,4 +144,32 @@ describe(`buildQuery function`, () => { expect(select).toHaveProperty(`content`) expect(select).toHaveProperty(`user`) }) + + it(`attaches query metadata to query IR`, () => { + const query = buildQuery((q) => + q + .from({ employees: employeesCollection }) + .meta({ scope: `tenant-1`, includeClients: true }), + ) + + expect(query.meta).toEqual({ + scope: `tenant-1`, + includeClients: true, + }) + }) + + it(`merges metadata across multiple meta calls`, () => { + const query = buildQuery((q) => + q + .from({ employees: employeesCollection }) + .meta({ scope: `tenant-1`, includeClients: false }) + .meta({ includeClients: true, includeParent: true }), + ) + + expect(query.meta).toEqual({ + scope: `tenant-1`, + includeClients: true, + includeParent: true, + }) + }) }) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 5fd1edec0..845b71d13 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -77,6 +77,47 @@ describe(`createLiveQueryCollection`, () => { expect(activeUsers.size).toBe(2) // Only Alice and Bob are active }) + it(`passes query-level meta to loadSubset`, async () => { + type Item = { id: number; name: string } + const loadSubsetSpy = vi.fn().mockReturnValue(true) + + const sourceCollection = createCollection({ + id: `meta-forwarding-source`, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: loadSubsetSpy, + } + }, + }, + }) + + const liveQuery = createLiveQueryCollection((q) => + q + .from({ item: sourceCollection }) + .where(({ item }) => eq(item.id, 1)) + .meta({ scope: `tenant-1`, includeClients: true }), + ) + + await liveQuery.preload() + + expect(loadSubsetSpy).toHaveBeenCalled() + const [firstCall] = loadSubsetSpy.mock.calls + expect(firstCall?.[0]).toEqual( + expect.objectContaining({ + meta: { + scope: `tenant-1`, + includeClients: true, + }, + }), + ) + }) + it(`should work with both callback and QueryBuilder instance via config`, async () => { // Test with callback const activeUsers1 = createLiveQueryCollection((q) => diff --git a/packages/db/tests/query/subset-dedupe.test.ts b/packages/db/tests/query/subset-dedupe.test.ts index 0234c31a1..4aebdb520 100644 --- a/packages/db/tests/query/subset-dedupe.test.ts +++ b/packages/db/tests/query/subset-dedupe.test.ts @@ -1006,4 +1006,60 @@ describe(`createDeduplicatedLoadSubset`, () => { expect(callCount).toBe(1) }) }) + + describe(`meta-aware deduplication`, () => { + it(`should NOT dedupe unlimited calls across different meta contexts`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) + + const where = gt(ref(`age`), val(10)) + + await deduplicated.loadSubset({ + where, + meta: { scope: `tenant-a` }, + }) + expect(callCount).toBe(1) + + await deduplicated.loadSubset({ + where, + meta: { scope: `tenant-b` }, + }) + expect(callCount).toBe(2) + }) + + it(`should dedupe when predicates and meta are identical`, async () => { + let callCount = 0 + const mockLoadSubset = () => { + callCount++ + return Promise.resolve() + } + + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: mockLoadSubset, + }) + + const where = gt(ref(`age`), val(10)) + const meta = { scope: `tenant-a`, includeClients: true } + + await deduplicated.loadSubset({ + where, + meta, + }) + expect(callCount).toBe(1) + + const result = await deduplicated.loadSubset({ + where: gt(ref(`age`), val(20)), + meta: { ...meta }, + }) + expect(result).toBe(true) + expect(callCount).toBe(1) + }) + }) }) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 7bc8f532b..ff41f2ae7 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -678,7 +678,11 @@ export function queryCollectionOptions( // Generate key using common function const key = generateQueryKeyFromOptions(opts) const hashedQueryKey = hashKey(key) - const extendedMeta = { ...meta, loadSubsetOptions: opts } + const extendedMeta = { + ...meta, + ...(opts.meta ?? {}), + loadSubsetOptions: opts, + } if (state.observers.has(hashedQueryKey)) { // We already have a query for this queryKey diff --git a/packages/query-db-collection/src/serialization.ts b/packages/query-db-collection/src/serialization.ts index 9849c4bd3..b90df4a81 100644 --- a/packages/query-db-collection/src/serialization.ts +++ b/packages/query-db-collection/src/serialization.ts @@ -50,6 +50,12 @@ export function serializeLoadSubsetOptions( result.offset = options.offset } + // Include dynamic query metadata to avoid cache key collisions between + // requests that share predicates but differ by runtime context (e.g. tenant scope). + if (options.meta && Object.keys(options.meta).length > 0) { + result.meta = serializeValue(options.meta) + } + return Object.keys(result).length === 0 ? undefined : JSON.stringify(result) } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 5c8a8d19a..0cf629a3a 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -449,6 +449,135 @@ describe(`QueryCollection`, () => { ) }) + it(`should merge query-level meta into queryFn context`, async () => { + const queryFn = vi.fn().mockResolvedValue([]) + + const config: QueryCollectionConfig = { + id: `dynamic-meta-test`, + queryClient, + queryKey: [`dynamic-meta-test`], + queryFn, + getKey, + syncMode: `on-demand`, + meta: { + staticFlag: `from-config`, + }, + } + + const collection = createCollection(queryCollectionOptions(config)) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.id, `1`)) + .meta({ + scope: `tenant-1`, + includeClients: true, + }), + }) + + await liveQuery.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalled() + }) + + expect(queryFn).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ + staticFlag: `from-config`, + scope: `tenant-1`, + includeClients: true, + loadSubsetOptions: expect.objectContaining({ + meta: expect.objectContaining({ + scope: `tenant-1`, + includeClients: true, + }), + }), + }), + }), + ) + }) + + it(`should prefer query-level meta values over collection-level meta`, async () => { + const queryFn = vi.fn().mockResolvedValue([]) + + const config: QueryCollectionConfig = { + id: `dynamic-meta-precedence-test`, + queryClient, + queryKey: [`dynamic-meta-precedence-test`], + queryFn, + getKey, + syncMode: `on-demand`, + meta: { + scope: `collection-scope`, + includeClients: false, + }, + } + + const collection = createCollection(queryCollectionOptions(config)) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.id, `1`)) + .meta({ + scope: `query-scope`, + includeClients: true, + }), + }) + + await liveQuery.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalled() + }) + + const context = queryFn.mock.calls[0]?.[0] + expect(context?.meta?.scope).toBe(`query-scope`) + expect(context?.meta?.includeClients).toBe(true) + }) + + it(`should use distinct query keys when only meta differs`, async () => { + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) + + const config: QueryCollectionConfig = { + id: `dynamic-meta-query-key-test`, + queryClient, + queryKey: [`dynamic-meta-query-key-test`], + queryFn, + getKey, + syncMode: `on-demand`, + } + + const collection = createCollection(queryCollectionOptions(config)) + + const queryA = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.id, `1`)) + .meta({ scope: `tenant-a` }), + }) + + const queryB = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.id, `1`)) + .meta({ scope: `tenant-b` }), + }) + + await queryA.preload() + await queryB.preload() + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + }) + }) + describe(`loadSubsetOptions passed to queryFn`, () => { it(`should pass eq where clause to queryFn via loadSubsetOptions`, async () => { const queryKey = [`loadSubsetTest`] diff --git a/packages/query-db-collection/tests/serialization.test.ts b/packages/query-db-collection/tests/serialization.test.ts new file mode 100644 index 000000000..1f19da6d7 --- /dev/null +++ b/packages/query-db-collection/tests/serialization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { serializeLoadSubsetOptions } from '../src/serialization' + +describe(`serializeLoadSubsetOptions`, () => { + it(`includes meta in serialized output`, () => { + const serialized = serializeLoadSubsetOptions({ + limit: 10, + meta: { scope: `tenant-a`, includeClients: true }, + }) + + expect(serialized).toContain(`"meta"`) + expect(serialized).toContain(`"scope":"tenant-a"`) + expect(serialized).toContain(`"includeClients":true`) + }) + + it(`produces different keys when only meta differs`, () => { + const serializedA = serializeLoadSubsetOptions({ + limit: 10, + meta: { scope: `tenant-a` }, + }) + + const serializedB = serializeLoadSubsetOptions({ + limit: 10, + meta: { scope: `tenant-b` }, + }) + + expect(serializedA).not.toEqual(serializedB) + }) +}) From b764e17da4640ffae2ab17698cda4317c41e97d9 Mon Sep 17 00:00:00 2001 From: Karol Date: Sat, 14 Mar 2026 16:48:10 +0100 Subject: [PATCH 2/2] Adding changeset. --- .changeset/meta-in-queries.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/meta-in-queries.md diff --git a/.changeset/meta-in-queries.md b/.changeset/meta-in-queries.md new file mode 100644 index 000000000..04356376f --- /dev/null +++ b/.changeset/meta-in-queries.md @@ -0,0 +1,8 @@ +--- +'@tanstack/query-db-collection': minor +'@tanstack/db': minor +--- + +Add support for meta field in queries to store query metadata + +This release adds a new `meta` field to queries, allowing developers to attach arbitrary metadata to query definitions. The metadata is preserved throughout the query lifecycle and is accessible in live queries and subscription events.