Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/meta-in-queries.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
163 changes: 163 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- [Reactive Effects (createEffect)](#reactive-effects-createeffect)
- [Expression Functions Reference](#expression-functions-reference)
Expand Down Expand Up @@ -1487,6 +1488,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.
Expand Down
15 changes: 15 additions & 0 deletions packages/db/src/collection/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
/** Callback that receives the raw loadSubset result for external tracking */
onLoadSubsetResult?: (result: Promise<void> | true) => void
}
Expand All @@ -39,6 +41,8 @@ type RequestLimitedSnapshotOptions = {
minValues?: Array<unknown>
/** Row offset for offset-based pagination (passed to sync layer) */
offset?: number
/** Optional metadata to include in loadSubset */
meta?: Record<string, unknown>
/** Whether to track the loadSubset promise on this subscription (default: true) */
trackLoadSubsetPromise?: boolean
/** Callback that receives the raw loadSubset result for external tracking */
Expand All @@ -49,6 +53,8 @@ type CollectionSubscriptionOptions = {
includeInitialState?: boolean
/** Pre-compiled expression for filtering changes */
whereExpression?: BasicExpression<boolean>
/** Optional metadata included in all loadSubset requests for this subscription */
meta?: Record<string, unknown>
/** Callback to call when the subscription is unsubscribed */
onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -425,6 +435,7 @@ export class CollectionSubscription
limit,
minValues,
offset,
meta,
trackLoadSubsetPromise: shouldTrackLoadSubsetPromise = true,
onLoadSubsetResult,
}: RequestLimitedSnapshotOptions) {
Expand Down Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,33 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
}) 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<string, unknown>): QueryBuilder<TContext> {
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.
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface QueryIR {
orderBy?: OrderBy
limit?: Limit
offset?: Offset
meta?: Record<string, unknown>
distinct?: true
singleResult?: true

Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/query/live/collection-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class CollectionSubscriber<
...(includeInitialState && { includeInitialState }),
whereExpression,
onStatusChange,
meta: this.collectionConfigBuilder.query.meta,
orderBy: hints.orderBy,
limit: hints.limit,
onLoadSubsetResult,
Expand Down Expand Up @@ -272,6 +273,7 @@ export class CollectionSubscriber<
const subscription = this.collection.subscribeChanges(sendChangesInRange, {
whereExpression,
onStatusChange,
meta: this.collectionConfigBuilder.query.meta,
})
subscriptionHolder.current = subscription

Expand Down
7 changes: 7 additions & 0 deletions packages/db/src/query/predicate-utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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).
//
Expand Down
Loading