diff --git a/.changeset/modern-dbs-hydrate.md b/.changeset/modern-dbs-hydrate.md
new file mode 100644
index 0000000000..87d98466e3
--- /dev/null
+++ b/.changeset/modern-dbs-hydrate.md
@@ -0,0 +1,25 @@
+---
+'@tanstack/db': minor
+'@tanstack/react-db': minor
+'@tanstack/svelte-db': minor
+'@tanstack/react-router-with-db': minor
+'@tanstack/electric-db-collection': minor
+'@tanstack/query-db-collection': patch
+'@tanstack/powersync-db-collection': patch
+'@tanstack/rxdb-db-collection': patch
+'@tanstack/trailbase-db-collection': patch
+'@tanstack/db-sqlite-persistence-core': patch
+---
+
+Add SSR through request-scoped `DbClient` instances, collection descriptors,
+explicit collection-row hydration, live-query result snapshots, adapter sync
+metadata, and React and Svelte descriptor resolution.
+
+React live queries now derive identity from structured query IR. Opaque queries
+can provide `queryKey`; legacy dependency arrays and unkeyed opaque queries keep
+working with development warnings until 1.0.
+
+Add TanStack Router integration that streams live queries discovered during a
+Suspense render as pending promises which resolve to ordered result snapshots.
+The browser starts normal source sync and atomically replaces the snapshot when
+its live result is ready.
diff --git a/.github/SSR_RELEASE_PLAN.md b/.github/SSR_RELEASE_PLAN.md
new file mode 100644
index 0000000000..8df2f4fbbf
--- /dev/null
+++ b/.github/SSR_RELEASE_PLAN.md
@@ -0,0 +1,69 @@
+# TanStack DB SSR Release Plan
+
+## Release Goal
+
+Ship TanStack DB SSR as a single coherent story:
+
+- explicit collection-row hydration and live-query result snapshots through
+ `DbClient`
+- React and Svelte provider and descriptor resolution
+- derived live query identity with `queryKey` only when necessary
+- backwards-compatible dependency arrays with dev warnings until 1.0
+- a working TanStack Start demo and E2E proof
+
+## Pre-release Validation
+
+- Run `pnpm --filter @tanstack/db test`.
+- Run `pnpm --filter @tanstack/react-db test`.
+- Run `pnpm --filter @tanstack/svelte-db test`.
+- Run `pnpm --filter @tanstack/react-router-with-db test` (includes type
+ tests).
+- Run `pnpm --filter @tanstack/query-db-collection test`.
+- Run `pnpm --filter @tanstack/db-sqlite-persistence-core test`.
+- Run `pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e`.
+- Run `pnpm --filter @tanstack/db-example-react-next-ssr-e2e test:e2e`.
+- Run `pnpm test:docs`.
+- Run `pnpm test:sherif`.
+- Run `pnpm build`.
+
+## Demo
+
+- Live URL: https://tanstack-db-ssr-demo.netlify.app/ssr-db
+- Deploy `examples/react/start-ssr-e2e` to an SSR-capable host.
+- Verify the deployed `/ssr-db` route serves SSR HTML with hydrated rows.
+- Verify browser hydration succeeds without console/page errors.
+- Verify the streamed collection chunk updates the live query.
+- Verify `/ssr-db-stream` streams a projected result, omits source-only data,
+ and hands off to browser sync.
+- Run `PLAYWRIGHT_BASE_URL=https://tanstack-db-ssr-demo.netlify.app pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e:hosted`.
+- Add the live URL to the PR description and release notes.
+
+## Docs
+
+- Publish the [SSR and Hydration guide](../docs/guides/ssr.md).
+- Link the guide from overview, quick start, live queries, and React overview.
+- Regenerate API reference docs in a dedicated docs-maintenance pass if broad
+ TypeDoc output churn is acceptable.
+- Confirm docs explain when `queryKey` is necessary and when it should be
+ omitted.
+- Confirm docs say dependency arrays warn now and are removed in 1.0.
+
+## Migration Messaging
+
+- Lead with: explicit collection preloads transport normalized rows; live-query
+ preloads transport only their result snapshot.
+- Emphasize that existing apps keep working.
+- State that `createCollection(...)` remains available, but SSR apps should use
+ `collectionOptions(...)` plus `DbClient`.
+- Explain that React dependency arrays are deprecated with a 1.0 removal path.
+- Show `queryKey` only for opaque functional query logic or hot render paths.
+
+## Announcement Checklist
+
+- PR description includes high-level summary, migration cheat sheet, and test
+ commands.
+- Release notes include a "No removals in this release" compatibility section.
+- Discord announcement links the SSR guide and live demo.
+- Example migration diff is available from the Start SSR demo.
+- Follow-up issues are filed for the remaining framework adapters and API
+ reference generation if they are not part of the shipping PR.
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 3678cd19f2..a340502b46 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -45,6 +45,7 @@ jobs:
run: |
pnpm --filter @tanstack/db-ivm build
pnpm --filter @tanstack/db build
+ pnpm --filter @tanstack/react-db build
pnpm --filter @tanstack/electric-db-collection build
pnpm --filter @tanstack/offline-transactions build
pnpm --filter @tanstack/query-db-collection build
@@ -68,6 +69,21 @@ jobs:
env:
ELECTRIC_URL: http://localhost:3000
+ - name: Install Playwright browsers
+ run: |
+ cd examples/react/start-ssr-e2e
+ pnpm exec playwright install --with-deps chromium
+
+ - name: Run React Start SSR E2E tests
+ run: |
+ cd examples/react/start-ssr-e2e
+ pnpm test:e2e
+
+ - name: Run Next.js SSR E2E tests
+ run: |
+ cd examples/react/next-ssr-e2e
+ pnpm test:e2e
+
- name: Run Node SQLite persisted collection E2E tests
run: |
cd packages/node-db-sqlite-persistence
diff --git a/.gitignore b/.gitignore
index 4ad9ee25d4..3bfb31bb9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ yarn.lock
build
coverage
dist
+playwright-report
+test-results
# misc
.DS_Store
@@ -19,6 +21,7 @@ dist
.env.test.local
.env.production.local
.next
+next-env.d.ts
npm-debug.log*
yarn-debug.log*
diff --git a/docs/collections/local-only-collection.md b/docs/collections/local-only-collection.md
index 17bf51ef4b..2f016baac5 100644
--- a/docs/collections/local-only-collection.md
+++ b/docs/collections/local-only-collection.md
@@ -192,10 +192,12 @@ export const modalStateCollection = createCollection(
// Use in component
function UserProfileModal() {
- const { data: modals } = useLiveQuery((q) =>
- q.from({ modal: modalStateCollection })
- .where(({ modal }) => eq(modal.id, 'user-profile'))
- )
+ const { data: modals } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ modal: modalStateCollection })
+ .where(({ modal }) => eq(modal.id, 'user-profile')),
+ })
const modalState = modals[0]
@@ -248,10 +250,12 @@ export const formDraftsCollection = createCollection(
// Use in component
function CreatePostForm() {
- const { data: drafts } = useLiveQuery((q) =>
- q.from({ draft: formDraftsCollection })
- .where(({ draft }) => eq(draft.id, 'new-post'))
- )
+ const { data: drafts } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ draft: formDraftsCollection })
+ .where(({ draft }) => eq(draft.id, 'new-post')),
+ })
const currentDraft = drafts[0]
diff --git a/docs/collections/local-storage-collection.md b/docs/collections/local-storage-collection.md
index 171e5cb9b4..59d3a981c0 100644
--- a/docs/collections/local-storage-collection.md
+++ b/docs/collections/local-storage-collection.md
@@ -263,10 +263,12 @@ export const userPreferencesCollection = createCollection(
// Use in component
function SettingsPanel() {
- const { data: prefs } = useLiveQuery((q) =>
- q.from({ pref: userPreferencesCollection })
- .where(({ pref }) => eq(pref.id, 'current-user'))
- )
+ const { data: prefs } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ pref: userPreferencesCollection })
+ .where(({ pref }) => eq(pref.id, 'current-user')),
+ })
const currentPrefs = prefs[0]
diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md
index 7389c77caa..a5f4f13680 100644
--- a/docs/collections/query-collection.md
+++ b/docs/collections/query-collection.md
@@ -25,22 +25,26 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
```typescript
import { QueryClient } from "@tanstack/query-core"
-import { createCollection } from "@tanstack/db"
+import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
const queryClient = new QueryClient()
+const db = new DbClient({ queryClient })
-const todosCollection = createCollection(
+const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
+ id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json()
},
- queryClient,
+ queryClient: client.requireDependency("queryClient"),
getKey: (item) => item.id,
})
)
+
+const todos = db.collection(todosCollection)
```
## Configuration Options
@@ -54,15 +58,15 @@ The `queryCollectionOptions` function accepts the following options:
- `queryClient`: TanStack Query client instance
- `getKey`: Function to extract the unique key from an item
-### Creating Collection Options from a Runtime QueryClient
-
-`queryCollectionOptions` needs a `queryClient` when the collection options are created. In SSR, TanStack Start, tests, or multi-tenant apps, that `QueryClient` is often request-local or route-local rather than module-global.
+### Request-scoped QueryClient
-Keep shared collection configuration in a factory function that accepts the runtime `QueryClient`:
+`queryCollectionOptions` needs a `queryClient`. In SSR, TanStack Start, tests,
+or multi-tenant apps, that client is request-local rather than module-global.
+Put it on `DbClient`, then resolve it inside the collection descriptor factory:
```typescript
import { QueryClient } from "@tanstack/query-core"
-import { createCollection } from "@tanstack/db"
+import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
interface Todo {
@@ -70,53 +74,42 @@ interface Todo {
title: string
}
-export function todoCollectionOptions(queryClient: QueryClient) {
- return queryCollectionOptions({
+export const todoCollection = collectionOptions("todos", (client) =>
+ queryCollectionOptions({
+ id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise>
},
- queryClient,
+ queryClient: client.requireDependency("queryClient"),
getKey: (todo) => todo.id,
})
-}
-
-function createTodosCollection(queryClient: QueryClient) {
- return createCollection(todoCollectionOptions(queryClient))
-}
-```
-
-Create the collection once for each scoped `QueryClient` and parameter set, then reuse that `Collection` instance. Creating multiple collections with the same `QueryClient` and `queryKey` gives each collection its own materialized state, lifecycle, subscriptions, and optimistic mutations.
-
-In request-scoped environments, store the collection in request or router context. For client-side scopes, memoize by `QueryClient`:
-
-```typescript
-type TodosCollection = ReturnType
-
-const collectionsByClient = new WeakMap()
-
-export function getTodosCollection(
- queryClient: QueryClient,
-): TodosCollection {
- let collection = collectionsByClient.get(queryClient)
-
- if (!collection) {
- collection = createTodosCollection(queryClient)
- collectionsByClient.set(queryClient, collection)
- }
+)
- return collection
+export function createRequestClients() {
+ const queryClient = new QueryClient()
+ const dbClient = new DbClient({ queryClient })
+ return { queryClient, dbClient }
}
```
-Avoid calling `createCollection(todoCollectionOptions(queryClient))` independently during render or in each consumer. Share the stable collection instance for the lifetime of that `QueryClient` scope.
+`dbClient.collection(todoCollection)` memoizes one collection instance for that
+descriptor and client. A second `DbClient` materializes fresh adapter state and
+uses its own `QueryClient`.
-This keeps SSR and request-scoped code from sharing a global `QueryClient` while keeping each collection instance stable within its scope.
+Passing `queryClient` directly to `queryCollectionOptions` remains supported for
+`createCollection(...)` and existing apps. When a descriptor is materialized,
+an explicit `DbClient` dependency takes precedence; the configured
+`queryClient` is the backwards-compatible fallback.
### Business-Scoped Collection Factories
-A tenant, project, account, or route parameter can define a **business scope**: the server resource that a collection represents. Include the scope in both the Query key and `queryFn`. This extends the [runtime `QueryClient` factory pattern](#creating-collection-options-from-a-runtime-queryclient) with an explicit scope parameter:
+A tenant, project, account, or route parameter can define a **business scope**:
+the server resource that a collection represents. Include the scope in the
+descriptor id, Query key, and `queryFn`. This extends the
+[request-scoped QueryClient pattern](#request-scoped-queryclient) with an
+explicit scope parameter:
```typescript
interface Todo {
@@ -130,54 +123,48 @@ async function fetchProjectTodos(projectId: string): Promise> {
return response.json()
}
-export function createProjectTodosCollection(
- queryClient: QueryClient,
+function createProjectTodosDescriptor(
projectId: string,
) {
- return createCollection(
+ return collectionOptions(`project:${projectId}:todos`, (client) =>
queryCollectionOptions({
+ id: `project:${projectId}:todos`,
queryKey: ["projects", projectId, "todos"],
queryFn: () => fetchProjectTodos(projectId),
- queryClient,
+ queryClient: client.requireDependency("queryClient"),
getKey: (todo) => todo.id,
})
)
}
```
-The scope is part of the collection's identity. Memoize by both the `QueryClient` and a stable scope key so consumers of the same project share one collection:
+The scope is part of the descriptor identity. Memoize descriptors by a stable
+scope key; `DbClient` handles collection memoization and QueryClient ownership:
```typescript
-type ProjectTodosCollection = ReturnType
+type ProjectTodosDescriptor = ReturnType
-const projectCollections = new WeakMap<
- QueryClient,
- Map
->()
+const projectDescriptors = new Map()
-export function getProjectTodosCollection(
- queryClient: QueryClient,
+export function getProjectTodosDescriptor(
projectId: string,
-): ProjectTodosCollection {
- let collectionsByProject = projectCollections.get(queryClient)
-
- if (!collectionsByProject) {
- collectionsByProject = new Map()
- projectCollections.set(queryClient, collectionsByProject)
+): ProjectTodosDescriptor {
+ let descriptor = projectDescriptors.get(projectId)
+ if (!descriptor) {
+ descriptor = createProjectTodosDescriptor(projectId)
+ projectDescriptors.set(projectId, descriptor)
}
-
- let collection = collectionsByProject.get(projectId)
-
- if (!collection) {
- collection = createProjectTodosCollection(queryClient, projectId)
- collectionsByProject.set(projectId, collection)
- }
-
- return collection
+ return descriptor
}
+
+const todos = dbClient.collection(getProjectTodosDescriptor(projectId))
```
-For multiple scope values, use nested maps or a collision-safe stable key that includes every value. Do not call the factory on each render. In a long-lived client, user-selected scopes can make the map grow without bound. Remove unused entries and call `await collection.cleanup()` when your application owns their lifecycle. Request-local maps can be discarded with the request.
+For multiple scope values, use nested maps or a collision-safe stable key that
+includes every value. Do not create a descriptor on each render. In a
+long-lived app, user-selected scopes can make the map grow without bound; remove
+unused descriptors and call `await dbClient.cleanup()` when the client scope
+ends. Request-local maps can be discarded with the request.
A business scope is separate from a **relational subset** requested by a live query. With `syncMode: "on-demand"`, `LoadSubsetOptions` describes predicates, ordering, limits, and offsets within one business-scoped collection. These options reach `queryFn` through `ctx.meta.loadSubsetOptions` and determine the subset Query keys. See [QueryFn and Predicate Push-Down](#queryfn-and-predicate-push-down).
@@ -298,11 +285,12 @@ If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tan
```typescript
import { QueryClient } from "@tanstack/query-core"
-import { createCollection } from "@tanstack/db"
+import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { queryOptions } from "@tanstack/react-query"
const queryClient = new QueryClient()
+const db = new DbClient({ queryClient })
const listOptions = queryOptions({
queryKey: ["todos"],
@@ -312,14 +300,17 @@ const listOptions = queryOptions({
},
})
-const todosCollection = createCollection(
+const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
+ id: "todos",
...listOptions,
queryFn: (context) => listOptions.queryFn!(context),
- queryClient,
+ queryClient: client.requireDependency("queryClient"),
getKey: (item) => item.id,
}),
)
+
+const todos = db.collection(todosCollection)
```
If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`.
diff --git a/docs/collections/trailbase-collection.md b/docs/collections/trailbase-collection.md
index 938e714a52..741cd8d80d 100644
--- a/docs/collections/trailbase-collection.md
+++ b/docs/collections/trailbase-collection.md
@@ -194,11 +194,13 @@ export const todosCollection = createCollection(
// Use in component
function TodoList() {
- const { data: todos } = useLiveQuery((q) =>
- q.from({ todo: todosCollection })
- .where(({ todo }) => not(todo.completed))
- .orderBy(({ todo }) => todo.created_at, 'desc')
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => not(todo.completed))
+ .orderBy(({ todo }) => todo.created_at, 'desc'),
+ })
const addTodo = (text: string) => {
todosCollection.insert({
diff --git a/docs/config.json b/docs/config.json
index 09ca7c62ba..94a08ef0fd 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -30,6 +30,10 @@
"label": "Live Queries",
"to": "guides/live-queries"
},
+ {
+ "label": "SSR and Hydration",
+ "to": "guides/ssr"
+ },
{
"label": "Mutations",
"to": "guides/mutations"
diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md
index f68d013d2f..857559e4f6 100644
--- a/docs/framework/react/overview.md
+++ b/docs/framework/react/overview.md
@@ -17,19 +17,34 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio
## Basic Usage
+Create a `DbClient` and provide it to your React tree:
+
+```tsx
+import { DbClient, DbProvider } from '@tanstack/react-db'
+
+const dbClient = new DbClient()
+
+root.render(
+
+
+
+)
+```
+
### useLiveQuery
The `useLiveQuery` hook creates a live query that automatically updates your component when data changes:
```tsx
-import { useLiveQuery, eq } from '@tanstack/react-db'
+import { and, eq, gt, useDbClient, useLiveQuery } from '@tanstack/react-db'
function TodoList() {
- const { data, isLoading } = useLiveQuery((q) =>
- q.from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.completed, false))
- .select(({ todos }) => ({ id: todos.id, text: todos.text }))
- )
+ const { data, isLoading } = useLiveQuery({
+ query: (q) =>
+ q.from({ todos: todoCollection })
+ .where(({ todos }) => eq(todos.completed, false))
+ .select(({ todos }) => ({ id: todos.id, text: todos.text })),
+ })
if (isLoading) return Loading...
@@ -41,29 +56,61 @@ function TodoList() {
}
```
-### Dependency Arrays
+### Query Identity
-All query hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array as their last parameter. This array works similarly to React's `useEffect` dependencies - when any value in the array changes, the query is recreated and re-executed.
+React live query hooks derive the live query identity from structured query IR by default. The hook runs the query builder, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created.
-#### When to Use Dependency Arrays
-
-Use dependency arrays when your query depends on external reactive values (props, state, or other hooks):
+That means normal structured queries do not need a separate `queryKey`. Collection descriptors provide stable collection IDs, and captured values inside structured expressions become part of the derived identity:
```tsx
function FilteredTodos({ minPriority }: { minPriority: number }) {
- const { data } = useLiveQuery(
- (q) => q.from({ todos: todosCollection })
+ const { data } = useLiveQuery({
+ query: (q) => q.from({ todos: todoCollection })
.where(({ todos }) => gt(todos.priority, minPriority)),
- [minPriority] // Re-run when minPriority changes
- )
+ })
return {data.length} high-priority todos
}
```
-#### What Happens When Dependencies Change
+#### Collection Hooks
+
+`useLiveQuery` resolves collection descriptors from `DbProvider` automatically. Create small collection hooks when components need imperative collection methods like `insert`, `update`, `delete`, or `preload`:
+
+```tsx
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
+```
+
+#### When to Use Query Keys
-When a dependency value changes:
+Use `queryKey` only when DB cannot derive identity from structured IR, or when you intentionally want to avoid deriving identity on a hot render path. The common case is a functional query variant such as `.fn.where`, `.fn.select`, or `.fn.having`:
+
+```tsx
+function SearchTodos({ search }: { search: string }) {
+ const { data } = useLiveQuery({
+ queryKey: [todoCollection.id, 'search', search],
+ query: (q) => q.from({ todos: todoCollection })
+ .fn.where(({ todos }) =>
+ todos.text.toLowerCase().includes(search.toLowerCase())
+ ),
+ })
+
+ return {data.length} matching todos
+}
+```
+
+Before 1.0, an unhashable query warns in development and keeps its legacy
+mount-stable identity. The query still runs, but captured values inside opaque
+logic only become reactive when they are represented in `queryKey`. In 1.0, an
+unhashable query without `queryKey` will throw. If deriving identity becomes
+expensive across renders, the hook warns once and suggests adding a `queryKey`
+as a performance escape hatch.
+
+#### What Happens When Identity Changes
+
+When the derived identity or explicit query key changes:
1. The previous live query collection is cleaned up
2. A new query is created with the updated values
3. The component re-renders with the new data
@@ -71,46 +118,41 @@ When a dependency value changes:
#### Best Practices
-**Include all external values used in the query:**
+**Use structured expressions when possible:**
```tsx
-// Good - all external values in deps
-const { data } = useLiveQuery(
- (q) => q.from({ todos: todosCollection })
+// Good - DB can derive identity from this structured IR
+const { data } = useLiveQuery({
+ query: (q) => q.from({ todos: todoCollection })
.where(({ todos }) => and(
eq(todos.userId, userId),
eq(todos.status, status)
)),
- [userId, status]
-)
-
-// Bad - missing dependencies
-const { data } = useLiveQuery(
- (q) => q.from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.userId, userId)),
- [] // Missing userId!
-)
+})
```
-**Empty array for static queries:**
+**Add a query key for opaque runtime logic:**
```tsx
-// No external dependencies - query never changes
-const { data } = useLiveQuery(
- (q) => q.from({ todos: todosCollection }),
- []
-)
+const { data } = useLiveQuery({
+ queryKey: [todoCollection.id, 'by-user-fn', userId],
+ query: (q) => q.from({ todos: todoCollection })
+ .fn.where(({ todos }) => todos.userId === userId),
+})
```
-**Omit the array for queries with no external dependencies:**
+**Omit query keys for static structured queries:**
```tsx
-// Same as above - no deps needed
-const { data } = useLiveQuery(
- (q) => q.from({ todos: todosCollection })
-)
+const { data } = useLiveQuery({
+ query: (q) => q.from({ todos: todoCollection }),
+})
```
+Dependency arrays are still accepted for backwards compatibility, but they warn in development and will be removed in 1.0.
+
+For SSR setup, collection hydration, and migration details, see the [SSR and Hydration guide](../../guides/ssr.md).
+
### useLiveInfiniteQuery
For paginated data with live updates, use `useLiveInfiniteQuery`:
@@ -125,14 +167,13 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
pageSize: 20,
getNextPageParam: (lastPage, allPages) =>
lastPage.length === 20 ? allPages.length : undefined
- },
- [category] // Re-run when category changes
+ }
)
```
`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` value and do not reject the promise.
-**Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection.
+The deprecated dependency array is only available when using the query function variant, not when passing a pre-created collection.
### useLiveSuspenseQuery
@@ -140,11 +181,10 @@ For React Suspense integration, use `useLiveSuspenseQuery`:
```tsx
function TodoList({ filter }: { filter: string }) {
- const { data } = useLiveSuspenseQuery(
- (q) => q.from({ todos: todosCollection })
+ const { data } = useLiveSuspenseQuery({
+ query: (q) => q.from({ todos: todoCollection })
.where(({ todos }) => eq(todos.filter, filter)),
- [filter] // Re-suspends when filter changes
- )
+ })
return (
@@ -162,4 +202,4 @@ function App() {
}
```
-When dependencies change, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready.
+When the derived identity or explicit `queryKey` changes, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready.
diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md
index e0515b7d9d..5eeea14c81 100644
--- a/docs/framework/svelte/overview.md
+++ b/docs/framework/svelte/overview.md
@@ -17,6 +17,27 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio
## Basic Usage
+### DbProvider
+
+Use one `DbClient` for each browser app and one per server request. `DbProvider`
+lets queries resolve collection descriptors against that client:
+
+```svelte
+
+
+
+
+
+```
+
+See [SSR and Hydration](../../guides/ssr.md) for server preloading,
+dehydration, and snapshot handoff.
+
### useLiveQuery
The `useLiveQuery` utility creates a live query that automatically updates your component when data changes. It returns reactive values powered by Svelte 5 runes:
@@ -26,11 +47,12 @@ The `useLiveQuery` utility creates a live query that automatically updates your
import { useLiveQuery } from '@tanstack/svelte-db'
import { eq } from '@tanstack/db'
- const query = useLiveQuery((q) =>
- q.from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.completed, false))
- .select(({ todos }) => ({ id: todos.id, text: todos.text }))
- )
+ const query = useLiveQuery({
+ query: (q) =>
+ q.from({ todos: todosCollection })
+ .where(({ todos }) => eq(todos.completed, false))
+ .select(({ todos }) => ({ id: todos.id, text: todos.text }))
+ })
{#if query.isLoading}
@@ -87,13 +109,13 @@ The query must include `orderBy`. The dependency array is available only with
the query-function form. You can also pass an ordered, pre-created live query
collection directly.
-### Dependency Arrays
-
-The `useLiveQuery` utility accepts an optional dependency array as its last parameter. When any value in the array changes, the query is recreated and re-executed.
+### Query Identity
-#### When to Use Dependency Arrays
+`useLiveQuery` derives identity from structured query IR. Svelte also tracks
+reactive values read while building the query, so normal builder queries do not
+need a dependency array or `queryKey`.
-Use dependency arrays when your query depends on external reactive values (props or state):
+Captured props and state become part of the derived identity:
```svelte
{query.data.length} high-priority todos
```
-**Note:** When using props or reactive state in the query, wrap them in a function for the dependency array.
-
-#### What Happens When Dependencies Change
-
-When a dependency value changes:
+When the derived identity changes:
1. The previous live query collection is cleaned up
2. A new query is created with the updated values
3. The component re-renders with the new data
4. The utility shows loading state again
-#### Best Practices
-
-**Include all external values used in the query:**
+Use `queryKey` for opaque functional variants such as `.fn.where`, because DB
+cannot inspect their closed-over values. Pass reactive key values through a
+getter:
```svelte
-
-{query.data.length} todos
-```
-
-**Empty array for static queries:**
-```svelte
-
-
-{query.data.length} todos
```
-**Omit the array for queries with no external dependencies:**
-
-```svelte
-
-
-{query.data.length} todos
-```
+The legacy dependency array remains supported. Prefer derived identity for
+structured queries and `queryKey` for opaque ones.
### Accessing Multiple Properties
diff --git a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md
index 05a023791e..8c0fae9760 100644
--- a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md
+++ b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md
@@ -48,8 +48,8 @@ controller. The query must include an `orderBy` clause.
```ts
function useLiveInfiniteQuery(
- queryFn,
- config,
+ queryFn,
+ config,
deps?): UseLiveInfiniteQueryReturn;
```
diff --git a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md
index ace644e59e..37894a398e 100644
--- a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md
+++ b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md
@@ -48,8 +48,8 @@ controller. The query must include an `orderBy` clause.
```ts
function useLiveInfiniteQuery(
- queryFn,
- config,
+ queryFn,
+ config,
deps?): UseLiveInfiniteQueryReturn;
```
diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md
index d1f4d55c4a..000585ccab 100644
--- a/docs/guides/collection-options-creator.md
+++ b/docs/guides/collection-options-creator.md
@@ -709,18 +709,23 @@ export function webSocketCollectionOptions(
## Usage Example
```typescript
-import { createCollection } from '@tanstack/react-db'
+import { DbClient, collectionOptions } from '@tanstack/react-db'
import { webSocketCollectionOptions } from './websocket-collection'
-const todos = createCollection(
+const db = new DbClient()
+
+const todosCollection = collectionOptions('todos', () =>
webSocketCollectionOptions({
+ id: 'todos',
url: 'ws://localhost:8080/todos',
getKey: (todo) => todo.id,
- schema: todoSchema
+ schema: todoSchema,
// Note: No onInsert/onUpdate/onDelete - handled by WebSocket automatically
})
)
+const todos = db.collection(todosCollection)
+
// Use the collection
todos.insert({ id: '1', text: 'Buy milk', completed: false })
diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md
index dfd4fa0b80..40bb758beb 100644
--- a/docs/guides/error-handling.md
+++ b/docs/guides/error-handling.md
@@ -91,7 +91,9 @@ const syncedCollection = createCollection(
// Component can check error state
function DataList() {
- const { data } = useLiveQuery((q) => q.from({ item: syncedCollection }))
+ const { data } = useLiveQuery({
+ query: (q) => q.from({ item: syncedCollection }),
+ })
const isError = syncedCollection.utils.isError
const errorCount = syncedCollection.utils.errorCount
diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md
index 9b6ee18b06..eb2bf08f04 100644
--- a/docs/guides/live-queries.md
+++ b/docs/guides/live-queries.md
@@ -164,14 +164,15 @@ bindings and reactive updates, use live queries instead.
In React, you can use the `useLiveQuery` hook:
```tsx
-import { useLiveQuery } from '@tanstack/react-db'
+import { eq, useLiveQuery } from '@tanstack/react-db'
function UserList() {
- const activeUsers = useLiveQuery((q) =>
- q
- .from({ user: usersCollection })
- .where(({ user }) => eq(user.active, true))
- )
+ const { data: activeUsers } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ user: usersCollection })
+ .where(({ user }) => eq(user.active, true)),
+ })
return (
@@ -206,7 +207,44 @@ export class UserListComponent {
}
```
-> **Note:** React hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array parameter to re-execute queries when values change, similar to React's `useEffect`. See the [React Adapter documentation](../framework/react/overview#dependency-arrays) for details on when and how to use dependency arrays.
+> **Note:** React hooks derive query identity from structured query IR by
+> default. Dependency arrays are still accepted for backwards compatibility,
+> but warn in development and will be removed in 1.0. Unhashable queries also
+> warn and keep legacy mount-stable identity until 1.0; add `queryKey` to make
+> captured opaque values reactive. See the [React Adapter
+> documentation](../framework/react/overview#query-identity) for details.
+
+For server rendering and hydration, live query preloading transports the ordered
+query result without implicitly serializing its source collections. Explicit
+collection preloading still transports normalized collection rows. See the
+[SSR and Hydration guide](./ssr.md).
+
+#### When React Needs a Query Key
+
+Use `queryKey` when the query contains opaque runtime logic that cannot be represented in structured IR, such as `.fn.where`, `.fn.select`, or `.fn.having`. The key becomes the explicit identity for that query:
+
+```tsx
+function UserSearch({ search }: { search: string }) {
+ const { data } = useLiveQuery({
+ queryKey: [usersCollection.id, 'search', search],
+ query: (q) =>
+ q
+ .from({ user: usersCollection })
+ .fn.where(({ user }) =>
+ user.name.toLowerCase().includes(search.toLowerCase())
+ ),
+ })
+
+ return {data.length} users
+}
+```
+
+You can also provide a `queryKey` as a performance escape hatch for a very hot render path, but normal structured queries should omit it.
+
+React development builds detect both cases. Before 1.0, opaque, unhashable IR
+warns and keeps legacy mount-stable identity; repeated expensive identity
+derivation also warns once. Both warnings point to the same `queryKey` escape
+hatch.
For more details on framework integration, see the [React](../framework/react/overview), [Vue](../framework/vue/overview), and [Angular](../framework/angular/overview) adapter documentation.
@@ -220,11 +258,12 @@ import { Suspense } from 'react'
function UserList() {
// This will suspend until data is ready
- const { data } = useLiveSuspenseQuery((q) =>
- q
- .from({ user: usersCollection })
- .where(({ user }) => eq(user.active, true))
- )
+ const { data } = useLiveSuspenseQuery({
+ query: (q) =>
+ q
+ .from({ user: usersCollection })
+ .where(({ user }) => eq(user.active, true)),
+ })
// data is always defined - no need for optional chaining
return (
@@ -251,9 +290,9 @@ The key difference from `useLiveQuery` is that `data` is always defined (never `
```tsx
function UserStats() {
- const { data } = useLiveSuspenseQuery((q) =>
- q.from({ user: usersCollection })
- )
+ const { data } = useLiveSuspenseQuery({
+ query: (q) => q.from({ user: usersCollection }),
+ })
// TypeScript knows data is Array, not Array | undefined
return Total users: {data.length}
@@ -284,9 +323,9 @@ After the initial load, data updates stream in without re-suspending:
```tsx
function UserList() {
- const { data } = useLiveSuspenseQuery((q) =>
- q.from({ user: usersCollection })
- )
+ const { data } = useLiveSuspenseQuery({
+ query: (q) => q.from({ user: usersCollection }),
+ })
// Suspends once during initial load
// After that, data updates automatically when users change
@@ -301,19 +340,18 @@ function UserList() {
}
```
-#### Re-suspending on Dependency Changes
+#### Re-suspending on Query Identity Changes
-When dependencies change, the hook re-suspends to load new data:
+When the derived query identity changes, the hook re-suspends to load new data:
```tsx
function FilteredUsers({ minAge }: { minAge: number }) {
- const { data } = useLiveSuspenseQuery(
- (q) =>
+ const { data } = useLiveSuspenseQuery({
+ query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => gt(user.age, minAge)),
- [minAge] // Re-suspend when minAge changes
- )
+ })
return (
@@ -334,7 +372,7 @@ function FilteredUsers({ minAge }: { minAge: number }) {
- The query always needs to run (not conditional)
- **Use `useLiveQuery`** when:
- - You need conditional/disabled queries
+ - You prefer conditional rendering for optional query inputs
- You prefer handling loading/error states within your component
- You want to show loading states inline without Suspense
- You need access to `status` and `isLoading` flags
@@ -343,9 +381,9 @@ function FilteredUsers({ minAge }: { minAge: number }) {
```tsx
// useLiveQuery - handle states in component
function UserList() {
- const { data, status, isLoading } = useLiveQuery((q) =>
- q.from({ user: usersCollection })
- )
+ const { data, status, isLoading } = useLiveQuery({
+ query: (q) => q.from({ user: usersCollection }),
+ })
if (isLoading) return Loading...
if (status === 'error') return Error loading users
@@ -355,9 +393,9 @@ function UserList() {
// useLiveSuspenseQuery - handle states with Suspense/ErrorBoundary
function UserList() {
- const { data } = useLiveSuspenseQuery((q) =>
- q.from({ user: usersCollection })
- )
+ const { data } = useLiveSuspenseQuery({
+ query: (q) => q.from({ user: usersCollection }),
+ })
return {data.map(user => {user.name} )}
}
@@ -377,9 +415,9 @@ const route = {
// In your component:
function UserList() {
// Collection is already loaded, so data is immediately available
- const { data } = useLiveQuery((q) =>
- q.from({ user: usersCollection })
- )
+ const { data } = useLiveQuery({
+ query: (q) => q.from({ user: usersCollection }),
+ })
return {data?.map(user => {user.name} )}
}
@@ -387,28 +425,28 @@ function UserList() {
### Conditional Queries
-In React, you can conditionally disable a query by returning `undefined` or `null` from the `useLiveQuery` callback. When disabled, the hook returns a special state indicating the query is not active.
+For optional inputs, prefer rendering the query component only after the inputs exist. That avoids creating a live query before all required values exist.
```tsx
import { useLiveQuery } from '@tanstack/react-db'
-function TodoList({ userId }: { userId?: string }) {
- const { data, isEnabled, status } = useLiveQuery((q) => {
- // Disable the query when userId is not available
- if (!userId) return undefined
+function TodosPanel({ userId }: { userId?: string }) {
+ if (!userId) return Please select a user
- return q
- .from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.userId, userId))
- }, [userId])
+ return
+}
- if (!isEnabled) {
- return Please select a user
- }
+function TodoList({ userId }: { userId: string }) {
+ const { data } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todos: todosCollection })
+ .where(({ todos }) => eq(todos.userId, userId)),
+ })
return (
- {data?.map(todo => (
+ {data.map(todo => (
{todo.text}
))}
@@ -416,32 +454,29 @@ function TodoList({ userId }: { userId?: string }) {
}
```
-When the query is disabled (callback returns `undefined` or `null`):
+The callback form can also return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array. When the query is disabled:
- `status` is `'disabled'`
- `data`, `state`, and `collection` are `undefined`
- `isEnabled` is `false`
- `isLoading`, `isReady`, `isIdle`, and `isError` are all `false`
-This pattern is useful for "wait until inputs exist" flows without needing to conditionally render the hook itself or manage an external enabled flag.
-
-### Alternative Callback Return Types
-
-The `useLiveQuery` callback can return different types depending on your use case:
+### Alternative Input Forms
#### Returning a Query Builder (Standard)
-The most common pattern is to return a query builder:
+The standard React pattern is an object with a query builder. For structured queries, React derives the identity from the query IR:
```tsx
-const { data } = useLiveQuery((q) =>
- q.from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.completed, false))
-)
+const { data } = useLiveQuery({
+ query: (q) =>
+ q.from({ todos: todosCollection })
+ .where(({ todos }) => eq(todos.completed, false)),
+})
```
#### Returning a Pre-created Collection
-You can return an existing collection directly:
+You can also subscribe to an existing collection directly:
```tsx
const activeUsersCollection = createLiveQueryCollection((q) =>
@@ -449,15 +484,10 @@ const activeUsersCollection = createLiveQueryCollection((q) =>
.where(({ users }) => eq(users.active, true))
)
-function UserList({ usePrebuilt }: { usePrebuilt: boolean }) {
- const { data } = useLiveQuery((q) => {
- // Toggle between pre-created collection and ad-hoc query
- if (usePrebuilt) return activeUsersCollection
-
- return q.from({ users: usersCollection })
- }, [usePrebuilt])
+function UserList() {
+ const { data } = useLiveQuery(activeUsersCollection)
- return {data?.map(user => {user.name} )}
+ return {data.map(user => {user.name} )}
}
```
@@ -466,13 +496,12 @@ function UserList({ usePrebuilt }: { usePrebuilt: boolean }) {
You can return a configuration object to specify additional options like a custom ID:
```tsx
-const { data } = useLiveQuery((q) => {
- return {
- query: q.from({ items: itemsCollection })
- .select(({ items }) => ({ id: items.id })),
- id: 'items-view', // Custom ID for debugging
- gcTime: 10000 // Custom garbage collection time
- }
+const { data } = useLiveQuery({
+ query: (q) =>
+ q.from({ items: itemsCollection })
+ .select(({ items }) => ({ id: items.id })),
+ id: 'items-view', // Custom ID for debugging
+ gcTime: 10000 // Custom garbage collection time
})
```
@@ -1360,19 +1389,20 @@ import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'
function ProjectList() {
- const { data: projects } = useLiveQuery((q) =>
- q.from({ p: projectsCollection }).select(({ p }) => ({
- id: p.id,
- name: p.name,
- issues: q
- .from({ i: issuesCollection })
- .where(({ i }) => eq(i.projectId, p.id))
- .select(({ i }) => ({
- id: i.id,
- title: i.title,
- })),
- })),
- )
+ const { data: projects } = useLiveQuery({
+ query: (q) =>
+ q.from({ p: projectsCollection }).select(({ p }) => ({
+ id: p.id,
+ name: p.name,
+ issues: q
+ .from({ i: issuesCollection })
+ .where(({ i }) => eq(i.projectId, p.id))
+ .select(({ i }) => ({
+ id: i.id,
+ title: i.title,
+ })),
+ })),
+ })
return (
@@ -1613,12 +1643,13 @@ import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'
function UserProfile({ userId }: { userId: string }) {
- const { data: user, isLoading } = useLiveQuery((q) =>
- q
- .from({ users: usersCollection })
- .where(({ users }) => eq(users.id, userId))
- .findOne()
- , [userId])
+ const { data: user, isLoading } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ users: usersCollection })
+ .where(({ users }) => eq(users.id, userId))
+ .findOne(),
+ })
if (isLoading) return Loading...
if (!user) return User not found
@@ -2014,14 +2045,15 @@ You can chain multiple reusable filters:
```tsx
import { useLiveQuery } from '@tanstack/react-db'
-const { data } = useLiveQuery((q) => {
- return q
- .from({ item: itemsCollection })
- .where(({ item }) => eq(item.id, 1))
- .where(activeItemFilter) // Reusable filter 1
- .where(verifiedItemFilter) // Reusable filter 2
- .select(({ item }) => ({ ...item }))
-}, [])
+const { data } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ item: itemsCollection })
+ .where(({ item }) => eq(item.id, 1))
+ .where(activeItemFilter) // Reusable filter 1
+ .where(verifiedItemFilter) // Reusable filter 2
+ .select(({ item }) => ({ ...item })),
+})
```
#### Using with Different Aliases
@@ -2399,7 +2431,9 @@ createEffect({
### Using with React
-The `useLiveQueryEffect` hook manages the effect lifecycle automatically — creating on mount, disposing on unmount, and recreating when dependencies change:
+The `useLiveQueryEffect` hook manages the effect lifecycle automatically —
+creating on mount, disposing on unmount, and recreating when effect dependencies
+change:
```tsx
import { useLiveQueryEffect } from '@tanstack/react-db'
@@ -2424,7 +2458,10 @@ function ChatComponent({ channelId }: { channelId: string }) {
}
```
-The second argument is a dependency array (like `useEffect`). When dependencies change, the old effect is disposed and a new one is created with the updated config.
+The second argument is still a React-style dependency array for the effect
+lifecycle. This is separate from `useLiveQuery` identity: React live query hooks
+derive identity from structured IR by default and use `queryKey` only for opaque
+or hot-path queries.
### Complete Example
diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md
index 9a60c47dc5..7a269aa36c 100644
--- a/docs/guides/mutations.md
+++ b/docs/guides/mutations.md
@@ -1653,9 +1653,9 @@ todoCollection.insert({
// Use view key for rendering
const TodoList = () => {
- const { data: todos } = useLiveQuery((q) =>
- q.from({ todo: todoCollection })
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
return (
diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md
new file mode 100644
index 0000000000..abb14c9062
--- /dev/null
+++ b/docs/guides/ssr.md
@@ -0,0 +1,826 @@
+---
+title: SSR and Hydration
+id: ssr
+---
+
+# SSR and Hydration
+
+TanStack DB SSR transports the smallest useful snapshot for the work the server
+performed:
+
+- Explicitly preloaded collections dehydrate as normalized collection rows.
+- Preloaded or render-discovered live queries dehydrate as ordered query-result
+ snapshots, without serializing all of their source collections.
+
+The browser renders either snapshot immediately, starts its normal collection
+sync and live-query pipeline, then atomically replaces a live-query snapshot
+when the browser result becomes authoritative.
+
+## High-level Summary
+
+The SSR-friendly API adds six concepts:
+
+- `DbClient` owns materialized collection instances for one request, browser app,
+ test, or script.
+- `collectionOptions(...)` creates a stable collection descriptor. Reusable
+ descriptors create fresh adapter config for each `DbClient`.
+- `dbClient.dehydrate()`, `dbClient.hydrate(state)`, and
+ `dbClient.applyCollectionChunk(chunk)` move explicit collection state across
+ the server/client boundary.
+- `dbClient.preloadLiveQuery(options)` captures only the ordered result of a
+ live query for hydration or streaming.
+- React and Svelte apps use `DbProvider` so hooks can resolve collection
+ descriptors against the current client.
+- `@tanstack/react-router-with-db` streams live queries discovered by Suspense
+ during a TanStack Start server render.
+
+Existing apps continue to work. `createCollection(...)` and direct collection
+instances still exist. The migration is required when you want SSR-safe request
+isolation, hydration, incremental chunks, Suspense streaming, or the 1.0-ready
+React hook shape.
+
+The old dependency-array form now warns:
+
+```tsx
+useLiveQuery((q) => q.from({ todos }).where(...), [status])
+```
+
+It still works, but warns in development and will be removed in 1.0. Prefer:
+
+```tsx
+useLiveQuery({
+ query: (q) => q.from({ todos: todoCollection }).where(...),
+})
+```
+
+React derives live query identity from structured query IR by default. Add
+`queryKey` only for opaque functional query logic or for a hot render path where
+you want to skip derived identity work.
+
+## Cheat Sheet
+
+| Task | Before | SSR-friendly |
+| --- | --- | --- |
+| Define a collection | `createCollection(options)` | `collectionOptions(id, factory)` |
+| Materialize a collection | module-level singleton | `dbClient.collection(todoCollection)` |
+| Scope collection state | module lifetime | `new DbClient()` per request/browser/test |
+| Provide React context | none | `` |
+| Query from React | direct collection instance | descriptor in `from`, resolved by `DbProvider` |
+| Mutate from React | import singleton collection | `useDbClient().collection(todoCollection)` |
+| Server preload | ad hoc collection preload | `collection.preload()` or `dbClient.preloadLiveQuery(...)` |
+| Serialize SSR state | none | `const state = dbClient.dehydrate()` |
+| Hydrate in browser | none | `dbClient.hydrate(state)` before hooks read it |
+| Apply rows incrementally | custom app state | `dbClient.applyCollectionChunk(chunk)` |
+| Stream render-time results | none | `routerWithDbClient(router, dbClient)` |
+| React query identity | dependency array | derived IR, or `queryKey` when needed |
+
+### Minimal React Pattern
+
+```tsx
+import {
+ DbClient,
+ DbProvider,
+ collectionOptions,
+ eq,
+ useDbClient,
+ useLiveQuery,
+} from '@tanstack/react-db'
+
+const todoCollection = collectionOptions('todos', () => ({
+ id: 'todos',
+ getKey: (todo: Todo) => todo.id,
+ sync: {
+ sync: ({ markReady }) => {
+ markReady()
+ },
+ },
+}))
+
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
+
+function Todos({ status }: { status: string }) {
+ const todos = useTodoCollection()
+
+ const { data } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, status)),
+ })
+
+ return (
+
+ {data.map((todo) => (
+ todos.update(todo.id, (draft) => {
+ draft.done = true
+ })}
+ >
+ {todo.title}
+
+ ))}
+
+ )
+}
+
+const dbClient = new DbClient()
+
+root.render(
+
+
+
+)
+```
+
+The factory matters when config contains mutable adapter state or closures.
+Every `DbClient` gets a fresh config and collection instance. First-party
+adapter option creators already attach an equivalent factory, so this is also
+safe:
+
+```tsx
+const todoCollection = collectionOptions(
+ localOnlyCollectionOptions({
+ id: 'todos',
+ getKey: (todo) => todo.id,
+ })
+)
+```
+
+A descriptor created from an arbitrary concrete config can be materialized by
+one `DbClient` only. Use the explicit factory form for custom adapters and
+request-scoped dependencies.
+
+## SSR Flow
+
+The server and browser use the same descriptors, but different `DbClient`
+instances.
+
+```txt
+server request
+ -> new DbClient()
+ -> preload an explicit collection or live-query result
+ -> dbClient.dehydrate()
+ -> send state through framework loader
+
+browser
+ -> new DbClient()
+ -> dbClient.hydrate(loaderState)
+ ->
+ -> useLiveQuery({ query })
+ -> start source sync
+ -> atomically replace any query snapshot with the live result
+```
+
+During React hydration, descriptor-backed queries read either hydrated
+collection rows or their matching query-result snapshot for the first browser
+render. Adapter sync and queued on-demand loads start when React commits the
+external-store subscription, so the initial markup still matches the server.
+The snapshot remains visible while the source is loading. Once the browser live
+query is ready, DB publishes one handoff from the snapshot to the live result.
+
+### Server
+
+Create a fresh `DbClient` for each request. Materialize descriptors through that
+client, preload the data needed for the route, and dehydrate the client.
+
+```tsx
+import { DbClient, collectionOptions, eq } from '@tanstack/db'
+
+export const todoCollection = collectionOptions('todos', () => ({
+ id: 'todos',
+ getKey: (todo: Todo) => todo.id,
+ syncMode: 'on-demand',
+ sync: {
+ sync: ({ markReady, begin, write, commit }) => {
+ markReady()
+
+ return {
+ loadSubset: async () => {
+ const todos = await api.todos.list()
+ begin({ immediate: true })
+ for (const todo of todos) {
+ write({ type: 'insert', value: todo })
+ }
+ commit()
+ return true
+ },
+ }
+ },
+ },
+}))
+
+export async function loadTodosForSsr() {
+ const dbClient = new DbClient()
+ const todos = dbClient.collection(todoCollection)
+ await todos.preload()
+
+ return dbClient.dehydrate()
+}
+```
+
+This explicit collection preload dehydrates normalized source rows. Use it when
+multiple browser queries need the same source data.
+
+If the source is much larger than the rendered result, preload the query instead:
+
+```tsx
+const dbClient = new DbClient()
+
+await dbClient.preloadLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, 'open'))
+ .select(({ todo }) => ({ id: todo.id, title: todo.title })),
+})
+
+const state = dbClient.dehydrate()
+```
+
+This payload contains the projected query result and no source collection rows
+unless that collection was also materialized explicitly.
+
+### Browser
+
+Hydrate the browser client before rendering components that read from DB.
+
+```tsx
+import {
+ DbClient,
+ DbProvider,
+ HydrationBoundary,
+} from '@tanstack/react-db'
+
+function App({ dehydratedDbState }: { dehydratedDbState: DehydratedDbState }) {
+ const [dbClient] = React.useState(() => new DbClient())
+
+ return (
+
+
+
+
+
+ )
+}
+```
+
+Frameworks differ in how loader data reaches the client, but the DB handoff is
+the same: `DbClient` on the server, `dehydrate()`, then `hydrate()` into the
+browser client.
+
+### Svelte
+
+Svelte resolves descriptors from its own `DbProvider` and reads hydrated query
+snapshots synchronously during server rendering:
+
+```svelte
+
+
+
+
+
+```
+
+Inside `Todos.svelte`, `useLiveQuery({ query })` can use collection descriptors
+directly. The browser subscription starts source sync and performs the same
+snapshot-to-live-result handoff as React.
+
+Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db
+
+## Suspense Streaming with TanStack Start
+
+`@tanstack/react-router-with-db` follows the same integration pattern as
+`@tanstack/react-router-with-query`:
+
+```tsx
+import { DbClient } from '@tanstack/react-db'
+import { createRouter } from '@tanstack/react-router'
+import { routerWithDbClient } from '@tanstack/react-router-with-db'
+
+export type RouterContext = {
+ dbClient: DbClient
+}
+
+export function getRouter() {
+ const dbClient = new DbClient()
+ const router = createRouter({
+ routeTree,
+ context: { dbClient },
+ })
+
+ return routerWithDbClient(router, dbClient)
+}
+```
+
+The adapter adds `dbClient` to router context, wraps the app in `DbProvider`,
+dehydrates critical state, and opens a stream for query results discovered later
+during rendering.
+
+```tsx
+function RouteComponent() {
+ return (
+ Loading todos
}>
+
+
+ )
+}
+
+function TodoList() {
+ const { data } = useLiveSuspenseQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, 'open')),
+ })
+
+ return data.map((todo) => )
+}
+```
+
+When `TodoList` suspends on the server, the adapter streams the pending query
+promise. That promise resolves to the ordered live-query result snapshot inside
+the streamed `DehydratedDbState`. The source collections and D2 graph do not
+cross the wire. The browser shows the snapshot, starts the source collections
+and live query normally, then replaces the snapshot when the browser result is
+ready.
+
+The server and browser must derive the same live-query identity. Structured
+queries do this automatically. An opaque query must provide a serializable
+`queryKey`; render-time streaming throws if it cannot derive an identity.
+
+## Suspense Streaming with Next.js
+
+Next.js App Router can transport the same pending query promise through React
+Server Components. Start the preload without awaiting it, dehydrate the pending
+result, and pass that state to a client hydration boundary:
+
+```tsx
+export default function Page() {
+ const dbClient = new DbClient()
+ void dbClient.preloadLiveQuery(openTodosQuery)
+
+ const state = dbClient.dehydrate({
+ shouldDehydrateCollection: () => false,
+ shouldDehydrateLiveQuery: () => true,
+ })
+
+ return (
+
+ Loading todos}>
+
+
+
+ )
+}
+```
+
+`DbHydration` is a client component that creates one browser `DbClient`, wraps
+children in `DbProvider`, and passes `state` to `HydrationBoundary`. React streams
+the promise result into that boundary. The full working integration is in
+`examples/react/next-ssr-e2e`.
+
+## Incremental Collection Hydration
+
+Applications can also apply collection rows received through their own stream.
+Incremental hydration uses the same collection chunk shape as holistic
+dehydration:
+
+```ts
+dbClient.applyCollectionChunk({
+ collectionId: 'todos',
+ rows: [
+ {
+ key: 'todo-1',
+ value: {
+ id: 'todo-1',
+ title: 'Streamed row',
+ status: 'open',
+ },
+ metadata: { source: 'stream' },
+ },
+ ],
+ syncMeta: { version: 1, cursor: 'abc' },
+})
+```
+
+If the target collection is already materialized, the rows apply immediately and
+existing live queries react from collection state. If the collection is not
+materialized yet, the chunk is stored and applied when that `collectionId`
+materializes.
+
+## What Gets Serialized
+
+`dbClient.dehydrate()` can emit two independent snapshot types.
+
+Serialized:
+
+- explicit collection snapshots: collection id, synced row keys and values, row
+ metadata, and adapter sync metadata from `exportSyncMeta`
+- live-query snapshots: query hash and ordered result rows; completed explicit
+ preloads are included by default, while framework integrations opt pending
+ promises into streaming
+
+Not serialized:
+
+- mutation handlers
+- pending optimistic mutations
+- pending subscriptions
+- D2 graphs or compiled pipelines
+- transaction stacks
+- module-level runtime state
+- source collection rows for a query-result snapshot, unless that collection was
+ also explicitly materialized for dehydration
+
+Choose the payload unit according to what the browser needs. Explicit collection
+preloading preserves normalized rows for reuse across queries. Live-query
+preloading avoids shipping a 50-100x larger source when the rendered projection
+is small. Neither mode serializes executable query state.
+
+## Sync Metadata
+
+Adapters can participate in resumable sync with three optional hooks:
+
+```ts
+type SyncConfig = {
+ exportSyncMeta?: () => unknown
+ importSyncMeta?: (meta: unknown) => void
+ mergeSyncMeta?: (current: unknown, incoming: unknown) => unknown
+}
+```
+
+The metadata shape is adapter-owned. Version it inside the adapter payload. If an
+adapter cannot understand incoming metadata, it should ignore it and restart
+sync from a safe point.
+
+During hydration, DB imports `syncMeta` into the materialized collection. If the
+collection already has current metadata, DB calls `mergeSyncMeta(current,
+incoming)` when provided and imports the merged result.
+
+If an adapter does not implement sync metadata hooks, row snapshots still hydrate
+and the adapter can restart sync normally.
+
+## Initial Data
+
+`initialData` is a startup seed, not a sync-ready signal.
+
+Before adapter sync starts, current `DbClient` precedence from lowest to highest
+is:
+
+1. per-materialization `initialData`
+2. persisted rows
+3. hydrated rows
+
+Fresh adapter sync is authoritative over all three. Hydrated and initial rows
+are provisional base state, so the adapter's first insert for the same key is
+reconciled as an update instead of raising a duplicate-key error.
+
+Hydrated rows and `initialData` never mark adapter sync as ready by themselves.
+The adapter still owns readiness through its sync lifecycle.
+
+## React Query Identity
+
+React hooks derive live query identity from structured query IR by default:
+
+```tsx
+function Todos({ status }: { status: string }) {
+ return useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, status)),
+ })
+}
+```
+
+The captured `status` value is represented in the structured IR, so no
+dependency array or `queryKey` is required.
+
+Use `queryKey` when the query contains opaque runtime logic that DB cannot
+stably represent:
+
+```tsx
+function SearchTodos({ search }: { search: string }) {
+ return useLiveQuery({
+ queryKey: [todoCollection.id, 'search', search],
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .fn.where(({ todo }) =>
+ todo.title.toLowerCase().includes(search.toLowerCase())
+ ),
+ })
+}
+```
+
+Common reasons to add `queryKey`:
+
+- `.fn.where(...)`
+- `.fn.select(...)`
+- `.fn.having(...)`
+- function values, symbols, class instances, or circular objects captured inside
+ the structured query
+- a render path where derived identity becomes measurably expensive
+
+Before 1.0, DB warns when structured IR cannot be hashed and preserves the
+legacy mount-stable identity. The query still works, but captured values inside
+opaque logic are not reactive unless they are represented in `queryKey`. In 1.0,
+an unhashable query without `queryKey` will throw.
+
+DB also warns once in development if deriving identity becomes expensive enough
+that an explicit `queryKey` would be better.
+
+Dependency arrays are accepted for backwards compatibility:
+
+```tsx
+useLiveQuery((q) => q.from({ todo: todoCollection }), [status])
+```
+
+They warn in development and will be removed in 1.0. Migrate to the config
+object form:
+
+```tsx
+useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+})
+```
+
+Add `queryKey` only if the query uses opaque logic or trips the performance
+warning.
+
+## Migration Guide
+
+### 1. Create descriptors instead of SSR singletons
+
+For collections that need SSR, replace module-level `createCollection(...)`
+with a reusable `collectionOptions(...)` descriptor.
+
+```tsx
+// Before
+export const todoCollection = createCollection({
+ id: 'todos',
+ getKey: (todo) => todo.id,
+ sync: todoSync,
+})
+
+// After
+export const todoCollection = collectionOptions('todos', () => ({
+ id: 'todos',
+ getKey: (todo: Todo) => todo.id,
+ sync: createTodoSync(),
+}))
+```
+
+Put mutable state and closures inside the factory. First-party adapter option
+creators can also be passed directly because they provide a fresh config
+factory. Collections that never participate in SSR can keep using
+`createCollection`.
+
+### 2. Add a `DbClient`
+
+Use a new client for every server request and a stable client for each browser
+app instance.
+
+```tsx
+const dbClient = new DbClient()
+```
+
+In tests, create a new client per test unless the test is explicitly covering
+shared state.
+
+### 3. Wrap React with `DbProvider`
+
+```tsx
+root.render(
+
+
+
+)
+```
+
+Hooks that resolve collection descriptors need this provider. Without it, DB
+throws instead of falling back to hidden global state.
+
+### 4. Use collection hooks for imperative operations
+
+Use descriptors directly in live query sources, and materialize only when you
+need collection methods:
+
+```tsx
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
+
+function TodoActions({ id }: { id: string }) {
+ const todos = useTodoCollection()
+
+ return (
+ todos.delete(id)}>
+ Delete
+
+ )
+}
+```
+
+This keeps request/client scoping in one place and avoids reintroducing
+module-level collections.
+
+### 5. Replace dependency arrays
+
+Most queries can drop the dependency array entirely:
+
+```tsx
+// Before
+useLiveQuery(
+ (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, status)),
+ [status],
+)
+
+// After
+useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.status, status)),
+})
+```
+
+If the query uses opaque functional variants, add `queryKey`:
+
+```tsx
+useLiveQuery({
+ queryKey: [todoCollection.id, 'status-fn', status],
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .fn.where(({ todo }) => todo.status === status),
+})
+```
+
+### 6. Preload and dehydrate on the server
+
+Preload a collection when the browser should receive normalized source rows:
+
+```tsx
+const dbClient = new DbClient()
+const todos = dbClient.collection(todoCollection)
+await todos.preload()
+
+return {
+ dbState: dbClient.dehydrate(),
+}
+```
+
+Preload a live query when the browser only needs the rendered result:
+
+```tsx
+const dbClient = new DbClient()
+await dbClient.preloadLiveQuery(openTodosQuery)
+
+return {
+ dbState: dbClient.dehydrate(),
+}
+```
+
+### 7. Hydrate before client hooks read DB
+
+```tsx
+
+
+
+
+
+```
+
+Imperative integrations can call `client.hydrate(loaderData.dbState)` before
+rendering instead.
+
+## Compatibility
+
+No existing public API is removed by this change.
+
+Still supported:
+
+- `createCollection(...)`
+- passing collection instances to `useLiveQuery(...)`
+- `useLiveQuery(queryFn, deps)`
+- `useLiveSuspenseQuery(queryFn, deps)`
+- mutation APIs such as `insert`, `update`, `delete`, `subscribe`, and
+ optimistic mutation helpers
+
+Warnings:
+
+- React dependency arrays warn in development and will be removed in 1.0.
+- Opaque query IR without `queryKey` warns in development and keeps legacy
+ mount-stable identity until 1.0. In 1.0 it will throw.
+- Expensive derived identity warns in development and suggests `queryKey`.
+
+Required for SSR:
+
+- stable explicit collection ids
+- request-scoped server `DbClient`
+- browser-scoped client `DbClient`
+- `DbProvider` for descriptor resolution in React
+- `dehydrate()` on the server and `hydrate()` in the browser
+
+Required for render-time Suspense streaming:
+
+- `routerWithDbClient(router, dbClient)`
+- `useLiveSuspenseQuery(...)` inside a Suspense boundary
+- a stable derived query identity or explicit serializable `queryKey`
+
+## Detailed Changelog
+
+### Added
+
+- `DbClient`
+- `collectionOptions(...)`
+- `CollectionOptions` descriptor type
+- `CollectionMaterializeOptions`
+- `DehydratedDbState`
+- `DehydratedCollectionChunk`
+- `DehydratedCollectionRow`
+- `dbClient.collection(descriptor, options?)`
+- `dbClient.dehydrate()`
+- `dbClient.hydrate(state)`
+- `dbClient.applyCollectionChunk(chunk)`
+- `dbClient.subscribe(listener)`
+- `dbClient.createTransaction(config)`
+- `dbClient.cleanup()`
+- React `DbProvider`
+- React `useDbClient()`
+- React `useOptionalDbClient()`
+- React `HydrationBoundary`
+- React descriptor resolution inside live query builders
+- React derived structured query identity
+- React `queryKey` escape hatch for opaque or hot-path queries
+- React per-query `client` override
+- SSR-capable `useSyncExternalStore` server snapshot support
+- `dbClient.preloadLiveQuery(...)`
+- Svelte `DbProvider`, `useDbClient()`, descriptor resolution, and synchronous
+ server snapshot support
+- TanStack Start and Next.js Playwright SSR E2E coverage
+- `@tanstack/react-router-with-db`
+- render-time `useLiveSuspenseQuery` promise streaming
+
+### Changed
+
+- React `useLiveQuery({ query })` can use collection descriptors directly in
+ `from`, `join`, `leftJoin`, and `unionAll` sources when a `DbProvider` is
+ present.
+- React live query identity is derived from normalized structured IR when no
+ explicit `queryKey` or legacy dependency array is supplied.
+- Explicit collection preloading serializes normalized collection rows.
+- Live-query preloading and render-time discovery serialize ordered result
+ snapshots without implicitly serializing source collections.
+- Browser observers keep the hydrated result visible while normal source sync
+ starts, then publish one authoritative handoff.
+- Hydration applies rows as committed synced state without invoking mutation
+ handlers or creating optimistic state.
+- Hydration and adapter sync begin in a deterministic order: pending rows and
+ sync metadata are imported before sync starts.
+- `DbClient` owns collection instances and ambient transaction scope; cleanup
+ releases both.
+- Incremental chunks use the same collection payload shape as full dehydration.
+- Streamed live-query promises resolve to live-query result snapshots.
+
+### Deprecated
+
+- React dependency arrays for `useLiveQuery` and wrappers that delegate to it.
+ They still work and warn in development. They are planned for removal in 1.0.
+
+### Not Changed
+
+- `createCollection(...)` remains available.
+- Direct collection runtime APIs remain available.
+- Vue, Solid, and Angular keep their existing dependency/reactivity model until
+ they get their own SSR/client-provider work. Svelte is covered by this change.
+- Query collection `queryKey` is still TanStack Query's cache key. It is
+ separate from React live query identity.
+
+## Validation
+
+The SSR strategy is covered by:
+
+- core `DbClient` tests for hydration, streaming chunks, sync metadata,
+ initial data precedence, explicit ids, and no optimistic serialization
+- React tests for `DbProvider`, descriptor resolution, derived query identity,
+ `queryKey`, deprecation warnings, SSR result snapshots, and atomic handoff
+- Svelte tests for provider ownership, server snapshot rendering, and browser
+ handoff
+- query adapter tests to ensure Query cache behavior still holds
+- persistence core tests to ensure persisted row behavior remains intact
+- TanStack Start and Next.js Playwright E2Es that verify a Suspense fallback,
+ streamed query result, omitted source-only data, clean hydration, and atomic
+ replacement by browser sync
diff --git a/docs/overview.md b/docs/overview.md
index 63f0965d96..d129103a1d 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -35,6 +35,7 @@ It extends TanStack Query with collections, live queries and optimistic mutation
## Contents
- [How it works](#how-it-works) — understand the TanStack DB development model and how the pieces fit together
+- [SSR and hydration](./guides/ssr.md) — use `DbClient` to transport explicit collection rows or live-query result snapshots
- [API reference](#api-reference) — for the primitives and function interfaces
- [Usage examples](#usage-examples) — examples of common usage patterns
- [More info](#more-info) — where to find support and more information
@@ -48,21 +49,38 @@ TanStack DB works by:
- [making optimistic mutations](#making-optimistic-mutations) using transactional mutators
```tsx
-// Define collections to load data into
-const todoCollection = createCollection({
+import {
+ DbClient,
+ DbProvider,
+ collectionOptions,
+ not,
+ useDbClient,
+ useLiveQuery,
+} from '@tanstack/react-db'
+
+// Define stable collection descriptors to load data into
+const todoCollection = collectionOptions('todos', () => ({
+ id: 'todos',
// ...your config
onUpdate: updateMutationFn,
-})
+}))
+
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
const Todos = () => {
+ const todosCollection = useTodoCollection()
+
// Bind data using live queries
- const { data: todos } = useLiveQuery((q) =>
- q.from({ todo: todoCollection }).where(({ todo }) => not(todo.completed))
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q.from({ todo: todoCollection }).where(({ todo }) => not(todo.completed)),
+ })
const complete = (todo) => {
// Instantly applies optimistic state
- todoCollection.update(todo.id, (draft) => {
+ todosCollection.update(todo.id, (draft) => {
draft.completed = true
})
}
@@ -77,6 +95,14 @@ const Todos = () => {
)
}
+
+const dbClient = new DbClient()
+
+const App = () => (
+
+
+
+)
```
### Defining collections
@@ -103,14 +129,17 @@ Collections support three sync modes to optimize data loading:
With on-demand mode, your component's query becomes the API call:
```tsx
-const productsCollection = createCollection(
+const productsCollection = collectionOptions('products', (client) =>
queryCollectionOptions({
+ id: 'products',
queryKey: ['products'],
+ queryClient: client.requireDependency('queryClient'),
queryFn: async (ctx) => {
// Query predicates passed automatically in ctx.meta
const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions)
return api.getProducts(params) // e.g., GET /api/products?category=electronics&price_lt=100
},
+ getKey: (product) => product.id,
syncMode: 'on-demand', // ← Enable query-driven sync
})
)
@@ -143,17 +172,18 @@ Collections support `insert`, `update` and `delete` operations. When called, by
```ts
// Define collection with persistence handlers
-const todoCollection = createCollection({
+const todoCollection = collectionOptions('todos', () => ({
id: "todos",
// ... other config
onUpdate: async ({ transaction }) => {
const { original, changes } = transaction.mutations[0]
await api.todos.update(original.id, changes)
},
-})
+}))
+const todosCollection = dbClient.collection(todoCollection)
// Immediately applies optimistic state
-todoCollection.update(todo.id, (draft) => {
+todosCollection.update(todo.id, (draft) => {
draft.completed = true
})
```
@@ -227,12 +257,14 @@ const todoSchema = z.object({
priority: z.number().default(0)
})
-const collection = createCollection(
+const todoCollection = collectionOptions(
queryCollectionOptions({
+ id: "todos",
schema: todoSchema,
// ...
})
)
+const collection = dbClient.collection(todoCollection)
// Users provide simple inputs
collection.insert({
@@ -269,16 +301,17 @@ import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'
const Todos = () => {
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todo: todoCollection })
- .where(({ todo }) => eq(todo.completed, false))
- .orderBy(({ todo }) => todo.created_at, 'asc')
- .select(({ todo }) => ({
- id: todo.id,
- text: todo.text
- }))
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.completed, false))
+ .orderBy(({ todo }) => todo.created_at, 'asc')
+ .select(({ todo }) => ({
+ id: todo.id,
+ text: todo.text
+ })),
+ })
return
}
@@ -291,21 +324,22 @@ import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'
const Todos = () => {
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todos: todoCollection })
- .join(
- { lists: listCollection },
- ({ todos, lists }) => eq(lists.id, todos.listId),
- 'inner'
- )
- .where(({ lists }) => eq(lists.active, true))
- .select(({ todos, lists }) => ({
- id: todos.id,
- title: todos.title,
- listName: lists.name
- }))
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todos: todoCollection })
+ .join(
+ { lists: listCollection },
+ ({ todos, lists }) => eq(lists.id, todos.listId),
+ 'inner'
+ )
+ .where(({ lists }) => eq(lists.active, true))
+ .select(({ todos, lists }) => ({
+ id: todos.id,
+ title: todos.title,
+ listName: lists.name
+ })),
+ })
return
}
@@ -321,11 +355,12 @@ import { Suspense } from 'react'
const Todos = () => {
// data is always defined - no need for optional chaining
- const { data: todos } = useLiveSuspenseQuery((q) =>
- q
- .from({ todo: todoCollection })
- .where(({ todo }) => eq(todo.completed, false))
- )
+ const { data: todos } = useLiveSuspenseQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.completed, false)),
+ })
return
}
@@ -397,15 +432,26 @@ The steps are to:
2. implement mutation handlers that handle mutations by posting them to your API endpoints
```tsx
-import { useLiveQuery, createCollection } from "@tanstack/react-db"
+import {
+ DbClient,
+ DbProvider,
+ collectionOptions,
+ useLiveQuery,
+} from "@tanstack/react-db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
+import { QueryClient } from "@tanstack/query-core"
+
+const queryClient = new QueryClient()
+const dbClient = new DbClient({ queryClient })
// Load data into collections using TanStack Query.
// It's common to define these in a `collections` module.
-const todoCollection = createCollection(
+const todoCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
+ id: "todos",
queryKey: ["todos"],
- queryFn: async () => fetch("/api/todos"),
+ queryClient: client.requireDependency("queryClient"),
+ queryFn: async () => fetch("/api/todos").then((response) => response.json()),
getKey: (item) => item.id,
schema: todoSchema, // any standard schema
onInsert: async ({ transaction }) => {
@@ -417,10 +463,13 @@ const todoCollection = createCollection(
// also add onUpdate, onDelete as needed.
})
)
-const listCollection = createCollection(
+const listCollection = collectionOptions("todo-lists", (client) =>
queryCollectionOptions({
+ id: "todo-lists",
queryKey: ["todo-lists"],
- queryFn: async () => fetch("/api/todo-lists"),
+ queryClient: client.requireDependency("queryClient"),
+ queryFn: async () =>
+ fetch("/api/todo-lists").then((response) => response.json()),
getKey: (item) => item.id,
schema: todoListSchema,
onInsert: async ({ transaction }) => {
@@ -436,25 +485,32 @@ const listCollection = createCollection(
const Todos = () => {
// Read the data using live queries. Here we show a live
// query that joins across two collections.
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todo: todoCollection })
- .join(
- { list: listCollection },
- ({ todo, list }) => eq(list.id, todo.list_id),
- "inner"
- )
- .where(({ list }) => eq(list.active, true))
- .select(({ todo, list }) => ({
- id: todo.id,
- text: todo.text,
- status: todo.status,
- listName: list.name,
- }))
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .join(
+ { list: listCollection },
+ ({ todo, list }) => eq(list.id, todo.list_id),
+ "inner"
+ )
+ .where(({ list }) => eq(list.active, true))
+ .select(({ todo, list }) => ({
+ id: todo.id,
+ text: todo.text,
+ status: todo.status,
+ listName: list.name,
+ })),
+ })
// ...
}
+
+const App = () => (
+
+
+
+)
```
This pattern allows you to extend an existing TanStack Query application, or any application built on a REST API, with blazing fast, cross-collection live queries and local optimistic mutations with automatically managed optimistic state.
@@ -476,15 +532,14 @@ This pattern enables the "load everything once" approach that makes apps like Li
Here, we illustrate this pattern using [ElectricSQL](https://electric-sql.com) as the sync engine, but this pattern also works with other sync engines like [PowerSync](https://www.powersync.com/?utm_source=tanstack&utm_campaign=tanstack_partner), [RxDB](https://rxdb.info/), and [TrailBase](https://trailbase.io/).
```tsx
-import type { Collection } from "@tanstack/db"
import type {
MutationFn,
PendingMutation,
- createCollection,
} from "@tanstack/react-db"
+import { collectionOptions, useDbClient } from "@tanstack/react-db"
import { electricCollectionOptions } from "@tanstack/electric-db-collection"
-export const todoCollection = createCollection(
+export const todoCollection = collectionOptions(
electricCollectionOptions({
id: "todos",
schema: todoSchema,
@@ -497,7 +552,6 @@ export const todoCollection = createCollection(
},
},
getKey: (item) => item.id,
- schema: todoSchema,
onInsert: async ({ transaction }) => {
const response = await api.todos.create(transaction.mutations[0].modified)
@@ -508,9 +562,11 @@ export const todoCollection = createCollection(
)
const AddTodo = () => {
+ const todosCollection = useDbClient().collection(todoCollection)
+
return (
todoCollection.insert({ text: "🔥 Make app faster" })}
+ onClick={() => todosCollection.insert({ text: "🔥 Make app faster" })}
/>
)
}
diff --git a/docs/quick-start.md b/docs/quick-start.md
index ea67999740..04a5eb3cb6 100644
--- a/docs/quick-start.md
+++ b/docs/quick-start.md
@@ -10,13 +10,26 @@ TanStack DB is the reactive client-first store for your API. Stop building custo
- **Mutate data** with instant optimistic updates
```tsx
-import { createCollection, eq, useLiveQuery } from '@tanstack/react-db'
+import {
+ DbClient,
+ DbProvider,
+ collectionOptions,
+ eq,
+ useDbClient,
+ useLiveQuery,
+} from '@tanstack/react-db'
+import { QueryClient } from '@tanstack/query-core'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
-// Define a collection that loads data using TanStack Query
-const todoCollection = createCollection(
+const queryClient = new QueryClient()
+const dbClient = new DbClient({ queryClient })
+
+// Define a stable collection descriptor that loads data using TanStack Query
+const todoCollection = collectionOptions('todos', (client) =>
queryCollectionOptions({
+ id: 'todos',
queryKey: ['todos'],
+ queryClient: client.requireDependency('queryClient'),
queryFn: async () => {
const response = await fetch('/api/todos')
return response.json()
@@ -32,17 +45,24 @@ const todoCollection = createCollection(
})
)
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
+
function Todos() {
+ const todosCollection = useTodoCollection()
+
// Live query that updates automatically when data changes
- const { data: todos } = useLiveQuery((q) =>
- q.from({ todo: todoCollection })
- .where(({ todo }) => eq(todo.completed, false))
- .orderBy(({ todo }) => todo.createdAt, 'desc')
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q.from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.completed, false))
+ .orderBy(({ todo }) => todo.createdAt, 'desc'),
+ })
const toggleTodo = (todo) => {
// Instantly applies optimistic state, then syncs to server
- todoCollection.update(todo.id, (draft) => {
+ todosCollection.update(todo.id, (draft) => {
draft.completed = !draft.completed
})
}
@@ -57,14 +77,29 @@ function Todos() {
)
}
+
+function App() {
+ return (
+
+
+
+ )
+}
```
You now have collections, live queries, and optimistic mutations! Let's break this down further.
+If you are building with SSR, see the [SSR and Hydration guide](./guides/ssr.md)
+after this quick start. The short version is that SSR apps use stable
+`collectionOptions(...)` descriptors, materialize them through a request-scoped
+`DbClient` on the server, then hydrate a browser `DbClient` with explicit
+collection rows or a preloaded live-query result before React hooks read from
+DB.
+
## Installation
```bash
-npm install @tanstack/react-db @tanstack/query-db-collection
+npm install @tanstack/react-db @tanstack/query-db-collection @tanstack/query-core
```
## 1. Create a Collection
@@ -72,9 +107,11 @@ npm install @tanstack/react-db @tanstack/query-db-collection
Collections store your data and handle persistence. The `queryCollectionOptions` loads data using TanStack Query and defines mutation handlers for server sync:
```tsx
-const todoCollection = createCollection(
+const todoCollection = collectionOptions('todos', (client) =>
queryCollectionOptions({
+ id: 'todos',
queryKey: ['todos'],
+ queryClient: client.requireDependency('queryClient'),
queryFn: async () => {
const response = await fetch('/api/todos')
return response.json()
@@ -103,41 +140,58 @@ const todoCollection = createCollection(
)
```
-## 2. Query with Live Queries
+The `queryKey` above is TanStack Query's cache key for loading the collection.
+React live queries below derive their own identity from structured query IR.
+
+## 2. Materialize the Collection
-Live queries reactively update when data changes. They support filtering, sorting, joins, and transformations:
+Use the `DbClient` from context to materialize the descriptor. A tiny collection hook keeps components from repeating the client lookup:
+
+```tsx
+function useTodoCollection() {
+ return useDbClient().collection(todoCollection)
+}
+```
+
+## 3. Query with Live Queries
+
+Live queries reactively update when data changes. They support filtering, sorting, joins, and transformations. React hooks derive query identity from the structured query by default, so normal builder queries do not need a separate `queryKey`:
```tsx
function TodoList() {
// Basic filtering and sorting
- const { data: incompleteTodos } = useLiveQuery((q) =>
- q.from({ todo: todoCollection })
- .where(({ todo }) => eq(todo.completed, false))
- .orderBy(({ todo }) => todo.createdAt, 'desc')
- )
+ const { data: incompleteTodos } = useLiveQuery({
+ query: (q) =>
+ q.from({ todo: todoCollection })
+ .where(({ todo }) => eq(todo.completed, false))
+ .orderBy(({ todo }) => todo.createdAt, 'desc'),
+ })
// Transform the data
- const { data: todoSummary } = useLiveQuery((q) =>
- q.from({ todo: todoCollection })
- .select(({ todo }) => ({
- id: todo.id,
- summary: `${todo.text} (${todo.completed ? 'done' : 'pending'})`,
- priority: todo.priority || 'normal'
- }))
- )
+ const { data: todoSummary } = useLiveQuery({
+ query: (q) =>
+ q.from({ todo: todoCollection })
+ .select(({ todo }) => ({
+ id: todo.id,
+ summary: `${todo.text} (${todo.completed ? 'done' : 'pending'})`,
+ priority: todo.priority || 'normal'
+ })),
+ })
return {/* Render todos */}
}
```
-## 3. Optimistic Mutations
+## 4. Optimistic Mutations
Mutations apply instantly and sync to your server. If the server request fails, changes automatically roll back:
```tsx
function TodoActions({ todo }) {
+ const todosCollection = useTodoCollection()
+
const addTodo = () => {
- todoCollection.insert({
+ todosCollection.insert({
id: crypto.randomUUID(),
text: 'New todo',
completed: false,
@@ -146,19 +200,19 @@ function TodoActions({ todo }) {
}
const toggleComplete = () => {
- todoCollection.update(todo.id, (draft) => {
+ todosCollection.update(todo.id, (draft) => {
draft.completed = !draft.completed
})
}
const updateText = (newText) => {
- todoCollection.update(todo.id, (draft) => {
+ todosCollection.update(todo.id, (draft) => {
draft.text = newText
})
}
const deleteTodo = () => {
- todoCollection.delete(todo.id)
+ todosCollection.delete(todo.id)
}
return (
diff --git a/docs/reference/interfaces/LiveQuerySnapshot.md b/docs/reference/interfaces/LiveQuerySnapshot.md
index fcbe2436f1..5f77269038 100644
--- a/docs/reference/interfaces/LiveQuerySnapshot.md
+++ b/docs/reference/interfaces/LiveQuerySnapshot.md
@@ -29,7 +29,7 @@ an older snapshot cannot expose rows from a later revision.
### collection
```ts
-collection:
+collection:
| Collection, T>
| undefined;
```
diff --git a/docs/reference/interfaces/LiveQueryWindowSnapshot.md b/docs/reference/interfaces/LiveQueryWindowSnapshot.md
index 33302cc3af..c2e4386fb3 100644
--- a/docs/reference/interfaces/LiveQueryWindowSnapshot.md
+++ b/docs/reference/interfaces/LiveQueryWindowSnapshot.md
@@ -28,7 +28,7 @@ A page-windowed view of a live query at a point in time.
### collection
```ts
-collection:
+collection:
| Collection, T>
| undefined;
```
diff --git a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md
index f01025d905..86a83ad338 100644
--- a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md
+++ b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md
@@ -6,7 +6,7 @@ title: ResolvedLiveQueryWindowInput
# Type Alias: ResolvedLiveQueryWindowInput\
```ts
-type ResolvedLiveQueryWindowInput =
+type ResolvedLiveQueryWindowInput =
| {
collection: Collection;
kind: "collection";
diff --git a/examples/react-native/offline-transactions/src/components/TodoList.tsx b/examples/react-native/offline-transactions/src/components/TodoList.tsx
index f5aa666c2b..aaf3fe8d2c 100644
--- a/examples/react-native/offline-transactions/src/components/TodoList.tsx
+++ b/examples/react-native/offline-transactions/src/components/TodoList.tsx
@@ -30,9 +30,12 @@ export function TodoList({ collection, executor }: TodoListProps) {
[executor, collection],
)
- const { data: todoList = [], isLoading } = useLiveQuery((q) =>
- q.from({ todo: collection }).orderBy(({ todo }) => todo.createdAt, `desc`),
- )
+ const { data: todoList = [], isLoading } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: collection })
+ .orderBy(({ todo }) => todo.createdAt, `desc`),
+ })
// Monitor network status for UI display
// (The executor's ReactNativeOnlineDetector handles sync retries internally)
diff --git a/examples/react-native/shopping-list/app/list/[id].tsx b/examples/react-native/shopping-list/app/list/[id].tsx
index 5e97589059..5d9d1f9c45 100644
--- a/examples/react-native/shopping-list/app/list/[id].tsx
+++ b/examples/react-native/shopping-list/app/list/[id].tsx
@@ -1,7 +1,6 @@
-import { useLocalSearchParams, Stack } from 'expo-router'
+import { Stack, useLocalSearchParams } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
-import { useLiveQuery } from '@tanstack/react-db'
-import { eq } from '@tanstack/react-db'
+import { eq, useLiveQuery } from '@tanstack/react-db'
import { listsCollection } from '../../src/db/collections'
import { ListDetail } from '../../src/components/ListDetail'
@@ -9,15 +8,14 @@ export default function ListScreen() {
const { id } = useLocalSearchParams<{ id: string }>() as { id: string }
// Get the list name for the header
- const listResult = useLiveQuery((q) =>
- q
- .from({ list: listsCollection })
- .where(({ list }) => eq(list.id, id))
- .select(({ list }) => ({ id: list.id, name: list.name })),
- )
- const list = (listResult.data ?? [])[0] as
- | { id: string; name: string }
- | undefined
+ const listResult = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ list: listsCollection })
+ .where(({ list }) => eq(list.id, id))
+ .select(({ list }) => ({ id: list.id, name: list.name })),
+ })
+ const list = listResult.data[0] as { id: string; name: string } | undefined
return (
<>
diff --git a/examples/react-native/shopping-list/src/components/ListDetail.tsx b/examples/react-native/shopping-list/src/components/ListDetail.tsx
index 6bcd31788b..2b3e300e3a 100644
--- a/examples/react-native/shopping-list/src/components/ListDetail.tsx
+++ b/examples/react-native/shopping-list/src/components/ListDetail.tsx
@@ -82,12 +82,13 @@ export function ListDetail({ listId }: ListDetailProps) {
const { itemActions } = useShopping()
// Get items for this list
- const itemsResult = useLiveQuery((q) =>
- q
- .from({ item: itemsCollection })
- .where(({ item }) => eq(item.listId, listId))
- .orderBy(({ item }) => item.createdAt, `asc`),
- )
+ const itemsResult = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ item: itemsCollection })
+ .where(({ item }) => eq(item.listId, listId))
+ .orderBy(({ item }) => item.createdAt, `asc`),
+ })
const items = itemsResult.data as Array
const handleAddItem = async () => {
diff --git a/examples/react-native/shopping-list/src/components/ListsScreen.tsx b/examples/react-native/shopping-list/src/components/ListsScreen.tsx
index 1a5b501c3d..d46b277644 100644
--- a/examples/react-native/shopping-list/src/components/ListsScreen.tsx
+++ b/examples/react-native/shopping-list/src/components/ListsScreen.tsx
@@ -107,37 +107,38 @@ export function ListsScreen() {
// ★ Includes query with aggregate subqueries: each list gets child collections
// with computed counts. ListCard subscribes to them via useLiveQuery.
- const queryResult = useLiveQuery((q) =>
- q
- .from({ list: listsCollection })
- .select(({ list }) => ({
- id: list.id,
- name: list.name,
- createdAt: list.createdAt,
- $synced: list.$synced,
- totalItems: q
- .from({ item: itemsCollection })
- .where(({ item }) => eq(item.listId, list.id))
- .select(({ item }) => ({ n: count(item.id) })),
- uncheckedPreview: q
- .from({ item: itemsCollection })
- .where(({ item }) => eq(item.listId, list.id))
- .where(({ item }) => eq(item.checked, false))
- .select(({ item }) => ({
- id: item.id,
- text: item.text,
- createdAt: item.createdAt,
- }))
- .orderBy(({ item }) => item.createdAt, `asc`)
- .limit(3),
- checkedItems: q
- .from({ item: itemsCollection })
- .where(({ item }) => eq(item.listId, list.id))
- .where(({ item }) => eq(item.checked, true))
- .select(({ item }) => ({ n: count(item.id) })),
- }))
- .orderBy(({ list }) => list.createdAt, `desc`),
- )
+ const queryResult = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ list: listsCollection })
+ .select(({ list }) => ({
+ id: list.id,
+ name: list.name,
+ createdAt: list.createdAt,
+ $synced: list.$synced,
+ totalItems: q
+ .from({ item: itemsCollection })
+ .where(({ item }) => eq(item.listId, list.id))
+ .select(({ item }) => ({ n: count(item.id) })),
+ uncheckedPreview: q
+ .from({ item: itemsCollection })
+ .where(({ item }) => eq(item.listId, list.id))
+ .where(({ item }) => eq(item.checked, false))
+ .select(({ item }) => ({
+ id: item.id,
+ text: item.text,
+ createdAt: item.createdAt,
+ }))
+ .orderBy(({ item }) => item.createdAt, `asc`)
+ .limit(3),
+ checkedItems: q
+ .from({ item: itemsCollection })
+ .where(({ item }) => eq(item.listId, list.id))
+ .where(({ item }) => eq(item.checked, true))
+ .select(({ item }) => ({ n: count(item.id) })),
+ }))
+ .orderBy(({ list }) => list.createdAt, `desc`),
+ })
const lists = queryResult.data as unknown as Array<{
id: string
name: string
diff --git a/examples/react/next-ssr-e2e/app/db-hydration.tsx b/examples/react/next-ssr-e2e/app/db-hydration.tsx
new file mode 100644
index 0000000000..2bbe8b42cf
--- /dev/null
+++ b/examples/react/next-ssr-e2e/app/db-hydration.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import { useState } from 'react'
+import { DbClient } from '@tanstack/db'
+import { DbProvider, HydrationBoundary } from '@tanstack/react-db'
+import type { DehydratedDbState } from '@tanstack/db'
+import type { ReactNode } from 'react'
+
+export function DbHydration({
+ state,
+ children,
+}: {
+ state: DehydratedDbState
+ children: ReactNode
+}) {
+ const [client] = useState(() => new DbClient({ runtime: `browser` }))
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/examples/react/next-ssr-e2e/app/layout.tsx b/examples/react/next-ssr-e2e/app/layout.tsx
new file mode 100644
index 0000000000..d7cbe9fc83
--- /dev/null
+++ b/examples/react/next-ssr-e2e/app/layout.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next'
+import type { ReactNode } from 'react'
+
+export const metadata: Metadata = {
+ title: `TanStack DB Next.js SSR E2E`,
+}
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/examples/react/next-ssr-e2e/app/page.tsx b/examples/react/next-ssr-e2e/app/page.tsx
new file mode 100644
index 0000000000..7f6f18bb3d
--- /dev/null
+++ b/examples/react/next-ssr-e2e/app/page.tsx
@@ -0,0 +1,27 @@
+import { Suspense } from 'react'
+import { DbClient } from '@tanstack/db'
+import { DbHydration } from './db-hydration'
+import { streamedTodoQuery } from './ssr-fixture'
+import { StreamedTodos } from './streamed-todos'
+
+export const dynamic = `force-dynamic`
+
+export default function Page() {
+ const dbClient = new DbClient({ runtime: `server` })
+ void dbClient.preloadLiveQuery(streamedTodoQuery)
+ const state = dbClient.dehydrate({
+ shouldDehydrateCollection: () => false,
+ shouldDehydrateLiveQuery: () => true,
+ })
+
+ return (
+
+ TanStack DB Next.js SSR
+
+ Loading todos}>
+
+
+
+
+ )
+}
diff --git a/examples/react/next-ssr-e2e/app/ssr-fixture.ts b/examples/react/next-ssr-e2e/app/ssr-fixture.ts
new file mode 100644
index 0000000000..c3001cc6f5
--- /dev/null
+++ b/examples/react/next-ssr-e2e/app/ssr-fixture.ts
@@ -0,0 +1,70 @@
+import { collectionOptions, eq } from '@tanstack/db'
+import type { InitialQueryBuilder } from '@tanstack/db'
+
+export type StreamedTodo = {
+ id: string
+ text: string
+ status: `open` | `done`
+ sourcePayload: string
+}
+
+const serverTodo: StreamedTodo = {
+ id: `next-server-1`,
+ text: `Streamed from Next.js`,
+ status: `open`,
+ sourcePayload: `NEXT_SOURCE_ONLY_DO_NOT_TRANSPORT`,
+}
+
+const browserTodo: StreamedTodo = {
+ id: `next-browser-1`,
+ text: `Reconciled by Next.js browser sync`,
+ status: `open`,
+ sourcePayload: `NEXT_BROWSER_SOURCE_ONLY_DO_NOT_TRANSPORT`,
+}
+
+export const streamedTodoCollection = collectionOptions(
+ `next-ssr-stream-todos`,
+ (client) => {
+ const runtime = client.requireDependency<`server` | `browser`>(`runtime`)
+
+ return {
+ id: `next-ssr-stream-todos`,
+ getKey: (todo: StreamedTodo) => todo.id,
+ syncMode: `on-demand` as const,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ markReady()
+ return {
+ loadSubset: () =>
+ new Promise((resolve) => {
+ setTimeout(
+ () => {
+ begin({ immediate: true })
+ write({
+ type: `insert`,
+ value: runtime === `server` ? serverTodo : browserTodo,
+ })
+ commit()
+ resolve()
+ },
+ runtime === `server` ? 1000 : 1500,
+ )
+ }),
+ }
+ },
+ },
+ }
+ },
+)
+
+export const streamedTodoQuery = {
+ query: (q: InitialQueryBuilder) =>
+ q
+ .from({ todo: streamedTodoCollection })
+ .where(({ todo }) => eq(todo.status, `open`))
+ .select(({ todo }) => ({
+ id: todo.id,
+ text: todo.text,
+ status: todo.status,
+ })),
+}
diff --git a/examples/react/next-ssr-e2e/app/streamed-todos.tsx b/examples/react/next-ssr-e2e/app/streamed-todos.tsx
new file mode 100644
index 0000000000..b893421cae
--- /dev/null
+++ b/examples/react/next-ssr-e2e/app/streamed-todos.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { useLiveSuspenseQuery } from '@tanstack/react-db'
+import { streamedTodoQuery } from './ssr-fixture'
+
+export function StreamedTodos() {
+ const { data: todos } = useLiveSuspenseQuery(streamedTodoQuery)
+
+ return (
+
+ {todos.map((todo) => (
+
+ {todo.text}
+
+ ))}
+
+ )
+}
diff --git a/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts b/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts
new file mode 100644
index 0000000000..02d046cc62
--- /dev/null
+++ b/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '@playwright/test'
+
+test(`Next.js streams a DB result snapshot and hands off to browser sync`, async ({
+ page,
+ request,
+}) => {
+ const response = await request.get(`/`)
+ expect(response.ok()).toBe(true)
+ const html = await response.text()
+ expect(html).toContain(`Streamed from Next.js`)
+ expect(html).not.toContain(`NEXT_SOURCE_ONLY_DO_NOT_TRANSPORT`)
+
+ const browserErrors: Array = []
+ page.on(`console`, (message) => {
+ if (message.type() === `error`) browserErrors.push(message.text())
+ })
+ page.on(`pageerror`, (error) => {
+ browserErrors.push(error.message)
+ })
+
+ await page.goto(`/`, { waitUntil: `commit` })
+
+ await expect(page.getByTestId(`stream-fallback`)).toBeVisible()
+ await expect(page.getByTestId(`streamed-todo-next-server-1`)).toHaveText(
+ `Streamed from Next.js`,
+ )
+ await expect(page.getByTestId(`stream-fallback`)).not.toBeVisible()
+ await expect(
+ page.getByTestId(`streamed-todo-next-server-1`),
+ ).not.toBeVisible()
+ await expect(page.getByTestId(`streamed-todo-next-browser-1`)).toHaveText(
+ `Reconciled by Next.js browser sync`,
+ )
+ expect(browserErrors).toEqual([])
+})
diff --git a/examples/react/next-ssr-e2e/next.config.ts b/examples/react/next-ssr-e2e/next.config.ts
new file mode 100644
index 0000000000..6491458f0a
--- /dev/null
+++ b/examples/react/next-ssr-e2e/next.config.ts
@@ -0,0 +1,8 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {
+ reactStrictMode: true,
+ transpilePackages: [`@tanstack/db`, `@tanstack/react-db`],
+}
+
+export default nextConfig
diff --git a/examples/react/next-ssr-e2e/package.json b/examples/react/next-ssr-e2e/package.json
new file mode 100644
index 0000000000..c5987333f6
--- /dev/null
+++ b/examples/react/next-ssr-e2e/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@tanstack/db-example-react-next-ssr-e2e",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev",
+ "start": "next start",
+ "test:e2e": "pnpm --filter @tanstack/db build && pnpm --filter @tanstack/react-db build && playwright test"
+ },
+ "dependencies": {
+ "@tanstack/db": "workspace:*",
+ "@tanstack/react-db": "workspace:*",
+ "next": "^16.3.1",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.60.0",
+ "@types/node": "^25.2.2",
+ "@types/react": "^19.2.13",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5.9.2"
+ }
+}
diff --git a/examples/react/next-ssr-e2e/playwright.config.ts b/examples/react/next-ssr-e2e/playwright.config.ts
new file mode 100644
index 0000000000..7a6ae0db52
--- /dev/null
+++ b/examples/react/next-ssr-e2e/playwright.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:4176`
+const shouldStartWebServer = process.env.PLAYWRIGHT_BASE_URL === undefined
+
+export default defineConfig({
+ testDir: `./e2e`,
+ timeout: 30000,
+ expect: {
+ timeout: 10000,
+ },
+ fullyParallel: false,
+ use: {
+ baseURL,
+ trace: `on-first-retry`,
+ },
+ webServer: shouldStartWebServer
+ ? {
+ command: `pnpm dev --hostname 127.0.0.1 --port 4176`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120000,
+ url: baseURL,
+ }
+ : undefined,
+ projects: [
+ {
+ name: `chromium`,
+ use: { ...devices[`Desktop Chrome`] },
+ },
+ ],
+})
diff --git a/examples/react/next-ssr-e2e/tsconfig.json b/examples/react/next-ssr-e2e/tsconfig.json
new file mode 100644
index 0000000000..b134f6f799
--- /dev/null
+++ b/examples/react/next-ssr-e2e/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{ "name": "next" }]
+ },
+ "include": [
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.ts",
+ "**/*.tsx"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx
index e7252f5f4e..c7f25cd99c 100644
--- a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx
+++ b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx
@@ -11,9 +11,12 @@ export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) {
const [newTodoText, setNewTodoText] = useState(``)
const [error, setError] = useState(null)
- const { data: todoList = [] } = useLiveQuery((q) =>
- q.from({ todo: collection }).orderBy(({ todo }) => todo.createdAt, `desc`),
- )
+ const { data: todoList = [] } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: collection })
+ .orderBy(({ todo }) => todo.createdAt, `desc`),
+ })
const handleAddTodo = () => {
if (!newTodoText.trim()) return
diff --git a/examples/react/offline-transactions/src/components/TodoDemo.tsx b/examples/react/offline-transactions/src/components/TodoDemo.tsx
index fcdf088da1..4f95b79a92 100644
--- a/examples/react/offline-transactions/src/components/TodoDemo.tsx
+++ b/examples/react/offline-transactions/src/components/TodoDemo.tsx
@@ -25,11 +25,12 @@ export function TodoDemo({
console.log({ offline, actions })
// Use live query to get todos
- const { data: todoList = [], isLoading } = useLiveQuery((q) =>
- q
- .from({ todo: todoCollection })
- .orderBy(({ todo }) => todo.createdAt, `desc`),
- )
+ const { data: todoList = [], isLoading } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todoCollection })
+ .orderBy(({ todo }) => todo.createdAt, `desc`),
+ })
// Monitor online status
useEffect(() => {
diff --git a/examples/react/projects/src/routes/_authenticated.tsx b/examples/react/projects/src/routes/_authenticated.tsx
index 17ed734276..43142ee5ca 100644
--- a/examples/react/projects/src/routes/_authenticated.tsx
+++ b/examples/react/projects/src/routes/_authenticated.tsx
@@ -20,7 +20,9 @@ function AuthenticatedLayout() {
const [showNewProjectForm, setShowNewProjectForm] = useState(false)
const [newProjectName, setNewProjectName] = useState(``)
- const { data: projects } = useLiveQuery((q) => q.from({ projectCollection }))
+ const { data: projects } = useLiveQuery({
+ query: (q) => q.from({ projectCollection }),
+ })
const handleLogout = async () => {
await authClient.signOut()
diff --git a/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx b/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx
index 0ac0be409e..a60c4848d3 100644
--- a/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx
+++ b/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx
@@ -25,41 +25,40 @@ export const Route = createFileRoute(`/_authenticated/project/$projectId`)({
function ProjectPage() {
const { projectId } = Route.useParams()
+ const projectIdNumber = parseInt(projectId, 10)
const { data: session } = authClient.useSession()
const [newTodoText, setNewTodoText] = useState(``)
- const { data: todos } = useLiveQuery(
- (q) =>
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
q
.from({ todo: todoCollection })
- .where(({ todo }) => eq(todo.project_id, parseInt(projectId, 10)))
+ .where(({ todo }) => eq(todo.project_id, projectIdNumber))
.orderBy(({ todo }) => todo.created_at),
- [projectId]
- )
-
- const { data: users } = useLiveQuery((q) =>
- q.from({ users: usersCollection })
- )
- const { data: usersInProjects } = useLiveQuery(
- (q) =>
+ })
+
+ const { data: users } = useLiveQuery({
+ query: (q) => q.from({ users: usersCollection }),
+ })
+ const { data: usersInProjects } = useLiveQuery({
+ queryKey: [projectCollection.id, `users-in-project`, projectIdNumber],
+ query: (q) =>
q
.from({ projects: projectCollection })
- .where(({ projects }) => eq(projects.id, parseInt(projectId, 10)))
+ .where(({ projects }) => eq(projects.id, projectIdNumber))
.fn.select(({ projects }) => ({
users: projects.shared_user_ids.concat(projects.owner_id),
owner: projects.owner_id,
})),
- [projectId]
- )
+ })
const usersInProject = usersInProjects[0]
- const { data: projects } = useLiveQuery(
- (q) =>
+ const { data: projects } = useLiveQuery({
+ query: (q) =>
q
.from({ p: projectCollection })
- .where(({ p }) => eq(p.id, parseInt(projectId, 10))),
- [projectId]
- )
+ .where(({ p }) => eq(p.id, projectIdNumber)),
+ })
const project = projects[0]
const addTodo = () => {
@@ -69,7 +68,7 @@ function ProjectPage() {
id: Math.floor(Math.random() * 100000),
text: newTodoText.trim(),
completed: false,
- project_id: parseInt(projectId),
+ project_id: projectIdNumber,
user_ids: [],
created_at: new Date(),
})
diff --git a/examples/react/start-ssr-e2e/README.md b/examples/react/start-ssr-e2e/README.md
new file mode 100644
index 0000000000..3fbfd0d042
--- /dev/null
+++ b/examples/react/start-ssr-e2e/README.md
@@ -0,0 +1,55 @@
+# TanStack DB Start SSR Demo
+
+This example is a minimal TanStack Start app that demonstrates TanStack DB SSR
+with collection-row hydration.
+
+It verifies five things:
+
+- server HTML contains rows loaded through a request-scoped `DbClient`
+- the browser hydrates those rows into a client `DbClient`
+- fresh adapter sync replaces a stale hydrated row with the same key
+- an incremental collection chunk updates an existing live query
+- critical collection rows hydrate while a query discovered later in the same
+ render streams through a Suspense boundary
+
+Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db
+
+## Run Locally
+
+```sh
+pnpm --filter @tanstack/db build
+pnpm --filter @tanstack/react-db build
+pnpm --filter @tanstack/db-example-react-start-ssr-e2e dev
+```
+
+Open `/ssr-db` for holistic and incremental hydration, or `/ssr-db-stream` for
+render-time Suspense streaming.
+
+## Run E2E
+
+```sh
+pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e
+```
+
+The Playwright tests cover raw SSR HTML, browser hydration, fresh-sync
+reconciliation, incremental collection hydration, and critical hydration plus a
+render-time query in the same request. The streaming route shows its Suspense
+fallback before the streamed server rows arrive.
+
+## Deploy Demo
+
+The demo requires an SSR-capable host for TanStack Start.
+
+Netlify deployment is configured through `netlify.toml` and
+`netlify/functions/server.mjs`. Deploy with:
+
+```sh
+cd examples/react/start-ssr-e2e
+netlify deploy --prod --site-name tanstack-db-ssr-demo --team tanstack
+```
+
+After deployment, verify the live URL with:
+
+```sh
+PLAYWRIGHT_BASE_URL=https://your-demo-url pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e:hosted
+```
diff --git a/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts
new file mode 100644
index 0000000000..f9461d6fac
--- /dev/null
+++ b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts
@@ -0,0 +1,93 @@
+import { expect, test } from '@playwright/test'
+
+test(`TanStack Start hydrates, reconciles, and incrementally applies DB rows`, async ({
+ page,
+ request,
+}) => {
+ const response = await request.get(`/ssr-db`)
+ expect(response.ok()).toBe(true)
+
+ const html = await response.text()
+ expect(html).toContain(`Pay invoices`)
+ expect(html).not.toContain(`Pay invoices (reconciled from sync)`)
+ expect(html).toContain(`Review pull requests`)
+ expect(html).toContain(`ssr`)
+ expect(html).not.toContain(`Streamed from collection chunk`)
+
+ const browserErrors: Array = []
+ page.on(`console`, (message) => {
+ if (message.type() === `error`) {
+ browserErrors.push(message.text())
+ }
+ })
+ page.on(`pageerror`, (error) => {
+ browserErrors.push(error.message)
+ })
+
+ await page.goto(`/ssr-db`)
+
+ await expect(page.getByTestId(`hydration-state`)).toHaveText(`hydrated`)
+ await expect(page.getByTestId(`ready-state`)).toHaveText(`ready`)
+ await expect(page.getByTestId(`streamed-status`)).toHaveText(`waiting`)
+ await expect(page.getByTestId(`ssr-row-count`)).toHaveText(`2`)
+ await expect(page.getByTestId(`ssr-todo-list`)).toContainText(
+ `Pay invoices (reconciled from sync)`,
+ )
+ await expect(page.getByTestId(`ssr-todo-server-1`)).toContainText(`(sync)`)
+ await expect(page.getByTestId(`ssr-todo-list`)).toContainText(
+ `Review pull requests`,
+ )
+ await expect(page.getByTestId(`ssr-todo-list`)).not.toContainText(
+ `Archived roadmap`,
+ )
+
+ await page.getByTestId(`apply-stream-chunk`).click()
+
+ await expect(page.getByTestId(`streamed-status`)).toHaveText(`streamed`)
+ await expect(page.getByTestId(`ssr-row-count`)).toHaveText(`3`)
+ await expect(page.getByTestId(`ssr-todo-streamed-1`)).toBeVisible()
+ await expect(page.getByTestId(`ssr-todo-streamed-1`)).toContainText(
+ `Streamed from collection chunk`,
+ )
+ expect(browserErrors).toEqual([])
+})
+
+test(`TanStack Start streams a DB result snapshot and hands off to browser sync`, async ({
+ page,
+ request,
+}) => {
+ const response = await request.get(`/ssr-db-stream`)
+ expect(response.ok()).toBe(true)
+ const html = await response.text()
+ expect(html).toContain(`Streamed while rendering`)
+ expect(html).not.toContain(`SOURCE_ONLY_DO_NOT_TRANSPORT`)
+
+ const browserErrors: Array = []
+ page.on(`console`, (message) => {
+ if (message.type() === `error`) {
+ browserErrors.push(message.text())
+ }
+ })
+ page.on(`pageerror`, (error) => {
+ browserErrors.push(error.message)
+ })
+
+ await page.goto(`/ssr-db-stream`, { waitUntil: `commit` })
+
+ await expect(page.getByTestId(`critical-todo-server-1`)).toContainText(
+ `Pay invoices`,
+ )
+ await expect(page.getByTestId(`stream-fallback`)).toBeVisible()
+ await expect(page.getByTestId(`streamed-todo-list`)).not.toBeVisible()
+ await expect(page.getByTestId(`streamed-todo-streamed-server-1`)).toHaveText(
+ `Streamed while rendering`,
+ )
+ await expect(page.getByTestId(`stream-fallback`)).not.toBeVisible()
+ await expect(
+ page.getByTestId(`streamed-todo-streamed-server-1`),
+ ).not.toBeVisible()
+ await expect(page.getByTestId(`streamed-todo-streamed-browser-1`)).toHaveText(
+ `Reconciled from browser sync`,
+ )
+ expect(browserErrors).toEqual([])
+})
diff --git a/examples/react/start-ssr-e2e/netlify.toml b/examples/react/start-ssr-e2e/netlify.toml
new file mode 100644
index 0000000000..8b7d900e4c
--- /dev/null
+++ b/examples/react/start-ssr-e2e/netlify.toml
@@ -0,0 +1,8 @@
+[build]
+command = "pnpm build"
+publish = "dist/client"
+functions = "netlify/functions"
+
+[functions]
+node_bundler = "esbuild"
+included_files = ["dist/server/**"]
diff --git a/examples/react/start-ssr-e2e/netlify/functions/server.mjs b/examples/react/start-ssr-e2e/netlify/functions/server.mjs
new file mode 100644
index 0000000000..ce2ee0d83d
--- /dev/null
+++ b/examples/react/start-ssr-e2e/netlify/functions/server.mjs
@@ -0,0 +1,10 @@
+import server from '../../dist/server/server.js'
+
+export const config = {
+ path: '/*',
+ preferStatic: true,
+}
+
+export default function handler(request) {
+ return server.fetch(request)
+}
diff --git a/examples/react/start-ssr-e2e/package.json b/examples/react/start-ssr-e2e/package.json
new file mode 100644
index 0000000000..8f8788a9be
--- /dev/null
+++ b/examples/react/start-ssr-e2e/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@tanstack/db-example-react-start-ssr-e2e",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite dev",
+ "test:e2e": "pnpm --filter @tanstack/db build && pnpm --filter @tanstack/react-db build && pnpm --filter @tanstack/react-router-with-db build && playwright test",
+ "test:e2e:hosted": "playwright test"
+ },
+ "dependencies": {
+ "@tanstack/react-db": "^0.2.1",
+ "@tanstack/react-router": "^1.159.5",
+ "@tanstack/react-router-with-db": "workspace:*",
+ "@tanstack/react-start": "^1.159.5",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "vite-tsconfig-paths": "^5.1.4"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.60.0",
+ "@types/node": "^25.2.2",
+ "@types/react": "^19.2.13",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.3",
+ "typescript": "^5.9.2",
+ "vite": "^7.3.0"
+ }
+}
diff --git a/examples/react/start-ssr-e2e/playwright.config.ts b/examples/react/start-ssr-e2e/playwright.config.ts
new file mode 100644
index 0000000000..ef3628ad29
--- /dev/null
+++ b/examples/react/start-ssr-e2e/playwright.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:4175`
+const shouldStartWebServer = process.env.PLAYWRIGHT_BASE_URL === undefined
+
+export default defineConfig({
+ testDir: `./e2e`,
+ timeout: 30000,
+ expect: {
+ timeout: 10000,
+ },
+ fullyParallel: false,
+ use: {
+ baseURL,
+ trace: `on-first-retry`,
+ },
+ webServer: shouldStartWebServer
+ ? {
+ command: `pnpm dev --host 127.0.0.1 --port 4175`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120000,
+ url: baseURL,
+ }
+ : undefined,
+ projects: [
+ {
+ name: `chromium`,
+ use: { ...devices[`Desktop Chrome`] },
+ },
+ ],
+})
diff --git a/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts b/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts
new file mode 100644
index 0000000000..affaeda9bd
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts
@@ -0,0 +1,108 @@
+import { DbClient, collectionOptions, eq } from '@tanstack/react-db'
+import type { DehydratedDbState } from '@tanstack/react-db'
+
+export type SsrTodo = {
+ id: string
+ text: string
+ status: `open` | `done`
+ source: `server` | `sync` | `stream`
+}
+
+export const ssrTodoCollectionId = `ssr-e2e-todos`
+
+const serverTodos: Array = [
+ {
+ id: `server-1`,
+ text: `Pay invoices`,
+ status: `open`,
+ source: `server`,
+ },
+ {
+ id: `server-2`,
+ text: `Review pull requests`,
+ status: `open`,
+ source: `server`,
+ },
+ {
+ id: `server-3`,
+ text: `Archived roadmap`,
+ status: `done`,
+ source: `server`,
+ },
+]
+
+const browserTodos: Array = serverTodos.map((todo) =>
+ todo.id === `server-1`
+ ? {
+ ...todo,
+ text: `Pay invoices (reconciled from sync)`,
+ source: `sync`,
+ }
+ : { ...todo, source: `sync` },
+)
+
+export const streamedTodo: SsrTodo = {
+ id: `streamed-1`,
+ text: `Streamed from collection chunk`,
+ status: `open`,
+ source: `stream`,
+}
+
+export const ssrTodoCollection = collectionOptions(ssrTodoCollectionId, () => ({
+ id: ssrTodoCollectionId,
+ getKey: (todo: SsrTodo) => todo.id,
+ syncMode: `on-demand`,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ markReady()
+
+ return {
+ loadSubset: () => {
+ const todos =
+ typeof window === `undefined` ? serverTodos : browserTodos
+
+ begin({ immediate: true })
+ for (const todo of todos) {
+ write({
+ type: `insert`,
+ value: todo,
+ })
+ }
+ commit()
+ return true
+ },
+ }
+ },
+ },
+}))
+
+export async function preloadSsrTodos(dbClient: DbClient): Promise {
+ await dbClient.preloadLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: ssrTodoCollection })
+ .where(({ todo }) => eq(todo.status, `open`)),
+ })
+}
+
+export async function createDehydratedSsrTodoState(): Promise {
+ const dbClient = new DbClient()
+ try {
+ await preloadSsrTodos(dbClient)
+ return dbClient.dehydrate()
+ } finally {
+ await dbClient.cleanup()
+ }
+}
+
+export function applyStreamedTodo(dbClient: DbClient): void {
+ dbClient.applyCollectionChunk({
+ collectionId: ssrTodoCollectionId,
+ rows: [
+ {
+ key: streamedTodo.id,
+ value: streamedTodo,
+ },
+ ],
+ })
+}
diff --git a/examples/react/start-ssr-e2e/src/main.tsx b/examples/react/start-ssr-e2e/src/main.tsx
new file mode 100644
index 0000000000..7c9866dcd3
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from '@tanstack/react-router'
+import { getRouter } from './router'
+
+const router = getRouter()
+
+createRoot(document.getElementById(`root`)!).render(
+
+
+ ,
+)
diff --git a/examples/react/start-ssr-e2e/src/routeTree.gen.ts b/examples/react/start-ssr-e2e/src/routeTree.gen.ts
new file mode 100644
index 0000000000..b6d5a30439
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/routeTree.gen.ts
@@ -0,0 +1,105 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as SsrDbStreamRouteImport } from './routes/ssr-db-stream'
+import { Route as SsrDbRouteImport } from './routes/ssr-db'
+import { Route as IndexRouteImport } from './routes/index'
+
+const SsrDbStreamRoute = SsrDbStreamRouteImport.update({
+ id: '/ssr-db-stream',
+ path: '/ssr-db-stream',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SsrDbRoute = SsrDbRouteImport.update({
+ id: '/ssr-db',
+ path: '/ssr-db',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/ssr-db': typeof SsrDbRoute
+ '/ssr-db-stream': typeof SsrDbStreamRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/ssr-db': typeof SsrDbRoute
+ '/ssr-db-stream': typeof SsrDbStreamRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/ssr-db': typeof SsrDbRoute
+ '/ssr-db-stream': typeof SsrDbStreamRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/ssr-db' | '/ssr-db-stream'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/ssr-db' | '/ssr-db-stream'
+ id: '__root__' | '/' | '/ssr-db' | '/ssr-db-stream'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ SsrDbRoute: typeof SsrDbRoute
+ SsrDbStreamRoute: typeof SsrDbStreamRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/ssr-db-stream': {
+ id: '/ssr-db-stream'
+ path: '/ssr-db-stream'
+ fullPath: '/ssr-db-stream'
+ preLoaderRoute: typeof SsrDbStreamRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/ssr-db': {
+ id: '/ssr-db'
+ path: '/ssr-db'
+ fullPath: '/ssr-db'
+ preLoaderRoute: typeof SsrDbRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ SsrDbRoute: SsrDbRoute,
+ SsrDbStreamRoute: SsrDbStreamRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { startInstance } from './start.tsx'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ config: Awaited>
+ }
+}
diff --git a/examples/react/start-ssr-e2e/src/router.tsx b/examples/react/start-ssr-e2e/src/router.tsx
new file mode 100644
index 0000000000..b091853cb9
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/router.tsx
@@ -0,0 +1,27 @@
+import { createRouter as createTanstackRouter } from '@tanstack/react-router'
+import { createIsomorphicFn } from '@tanstack/react-start'
+import { DbClient } from '@tanstack/react-db'
+import { routerWithDbClient } from '@tanstack/react-router-with-db'
+import { routeTree } from './routeTree.gen'
+import './styles.css'
+
+export type RouterContext = {
+ dbClient: DbClient
+}
+
+const getRuntime = createIsomorphicFn()
+ .server(() => `server` as const)
+ .client(() => `browser` as const)
+
+export function getRouter() {
+ const dbClient = new DbClient({
+ runtime: getRuntime(),
+ })
+ const router = createTanstackRouter({
+ routeTree,
+ context: { dbClient },
+ scrollRestoration: true,
+ })
+
+ return routerWithDbClient(router, dbClient)
+}
diff --git a/examples/react/start-ssr-e2e/src/routes/__root.tsx b/examples/react/start-ssr-e2e/src/routes/__root.tsx
new file mode 100644
index 0000000000..ad71067417
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/routes/__root.tsx
@@ -0,0 +1,48 @@
+import * as React from 'react'
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRouteWithContext,
+} from '@tanstack/react-router'
+import appCss from '../styles.css?url'
+import type { RouterContext } from '../router'
+
+export const Route = createRootRouteWithContext()({
+ head: () => ({
+ meta: [
+ {
+ charSet: `utf-8`,
+ },
+ {
+ name: `viewport`,
+ content: `width=device-width, initial-scale=1`,
+ },
+ {
+ title: `TanStack DB Start SSR E2E`,
+ },
+ ],
+ links: [
+ {
+ rel: `stylesheet`,
+ href: appCss,
+ },
+ ],
+ }),
+ shellComponent: RootDocument,
+ component: () => ,
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/examples/react/start-ssr-e2e/src/routes/index.tsx b/examples/react/start-ssr-e2e/src/routes/index.tsx
new file mode 100644
index 0000000000..1131099207
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/routes/index.tsx
@@ -0,0 +1,14 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute(`/`)({
+ component: HomePage,
+})
+
+function HomePage() {
+ return (
+
+ TanStack DB Start SSR E2E
+ Open SSR DB route
+
+ )
+}
diff --git a/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx b/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx
new file mode 100644
index 0000000000..14c3aea8e1
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx
@@ -0,0 +1,131 @@
+import * as React from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import {
+ collectionOptions,
+ eq,
+ useLiveQuery,
+ useLiveSuspenseQuery,
+} from '@tanstack/react-db'
+import { preloadSsrTodos, ssrTodoCollection } from '../lib/ssr-fixture'
+
+type StreamedTodo = {
+ id: string
+ text: string
+ status: `open` | `done`
+ sourcePayload: string
+}
+
+const serverTodo: StreamedTodo = {
+ id: `streamed-server-1`,
+ text: `Streamed while rendering`,
+ status: `open`,
+ sourcePayload: `SOURCE_ONLY_DO_NOT_TRANSPORT`,
+}
+
+const browserTodo: StreamedTodo = {
+ id: `streamed-browser-1`,
+ text: `Reconciled from browser sync`,
+ status: `open`,
+ sourcePayload: `BROWSER_SOURCE_ONLY_DO_NOT_TRANSPORT`,
+}
+
+const streamedTodoCollection = collectionOptions(
+ `ssr-suspense-stream-todos`,
+ (client) => {
+ const runtime = client.requireDependency<`server` | `browser`>(`runtime`)
+
+ return {
+ id: `ssr-suspense-stream-todos`,
+ getKey: (todo: StreamedTodo) => todo.id,
+ syncMode: `on-demand` as const,
+ sync: {
+ sync: ({ begin, write, commit, markReady }) => {
+ markReady()
+
+ return {
+ loadSubset: () => {
+ return new Promise((resolve) => {
+ setTimeout(
+ () => {
+ begin({ immediate: true })
+ write({
+ type: `insert`,
+ value: runtime === `server` ? serverTodo : browserTodo,
+ })
+ commit()
+ resolve()
+ },
+ runtime === `server` ? 1000 : 1500,
+ )
+ })
+ },
+ }
+ },
+ },
+ }
+ },
+)
+
+export const Route = createFileRoute(`/ssr-db-stream`)({
+ loader: async ({ context }) => {
+ await preloadSsrTodos(context.dbClient)
+ },
+ component: SsrDbStreamRoute,
+})
+
+function SsrDbStreamRoute() {
+ return (
+
+ TanStack DB Suspense Streaming
+
+ Loading todos}
+ >
+
+
+
+ )
+}
+
+function CriticalTodoList() {
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: ssrTodoCollection })
+ .where(({ todo }) => eq(todo.status, `open`)),
+ })
+
+ return (
+
+ {todos.map((todo) => (
+
+ {todo.text}
+
+ ))}
+
+ )
+}
+
+function StreamedTodoList() {
+ const { data: todos } = useLiveSuspenseQuery({
+ query: (q) =>
+ q
+ .from({ todo: streamedTodoCollection })
+ .where(({ todo }) => eq(todo.status, `open`))
+ .select(({ todo }) => ({
+ id: todo.id,
+ text: todo.text,
+ status: todo.status,
+ })),
+ })
+
+ return (
+
+ {todos.map((todo) => (
+
+ {todo.text}
+
+ ))}
+
+ )
+}
diff --git a/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx
new file mode 100644
index 0000000000..6b31a87460
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx
@@ -0,0 +1,110 @@
+import * as React from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import { eq, useLiveQuery } from '@tanstack/react-db'
+import {
+ applyStreamedTodo,
+ createDehydratedSsrTodoState,
+ ssrTodoCollection,
+} from '../lib/ssr-fixture'
+import type { DbClient } from '@tanstack/react-db'
+
+export const Route = createFileRoute(`/ssr-db`)({
+ loader: async () => {
+ return {
+ dbState: await createDehydratedSsrTodoState(),
+ }
+ },
+ component: SsrDbRoute,
+})
+
+function SsrDbRoute() {
+ const { dbState } = Route.useLoaderData()
+ const { dbClient } = Route.useRouteContext()
+ const [hydratedDbClient] = React.useState(() => {
+ dbClient.hydrate(dbState)
+ return dbClient
+ })
+
+ return
+}
+
+function SsrDbTodos({ dbClient }: { dbClient: DbClient }) {
+ const [hydrated, setHydrated] = React.useState(false)
+ const [streamed, setStreamed] = React.useState(false)
+ const { data: todos, isReady } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: ssrTodoCollection })
+ .where(({ todo }) => eq(todo.status, `open`))
+ .orderBy(({ todo }) => todo.id, `asc`),
+ })
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+
+
+ TanStack DB SSR
+
+
+
+ {hydrated ? `hydrated` : `ssr`}
+
+ {isReady ? `ready` : `loading`}
+
+ {streamed ? `streamed` : `waiting`}
+
+
+ rows: {todos.length}
+
+
+
+
+ {todos.map((todo) => (
+
+ {todo.text} ({todo.source})
+
+ ))}
+
+
+ {
+ applyStreamedTodo(dbClient)
+ setStreamed(true)
+ }}
+ style={{
+ background: `#15803d`,
+ border: 0,
+ borderRadius: 6,
+ color: `white`,
+ cursor: `pointer`,
+ marginTop: 16,
+ padding: `10px 14px`,
+ }}
+ type="button"
+ >
+ Apply streamed chunk
+
+
+
+ )
+}
diff --git a/examples/react/start-ssr-e2e/src/start.tsx b/examples/react/start-ssr-e2e/src/start.tsx
new file mode 100644
index 0000000000..bb197cafb1
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/start.tsx
@@ -0,0 +1,7 @@
+import { createStart } from '@tanstack/react-start'
+
+export const startInstance = createStart(() => {
+ return {
+ defaultSsr: true,
+ }
+})
diff --git a/examples/react/start-ssr-e2e/src/styles.css b/examples/react/start-ssr-e2e/src/styles.css
new file mode 100644
index 0000000000..251e05865f
--- /dev/null
+++ b/examples/react/start-ssr-e2e/src/styles.css
@@ -0,0 +1,15 @@
+body {
+ margin: 0;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+button {
+ font: inherit;
+}
diff --git a/examples/react/start-ssr-e2e/tsconfig.json b/examples/react/start-ssr-e2e/tsconfig.json
new file mode 100644
index 0000000000..19dcb2d948
--- /dev/null
+++ b/examples/react/start-ssr-e2e/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "e2e/**/*.ts",
+ "playwright.config.ts",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "vite.config.ts"
+ ],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/examples/react/start-ssr-e2e/vite.config.ts b/examples/react/start-ssr-e2e/vite.config.ts
new file mode 100644
index 0000000000..856428d501
--- /dev/null
+++ b/examples/react/start-ssr-e2e/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteTsConfigPaths from 'vite-tsconfig-paths'
+
+export default defineConfig({
+ plugins: [
+ viteTsConfigPaths({
+ projects: [`./tsconfig.json`],
+ }),
+ tanstackStart({
+ srcDirectory: `src`,
+ start: { entry: `./start.tsx` },
+ }),
+ react(),
+ ],
+})
diff --git a/examples/react/todo/src/routes/electric.tsx b/examples/react/todo/src/routes/electric.tsx
index 61629b81f2..16da41b9ff 100644
--- a/examples/react/todo/src/routes/electric.tsx
+++ b/examples/react/todo/src/routes/electric.tsx
@@ -24,15 +24,16 @@ export const Route = createFileRoute(`/electric`)({
function ElectricPage() {
// Get data using live queries with Electric collections
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todo: electricTodoCollection })
- .orderBy(({ todo }) => todo.created_at, `asc`),
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: electricTodoCollection })
+ .orderBy(({ todo }) => todo.created_at, `asc`),
+ })
- const { data: configData } = useLiveQuery((q) =>
- q.from({ config: electricConfigCollection }),
- )
+ const { data: configData } = useLiveQuery({
+ query: (q) => q.from({ config: electricConfigCollection }),
+ })
// Electric collections use txid to track sync
const configMutationFn = async ({
diff --git a/examples/react/todo/src/routes/query.tsx b/examples/react/todo/src/routes/query.tsx
index 62c0ad37dc..5cbf4f28de 100644
--- a/examples/react/todo/src/routes/query.tsx
+++ b/examples/react/todo/src/routes/query.tsx
@@ -21,15 +21,16 @@ export const Route = createFileRoute(`/query`)({
function QueryPage() {
// Get data using live queries with Query collections
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todo: queryTodoCollection })
- .orderBy(({ todo }) => todo.created_at, `asc`),
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: queryTodoCollection })
+ .orderBy(({ todo }) => todo.created_at, `asc`),
+ })
- const { data: configData } = useLiveQuery((q) =>
- q.from({ config: queryConfigCollection }),
- )
+ const { data: configData } = useLiveQuery({
+ query: (q) => q.from({ config: queryConfigCollection }),
+ })
// Query collections automatically refetch after handler completes
const configMutationFn = async ({
diff --git a/examples/react/todo/src/routes/trailbase.tsx b/examples/react/todo/src/routes/trailbase.tsx
index 96e05e11ac..d4b5b46556 100644
--- a/examples/react/todo/src/routes/trailbase.tsx
+++ b/examples/react/todo/src/routes/trailbase.tsx
@@ -22,15 +22,16 @@ export const Route = createFileRoute(`/trailbase`)({
function TrailBasePage() {
// Get data using live queries with TrailBase collections
- const { data: todos } = useLiveQuery((q) =>
- q
- .from({ todo: trailBaseTodoCollection })
- .orderBy(({ todo }) => todo.created_at, `asc`),
- )
+ const { data: todos } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: trailBaseTodoCollection })
+ .orderBy(({ todo }) => todo.created_at, `asc`),
+ })
- const { data: configData } = useLiveQuery((q) =>
- q.from({ config: trailBaseConfigCollection }),
- )
+ const { data: configData } = useLiveQuery({
+ query: (q) => q.from({ config: trailBaseConfigCollection }),
+ })
// Note: TrailBase collections use recordApi internally, which is not exposed
// as a collection utility. For this example, we're not using serialized
diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts
index ca60a050ab..be32f3eb00 100644
--- a/packages/db-sqlite-persistence-core/src/persisted.ts
+++ b/packages/db-sqlite-persistence-core/src/persisted.ts
@@ -2,6 +2,7 @@ import {
compileSingleRowExpression,
safeRandomUUID,
toBooleanPredicate,
+ withCollectionConfigFactory,
} from '@tanstack/db'
import {
InvalidPersistedCollectionConfigError,
@@ -1244,6 +1245,9 @@ class PersistedCollectionRuntime<
this.syncControls.begin?.({ immediate: true })
for (const row of rows) {
+ if (this.collection?._hasHydratedKey(row.key)) {
+ continue
+ }
this.syncControls.write?.({
type: `update`,
value: row.value,
@@ -2641,12 +2645,21 @@ export function persistedCollectionOptions<
collectionId,
)
- return {
+ const result = {
...syncOptions,
id: collectionId,
sync: createWrappedSyncConfig(syncOptions.sync, runtime),
persistence,
}
+
+ return withCollectionConfigFactory(
+ result,
+ () =>
+ persistedCollectionOptions({
+ ...options,
+ id: collectionId,
+ }) as typeof result,
+ )
}
const { schemaVersion, ...localOnlyOptions } = options
@@ -2734,7 +2747,7 @@ export function persistedCollectionOptions<
...persistedUtils,
}
- return {
+ const result = {
...localOnlyOptions,
id: collectionId,
persistence,
@@ -2746,6 +2759,15 @@ export function persistedCollectionOptions<
startSync: true,
gcTime: localOnlyOptions.gcTime ?? 0,
}
+
+ return withCollectionConfigFactory(
+ result,
+ () =>
+ persistedCollectionOptions({
+ ...options,
+ id: collectionId,
+ }) as typeof result,
+ )
}
export function encodePersistedStorageKey(key: string | number): string {
diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts
index 42bbcf5633..78087419fa 100644
--- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts
+++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
BasicIndex,
+ DbClient,
IR,
+ collectionOptions,
createCollection,
createTransaction,
} from '@tanstack/db'
@@ -912,6 +914,51 @@ describe(`persistedCollectionOptions`, () => {
expect(adapter.loadSubsetCalls[0]?.collectionId).toBe(collection.id)
})
+ it(`keeps hydrated rows ahead of persisted startup rows`, async () => {
+ const adapter = createRecordingAdapter([
+ { id: `1`, title: `Persisted title` },
+ ])
+ const descriptor = collectionOptions(
+ persistedCollectionOptions({
+ id: `hydration-precedence`,
+ getKey: (item) => item.id,
+ sync: {
+ sync: ({ markReady }) => {
+ markReady()
+ },
+ },
+ persistence: {
+ adapter,
+ },
+ }),
+ )
+ const client = new DbClient()
+ client.hydrate({
+ collections: [
+ {
+ collectionId: descriptor.id,
+ rows: [
+ {
+ key: `1`,
+ value: { id: `1`, title: `SSR title` },
+ },
+ ],
+ },
+ ],
+ })
+
+ const collection = client.collection(descriptor)
+ await collection.stateWhenReady()
+ await flushAsyncWork()
+
+ expect(collection.get(`1`)).toMatchObject({
+ id: `1`,
+ title: `SSR title`,
+ })
+
+ await client.cleanup()
+ })
+
it(`bootstraps and tracks persisted index lifecycle in sync-present mode`, async () => {
const adapter = createRecordingAdapter()
const collection = createCollection(
diff --git a/packages/db/skills/db-core/live-queries/SKILL.md b/packages/db/skills/db-core/live-queries/SKILL.md
index 00da8635ff..4152253383 100644
--- a/packages/db/skills/db-core/live-queries/SKILL.md
+++ b/packages/db/skills/db-core/live-queries/SKILL.md
@@ -415,15 +415,18 @@ JS `.filter()` / `.map()` on the result array throws away incremental maintenanc
```ts
// WRONG -- re-runs filter on every change
-const { data } = useLiveQuery((q) => q.from({ todos: todosCollection }))
+const { data } = useLiveQuery({
+ query: (q) => q.from({ todos: todosCollection }),
+})
const active = data.filter((t) => t.completed === false)
// CORRECT -- incrementally maintained
-const { data } = useLiveQuery((q) =>
- q
- .from({ todos: todosCollection })
- .where(({ todos }) => eq(todos.completed, false)),
-)
+const { data } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todos: todosCollection })
+ .where(({ todos }) => eq(todos.completed, false)),
+})
```
### HIGH: Not using the full operator set
diff --git a/packages/db/skills/meta-framework/SKILL.md b/packages/db/skills/meta-framework/SKILL.md
index d8aef668f6..fbeacc1c50 100644
--- a/packages/db/skills/meta-framework/SKILL.md
+++ b/packages/db/skills/meta-framework/SKILL.md
@@ -62,7 +62,9 @@ export const Route = createFileRoute('/todos')({
})
function TodoPage() {
- const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection }))
+ const { data: todos } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
return (
{todos.map((t) => (
@@ -127,9 +129,9 @@ import { useEffect, useState } from 'react'
import { useLiveQuery } from '@tanstack/react-db'
export default function TodoPage() {
- const { data: todos, isLoading } = useLiveQuery((q) =>
- q.from({ todo: todoCollection }),
- )
+ const { data: todos, isLoading } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
if (isLoading) return Loading...
return (
@@ -157,7 +159,9 @@ import { useLiveQuery } from '@tanstack/react-db'
const preloadPromise = todoCollection.preload()
export default function TodoPage() {
- const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection }))
+ const { data: todos } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
return (
{todos.map((t) => (
@@ -186,7 +190,9 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
export const loader = () => null
export default function TodoPage() {
- const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection }))
+ const { data: todos } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
return (
{todos.map((t) => (
@@ -296,7 +302,9 @@ export const Route = createFileRoute('/todos')({
return null
},
component: () => {
- const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }))
+ const { data } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
// ...
},
})
@@ -387,7 +395,9 @@ export const Route = createFileRoute('/todos')({
ssr: false,
loader: async () => { await todoCollection.preload() },
component: () => {
- const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }))
+ const { data } = useLiveQuery({
+ query: (q) => q.from({ todo: todoCollection }),
+ })
},
})
```
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
new file mode 100644
index 0000000000..7ba774aa6c
--- /dev/null
+++ b/packages/db/src/client.ts
@@ -0,0 +1,898 @@
+import { createCollection } from './collection/index.js'
+import {
+ collectionOptionsBrand,
+ collectionOptionsFactory,
+ hasCollectionOptionsBrand,
+} from './collection-options.js'
+import { TransactionScope } from './transactions.js'
+import { getBuilderFromConfig } from './query/live/collection-registry.js'
+import { createLiveQueryCollection } from './query/live-query-collection.js'
+import { createLiveQueryObserver } from './live-query-observer.js'
+import {
+ getLiveQueryHash,
+ prepareLiveQueryValue,
+} from './live-query-options.js'
+import type { StandardSchemaV1 } from '@standard-schema/spec'
+import type { Collection } from './collection/index.js'
+import type { CollectionOptionsIdentity } from './collection-options.js'
+import type {
+ CollectionConfig,
+ InferSchemaInput,
+ InferSchemaOutput,
+ NonSingleResult,
+ SingleResult,
+ TransactionConfig,
+ UtilsRecord,
+} from './types.js'
+import type {
+ DeferredLiveQueryCollections,
+ LiveQueryOptions,
+} from './live-query-options.js'
+
+const collectionConfigFactory: unique symbol = Symbol.for(
+ `@tanstack/db.collectionConfig.factory`,
+) as never
+
+type AnyCollectionConfig = CollectionConfig
+
+export type CollectionOptions<
+ T extends object = Record,
+ TKey extends string | number = string | number,
+ TSchema extends StandardSchemaV1 = never,
+ TUtils extends UtilsRecord = UtilsRecord,
+> = CollectionOptionsIdentity
+
+type AnyCollectionOptions = CollectionOptions
+type AnyCollection = Collection
+
+type DescriptorFromConfig =
+ TConfig extends {
+ getKey: (item: infer T) => infer TKey
+ }
+ ? CollectionOptions<
+ Extract,
+ Extract,
+ TConfig extends {
+ schema: infer TSchema extends StandardSchemaV1
+ }
+ ? TSchema
+ : never,
+ TConfig extends {
+ utils: infer TUtils extends UtilsRecord
+ }
+ ? TUtils
+ : UtilsRecord
+ > &
+ (TConfig extends SingleResult ? SingleResult : NonSingleResult)
+ : never
+
+type CollectionConfigWithFactory =
+ TConfig & {
+ readonly [collectionConfigFactory]: (client: DbClient) => TConfig
+ }
+
+/**
+ * Adds a fresh-config materializer to an adapter options object.
+ *
+ * Adapter option creators should use this so a module-scoped descriptor can be
+ * materialized safely by more than one DbClient.
+ */
+export function withCollectionConfigFactory<
+ TConfig extends AnyCollectionConfig,
+>(
+ config: TConfig,
+ factory: (client: DbClient) => TConfig,
+): CollectionConfigWithFactory {
+ Object.defineProperty(config, collectionConfigFactory, {
+ value: factory,
+ enumerable: false,
+ })
+ return config as CollectionConfigWithFactory
+}
+
+export type CollectionMaterializeOptions = {
+ initialData?: Array
+}
+
+export type DehydratedCollectionRow<
+ T extends object = Record,
+ TKey extends string | number = string | number,
+> = {
+ key: TKey
+ value: T
+ metadata?: unknown
+}
+
+export type DehydratedCollectionChunk<
+ T extends object = Record,
+ TKey extends string | number = string | number,
+> = {
+ collectionId: string
+ rows: Array>
+ syncMeta?: unknown
+}
+
+export type DehydratedLiveQuery = {
+ queryHash: string
+ dehydratedAt: number
+ snapshot?: DehydratedLiveQueryResult
+ promise?: Promise
+}
+
+export type DehydratedLiveQueryResult<
+ T extends object = object,
+ TKey extends string | number = string | number,
+> = {
+ rows: Array>
+}
+
+export type DehydratedDbState = {
+ collections: Array
+ liveQueries?: Array
+}
+
+export type DbClientLiveQueryState = `pending` | `success` | `error`
+
+export type DbClientLiveQuery = {
+ readonly queryHash: string
+ readonly dehydratedAt: number
+ readonly status: DbClientLiveQueryState
+ readonly promise: Promise
+ readonly snapshot?: DehydratedLiveQueryResult
+ readonly error?: unknown
+}
+
+export type DbClientEvent =
+ | {
+ type: `liveQueryAdded` | `liveQueryUpdated`
+ query: DbClientLiveQuery
+ }
+ | {
+ type: `liveQueryStreamError`
+ error: unknown
+ }
+
+export type DehydrateDbClientOptions = {
+ shouldDehydrateCollection?: (collection: Collection) => boolean
+ shouldDehydrateLiveQuery?: (query: DbClientLiveQuery) => boolean
+}
+
+type CollectionRecord = {
+ collection: AnyCollection
+ shouldDehydrate: boolean
+}
+
+type LiveQueryRecord = {
+ queryHash: string
+ dehydratedAt: number
+ status: DbClientLiveQueryState
+ promise: Promise
+ resultPromise: Promise
+ succeed: (snapshot: DehydratedLiveQueryResult) => void
+ fail: (error: unknown) => void
+ snapshot?: DehydratedLiveQueryResult
+ error?: unknown
+}
+
+export type DbClientOptions = Record
+
+export function collectionOptions<
+ T extends StandardSchemaV1,
+ TKey extends string | number,
+ TUtils extends UtilsRecord,
+>(
+ options: CollectionConfig, TKey, T, TUtils> & {
+ schema: T
+ } & NonSingleResult,
+): CollectionOptions, TKey, T, TUtils> & NonSingleResult
+export function collectionOptions<
+ T extends StandardSchemaV1,
+ TKey extends string | number,
+ TUtils extends UtilsRecord,
+>(
+ options: CollectionConfig, TKey, T, TUtils> & {
+ schema: T
+ } & SingleResult,
+): CollectionOptions, TKey, T, TUtils> & SingleResult
+export function collectionOptions<
+ T extends object,
+ TKey extends string | number = string | number,
+ TUtils extends UtilsRecord = UtilsRecord,
+>(
+ options: CollectionConfig & {
+ schema?: never
+ } & NonSingleResult,
+): CollectionOptions & NonSingleResult
+export function collectionOptions<
+ T extends object,
+ TKey extends string | number = string | number,
+ TUtils extends UtilsRecord = UtilsRecord,
+>(
+ options: CollectionConfig & {
+ schema?: never
+ } & SingleResult,
+): CollectionOptions & SingleResult
+export function collectionOptions(
+ id: string,
+ factory: (client: DbClient) => TConfig,
+): DescriptorFromConfig
+export function collectionOptions(
+ optionsOrId: AnyCollectionConfig | string,
+ explicitFactory?: (client: DbClient) => unknown,
+): any {
+ const config = typeof optionsOrId === `string` ? undefined : optionsOrId
+ const id = typeof optionsOrId === `string` ? optionsOrId : optionsOrId.id
+
+ if (!id) {
+ throw new Error(
+ `collectionOptions requires a non-empty explicit id so the descriptor is stable across DbClient instances and SSR boundaries.`,
+ )
+ }
+
+ if (typeof optionsOrId === `string` && !explicitFactory) {
+ throw new Error(
+ `collectionOptions("${id}") requires a factory as its second argument.`,
+ )
+ }
+
+ const reusableFactory:
+ | ((client: DbClient) => AnyCollectionConfig)
+ | undefined = config
+ ? (config as CollectionConfigWithFactory)[
+ collectionConfigFactory
+ ]
+ : (explicitFactory as
+ | ((client: DbClient) => AnyCollectionConfig)
+ | undefined)
+
+ let owner: DbClient | undefined
+ const materialize = (client: DbClient): AnyCollectionConfig => {
+ let materialized: AnyCollectionConfig
+
+ if (reusableFactory) {
+ materialized = reusableFactory(client)
+ } else {
+ if (owner && owner !== client) {
+ throw new Error(
+ `Collection descriptor "${id}" was created from a concrete config that cannot be safely reused across DbClient instances. ` +
+ `Use collectionOptions("${id}", (client) => adapterCollectionOptions(...)) or an adapter options creator that supports DbClient materialization.`,
+ )
+ }
+ owner = client
+ materialized = config!
+ }
+
+ if (materialized.id !== undefined && materialized.id !== id) {
+ throw new Error(
+ `Collection descriptor "${id}" materialized a config with id "${materialized.id}". Descriptor and collection ids must match.`,
+ )
+ }
+
+ return materialized.id === id ? materialized : { ...materialized, id }
+ }
+
+ const descriptor = {
+ id,
+ ...((config as { singleResult?: boolean } | undefined)?.singleResult ===
+ true
+ ? { singleResult: true as const }
+ : {}),
+ } as Record
+
+ Object.defineProperties(descriptor, {
+ [collectionOptionsBrand]: {
+ value: true,
+ enumerable: false,
+ },
+ [collectionOptionsFactory]: {
+ value: materialize,
+ enumerable: false,
+ },
+ })
+
+ return Object.freeze(descriptor) as CollectionOptions<
+ any,
+ string | number,
+ any,
+ UtilsRecord
+ >
+}
+
+export function isCollectionOptions(
+ value: unknown,
+): value is CollectionOptions {
+ return hasCollectionOptionsBrand(value)
+}
+
+export class DbClient {
+ private collectionsByOptions = new WeakMap()
+ private collectionsById = new Map()
+ private pendingHydration = new Map>()
+ private liveQueries = new Map()
+ private preloadedLiveQueries = new Map<
+ string,
+ {
+ collection: AnyCollection
+ observer: { dispose: () => void }
+ }
+ >()
+ private liveQueryResources = new Map Promise>()
+ private listeners = new Set<(event: DbClientEvent) => void>()
+ private ssrStreamingEnabled = false
+ private ssrServerCleanupEnabled = false
+ private lastLiveQueryTimestamp = 0
+ private readonly transactionScope = new TransactionScope()
+
+ constructor(private readonly options: DbClientOptions = {}) {}
+
+ getDependency(key: string): T | undefined {
+ return this.options[key] as T | undefined
+ }
+
+ requireDependency(key: string): T {
+ const dependency = this.getDependency(key)
+ if (dependency === undefined) {
+ throw new Error(
+ `DbClient is missing the required "${key}" dependency. Pass it explicitly when constructing the client: new DbClient({ ${key} }).`,
+ )
+ }
+ return dependency
+ }
+
+ get activeTransaction() {
+ return this.transactionScope.getActiveTransaction()
+ }
+
+ createTransaction>(
+ config: TransactionConfig,
+ ) {
+ return this.transactionScope.createTransaction(config)
+ }
+
+ preloadLiveQuery(options: LiveQueryOptions): Promise {
+ const deferredCollections: DeferredLiveQueryCollections = new Set()
+ try {
+ const prepared = prepareLiveQueryValue(options, this, deferredCollections)
+ const queryHash = getLiveQueryHash(prepared, options.queryKey)
+ const existing = this.liveQueries.get(queryHash)
+ if (existing && existing.status !== `error`) return existing.promise
+
+ const failedPreload = this.preloadedLiveQueries.get(queryHash)
+ if (failedPreload) {
+ failedPreload.observer.dispose()
+ void failedPreload.collection.cleanup().catch(() => {})
+ this.preloadedLiveQueries.delete(queryHash)
+ }
+
+ const collection = createLiveQueryCollection({
+ ...(prepared as LiveQueryOptions),
+ startSync: true,
+ }) as AnyCollection
+ const observer = createLiveQueryObserver(collection, {
+ client: this,
+ queryHash,
+ mode: `wholesale`,
+ })
+ this.preloadedLiveQueries.set(queryHash, { collection, observer })
+
+ return this._registerLiveQuery(
+ queryHash,
+ collection.preload().then(() => observer.dehydrate()),
+ )
+ } finally {
+ for (const source of deferredCollections) source._resumeSyncStart()
+ deferredCollections.clear()
+ }
+ }
+
+ collection<
+ T extends StandardSchemaV1,
+ TKey extends string | number,
+ TUtils extends UtilsRecord,
+ >(
+ options: CollectionOptions, TKey, T, TUtils> &
+ NonSingleResult,
+ materializeOptions?: CollectionMaterializeOptions>,
+ ): Collection, TKey, TUtils, T, InferSchemaInput> &
+ NonSingleResult
+ collection<
+ T extends StandardSchemaV1,
+ TKey extends string | number,
+ TUtils extends UtilsRecord,
+ >(
+ options: CollectionOptions, TKey, T, TUtils> &
+ SingleResult,
+ materializeOptions?: CollectionMaterializeOptions>,
+ ): Collection, TKey, TUtils, T, InferSchemaInput> &
+ SingleResult
+ collection<
+ T extends object,
+ TKey extends string | number = string | number,
+ TUtils extends UtilsRecord = UtilsRecord,
+ >(
+ options: CollectionOptions & NonSingleResult,
+ materializeOptions?: CollectionMaterializeOptions,
+ ): Collection & NonSingleResult
+ collection<
+ T extends object,
+ TKey extends string | number = string | number,
+ TUtils extends UtilsRecord = UtilsRecord,
+ >(
+ options: CollectionOptions & SingleResult,
+ materializeOptions?: CollectionMaterializeOptions,
+ ): Collection & SingleResult
+ collection(
+ options: AnyCollectionOptions,
+ materializeOptions?: CollectionMaterializeOptions,
+ ): AnyCollection {
+ return this.materializeCollection(options, materializeOptions, false)
+ }
+
+ /** @internal */
+ _materializeCollectionForRender<
+ T extends object,
+ TKey extends string | number,
+ TSchema extends StandardSchemaV1,
+ TUtils extends UtilsRecord,
+ >(
+ options: CollectionOptions,
+ ): Collection<
+ T,
+ TKey,
+ TUtils,
+ TSchema,
+ [TSchema] extends [never] ? T : InferSchemaInput
+ > {
+ return this.materializeCollection(options, undefined, true)
+ }
+
+ private materializeCollection(
+ options: AnyCollectionOptions,
+ materializeOptions: CollectionMaterializeOptions | undefined,
+ deferSyncStart: boolean,
+ ): AnyCollection {
+ const existing = this.collectionsByOptions.get(options)
+ if (existing) {
+ if (!deferSyncStart) {
+ this.collectionsById.get(existing.id)!.shouldDehydrate = true
+ }
+ if (deferSyncStart) {
+ existing._deferSyncStart()
+ }
+ return existing
+ }
+
+ if (this.collectionsById.has(options.id)) {
+ throw new Error(
+ `Cannot materialize collection "${options.id}" because this DbClient already has a different collection with that id. SSR hydration requires collection ids to be unique per DbClient.`,
+ )
+ }
+
+ const config = options[collectionOptionsFactory](this)
+ const shouldStartSync = config.startSync === true
+ const collection = createCollection({
+ ...config,
+ startSync: false,
+ } as any)
+ collection._setTransactionScope(this.transactionScope)
+ if (deferSyncStart) {
+ collection._deferSyncStart()
+ }
+
+ this.collectionsByOptions.set(options, collection)
+ this.collectionsById.set(collection.id, {
+ collection,
+ shouldDehydrate: !deferSyncStart,
+ })
+
+ if (materializeOptions?.initialData?.length) {
+ this.applyRows(
+ collection,
+ {
+ collectionId: collection.id,
+ rows: materializeOptions.initialData.map((value) => ({ value })),
+ },
+ `initialData`,
+ )
+ }
+
+ const pendingChunks = this.pendingHydration.get(collection.id)
+ if (pendingChunks) {
+ for (const chunk of pendingChunks) {
+ this.applyRows(collection, chunk, `hydration`)
+ }
+ this.pendingHydration.delete(collection.id)
+ }
+
+ if (shouldStartSync) {
+ collection.startSyncImmediate()
+ }
+
+ return collection
+ }
+
+ dehydrate(options: DehydrateDbClientOptions = {}): DehydratedDbState {
+ const collections: Array = []
+
+ for (const {
+ collection,
+ shouldDehydrate,
+ } of this.collectionsById.values()) {
+ const collectionDecision = options.shouldDehydrateCollection?.(collection)
+ if (
+ getBuilderFromConfig(collection.config) ||
+ collectionDecision === false ||
+ (!shouldDehydrate && collectionDecision !== true)
+ ) {
+ continue
+ }
+
+ const rows = Array.from(collection._state.syncedData.entries()).map(
+ ([key, value]) => {
+ const metadata = collection._state.syncedMetadata.get(key)
+ return {
+ key,
+ value,
+ ...(metadata === undefined ? {} : { metadata }),
+ }
+ },
+ )
+
+ collections.push({
+ collectionId: collection.id,
+ rows,
+ syncMeta: collection.config.sync.exportSyncMeta?.(),
+ })
+ }
+
+ const liveQueries = Array.from(this.liveQueries.values()).flatMap(
+ (query): Array => {
+ const shouldDehydrate =
+ options.shouldDehydrateLiveQuery?.(query) ??
+ query.status === `success`
+ if (!shouldDehydrate || query.status === `error`) {
+ return []
+ }
+
+ return [
+ {
+ queryHash: query.queryHash,
+ dehydratedAt: query.dehydratedAt,
+ ...(query.snapshot
+ ? { snapshot: query.snapshot }
+ : { promise: query.resultPromise }),
+ },
+ ]
+ },
+ )
+
+ return {
+ collections,
+ ...(liveQueries.length > 0 ? { liveQueries } : {}),
+ }
+ }
+
+ hydrate(state: DehydratedDbState): void {
+ for (const chunk of state.collections) {
+ const record = this.collectionsById.get(chunk.collectionId)
+ if (record) {
+ this.applyRows(record.collection, chunk, `hydration`)
+ continue
+ }
+
+ const pendingChunks = this.pendingHydration.get(chunk.collectionId) ?? []
+ pendingChunks.push(chunk)
+ this.pendingHydration.set(chunk.collectionId, pendingChunks)
+ }
+
+ for (const dehydratedQuery of state.liveQueries ?? []) {
+ this.hydrateLiveQuery(dehydratedQuery)
+ }
+ }
+
+ applyCollectionChunk(chunk: DehydratedCollectionChunk): void {
+ this.hydrate({ collections: [chunk] })
+ }
+
+ subscribe(listener: (event: DbClientEvent) => void): () => void {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ /** @internal */
+ _setSsrStreamingEnabled(enabled: boolean): void {
+ this.ssrStreamingEnabled = enabled
+ }
+
+ /** @internal */
+ _isSsrStreamingEnabled(): boolean {
+ return this.ssrStreamingEnabled
+ }
+
+ /** @internal */
+ _setSsrServerCleanupEnabled(enabled: boolean): void {
+ this.ssrServerCleanupEnabled = enabled
+ }
+
+ /** @internal */
+ _isSsrServerCleanupEnabled(): boolean {
+ return this.ssrServerCleanupEnabled
+ }
+
+ /** @internal */
+ _getLiveQuery(queryHash: string): DbClientLiveQuery | undefined {
+ return this.liveQueries.get(queryHash)
+ }
+
+ /** @internal */
+ _consumeLiveQueryResult(queryHash: string, dehydratedAt: number): void {
+ const record = this.liveQueries.get(queryHash)
+ if (record?.dehydratedAt === dehydratedAt) {
+ this.liveQueries.delete(queryHash)
+ }
+ }
+
+ /** @internal */
+ _registerLiveQuery(
+ queryHash: string,
+ promise: Promise,
+ ): Promise {
+ const existing = this.liveQueries.get(queryHash)
+ if (existing && existing.status !== `error`) {
+ void Promise.resolve(promise).catch(() => {})
+ return existing.promise
+ }
+
+ const record = this.createLiveQueryRecord(
+ queryHash,
+ this.nextLiveQueryTimestamp(),
+ )
+
+ this.liveQueries.set(queryHash, record)
+ this.emit({ type: `liveQueryAdded`, query: record })
+ Promise.resolve(promise).then(record.succeed, record.fail)
+ return record.promise
+ }
+
+ /** @internal */
+ _registerLiveQueryResource(
+ owner: object,
+ cleanup: () => Promise,
+ ): () => void {
+ this.liveQueryResources.set(owner, cleanup)
+ return () => {
+ if (this.liveQueryResources.get(owner) === cleanup) {
+ this.liveQueryResources.delete(owner)
+ }
+ }
+ }
+
+ /** @internal */
+ _failPendingLiveQueries(error: unknown): void {
+ for (const record of this.liveQueries.values()) {
+ if (record.status === `pending`) record.fail(error)
+ }
+ this.emit({ type: `liveQueryStreamError`, error })
+ }
+
+ async cleanup(): Promise {
+ try {
+ const materializedCollections = Array.from(
+ this.collectionsById.values(),
+ ({ collection }) => collection,
+ )
+ const preloadedQueries = Array.from(this.preloadedLiveQueries.values())
+ const liveQueryCollections = new Set([
+ ...preloadedQueries.map(({ collection }) => collection),
+ ...materializedCollections.filter((collection) =>
+ getBuilderFromConfig(collection.config),
+ ),
+ ])
+
+ for (const { observer } of preloadedQueries) observer.dispose()
+
+ const cleanupResults = [
+ ...(await Promise.allSettled(
+ Array.from(this.liveQueryResources.values(), (cleanup) => cleanup()),
+ )),
+ ...(await Promise.allSettled(
+ Array.from(liveQueryCollections, (collection) =>
+ collection.cleanup(),
+ ),
+ )),
+ ...(await Promise.allSettled(
+ materializedCollections
+ .filter((collection) => !liveQueryCollections.has(collection))
+ .map((collection) => collection.cleanup()),
+ )),
+ ]
+ const failure = cleanupResults.find(
+ (result): result is PromiseRejectedResult =>
+ result.status === `rejected`,
+ )
+ if (failure) throw failure.reason
+ } finally {
+ this.transactionScope.clear()
+ this.collectionsByOptions = new WeakMap()
+ this.collectionsById.clear()
+ this.pendingHydration.clear()
+ this.liveQueries.clear()
+ this.preloadedLiveQueries.clear()
+ this.liveQueryResources.clear()
+ this.listeners.clear()
+ this.ssrStreamingEnabled = false
+ this.ssrServerCleanupEnabled = false
+ this.lastLiveQueryTimestamp = 0
+ }
+ }
+
+ private hydrateLiveQuery(dehydratedQuery: DehydratedLiveQuery): void {
+ const existing = this.liveQueries.get(dehydratedQuery.queryHash)
+ if (existing && existing.dehydratedAt >= dehydratedQuery.dehydratedAt) {
+ return
+ }
+
+ const record = this.createLiveQueryRecord(
+ dehydratedQuery.queryHash,
+ dehydratedQuery.dehydratedAt,
+ )
+ this.liveQueries.set(record.queryHash, record)
+ if (existing?.status === `pending`) {
+ void record.resultPromise.then(existing.succeed, existing.fail)
+ }
+ this.emit({ type: `liveQueryAdded`, query: record })
+
+ if (dehydratedQuery.snapshot) {
+ record.succeed(dehydratedQuery.snapshot)
+ } else if (dehydratedQuery.promise) {
+ Promise.resolve(dehydratedQuery.promise).then(record.succeed, record.fail)
+ } else {
+ record.fail(
+ new Error(
+ `Dehydrated live query "${dehydratedQuery.queryHash}" has neither a snapshot nor a promise.`,
+ ),
+ )
+ }
+ }
+
+ private nextLiveQueryTimestamp(): number {
+ this.lastLiveQueryTimestamp = Math.max(
+ Date.now(),
+ this.lastLiveQueryTimestamp + 1,
+ )
+ return this.lastLiveQueryTimestamp
+ }
+
+ private createLiveQueryRecord(
+ queryHash: string,
+ dehydratedAt: number,
+ ): LiveQueryRecord {
+ this.lastLiveQueryTimestamp = Math.max(
+ this.lastLiveQueryTimestamp,
+ dehydratedAt,
+ )
+
+ let resolveResult!: (snapshot: DehydratedLiveQueryResult) => void
+ let rejectResult!: (error: unknown) => void
+ let settled = false
+ const resultPromise = new Promise(
+ (resolve, reject) => {
+ resolveResult = resolve
+ rejectResult = reject
+ },
+ )
+ const promise = resultPromise.then(() => undefined)
+ resultPromise.catch(() => {})
+ promise.catch(() => {})
+
+ const record: LiveQueryRecord = {
+ queryHash,
+ dehydratedAt,
+ status: `pending`,
+ promise,
+ resultPromise,
+ succeed: (snapshot) => {
+ if (settled) return
+ settled = true
+ record.status = `success`
+ record.snapshot = snapshot
+ resolveResult(snapshot)
+ if (this.liveQueries.get(queryHash) === record) {
+ this.emit({ type: `liveQueryUpdated`, query: record })
+ }
+ },
+ fail: (error) => {
+ if (settled) return
+ settled = true
+ record.status = `error`
+ record.error = error
+ rejectResult(error)
+ if (this.liveQueries.get(queryHash) === record) {
+ this.emit({ type: `liveQueryUpdated`, query: record })
+ }
+ },
+ }
+
+ return record
+ }
+
+ private emit(event: DbClientEvent): void {
+ for (const listener of this.listeners) {
+ listener(event)
+ }
+ }
+
+ private applyRows(
+ collection: Collection,
+ chunk: Omit & {
+ rows: Array<
+ Omit & {
+ key?: string | number
+ }
+ >
+ },
+ seedKind?: `initialData` | `hydration`,
+ ): void {
+ const rows = chunk.rows.flatMap((row) => {
+ const value = collection.validateData(row.value, `insert`)
+ const key = collection.config.getKey(value)
+ const isAdapterAuthoritative =
+ seedKind === `hydration` &&
+ collection._state.syncedData.has(key) &&
+ !collection._state.hydrationSeedKeys.has(key)
+
+ return isAdapterAuthoritative ? [] : [{ ...row, key, value }]
+ })
+ const rowMetadataWrites = new Map<
+ string | number,
+ { type: `set`; value: unknown } | { type: `delete` }
+ >()
+
+ for (const row of rows) {
+ if (row.metadata !== undefined) {
+ rowMetadataWrites.set(row.key, { type: `set`, value: row.metadata })
+ }
+ }
+
+ if (seedKind) {
+ for (const row of rows) {
+ collection._state.hydrationSeedKeys.add(row.key)
+ if (seedKind === `hydration`) {
+ collection._state.hydratedKeys.add(row.key)
+ }
+ }
+ }
+
+ if (rows.length > 0) {
+ collection._state.pendingSyncedTransactions.push({
+ committed: true,
+ layoutChanged: false,
+ operations: rows.map((row) => ({
+ type: collection._state.syncedData.has(row.key)
+ ? (`update` as const)
+ : (`insert` as const),
+ key: row.key,
+ value: row.value,
+ })),
+ deletedKeys: new Set(),
+ rowMetadataWrites,
+ collectionMetadataWrites: new Map(),
+ immediate: true,
+ preserveHydrationSeedKeys: seedKind !== undefined,
+ })
+ collection._state.commitPendingTransactions()
+ }
+
+ if (chunk.syncMeta !== undefined) {
+ const currentMeta = collection.config.sync.exportSyncMeta?.()
+ const mergedMeta =
+ currentMeta === undefined
+ ? chunk.syncMeta
+ : (collection.config.sync.mergeSyncMeta?.(
+ currentMeta,
+ chunk.syncMeta,
+ ) ?? chunk.syncMeta)
+ collection.config.sync.importSyncMeta?.(mergedMeta)
+ }
+ }
+}
diff --git a/packages/db/src/collection-options.ts b/packages/db/src/collection-options.ts
new file mode 100644
index 0000000000..148841c4a6
--- /dev/null
+++ b/packages/db/src/collection-options.ts
@@ -0,0 +1,34 @@
+import type { StandardSchemaV1 } from '@standard-schema/spec'
+import type { CollectionConfig, UtilsRecord } from './types.js'
+
+export const collectionOptionsBrand: unique symbol = Symbol.for(
+ `@tanstack/db.collectionOptions`,
+) as never
+
+export const collectionOptionsFactory: unique symbol = Symbol.for(
+ `@tanstack/db.collectionOptions.factory`,
+) as never
+
+export type CollectionOptionsIdentity<
+ T extends object = Record,
+ TKey extends string | number = string | number,
+ TSchema extends StandardSchemaV1 = never,
+ TUtils extends UtilsRecord = UtilsRecord,
+ TClient = unknown,
+> = {
+ readonly id: string
+ readonly [collectionOptionsBrand]: true
+ readonly [collectionOptionsFactory]: (
+ client: TClient,
+ ) => CollectionConfig
+}
+
+export function hasCollectionOptionsBrand(
+ value: unknown,
+): value is CollectionOptionsIdentity {
+ return (
+ typeof value === `object` &&
+ value !== null &&
+ (value as Record)[collectionOptionsBrand] === true
+ )
+}
diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts
index f9bb465fe0..a23dec7648 100644
--- a/packages/db/src/collection/index.ts
+++ b/packages/db/src/collection/index.ts
@@ -42,6 +42,7 @@ import type {
import type { SingleRowRefProxy } from '../query/builder/ref-proxy'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { WithVirtualProps } from '../virtual-props.js'
+import type { TransactionScope } from '../transactions.js'
export type { CollectionIndexMetadata } from './events.js'
@@ -493,6 +494,26 @@ export class CollectionImpl<
this._sync.startSync()
}
+ /** @internal */
+ public _setTransactionScope(transactionScope: TransactionScope): void {
+ this._mutations.setTransactionScope(transactionScope)
+ }
+
+ /** @internal */
+ public _hasHydratedKey(key: TKey): boolean {
+ return this._state.hydratedKeys.has(key)
+ }
+
+ /** @internal */
+ public _deferSyncStart(): boolean {
+ return this._sync.deferStart()
+ }
+
+ /** @internal */
+ public _resumeSyncStart(): void {
+ this._sync.resumeStart()
+ }
+
/**
* Preload the collection data by starting sync if not already started
* Multiple concurrent calls will share the same promise
diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts
index abfb6693eb..9c91789780 100644
--- a/packages/db/src/collection/mutations.ts
+++ b/packages/db/src/collection/mutations.ts
@@ -27,11 +27,13 @@ import type {
OperationConfig,
PendingMutation,
StandardSchema,
+ TransactionConfig,
Transaction as TransactionType,
TransactionWithMutations,
UtilsRecord,
WritableDeep,
} from '../types'
+import type { TransactionScope } from '../transactions'
import type { CollectionLifecycleManager } from './lifecycle'
import type { CollectionStateManager } from './state'
@@ -46,6 +48,7 @@ export class CollectionMutationsManager<
private state!: CollectionStateManager
private collection!: CollectionImpl
private config!: CollectionConfig
+ private transactionScope?: TransactionScope
private id: string
constructor(config: CollectionConfig, id: string) {
@@ -63,6 +66,22 @@ export class CollectionMutationsManager<
this.collection = deps.collection
}
+ setTransactionScope(transactionScope: TransactionScope): void {
+ this.transactionScope = transactionScope
+ }
+
+ private getActiveTransaction() {
+ return this.transactionScope
+ ? this.transactionScope.getActiveTransactionForCollection()
+ : getActiveTransaction()
+ }
+
+ private createTransaction(config: TransactionConfig) {
+ return this.transactionScope
+ ? this.transactionScope.createTransaction(config)
+ : createTransaction(config)
+ }
+
private ensureStandardSchema(schema: unknown): StandardSchema {
// If the schema already implements the standard-schema interface, return it
if (schema && `~standard` in (schema as {})) {
@@ -169,7 +188,7 @@ export class CollectionMutationsManager<
insert = (data: TInput | Array, config?: InsertConfig) => {
this.lifecycle.validateCollectionUsable(`insert`)
const state = this.state
- const ambientTransaction = getActiveTransaction()
+ const ambientTransaction = this.getActiveTransaction()
// If no ambient transaction exists, check for an onInsert handler early
if (!ambientTransaction && !this.config.onInsert) {
@@ -231,7 +250,7 @@ export class CollectionMutationsManager<
return ambientTransaction
} else {
// Create a new transaction with a mutation function that calls the onInsert handler
- const directOpTransaction = createTransaction({
+ const directOpTransaction = this.createTransaction({
metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true },
mutationFn: async (params) => {
// Call the onInsert handler with the transaction and collection
@@ -281,7 +300,7 @@ export class CollectionMutationsManager<
const state = this.state
this.lifecycle.validateCollectionUsable(`update`)
- const ambientTransaction = getActiveTransaction()
+ const ambientTransaction = this.getActiveTransaction()
// If no ambient transaction exists, check for an onUpdate handler early
if (!ambientTransaction && !this.config.onUpdate) {
@@ -404,7 +423,7 @@ export class CollectionMutationsManager<
// If no changes were made, return an empty transaction early
if (mutations.length === 0) {
- const emptyTransaction = createTransaction({
+ const emptyTransaction = this.createTransaction({
mutationFn: async () => {},
})
// Errors still propagate through tx.isPersisted.promise; suppress the background commit from warning
@@ -428,7 +447,7 @@ export class CollectionMutationsManager<
// No need to check for onUpdate handler here as we've already checked at the beginning
// Create a new transaction with a mutation function that calls the onUpdate handler
- const directOpTransaction = createTransaction({
+ const directOpTransaction = this.createTransaction({
metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true },
mutationFn: async (params) => {
// Call the onUpdate handler with the transaction and collection
@@ -468,7 +487,7 @@ export class CollectionMutationsManager<
const state = this.state
this.lifecycle.validateCollectionUsable(`delete`)
- const ambientTransaction = getActiveTransaction()
+ const ambientTransaction = this.getActiveTransaction()
// If no ambient transaction exists, check for an onDelete handler early
if (!ambientTransaction && !this.config.onDelete) {
@@ -531,7 +550,7 @@ export class CollectionMutationsManager<
}
// Create a new transaction with a mutation function that calls the onDelete handler
- const directOpTransaction = createTransaction({
+ const directOpTransaction = this.createTransaction({
autoCommit: true,
metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true },
mutationFn: async (params) => {
diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts
index 1eeb92b2d4..69b8b6f9cf 100644
--- a/packages/db/src/collection/state.ts
+++ b/packages/db/src/collection/state.ts
@@ -35,6 +35,7 @@ interface PendingSyncedTransaction<
upserts: Map
deletes: Set
}
+ preserveHydrationSeedKeys?: boolean
/**
* When true, this transaction should be processed immediately even if there
* are persisting user transactions. Used by manual write operations (writeInsert,
@@ -76,6 +77,8 @@ export class CollectionStateManager<
public syncedData: SortedMap
public syncedMetadata = new Map()
public syncedCollectionMetadata = new Map()
+ public hydrationSeedKeys = new Set()
+ public hydratedKeys = new Set()
// Optimistic state tracking - make public for testing
public optimisticUpserts = new Map()
@@ -979,6 +982,8 @@ export class CollectionStateManager<
this.syncedData.clear()
this.syncedMetadata.clear()
this.syncedKeys.clear()
+ this.hydrationSeedKeys.clear()
+ this.hydratedKeys.clear()
this.clearOriginTrackingState()
// 3) Clear currentVisibleState for truncated keys to ensure subsequent operations
@@ -1056,6 +1061,10 @@ export class CollectionStateManager<
this.pendingOptimisticDirectDeletes.delete(key)
break
}
+ if (!transaction.preserveHydrationSeedKeys) {
+ this.hydrationSeedKeys.delete(key)
+ this.hydratedKeys.delete(key)
+ }
}
for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
@@ -1439,6 +1448,8 @@ export class CollectionStateManager<
this.pendingOptimisticDeletes.clear()
this.pendingOptimisticDirectUpserts.clear()
this.pendingOptimisticDirectDeletes.clear()
+ this.hydrationSeedKeys.clear()
+ this.hydratedKeys.clear()
this.clearOriginTrackingState()
this.isLocalOnly = false
this.size = 0
diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts
index d717106110..c47de23a92 100644
--- a/packages/db/src/collection/sync.ts
+++ b/packages/db/src/collection/sync.ts
@@ -8,6 +8,7 @@ import {
SyncTransactionAlreadyCommittedError,
SyncTransactionAlreadyCommittedWriteError,
} from '../errors'
+import { createDeferred } from '../deferred'
import { deepEquals } from '../utils'
import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js'
import type { StandardSchemaV1 } from '@standard-schema/spec'
@@ -25,6 +26,12 @@ import type { CollectionStateManager } from './state'
import type { CollectionLifecycleManager } from './lifecycle'
import type { CollectionEventsManager } from './events.js'
import type { LiveQueryCollectionUtils } from '../query/live/collection-config-builder.js'
+import type { Deferred } from '../deferred'
+
+type DeferredLoadSubset = {
+ options: LoadSubsetOptions
+ deferred: Deferred
+}
export class CollectionSyncManager<
TOutput extends object = Record,
@@ -49,6 +56,9 @@ export class CollectionSyncManager<
null
private pendingLoadSubsetPromises: Set> = new Set()
+ private syncStartDeferred = false
+ private syncStartRequested = false
+ private deferredLoadSubsets: Array = []
/**
* Creates a new CollectionSyncManager instance
@@ -88,6 +98,11 @@ export class CollectionSyncManager<
return // Already started or in progress
}
+ if (this.syncStartDeferred) {
+ this.syncStartRequested = true
+ return
+ }
+
this.lifecycle.setStatus(`loading`)
try {
@@ -151,10 +166,11 @@ export class CollectionSyncManager<
const valuesEqual =
existingValue !== undefined &&
deepEquals(existingValue, messageWithOptionalKey.value)
- if (valuesEqual) {
+ if (valuesEqual || this.state.hydrationSeedKeys.has(key)) {
// The "insert" is an echo of a value we already have locally.
- // Treat it as an update so we preserve optimistic intent without
- // throwing a duplicate-key error during reconciliation.
+ // Hydration and initialData are also provisional base state, so
+ // accept the adapter's first authoritative value as an update
+ // using the configured rowUpdateMode semantics.
messageType = `update`
} else {
const utils = this.config.utils as
@@ -278,6 +294,58 @@ export class CollectionSyncManager<
}
}
+ public deferStart(): boolean {
+ if (
+ this.lifecycle.status !== `idle` &&
+ this.lifecycle.status !== `cleaned-up`
+ ) {
+ return false
+ }
+
+ this.syncStartDeferred = true
+ return true
+ }
+
+ public resumeStart(): void {
+ if (!this.syncStartDeferred) {
+ return
+ }
+
+ this.syncStartDeferred = false
+ const shouldStart =
+ this.syncStartRequested || this.deferredLoadSubsets.length > 0
+ this.syncStartRequested = false
+ const deferredLoadSubsets = this.deferredLoadSubsets
+ this.deferredLoadSubsets = []
+
+ try {
+ if (shouldStart) {
+ this.startSync()
+ }
+ } catch (error) {
+ for (const { deferred } of deferredLoadSubsets) {
+ deferred.reject(error)
+ }
+ throw error
+ }
+
+ for (const { options, deferred } of deferredLoadSubsets) {
+ try {
+ const result = this.syncLoadSubsetFn?.(options) ?? true
+ if (result instanceof Promise) {
+ void result.then(
+ () => deferred.resolve(undefined),
+ (error: unknown) => deferred.reject(error),
+ )
+ } else {
+ deferred.resolve(undefined)
+ }
+ } catch (error) {
+ deferred.reject(error)
+ }
+ }
+ }
+
private getActivePendingSyncTransaction() {
const pendingTransaction =
this.state.pendingSyncedTransactions[
@@ -505,6 +573,14 @@ export class CollectionSyncManager<
return true
}
+ if (this.syncStartDeferred) {
+ this.syncStartRequested = true
+ const deferred = createDeferred()
+ this.deferredLoadSubsets.push({ options, deferred })
+ this.trackLoadPromise(deferred.promise)
+ return deferred.promise
+ }
+
if (this.syncLoadSubsetFn) {
const result = this.syncLoadSubsetFn(options)
// If the result is a promise, track it
@@ -522,6 +598,18 @@ export class CollectionSyncManager<
* @param options Options that identify what data is being unloaded
*/
public unloadSubset(options: LoadSubsetOptions): void {
+ if (this.syncStartDeferred) {
+ this.deferredLoadSubsets = this.deferredLoadSubsets.filter((request) => {
+ if (request.options !== options) {
+ return true
+ }
+
+ request.deferred.resolve(undefined)
+ return false
+ })
+ return
+ }
+
if (this.syncUnloadSubsetFn) {
this.syncUnloadSubsetFn(options)
}
@@ -548,6 +636,13 @@ export class CollectionSyncManager<
})
}
this.preloadPromise = null
+ this.syncStartDeferred = false
+ this.syncStartRequested = false
+ const deferredLoadSubsets = this.deferredLoadSubsets
+ this.deferredLoadSubsets = []
+ for (const { deferred } of deferredLoadSubsets) {
+ deferred.resolve(undefined)
+ }
}
}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 80814640dd..8c4d7258e0 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -6,12 +6,15 @@ import * as IR from './query/ir.js'
export * from './collection/index.js'
export * from './SortedMap'
export * from './transactions'
+export * from './client.js'
+export { withCollectionConfigFactory } from './client.js'
export * from './types'
export * from './proxy'
export * from './query/index.js'
export * from './optimistic-action'
export * from './live-query-adapter'
export * from './live-query-observer'
+export * from './live-query-options'
/** @internal Unstable adapter primitive for RFC #1623. */
export * from './live-query-window-controller'
export * from './local-only'
diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts
index 1cb0cba28a..735195816a 100644
--- a/packages/db/src/live-query-observer.ts
+++ b/packages/db/src/live-query-observer.ts
@@ -3,7 +3,9 @@ import {
getLiveQueryStatusFlags,
isSingleResultCollection,
} from './live-query-adapter.js'
+import { getBuilderFromConfig } from './query/live/collection-registry.js'
import type { Collection } from './collection/index.js'
+import type { DbClient, DehydratedLiveQueryResult } from './client.js'
import type { ChangeMessage, CollectionStatus } from './types.js'
/**
@@ -73,6 +75,8 @@ export interface LiveQueryObserver<
> {
/** Stable per-revision snapshot for wholesale materialization. */
getSnapshot: () => LiveQuerySnapshot
+ /** Stable server snapshot used by useSyncExternalStore-style adapters. */
+ getServerSnapshot: () => LiveQuerySnapshot
/**
* Subscribe to changes. The listener receives the change set (or `undefined`
* for the synthetic notify a ready collection emits on attach). Granular
@@ -82,6 +86,10 @@ export interface LiveQueryObserver<
subscribe: (listener: LiveQueryObserverListener) => () => void
/** Resolve once the collection has loaded its first data. */
preload: () => Promise
+ /** The transport or preload error for this query, if it has not produced data. */
+ getError: () => unknown
+ /** Capture the ordered query result without serializing its source collections. */
+ dehydrate: () => DehydratedLiveQueryResult
/** Idempotent teardown. */
dispose: () => void
}
@@ -126,6 +134,9 @@ class LiveQueryObserverImpl<
> implements LiveQueryObserver {
private readonly collection: Collection | null
private readonly wholesale: boolean
+ private readonly client: DbClient | undefined
+ private readonly queryHash: string | undefined
+ private readonly onPreload: (() => void) | undefined
private visibleStatus: CollectionStatus | undefined
private cachedEntries: Array<[TKey, T]> | undefined
private cachedCollectionRevision: number | undefined
@@ -144,29 +155,58 @@ class LiveQueryObserverImpl<
private blockDelivery = false
private attached = false
private collectionUnsub: (() => void) | null = null
+ private unregisterClientResource: (() => void) | undefined
+ private hydrationSeed:
+ | {
+ dehydratedAt: number
+ entries: Array<[TKey, T]>
+ }
+ | undefined
+ private hydrationError: unknown
+ private hasHydrationError = false
+ private liveResultIsAuthoritative = false
+ private handoffScheduled = false
+ private preloadPromise: Promise | undefined
private disposed = false
- // Construction is side-effect-free: sync activation belongs to the first
- // subscription (attach), so building an observer — e.g. in a React render
- // that may be abandoned — cannot activate resources on its own.
- constructor(collection: Collection | null, wholesale: boolean) {
+ // Sync activation belongs to the first subscription (attach), so building
+ // an observer cannot activate collection resources on its own. Server
+ // request clients still record ownership here because React may render an
+ // observer without ever subscribing to it.
+ constructor(
+ collection: Collection | null,
+ wholesale: boolean,
+ client: DbClient | undefined,
+ queryHash: string | undefined,
+ onPreload: (() => void) | undefined,
+ ) {
this.collection = collection
this.wholesale = wholesale
+ this.client = client
+ this.queryHash = queryHash
+ this.onPreload = onPreload
+ this.registerClientResource()
}
getSnapshot(): LiveQuerySnapshot {
const collection = this.collection
if (!collection) return DISABLED_SNAPSHOT
+ this.syncHydrationState()
if (!this.attached) this.refreshDetachedState(collection)
if (this.snapshotDirty) {
- const entries =
- this.cachedEntries ?? this.captureEntries(collection).entries
+ const entries = this.getVisibleEntries(collection)
const state = new Map(entries)
const data = entries.map(([, value]) => value)
const singleResult = isSingleResultCollection(collection)
- const status = this.visibleStatus ?? collection.status
+ const liveStatus = this.visibleStatus ?? collection.status
+ const status =
+ this.hasHydrationError || liveStatus === `error`
+ ? (`error` as const)
+ : this.hasHydrationSeed()
+ ? (`ready` as const)
+ : liveStatus
// Bump the layout revision when the ordered key sequence changes
// (membership, ordering, or an order-only move). Compare the key sequence
@@ -205,6 +245,180 @@ class LiveQueryObserverImpl<
return this.cachedSnapshot
}
+ getServerSnapshot(): LiveQuerySnapshot {
+ return this.getSnapshot()
+ }
+
+ getError(): unknown {
+ this.syncHydrationState()
+ return this.hasHydrationError ? this.hydrationError : undefined
+ }
+
+ dehydrate(): DehydratedLiveQueryResult {
+ const collection = this.collection
+ if (!collection) return { rows: [] }
+
+ const entries = this.hasHydrationSeed()
+ ? this.hydrationSeed!.entries
+ : this.readEntries(collection).entries
+
+ return {
+ rows: entries.map(([key, value]) => ({
+ key,
+ value,
+ })),
+ }
+ }
+
+ private hasHydrationSeed(): boolean {
+ return this.hydrationSeed !== undefined && !this.liveResultIsAuthoritative
+ }
+
+ private getVisibleEntries(
+ collection: Collection,
+ ): Array<[TKey, T]> {
+ if (this.hasHydrationSeed()) return this.hydrationSeed!.entries
+ return this.cachedEntries ?? this.captureEntries(collection).entries
+ }
+
+ private syncHydrationState(): boolean {
+ if (!this.client || !this.queryHash || this.liveResultIsAuthoritative) {
+ return false
+ }
+
+ const query = this.client._getLiveQuery(this.queryHash)
+ if (!query) return false
+
+ if (
+ this.attached &&
+ !this.hydrationSeed &&
+ this.collection?.status === `ready` &&
+ !this.collection.isLoadingSubset
+ ) {
+ return this.markLiveResultAuthoritative(query.dehydratedAt)
+ }
+
+ if (query.status === `error`) {
+ const changed =
+ !this.hasHydrationError || this.hydrationError !== query.error
+ this.hydrationError = query.error
+ this.hasHydrationError = true
+ if (changed) this.snapshotDirty = true
+ return changed
+ }
+
+ if (
+ query.status !== `success` ||
+ !query.snapshot ||
+ (this.hydrationSeed &&
+ this.hydrationSeed.dehydratedAt >= query.dehydratedAt)
+ ) {
+ return false
+ }
+
+ this.hydrationSeed = {
+ dehydratedAt: query.dehydratedAt,
+ entries: query.snapshot.rows.map((row) => [
+ row.key as TKey,
+ row.value as T,
+ ]),
+ }
+ this.hydrationError = undefined
+ this.hasHydrationError = false
+ this.snapshotDirty = true
+ return true
+ }
+
+ private diffEntries(
+ previous: Array<[TKey, T]>,
+ next: Array<[TKey, T]>,
+ ): Array> {
+ const previousByKey = new Map(previous)
+ const nextByKey = new Map(next)
+ const changes: Array> = []
+
+ for (const [key, value] of previous) {
+ if (!nextByKey.has(key)) changes.push({ type: `delete`, key, value })
+ }
+ for (const [key, value] of next) {
+ const previousValue = previousByKey.get(key)
+ if (previousValue === undefined) {
+ changes.push({ type: `insert`, key, value })
+ } else if (previousValue !== value) {
+ changes.push({
+ type: `update`,
+ key,
+ value,
+ previousValue,
+ })
+ }
+ }
+
+ return changes
+ }
+
+ private handoffHydrationSeed(collection: Collection): {
+ changes: Array>
+ entries: Array<[TKey, T]>
+ revision?: number
+ } {
+ const previous = this.hydrationSeed?.entries ?? []
+ const dehydratedAt = this.hydrationSeed?.dehydratedAt
+ const { entries, revision } = this.readEntries(collection)
+ this.hydrationSeed = undefined
+ this.markLiveResultAuthoritative(dehydratedAt)
+ this.updateCachedEntries(entries, revision)
+ this.snapshotDirty = true
+ return {
+ changes: this.diffEntries(previous, entries),
+ entries,
+ revision,
+ }
+ }
+
+ private markLiveResultAuthoritative(dehydratedAt?: number): boolean {
+ const changed = this.hasHydrationError
+ this.hydrationError = undefined
+ this.hasHydrationError = false
+ this.liveResultIsAuthoritative = true
+ if (dehydratedAt !== undefined && this.queryHash) {
+ this.client?._consumeLiveQueryResult(this.queryHash, dehydratedAt)
+ }
+ if (changed) this.snapshotDirty = true
+ return changed
+ }
+
+ private scheduleHydrationHandoff(): void {
+ if (this.handoffScheduled) return
+ this.handoffScheduled = true
+
+ queueMicrotask(() => {
+ this.handoffScheduled = false
+ const collection = this.collection
+ if (
+ this.disposed ||
+ !this.attached ||
+ !collection ||
+ !this.hasHydrationSeed() ||
+ collection.status !== `ready` ||
+ collection.isLoadingSubset
+ ) {
+ return
+ }
+
+ const handoff = this.handoffHydrationSeed(collection)
+ this.emit(
+ this.wholesale ? undefined : handoff.changes,
+ undefined,
+ handoff.entries,
+ collection.status,
+ handoff.revision,
+ this.getCollectionLayoutRevision(collection),
+ true,
+ )
+ })
+ }
+
private getCollectionRevision(
collection: Collection,
): number | undefined {
@@ -325,9 +539,7 @@ class LiveQueryObserverImpl<
if (!collection) return
const seedChanges: Array> = []
- for (const [key, value] of collection.entries() as IterableIterator<
- [TKey, T]
- >) {
+ for (const [key, value] of this.getVisibleEntries(collection)) {
seedChanges.push({ type: `insert`, key, value })
}
if (seedChanges.length === 0) return
@@ -338,11 +550,14 @@ class LiveQueryObserverImpl<
private attach(): void {
const collection = this.collection
if (!collection || this.disposed) return
+ this.registerClientResource()
+ this.syncHydrationState()
this.refreshDetachedState(collection)
this.attached = true
this.visibleStatus ??= collection.status
this.deliveredLayoutRevision = this.getCollectionLayoutRevision(collection)
- this.blockDelivery = this.wholesale
+ const attachedWithHydrationSeed = this.hasHydrationSeed()
+ this.blockDelivery = this.wholesale || attachedWithHydrationSeed
// Sync activation happens inside subscribeChanges (addSubscriber starts
// an idle/cleaned-up collection) — the same startSync path the old
@@ -363,6 +578,23 @@ class LiveQueryObserverImpl<
explicitLayoutChange = false,
) => {
if (this.disposed || this.subscriptions.size === 0) return
+
+ if (this.hasHydrationSeed()) {
+ if (status === `ready`) this.scheduleHydrationHandoff()
+ if (status !== `error`) return
+ }
+
+ if (
+ status === `ready` &&
+ !collection.isLoadingSubset &&
+ !this.liveResultIsAuthoritative &&
+ this.client &&
+ this.queryHash
+ ) {
+ const query = this.client._getLiveQuery(this.queryHash)
+ this.markLiveResultAuthoritative(query?.dehydratedAt)
+ }
+
const layoutRevision = this.getCollectionLayoutRevision(collection)
let layoutChanged = explicitLayoutChange
if (
@@ -427,7 +659,28 @@ class LiveQueryObserverImpl<
// ran during that replay (collectionUnsub no longer points at our hook),
// undo the subscription as soon as the call returns.
let subscription: { unsubscribe: () => void } | null = null
+ const clientUnsub =
+ this.client && this.queryHash
+ ? this.client.subscribe((event) => {
+ if (
+ event.type === `liveQueryStreamError` ||
+ event.query.queryHash !== this.queryHash
+ ) {
+ return
+ }
+
+ const previousEntries = this.getVisibleEntries(collection)
+ if (!this.syncHydrationState()) return
+ const nextEntries = this.getVisibleEntries(collection)
+ this.emit(
+ this.wholesale
+ ? undefined
+ : this.diffEntries(previousEntries, nextEntries),
+ )
+ })
+ : () => {}
const release = () => {
+ clientUnsub()
statusUnsub()
layoutUnsub()
subscription?.unsubscribe()
@@ -435,22 +688,26 @@ class LiveQueryObserverImpl<
this.collectionUnsub = release
subscription = collection.subscribeChanges(
(changes) => notify(changes as Array>),
- { includeInitialState: !this.wholesale },
+ { includeInitialState: !this.wholesale && !attachedWithHydrationSeed },
)
this.blockDelivery = false
if (this.collectionUnsub !== release) {
subscription.unsubscribe()
return
}
- if (this.wholesale) {
+ if (this.wholesale || attachedWithHydrationSeed) {
// Publications raised while subscribeChanges starts sync are part of the
// subscribe handshake. Apply their final snapshot state now, but suppress
// listener delivery: useSyncExternalStore performs its consistency read
// immediately after subscribe returns.
- this.flushPublications(false)
+ this.flushPublications(!this.wholesale)
const { entries, revision } = this.readEntries(collection)
this.updateCachedEntries(entries, revision)
}
+ if (this.hasHydrationSeed()) {
+ if (!this.wholesale) this.seed(Array.from(this.subscriptions)[0]!)
+ if (collection.status === `ready`) this.scheduleHydrationHandoff()
+ }
}
private detach(): void {
@@ -459,6 +716,28 @@ class LiveQueryObserverImpl<
this.attached = false
this.blockDelivery = false
this.publicationQueue.length = 0
+ this.unregisterClientResource?.()
+ this.unregisterClientResource = undefined
+ }
+
+ private registerClientResource(): void {
+ if (
+ this.unregisterClientResource ||
+ !this.client?._isSsrServerCleanupEnabled() ||
+ !this.collection ||
+ !getBuilderFromConfig(this.collection.config)
+ ) {
+ return
+ }
+
+ this.unregisterClientResource = this.client._registerLiveQueryResource(
+ this,
+ async () => {
+ const collection = this.collection
+ this.dispose()
+ await collection?.cleanup()
+ },
+ )
}
private emit(
@@ -524,8 +803,33 @@ class LiveQueryObserverImpl<
}
}
- async preload(): Promise {
- await this.collection?.preload()
+ preload(): Promise {
+ if (this.preloadPromise) return this.preloadPromise
+
+ if (this.client && this.queryHash) {
+ const query = this.client._getLiveQuery(this.queryHash)
+ if (query?.status === `pending`) return query.promise
+ if (query?.status === `success`) return Promise.resolve()
+ }
+
+ this.registerClientResource()
+ this.onPreload?.()
+ const collectionPromise = this.collection?.preload() ?? Promise.resolve()
+ const preloadPromise =
+ this.client?._isSsrStreamingEnabled() && this.queryHash
+ ? this.client._registerLiveQuery(
+ this.queryHash,
+ collectionPromise.then(() => this.dehydrate()),
+ )
+ : collectionPromise
+ this.preloadPromise = preloadPromise
+ const clearPreload = () => {
+ if (this.preloadPromise === preloadPromise) {
+ this.preloadPromise = undefined
+ }
+ }
+ void preloadPromise.then(clearPreload, clearPreload)
+ return preloadPromise
}
dispose(): void {
@@ -554,6 +858,12 @@ export interface CreateLiveQueryObserverOptions {
* `useSyncExternalStore`-style consumers safe by construction.
*/
mode?: `granular` | `wholesale`
+ /** DbClient cache that owns SSR snapshots for this query identity. */
+ client?: DbClient
+ /** Stable live-query identity used for dehydration and hydration. */
+ queryHash?: string
+ /** Resume framework-deferred query sources before a server preload. */
+ onPreload?: () => void
}
/**
@@ -575,5 +885,8 @@ export function createLiveQueryObserver<
return new LiveQueryObserverImpl(
collection ?? null,
options.mode === `wholesale`,
+ options.client,
+ options.queryHash,
+ options.onPreload,
)
}
diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts
new file mode 100644
index 0000000000..4005c3f9bf
--- /dev/null
+++ b/packages/db/src/live-query-options.ts
@@ -0,0 +1,124 @@
+import { BaseQueryBuilder } from './query/builder/index.js'
+import { isCollection } from './live-query-adapter.js'
+import {
+ getStableQueryBuilderHash,
+ getStableValueHash,
+} from './query/ir-stable-identity.js'
+import type { CollectionImpl } from './collection/index.js'
+import type { CollectionOptionsIdentity } from './collection-options.js'
+import type { CollectionOptions, DbClient } from './client.js'
+import type {
+ InitialQueryBuilder,
+ LiveQueryCollectionConfig,
+} from './query/index.js'
+
+export type LiveQueryKey = ReadonlyArray
+
+export type LiveQueryOptions = LiveQueryCollectionConfig & {
+ queryKey?: LiveQueryKey
+}
+
+export type DeferredLiveQueryCollections = Set<
+ CollectionImpl
+>
+
+function createInitialQueryBuilder(
+ dbClient: DbClient | undefined,
+ deferredCollections: DeferredLiveQueryCollections,
+): InitialQueryBuilder {
+ return new BaseQueryBuilder(
+ {},
+ dbClient
+ ? (
+ options: CollectionOptionsIdentity<
+ any,
+ string | number,
+ any,
+ any,
+ any
+ >,
+ ) => {
+ const collection = dbClient._materializeCollectionForRender(
+ options as CollectionOptions,
+ ) as CollectionImpl
+ if (collection._deferSyncStart()) deferredCollections.add(collection)
+ return collection
+ }
+ : undefined,
+ ) as InitialQueryBuilder
+}
+
+export function prepareLiveQueryValue(
+ value: unknown,
+ dbClient: DbClient | undefined,
+ deferredCollections: DeferredLiveQueryCollections,
+): unknown {
+ if (typeof value === `function`) {
+ return prepareLiveQueryValue(
+ value(createInitialQueryBuilder(dbClient, deferredCollections)),
+ dbClient,
+ deferredCollections,
+ )
+ }
+
+ if (
+ value &&
+ typeof value === `object` &&
+ !isCollection(value) &&
+ !(value instanceof BaseQueryBuilder) &&
+ `query` in value
+ ) {
+ const {
+ query,
+ queryKey: _queryKey,
+ client: _client,
+ ...config
+ } = value as LiveQueryCollectionConfig & {
+ queryKey?: LiveQueryKey
+ client?: DbClient
+ }
+
+ return {
+ ...config,
+ query:
+ typeof query === `function`
+ ? query(createInitialQueryBuilder(dbClient, deferredCollections))
+ : query,
+ }
+ }
+
+ return value
+}
+
+export function getPreparedLiveQueryIdentity(value: unknown): unknown {
+ if (isCollection(value)) return [`collection`, value.id]
+ if (value instanceof BaseQueryBuilder) {
+ return [`query`, getStableQueryBuilderHash(value)]
+ }
+ if (value && typeof value === `object` && `query` in value) {
+ const config = value as LiveQueryCollectionConfig
+ return [
+ `config`,
+ getPreparedLiveQueryIdentity(config.query),
+ [`getKey`, config.getKey],
+ [`schema`, config.schema],
+ [`singleResult`, config.singleResult === true],
+ [`defaultStringCollation`, config.defaultStringCollation],
+ ]
+ }
+ if (value === undefined || value === null) return [`disabled`]
+ return [`value`, value]
+}
+
+export function getLiveQueryHash(
+ preparedValue: unknown,
+ queryKey?: LiveQueryKey,
+): string {
+ const identity = queryKey?.length
+ ? [`queryKey`, queryKey]
+ : isCollection(preparedValue)
+ ? [`collection`, preparedValue.id]
+ : [`derived`, getPreparedLiveQueryIdentity(preparedValue)]
+
+ return getStableValueHash(identity, `queryKey`)
+}
diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts
index afcf3c9a76..911b0cb924 100644
--- a/packages/db/src/local-only.ts
+++ b/packages/db/src/local-only.ts
@@ -1,4 +1,5 @@
import { safeRandomUUID } from './utils/uuid'
+import { withCollectionConfigFactory } from './client.js'
import type {
BaseCollectionConfig,
CollectionConfig,
@@ -264,7 +265,7 @@ export function localOnlyCollectionOptions<
)
}
- return {
+ const options = {
...restConfig,
id: collectionId,
sync: syncResult.sync,
@@ -279,6 +280,17 @@ export function localOnlyCollectionOptions<
} as LocalOnlyCollectionOptionsResult & {
schema?: StandardSchemaV1
}
+
+ return withCollectionConfigFactory(options, () =>
+ (
+ localOnlyCollectionOptions as (
+ nextConfig: LocalOnlyCollectionConfig,
+ ) => typeof options
+ )({
+ ...config,
+ id: collectionId,
+ }),
+ )
}
/**
diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts
index 05ad388d7c..7ab6b98449 100644
--- a/packages/db/src/local-storage.ts
+++ b/packages/db/src/local-storage.ts
@@ -1,4 +1,5 @@
import { safeRandomUUID } from './utils/uuid'
+import { withCollectionConfigFactory } from './client.js'
import {
InvalidStorageDataFormatError,
InvalidStorageObjectFormatError,
@@ -607,7 +608,7 @@ export function localStorageCollectionOptions(
sync.confirmOperationsSync(collectionMutations)
}
- return {
+ const options = {
...restConfig,
id: collectionId,
sync,
@@ -620,6 +621,15 @@ export function localStorageCollectionOptions(
acceptMutations,
},
}
+
+ return withCollectionConfigFactory(
+ options,
+ () =>
+ localStorageCollectionOptions({
+ ...config,
+ id: collectionId,
+ }) as unknown as typeof options,
+ )
}
/**
diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts
index e8f370228f..0291c204cd 100644
--- a/packages/db/src/query/builder/index.ts
+++ b/packages/db/src/query/builder/index.ts
@@ -1,4 +1,5 @@
import { CollectionImpl } from '../../collection/index.js'
+import { hasCollectionOptionsBrand } from '../../collection-options.js'
import {
Aggregate as AggregateExpr,
CollectionRef,
@@ -36,6 +37,7 @@ import {
} from './functions.js'
import type { SourceClauseContext } from '../../errors.js'
import type { NamespacedRow, SingleResult } from '../../types.js'
+import type { CollectionOptionsIdentity } from '../../collection-options.js'
import type {
Aggregate,
BasicExpression,
@@ -75,13 +77,26 @@ import type {
const UNION_ALL_SOURCE_CONTEXT = `unionAll clause` satisfies SourceClauseContext
+type CollectionResolver = (
+ options: CollectionOptionsIdentity,
+) => CollectionImpl
+
export class BaseQueryBuilder {
private readonly query: Partial = {}
- constructor(query: Partial = {}) {
+ constructor(
+ query: Partial = {},
+ private readonly resolveCollection?: CollectionResolver,
+ ) {
this.query = { ...query }
}
+ private _clone(
+ query: Partial,
+ ): BaseQueryBuilder {
+ return new BaseQueryBuilder(query, this.resolveCollection)
+ }
+
/**
* Creates a CollectionRef or QueryRef from a source object
* @param source - An object with a single key-value pair
@@ -140,6 +155,13 @@ export class BaseQueryBuilder {
if (sourceValue instanceof CollectionImpl) {
ref = new CollectionRef(sourceValue, alias)
+ } else if (hasCollectionOptionsBrand(sourceValue)) {
+ if (!this.resolveCollection) {
+ throw new Error(
+ `Cannot use collection descriptor "${alias}" as a query source without a DbClient resolver. In React, wrap your tree in .`,
+ )
+ }
+ ref = new CollectionRef(this.resolveCollection(sourceValue), alias)
} else if (sourceValue instanceof BaseQueryBuilder) {
const subQuery = sourceValue._getQuery()
if (!(subQuery as Partial).from) {
@@ -177,7 +199,7 @@ export class BaseQueryBuilder {
): QueryBuilder> {
const [, from] = this._createRefForSource(source, `from clause`)
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
from,
}) as any
@@ -209,7 +231,7 @@ export class BaseQueryBuilder {
...branches: Array>
): QueryBuilder {
if (sourceOrBranch instanceof BaseQueryBuilder) {
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
from: new UnionAll(
[sourceOrBranch, ...branches].map((branch) =>
@@ -226,7 +248,7 @@ export class BaseQueryBuilder {
const from =
refs.length === 1 ? refs[0]![1] : new UnionFrom(refs.map((r) => r[1]))
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
from,
}) as any
@@ -308,7 +330,7 @@ export class BaseQueryBuilder {
const existingJoins = this.query.join || []
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
join: [...existingJoins, joinClause],
}) as any
@@ -467,7 +489,7 @@ export class BaseQueryBuilder {
const existingWhere = this.query.where || []
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
where: [...existingWhere, expression],
}) as any
@@ -527,7 +549,7 @@ export class BaseQueryBuilder {
const existingHaving = this.query.having || []
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
having: [...existingHaving, expression],
}) as any
@@ -593,7 +615,7 @@ export class BaseQueryBuilder {
const select = buildNestedSelect(selectObject, aliases)
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
select: select,
fnSelect: undefined, // remove the fnSelect clause if it exists
@@ -668,7 +690,7 @@ export class BaseQueryBuilder {
const existingOrderBy: OrderBy = this.query.orderBy || []
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
orderBy: [...existingOrderBy, ...orderByClauses],
}) as any
@@ -713,7 +735,7 @@ export class BaseQueryBuilder {
// Extend existing groupBy expressions (multiple groupBy calls should accumulate)
const existingGroupBy = this.query.groupBy || []
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
groupBy: [...existingGroupBy, ...newExpressions],
}) as any
@@ -736,7 +758,7 @@ export class BaseQueryBuilder {
* ```
*/
limit(count: number): QueryBuilder {
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
limit: count,
}) as any
@@ -760,7 +782,7 @@ export class BaseQueryBuilder {
* ```
*/
offset(count: number): QueryBuilder {
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
offset: count,
}) as any
@@ -781,7 +803,7 @@ export class BaseQueryBuilder {
* ```
*/
distinct(): QueryBuilder {
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
distinct: true,
}) as any
@@ -801,7 +823,7 @@ export class BaseQueryBuilder {
*```
*/
findOne(): QueryBuilder {
- return new BaseQueryBuilder({
+ return this._clone({
...this.query,
// TODO: enforcing return only one result with also a default orderBy if none is specified
// limit: 1,
@@ -871,7 +893,7 @@ export class BaseQueryBuilder {
select(
callback: (row: TContext[`schema`]) => TFuncSelectResult,
): QueryBuilder> {
- return new BaseQueryBuilder({
+ return builder._clone({
...builder.query,
select: undefined, // remove the select clause if it exists
fnSelect: callback,
@@ -895,7 +917,7 @@ export class BaseQueryBuilder {
where(
callback: (row: TContext[`schema`]) => any,
): QueryBuilder {
- return new BaseQueryBuilder({
+ return builder._clone({
...builder.query,
fnWhere: [
...(builder.query.fnWhere || []),
@@ -923,7 +945,7 @@ export class BaseQueryBuilder {
having(
callback: (row: FunctionalHavingRow) => any,
): QueryBuilder {
- return new BaseQueryBuilder({
+ return builder._clone({
...builder.query,
fnHaving: [
...(builder.query.fnHaving || []),
diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts
index f8cdbcabe4..db20942b59 100644
--- a/packages/db/src/query/builder/types.ts
+++ b/packages/db/src/query/builder/types.ts
@@ -1,4 +1,5 @@
import type { Collection, CollectionImpl } from '../../collection/index.js'
+import type { CollectionOptionsIdentity } from '../../collection-options.js'
import type { SingleResult, StringCollationConfig } from '../../types.js'
import type {
Aggregate,
@@ -89,7 +90,10 @@ export type ContextSchema = Record
* Example: `{ users: usersCollection }`
*/
export type Source = {
- [alias: string]: CollectionImpl | QueryBuilder
+ [alias: string]:
+ | CollectionImpl
+ | CollectionOptionsIdentity
+ | QueryBuilder
}
/**
@@ -101,7 +105,15 @@ export type Source = {
export type InferCollectionType =
T extends CollectionImpl
? WithVirtualProps
- : never
+ : T extends CollectionOptionsIdentity<
+ infer TOutput,
+ infer TKey,
+ any,
+ any,
+ any
+ >
+ ? WithVirtualProps
+ : never
/**
* SchemaFromSource - Converts a Source definition into a ContextSchema
@@ -116,9 +128,11 @@ export type InferCollectionType =
export type SchemaFromSource = Prettify<{
[K in keyof T]: T[K] extends CollectionImpl
? InferCollectionType
- : T[K] extends QueryBuilder
- ? GetRawResult
- : never
+ : T[K] extends CollectionOptionsIdentity
+ ? InferCollectionType
+ : T[K] extends QueryBuilder
+ ? GetRawResult
+ : never
}>
export type UnionRefsSchema = Prettify<{
diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts
index 8cda812b3f..c330b56b95 100644
--- a/packages/db/src/query/index.ts
+++ b/packages/db/src/query/index.ts
@@ -95,6 +95,13 @@ export { queryOnce, type QueryOnceConfig } from './query-once.js'
export { type LiveQueryCollectionConfig } from './live/types.js'
export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js'
+export {
+ UnhashableQueryIRError,
+ canonicalizeQueryIR,
+ getStableQueryBuilderHash,
+ getStableQueryIRHash,
+ getStableValueHash,
+} from './ir-stable-identity.js'
// Predicate utilities for predicate push-down
export {
diff --git a/packages/db/src/query/ir-stable-identity.ts b/packages/db/src/query/ir-stable-identity.ts
new file mode 100644
index 0000000000..a479536fb2
--- /dev/null
+++ b/packages/db/src/query/ir-stable-identity.ts
@@ -0,0 +1,576 @@
+import { isRefProxy, toExpression } from './builder/ref-proxy.js'
+import { getQueryIR } from './builder/index.js'
+import type {
+ Aggregate,
+ BasicExpression,
+ ConditionalSelect,
+ From,
+ Having,
+ IncludesSubquery,
+ JoinClause,
+ OrderByClause,
+ QueryIR,
+ Select,
+ Where,
+} from './ir.js'
+import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
+
+type StableIdentityValue =
+ | null
+ | boolean
+ | number
+ | string
+ | Array
+ | { [key: string]: StableIdentityValue }
+
+export class UnhashableQueryIRError extends Error {
+ constructor(
+ public readonly path: string,
+ public readonly reason: string,
+ ) {
+ super(`Query IR is not stably hashable at ${path}: ${reason}`)
+ this.name = `UnhashableQueryIRError`
+ }
+}
+
+export function getStableQueryIRHash(query: QueryIR): string {
+ return JSON.stringify(canonicalizeQueryIR(query))
+}
+
+export function getStableQueryBuilderHash(
+ query: InitialQueryBuilder | QueryBuilder,
+): string {
+ return getStableQueryIRHash(getQueryIR(query))
+}
+
+export function getStableValueHash(value: unknown, path = `value`): string {
+ return JSON.stringify(canonicalizeRuntimeValue(value, path, new WeakSet()))
+}
+
+export function canonicalizeQueryIR(query: QueryIR): StableIdentityValue {
+ return canonicalizeQuery(query, `query`, new WeakSet())
+}
+
+function canonicalizeQuery(
+ query: QueryIR,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (query.fnSelect) {
+ throw new UnhashableQueryIRError(`${path}.fnSelect`, `function select`)
+ }
+
+ if (query.fnWhere?.length) {
+ throw new UnhashableQueryIRError(`${path}.fnWhere`, `function where`)
+ }
+
+ if (query.fnHaving?.length) {
+ throw new UnhashableQueryIRError(`${path}.fnHaving`, `function having`)
+ }
+
+ const result: Record = {
+ type: `query`,
+ from: canonicalizeSource(query.from, `${path}.from`, seen),
+ }
+
+ if (query.select) {
+ result.select = canonicalizeSelect(query.select, `${path}.select`, seen)
+ }
+
+ if (query.join) {
+ result.join = query.join.map((join, index) =>
+ canonicalizeJoin(join, `${path}.join[${index}]`, seen),
+ )
+ }
+
+ if (query.where) {
+ result.where = query.where.map((where, index) =>
+ canonicalizeWhere(where, `${path}.where[${index}]`, seen),
+ )
+ }
+
+ if (query.groupBy) {
+ result.groupBy = query.groupBy.map((expression, index) =>
+ canonicalizeExpression(expression, `${path}.groupBy[${index}]`, seen),
+ )
+ }
+
+ if (query.having) {
+ result.having = query.having.map((having, index) =>
+ canonicalizeWhere(having, `${path}.having[${index}]`, seen),
+ )
+ }
+
+ if (query.orderBy) {
+ result.orderBy = query.orderBy.map((orderBy, index) =>
+ canonicalizeOrderBy(orderBy, `${path}.orderBy[${index}]`, seen),
+ )
+ }
+
+ if (query.limit !== undefined) {
+ result.limit = canonicalizeRuntimeValue(query.limit, `${path}.limit`, seen)
+ }
+
+ if (query.offset !== undefined) {
+ result.offset = canonicalizeRuntimeValue(
+ query.offset,
+ `${path}.offset`,
+ seen,
+ )
+ }
+
+ if (query.distinct) {
+ result.distinct = true
+ }
+
+ if (query.singleResult) {
+ result.singleResult = true
+ }
+
+ return result
+}
+
+function canonicalizeJoin(
+ join: JoinClause,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ return {
+ type: join.type,
+ from: canonicalizeSource(join.from, `${path}.from`, seen),
+ left: canonicalizeExpression(join.left, `${path}.left`, seen),
+ right: canonicalizeExpression(join.right, `${path}.right`, seen),
+ }
+}
+
+function canonicalizeSource(
+ source: From,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (source.type === `collectionRef`) {
+ return {
+ type: `collectionRef`,
+ alias: source.alias,
+ collectionId: canonicalizeRuntimeValue(
+ source.collection.id,
+ `${path}.collection.id`,
+ seen,
+ ),
+ }
+ }
+
+ if (source.type === `unionFrom`) {
+ return {
+ type: `unionFrom`,
+ sources: source.sources.map((unionSource, index) =>
+ canonicalizeSource(unionSource, `${path}.sources[${index}]`, seen),
+ ),
+ }
+ }
+
+ if (source.type === `unionAll`) {
+ return {
+ type: `unionAll`,
+ queries: source.queries.map((query, index) =>
+ canonicalizeQuery(query, `${path}.queries[${index}]`, seen),
+ ),
+ }
+ }
+
+ return {
+ type: `queryRef`,
+ alias: source.alias,
+ query: canonicalizeQuery(source.query, `${path}.query`, seen),
+ }
+}
+
+function canonicalizeSelect(
+ select: Select,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ return {
+ type: `select`,
+ fields: Object.keys(select)
+ .sort()
+ .map((key) => [
+ key,
+ canonicalizeSelectValue(select[key]!, `${path}.${key}`, seen),
+ ]),
+ }
+}
+
+function canonicalizeSelectValue(
+ value: unknown,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (isRefProxy(value)) {
+ return canonicalizeExpression(toExpression(value), path, seen)
+ }
+
+ if (isExpression(value)) {
+ return canonicalizeExpression(value, path, seen)
+ }
+
+ if (isPlainObject(value)) {
+ return canonicalizeSelect(value as Select, path, seen)
+ }
+
+ return canonicalizeRuntimeValue(value, path, seen)
+}
+
+function canonicalizeWhere(
+ where: Where | Having,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (isWhereObject(where)) {
+ const result: Record = {
+ type: `where`,
+ expression: canonicalizeExpression(
+ where.expression,
+ `${path}.expression`,
+ seen,
+ ),
+ }
+
+ if (where.residual === true) {
+ result.residual = true
+ }
+
+ return result
+ }
+
+ return canonicalizeExpression(where, path, seen)
+}
+
+function canonicalizeOrderBy(
+ orderBy: OrderByClause,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ return {
+ expression: canonicalizeExpression(
+ orderBy.expression,
+ `${path}.expression`,
+ seen,
+ ),
+ compareOptions: canonicalizeRuntimeValue(
+ orderBy.compareOptions,
+ `${path}.compareOptions`,
+ seen,
+ ),
+ }
+}
+
+function canonicalizeExpression(
+ expression:
+ | BasicExpression
+ | Aggregate
+ | IncludesSubquery
+ | ConditionalSelect,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (expression.type === `ref`) {
+ return {
+ type: `ref`,
+ path: expression.path.map((segment, index) =>
+ canonicalizeRuntimeValue(segment, `${path}.path[${index}]`, seen),
+ ),
+ }
+ }
+
+ if (expression.type === `val`) {
+ return {
+ type: `val`,
+ value: canonicalizeRuntimeValue(expression.value, `${path}.value`, seen),
+ }
+ }
+
+ if (expression.type === `func`) {
+ return {
+ type: `func`,
+ name: expression.name,
+ args: expression.args.map((arg, index) =>
+ canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
+ ),
+ }
+ }
+
+ if (expression.type === `agg`) {
+ return {
+ type: `agg`,
+ name: expression.name,
+ args: expression.args.map((arg, index) =>
+ canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
+ ),
+ }
+ }
+
+ if (expression.type === `conditionalSelect`) {
+ const result: Record = {
+ type: `conditionalSelect`,
+ branches: expression.branches.map((branch, index) => ({
+ condition: canonicalizeExpression(
+ branch.condition,
+ `${path}.branches[${index}].condition`,
+ seen,
+ ),
+ value: canonicalizeSelectValue(
+ branch.value,
+ `${path}.branches[${index}].value`,
+ seen,
+ ),
+ })),
+ }
+
+ if (expression.defaultValue !== undefined) {
+ result.defaultValue = canonicalizeSelectValue(
+ expression.defaultValue,
+ `${path}.defaultValue`,
+ seen,
+ )
+ }
+
+ return result
+ }
+
+ const result: Record = {
+ type: `includesSubquery`,
+ query: canonicalizeQuery(expression.query, `${path}.query`, seen),
+ correlationField: canonicalizeExpression(
+ expression.correlationField,
+ `${path}.correlationField`,
+ seen,
+ ),
+ childCorrelationField: canonicalizeExpression(
+ expression.childCorrelationField,
+ `${path}.childCorrelationField`,
+ seen,
+ ),
+ fieldName: expression.fieldName,
+ materialization: expression.materialization,
+ }
+
+ if (expression.parentFilters) {
+ result.parentFilters = expression.parentFilters.map((where, index) =>
+ canonicalizeWhere(where, `${path}.parentFilters[${index}]`, seen),
+ )
+ }
+
+ if (expression.parentProjection) {
+ result.parentProjection = expression.parentProjection.map(
+ (projection, index) =>
+ canonicalizeExpression(
+ projection,
+ `${path}.parentProjection[${index}]`,
+ seen,
+ ),
+ )
+ }
+
+ if (expression.scalarField !== undefined) {
+ result.scalarField = expression.scalarField
+ }
+
+ return result
+}
+
+function canonicalizeRuntimeValue(
+ value: unknown,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ if (value === null) return [`null`]
+
+ if (typeof value === `string`) {
+ return [`string`, value]
+ }
+
+ if (typeof value === `boolean`) {
+ return [`boolean`, value]
+ }
+
+ if (typeof value === `number`) {
+ if (Number.isNaN(value)) {
+ return [`number`, `NaN`]
+ }
+
+ if (value === Infinity) {
+ return [`number`, `Infinity`]
+ }
+
+ if (value === -Infinity) {
+ return [`number`, `-Infinity`]
+ }
+
+ if (Object.is(value, -0)) {
+ return [`number`, `-0`]
+ }
+
+ return [`number`, value]
+ }
+
+ if (typeof value === `undefined`) {
+ return [`undefined`]
+ }
+
+ if (typeof value === `bigint`) {
+ return [`bigint`, value.toString()]
+ }
+
+ if (typeof value === `function`) {
+ throw new UnhashableQueryIRError(path, `function value`)
+ }
+
+ if (typeof value === `symbol`) {
+ throw new UnhashableQueryIRError(path, `symbol value`)
+ }
+
+ if (isRefProxy(value)) {
+ return canonicalizeExpression(toExpression(value), path, seen)
+ }
+
+ if (Array.isArray(value)) {
+ return withCircularGuard(value, path, seen, () => [
+ `array`,
+ value.map((item, index) =>
+ canonicalizeRuntimeValue(item, `${path}[${index}]`, seen),
+ ),
+ ])
+ }
+
+ if (value instanceof Date) {
+ const timestamp = value.getTime()
+ if (Number.isNaN(timestamp)) {
+ throw new UnhashableQueryIRError(path, `invalid Date`)
+ }
+
+ return [`Date`, value.toISOString()]
+ }
+
+ if (value instanceof ArrayBuffer) {
+ return [`binary`, `ArrayBuffer`, Array.from(new Uint8Array(value))]
+ }
+
+ if (ArrayBuffer.isView(value)) {
+ return [
+ `binary`,
+ value.constructor.name,
+ Array.from(
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
+ ),
+ ]
+ }
+
+ if (value instanceof Map) {
+ return withCircularGuard(value, path, seen, () => {
+ const entries = Array.from(
+ value.entries(),
+ ([key, entryValue], index) => [
+ canonicalizeRuntimeValue(key, `${path}.key[${index}]`, seen),
+ canonicalizeRuntimeValue(entryValue, `${path}.value[${index}]`, seen),
+ ],
+ )
+ entries.sort(compareStableIdentityValues)
+ return [`Map`, entries]
+ })
+ }
+
+ if (value instanceof Set) {
+ return withCircularGuard(value, path, seen, () => {
+ const entries = Array.from(value, (entry, index) =>
+ canonicalizeRuntimeValue(entry, `${path}[${index}]`, seen),
+ )
+ entries.sort(compareStableIdentityValues)
+ return [`Set`, entries]
+ })
+ }
+
+ if (isPlainObject(value)) {
+ return canonicalizeObject(value, path, seen)
+ }
+
+ throw new UnhashableQueryIRError(path, `non-plain object value`)
+}
+
+function compareStableIdentityValues(
+ left: StableIdentityValue,
+ right: StableIdentityValue,
+): number {
+ const serializedLeft = JSON.stringify(left)
+ const serializedRight = JSON.stringify(right)
+ return serializedLeft < serializedRight
+ ? -1
+ : serializedLeft > serializedRight
+ ? 1
+ : 0
+}
+
+function canonicalizeObject(
+ value: Record,
+ path: string,
+ seen: WeakSet,
+): StableIdentityValue {
+ return withCircularGuard(value, path, seen, () => [
+ `object`,
+ Object.keys(value)
+ .sort()
+ .map((key) => [
+ key,
+ canonicalizeRuntimeValue(value[key], `${path}.${key}`, seen),
+ ]),
+ ])
+}
+
+function withCircularGuard