Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/query-once-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': minor
---

Add `queryOnce` helper for one-shot query execution, including `findOne()` support and optional QueryBuilder configs.
31 changes: 31 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The result types are automatically inferred from your query structure, providing
## Table of Contents

- [Creating Live Query Collections](#creating-live-query-collections)
- [One-shot Queries with queryOnce](#one-shot-queries-with-queryonce)
- [From Clause](#from-clause)
- [Where Clauses](#where-clauses)
- [Select Projections](#select)
Expand Down Expand Up @@ -114,6 +115,36 @@ const activeUsers = createLiveQueryCollection((q) =>
)
```

## One-shot Queries with queryOnce

If you need a one-time snapshot (no ongoing reactivity), use `queryOnce`. It
creates a live query collection, preloads it, extracts the results, and cleans
up automatically so you do not have to remember to call `cleanup()`.

```ts
import { eq, queryOnce } from '@tanstack/db'

// Basic one-shot query
const activeUsers = await queryOnce((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({ id: user.id, name: user.name }))
)

// Single result with findOne()
const user = await queryOnce((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.id, userId))
.findOne()
)
```

Use `queryOnce` for scripts, background tasks, data export, or AI/LLM context
building. `findOne()` resolves to `undefined` when no rows match. For UI
bindings and reactive updates, use live queries instead.

### Using with Frameworks

In React, you can use the `useLiveQuery` hook:
Expand Down
3 changes: 3 additions & 0 deletions packages/db/src/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export {
liveQueryCollectionOptions,
} from './live-query-collection.js'

// One-shot query execution
export { queryOnce, type QueryOnceConfig } from './query-once.js'

export { type LiveQueryCollectionConfig } from './live/types.js'
export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js'

Expand Down
115 changes: 115 additions & 0 deletions packages/db/src/query/query-once.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { createLiveQueryCollection } from './live-query-collection.js'
import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
import type { Context, InferResultType } from './builder/types.js'

/**
* Configuration options for queryOnce
*/
export interface QueryOnceConfig<TContext extends Context> {
/**
* Query builder function that defines the query
*/
query:
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
| QueryBuilder<TContext>
// Future: timeout, signal, etc.
}

// Overload 1: Simple query function returning array (non-single result)
/**
* Executes a one-shot query and returns the results as an array.
*
* This function creates a live query collection, preloads it, extracts the results,
* and automatically cleans up the collection. It's ideal for:
* - AI/LLM context building
* - Data export
* - Background processing
* - Testing
*
* @param queryFn - A function that receives the query builder and returns a query
* @returns A promise that resolves to an array of query results
*
* @example
* ```typescript
* // Basic query
* const users = await queryOnce((q) =>
* q.from({ user: usersCollection })
* )
*
* // With filtering and projection
* const activeUserNames = await queryOnce((q) =>
* q.from({ user: usersCollection })
* .where(({ user }) => eq(user.active, true))
* .select(({ user }) => ({ name: user.name }))
* )
* ```
*/
export function queryOnce<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
): Promise<InferResultType<TContext>>

// Overload 2: Config object form returning array (non-single result)
/**
* Executes a one-shot query using a configuration object.
*
* @param config - Configuration object with the query function
* @returns A promise that resolves to an array of query results
*
* @example
* ```typescript
* const recentOrders = await queryOnce({
* query: (q) =>
* q.from({ order: ordersCollection })
* .orderBy(({ order }) => desc(order.createdAt))
* .limit(100),
* })
* ```
*/
export function queryOnce<TContext extends Context>(
config: QueryOnceConfig<TContext>,
): Promise<InferResultType<TContext>>

// Implementation
export async function queryOnce<TContext extends Context>(
configOrQuery:
| QueryOnceConfig<TContext>
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>),
): Promise<InferResultType<TContext>> {
// Normalize input
const config: QueryOnceConfig<TContext> =
typeof configOrQuery === `function`
? { query: configOrQuery }
: configOrQuery

const query = (q: InitialQueryBuilder) => {
const queryConfig = config.query
return typeof queryConfig === `function` ? queryConfig(q) : queryConfig
}

// Create collection with minimal GC time; preload handles sync start
const collection = createLiveQueryCollection({
query,
gcTime: 1, // Cleanup in next tick when no subscribers (0 disables GC)
})

try {
// Wait for initial data load
await collection.preload()

// Check if this is a single-result query (findOne was called)
const isSingleResult =
(collection.config as { singleResult?: boolean }).singleResult === true

// Extract and return results
if (isSingleResult) {
const first = collection.values().next().value as
| InferResultType<TContext>
| undefined
return first as InferResultType<TContext>
}
return collection.toArray as InferResultType<TContext>
} finally {
// Always cleanup, even on error
await collection.cleanup()
}
}
Loading
Loading