diff --git a/.changeset/modern-dbs-hydrate.md b/.changeset/modern-dbs-hydrate.md new file mode 100644 index 0000000000..c7094fdb7a --- /dev/null +++ b/.changeset/modern-dbs-hydrate.md @@ -0,0 +1,18 @@ +--- +'@tanstack/db': minor +'@tanstack/react-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 collection-row SSR through request-scoped `DbClient` instances, collection +descriptors, holistic and incremental hydration, adapter sync metadata, and +React 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. diff --git a/.github/SSR_RELEASE_PLAN.md b/.github/SSR_RELEASE_PLAN.md new file mode 100644 index 0000000000..8741593a3e --- /dev/null +++ b/.github/SSR_RELEASE_PLAN.md @@ -0,0 +1,61 @@ +# TanStack DB SSR Release Plan + +## Release Goal + +Ship TanStack DB SSR as a single coherent story: + +- collection-row hydration through `DbClient` +- React 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/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 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. +- 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: SSR hydration is collection-row based. +- 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 non-React framework parity 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..7dff92fade 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,16 @@ 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 Node SQLite persisted collection E2E tests run: | cd packages/node-db-sqlite-persistence 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 1c10d644c6..9128d5b033 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,24 +167,20 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( pageSize: 20, getNextPageParam: (lastPage, allPages) => lastPage.length === 20 ? allPages.length : undefined - }, - [category] // Re-run when category changes + } ) ``` -**Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection. - ### useLiveSuspenseQuery 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 (
    @@ -160,4 +198,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/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..3ae1f0566d 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,42 @@ 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 feeds source collection +rows into the `DbClient` payload. 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 +256,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 +288,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 +321,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 +338,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 +370,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 +379,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 +391,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 +413,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 +423,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 +452,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 +482,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 +494,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 +1387,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 +1641,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 +2043,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 +2429,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 +2456,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..8cf97e127e --- /dev/null +++ b/docs/guides/ssr.md @@ -0,0 +1,662 @@ +--- +title: SSR and Hydration +id: ssr +--- + +# SSR and Hydration + +TanStack DB SSR is based on collection-row hydration. The server loads rows into +request-scoped collections, serializes those collection rows, and the browser +hydrates them into a client-scoped `DbClient`. Live queries then read from the +hydrated collections exactly like they read from synced data. + +This keeps the SSR model aligned with why you use DB in the first place: +normalized data lives in collections, and live queries are views over those +collections. + +## High-level Summary + +The SSR-friendly API adds four 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 collection rows across the + server/client boundary. +- React apps use `` so hooks can resolve + collection descriptors against the current client. + +Existing apps continue to work. `createCollection(...)` and direct collection +instances still exist. The migration is required when you want SSR-safe request +isolation, hydration, streaming chunks, 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 | preload client-bound collection or live query | +| Serialize SSR state | none | `const state = dbClient.dehydrate()` | +| Hydrate in browser | none | `dbClient.hydrate(state)` before hooks read it | +| Stream rows later | custom app state | `dbClient.applyCollectionChunk(chunk)` | +| 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() + -> dbClient.collection(todoCollection) + -> preload collections or live queries + -> dbClient.dehydrate() + -> send state through framework loader + +browser + -> new DbClient() + -> dbClient.hydrate(loaderState) + -> + -> useLiveQuery({ query }) +``` + +During React hydration, descriptor-backed queries read the hydrated collection +rows 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. Fresh adapter data remains authoritative and reconciles +immediately after that commit. + +### 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, + createLiveQueryCollection, + 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) + + const openTodos = createLiveQueryCollection({ + query: (q) => + q + .from({ todo: todos }) + .where(({ todo }) => eq(todo.status, 'open')), + }) + + await openTodos.preload() + + return dbClient.dehydrate() +} +``` + +Preloading a live query loads the source collection rows required by that query. +The dehydrated payload contains collection rows, not a live-query result +snapshot. + +### Browser + +Hydrate the browser client before rendering components that read from DB. + +```tsx +import { DbClient, DbProvider } from '@tanstack/react-db' + +function App({ dehydratedDbState }: { dehydratedDbState: DehydratedDbState }) { + const [dbClient] = React.useState(() => { + const client = new DbClient() + client.hydrate(dehydratedDbState) + return client + }) + + 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. + +Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db + +## Streaming and Incremental Hydration + +Streaming 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()` serializes only collection state that can safely cross the +server/client boundary. + +Serialized: + +- collection ids +- synced row keys and values +- row metadata +- adapter sync metadata from `exportSyncMeta` + +Not serialized: + +- mutation handlers +- pending optimistic mutations +- pending subscriptions +- live query result objects +- D2 graphs or compiled pipelines +- transaction stacks +- module-level runtime state + +The rule is: if the client can reconstruct it from collection state or adapter +sync, it does not belong in the payload. If the row data cannot be reconstructed +without another round trip, it belongs in the payload. + +## 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. + +`initialData` never marks adapter sync as ready by itself. 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 ( + + ) +} +``` + +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 the route's collections or live queries, then serialize: + +```tsx +const dbClient = new DbClient() +const todos = dbClient.collection(todoCollection) + +const openTodos = createLiveQueryCollection({ + query: (q) => q.from({ todo: todos }).where(({ todo }) => eq(todo.status, 'open')), +}) + +await openTodos.preload() + +return { + dbState: dbClient.dehydrate(), +} +``` + +### 7. Hydrate before client hooks read DB + +```tsx +const client = new DbClient() +client.hydrate(loaderData.dbState) +``` + +Then provide it with `DbProvider`. + +## 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 + +## 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.createTransaction(config)` +- `dbClient.cleanup()` +- React `DbProvider` +- React `useDbClient()` +- React `useOptionalDbClient()` +- 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 +- TanStack Start + Playwright SSR E2E coverage + +### 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. +- Live query preloading for SSR serializes source collection rows by default, + not live query result snapshots. +- 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. +- Streaming chunks use the same payload shape as full dehydration. + +### 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. +- Non-React adapters keep their existing dependency/reactivity model until they + get their own SSR/client-provider work. +- 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, and SSR hydration +- query adapter tests to ensure Query cache behavior still holds +- persistence core tests to ensure persisted row behavior remains intact +- a TanStack Start + Playwright E2E that verifies server HTML contains hydrated + DB rows, browser hydration has no markup mismatch, fresh sync reconciles a + hydrated row, and an incremental collection chunk updates an existing live + query diff --git a/docs/overview.md b/docs/overview.md index 63f0965d96..7dcade952b 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 dehydrate collection rows on the server and hydrate them in the browser - [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,31 @@ 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 } 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 +88,14 @@ const Todos = () => {
          ) } + +const dbClient = new DbClient() + +const App = () => ( + + + +) ``` ### Defining collections @@ -103,14 +122,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 +165,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 +250,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 +294,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 +317,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 +348,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 +425,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 +456,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 +478,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 +525,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 +545,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 +555,11 @@ export const todoCollection = createCollection( ) const AddTodo = () => { + const todosCollection = useDbClient().collection(todoCollection) + return (
        ) } + +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` 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 +106,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 +139,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 +199,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/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/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..775d3630b0 --- /dev/null +++ b/examples/react/start-ssr-e2e/README.md @@ -0,0 +1,50 @@ +# TanStack DB Start SSR Demo + +This example is a minimal TanStack Start app that demonstrates TanStack DB SSR +with collection-row hydration. + +It verifies four 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 + +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`. + +## Run E2E + +```sh +pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e +``` + +The Playwright test checks raw SSR HTML first, browser hydration and fresh-sync +reconciliation for the same row key, then an incremental collection chunk. + +## 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..ef98cb5983 --- /dev/null +++ b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test' + +test(`TanStack Start hydrates, reconciles, and incrementally streams 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([]) +}) 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..3166315acb --- /dev/null +++ b/examples/react/start-ssr-e2e/package.json @@ -0,0 +1,29 @@ +{ + "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 && playwright test", + "test:e2e:hosted": "playwright test" + }, + "dependencies": { + "@tanstack/react-db": "^0.1.95", + "@tanstack/react-router": "^1.159.5", + "@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..b0f5539865 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts @@ -0,0 +1,106 @@ +import { + DbClient, + collectionOptions, + createLiveQueryCollection, + 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 createDehydratedSsrTodoState(): Promise { + const dbClient = new DbClient() + const todos = dbClient.collection(ssrTodoCollection) + const openTodos = createLiveQueryCollection((q) => + q.from({ todo: todos }).where(({ todo }) => eq(todo.status, `open`)), + ) + + await openTodos.preload() + + return dbClient.dehydrate() +} + +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..9760a1c05e --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routeTree.gen.ts @@ -0,0 +1,87 @@ +/* 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 SsrDbRouteImport } from './routes/ssr-db' +import { Route as IndexRouteImport } from './routes/index' + +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 +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/ssr-db': typeof SsrDbRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/ssr-db': typeof SsrDbRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/ssr-db' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/ssr-db' + id: '__root__' | '/' | '/ssr-db' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + SsrDbRoute: typeof SsrDbRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/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, +} +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..923234f94f --- /dev/null +++ b/examples/react/start-ssr-e2e/src/router.tsx @@ -0,0 +1,10 @@ +import { createRouter as createTanstackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import './styles.css' + +export function getRouter() { + return createTanstackRouter({ + routeTree, + scrollRestoration: true, + }) +} 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..399d4cc20a --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/__root.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import appCss from '../styles.css?url' + +export const Route = createRootRoute({ + 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.tsx b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx new file mode 100644 index 0000000000..07e23f6214 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx @@ -0,0 +1,113 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { DbClient, DbProvider, eq, useLiveQuery } from '@tanstack/react-db' +import { + applyStreamedTodo, + createDehydratedSsrTodoState, + ssrTodoCollection, +} from '../lib/ssr-fixture' + +export const Route = createFileRoute(`/ssr-db`)({ + loader: async () => { + return { + dbState: await createDehydratedSsrTodoState(), + } + }, + component: SsrDbRoute, +}) + +function SsrDbRoute() { + const { dbState } = Route.useLoaderData() + const [dbClient] = React.useState(() => { + const client = new DbClient() + client.hydrate(dbState) + return client + }) + + 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}) +
        • + ))} +
        + + +
        +
        + ) +} 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 fe55967ca9..b07dd45076 100644 --- a/packages/db/skills/db-core/live-queries/SKILL.md +++ b/packages/db/skills/db-core/live-queries/SKILL.md @@ -372,15 +372,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 e93dc019d6..395ffe0687 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) => ( @@ -295,7 +301,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 }), + }) // ... }, }) @@ -377,7 +385,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..2ca8e04c28 --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,529 @@ +import { createCollection } from './collection/index.js' +import { TransactionScope } from './transactions.js' +import { getBuilderFromConfig } from './query/live/collection-registry.js' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { Collection } from './collection/index.js' +import type { + CollectionConfig, + InferSchemaInput, + InferSchemaOutput, + NonSingleResult, + SingleResult, + TransactionConfig, + UtilsRecord, +} from './types.js' + +const collectionOptionsBrand: unique symbol = Symbol.for( + `@tanstack/db.collectionOptions`, +) as never +const collectionOptionsFactory: unique symbol = Symbol.for( + `@tanstack/db.collectionOptions.factory`, +) as never +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, +> = { + readonly id: string + readonly [collectionOptionsBrand]: true + readonly [collectionOptionsFactory]: ( + client: DbClient, + ) => CollectionConfig +} + +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 DehydratedDbState = { + collections: Array +} + +type CollectionRecord = { + collection: AnyCollection +} + +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.`, + ) + } + + 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 ( + typeof value === `object` && + value !== null && + (value as Record)[collectionOptionsBrand] === true + ) +} + +export class DbClient { + private collectionsByOptions = new WeakMap() + private collectionsById = new Map() + private pendingHydration = new Map>() + 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) + } + + 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) { + 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, + }) + + if (materializeOptions?.initialData?.length) { + this.applyRows( + collection, + { + collectionId: collection.id, + rows: materializeOptions.initialData.map((value) => { + const validated = collection.validateData(value, `insert`) + return { + key: config.getKey(validated), + value: validated, + } + }), + }, + `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(): DehydratedDbState { + const collections: Array = [] + + for (const { collection } of this.collectionsById.values()) { + if (getBuilderFromConfig(collection.config)) { + 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?.(), + }) + } + + return { collections } + } + + hydrate(state: DehydratedDbState): void { + for (const chunk of state.collections) { + const record = this.collectionsById.get(chunk.collectionId) + if (record) { + this.applyRows( + record.collection, + chunk, + record.collection.status !== `ready` ? `hydration` : undefined, + ) + continue + } + + const pendingChunks = this.pendingHydration.get(chunk.collectionId) ?? [] + pendingChunks.push(chunk) + this.pendingHydration.set(chunk.collectionId, pendingChunks) + } + } + + applyCollectionChunk(chunk: DehydratedCollectionChunk): void { + this.hydrate({ collections: [chunk] }) + } + + async cleanup(): Promise { + try { + await Promise.all( + Array.from(this.collectionsById.values(), ({ collection }) => + collection.cleanup(), + ), + ) + } finally { + this.transactionScope.clear() + this.collectionsByOptions = new WeakMap() + this.collectionsById.clear() + this.pendingHydration.clear() + } + } + + private applyRows( + collection: Collection, + chunk: DehydratedCollectionChunk, + seedKind?: `initialData` | `hydration`, + ): void { + const rowMetadataWrites = new Map< + string | number, + { type: `set`; value: unknown } | { type: `delete` } + >() + + collection._state.pendingSyncedTransactions.push({ + committed: true, + operations: chunk.rows.map((row) => { + rowMetadataWrites.set( + row.key, + row.metadata === undefined + ? { type: `delete` as const } + : { type: `set` as const, value: row.metadata }, + ) + + return { + type: collection._state.syncedData.has(row.key) ? `update` : `insert`, + key: row.key, + value: row.value, + } + }), + deletedKeys: new Set(), + rowMetadataWrites, + collectionMetadataWrites: new Map(), + immediate: true, + preserveHydrationSeedKeys: seedKind !== undefined, + }) + + if (seedKind) { + for (const row of chunk.rows) { + collection._state.hydrationSeedKeys.add(row.key) + if (seedKind === `hydration`) { + collection._state.hydratedKeys.add(row.key) + } + } + } + + 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/index.ts b/packages/db/src/collection/index.ts index 137fd5f595..1d6b30917c 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' @@ -466,6 +467,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 0f7b3b868c..663d8dfe08 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -34,6 +34,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, @@ -75,6 +76,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() @@ -975,6 +978,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 @@ -1052,6 +1057,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) { @@ -1435,6 +1444,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 af89ed2cf3..0102d97841 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -49,6 +49,9 @@ export class CollectionSyncManager< null private pendingLoadSubsetPromises: Set> = new Set() + private syncStartDeferred = false + private syncStartRequested = false + private deferredLoadSubsetOptions: Array = [] /** * Creates a new CollectionSyncManager instance @@ -83,6 +86,11 @@ export class CollectionSyncManager< return // Already started or in progress } + if (this.syncStartDeferred) { + this.syncStartRequested = true + return + } + this.lifecycle.setStatus(`loading`) try { @@ -145,10 +153,10 @@ 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 + // the adapter's first authoritative value replaces that seed. messageType = `update` } else { const utils = this.config.utils as @@ -272,6 +280,39 @@ 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.deferredLoadSubsetOptions.length > 0 + this.syncStartRequested = false + const deferredOptions = this.deferredLoadSubsetOptions + this.deferredLoadSubsetOptions = [] + + if (shouldStart) { + this.startSync() + } + + for (const options of deferredOptions) { + this.loadSubset(options) + } + } + private getActivePendingSyncTransaction() { const pendingTransaction = this.state.pendingSyncedTransactions[ @@ -486,6 +527,12 @@ export class CollectionSyncManager< return true } + if (this.syncStartDeferred) { + this.syncStartRequested = true + this.deferredLoadSubsetOptions.push(options) + return true + } + if (this.syncLoadSubsetFn) { const result = this.syncLoadSubsetFn(options) // If the result is a promise, track it @@ -503,6 +550,13 @@ export class CollectionSyncManager< * @param options Options that identify what data is being unloaded */ public unloadSubset(options: LoadSubsetOptions): void { + if (this.syncStartDeferred) { + this.deferredLoadSubsetOptions = this.deferredLoadSubsetOptions.filter( + (deferredOptions) => deferredOptions !== options, + ) + return + } + if (this.syncUnloadSubsetFn) { this.syncUnloadSubsetFn(options) } @@ -529,6 +583,9 @@ export class CollectionSyncManager< }) } this.preloadPromise = null + this.syncStartDeferred = false + this.syncStartRequested = false + this.deferredLoadSubsetOptions = [] } } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 71e264d712..4bd857253c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -6,6 +6,8 @@ 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' 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..bd8a121d37 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 { isCollectionOptions } from '../../client.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 { CollectionOptions } from '../../client.js' import type { Aggregate, BasicExpression, @@ -75,13 +77,26 @@ import type { const UNION_ALL_SOURCE_CONTEXT = `unionAll clause` satisfies SourceClauseContext +type CollectionResolver = ( + options: CollectionOptions, +) => 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 (isCollectionOptions(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 @@ -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..530cc2d908 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 { CollectionOptions } from '../../client.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 + | CollectionOptions + | QueryBuilder } /** @@ -101,7 +105,9 @@ export type Source = { export type InferCollectionType = T extends CollectionImpl ? WithVirtualProps - : never + : T extends CollectionOptions + ? WithVirtualProps + : never /** * SchemaFromSource - Converts a Source definition into a ContextSchema @@ -116,9 +122,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 CollectionOptions + ? 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..7ab1be7f76 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -95,6 +95,12 @@ 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, +} 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..a93b4030d3 --- /dev/null +++ b/packages/db/src/query/ir-stable-identity.ts @@ -0,0 +1,537 @@ +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 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] + .sort((a, b) => a.alias.localeCompare(b.alias)) + .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 (isPlainObject(value)) { + return canonicalizeObject(value, path, seen) + } + + throw new UnhashableQueryIRError(path, `non-plain object value`) +} + +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( + value: object, + path: string, + seen: WeakSet, + callback: () => T, +): T { + if (seen.has(value)) { + throw new UnhashableQueryIRError(path, `circular value`) + } + + seen.add(value) + try { + return callback() + } finally { + seen.delete(value) + } +} + +function isWhereObject( + where: Where | Having, +): where is { expression: BasicExpression; residual?: boolean } { + return `expression` in where +} + +function isExpression( + value: unknown, +): value is BasicExpression | Aggregate | IncludesSubquery { + if (value === null || typeof value !== `object`) { + return false + } + + const expressionType = (value as { type?: unknown }).type + return ( + expressionType === `agg` || + expressionType === `conditionalSelect` || + expressionType === `func` || + expressionType === `ref` || + expressionType === `val` || + expressionType === `includesSubquery` + ) +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== `object`) return false + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index 4a3e2f80f2..4186fc0f79 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -17,10 +17,127 @@ import type { TransactionWithMutations, } from './types' -const transactions: Array> = [] -let transactionStack: Array> = [] +export class TransactionScope { + private transactions: Array> = [] + private transactionStack: Array> = [] + private sequenceNumber = 0 + + createTransaction>( + config: TransactionConfig, + ): Transaction { + const transaction = new Transaction(config, this, this.sequenceNumber++) + this.transactions.push(transaction) + return transaction + } + + getActiveTransaction(): Transaction | undefined { + return this.transactionStack.at(-1) + } + + getActiveTransactionForCollection(): Transaction | undefined { + const activeTransaction = this.getActiveTransaction() + if (activeTransaction) { + return activeTransaction + } + + if (this === defaultTransactionScope) { + return undefined + } + + return defaultTransactionScope.claimActiveTransaction(this) + } + + private claimActiveTransaction( + targetScope: TransactionScope, + ): Transaction | undefined { + const transaction = this.getActiveTransaction() + if (!transaction) { + return undefined + } + + const owner = getTransactionScope(transaction) + if (owner === targetScope) { + return transaction + } + if (owner !== this) { + throw new Error( + `A transaction created with createTransaction() cannot mutate collections from multiple DbClient instances. Use dbClient.createTransaction() for explicit client scope.`, + ) + } + + this.removeTransaction(transaction) + targetScope.transactions.push(transaction) + targetScope.transactionStack.push(transaction) + transaction.sequenceNumber = targetScope.sequenceNumber++ + transactionScopes.set(transaction, targetScope) + return transaction + } + + registerTransaction(transaction: Transaction): void { + // Clear stale work left by an aborted mutate scope before reusing the id. + transactionScopedScheduler.clear(transaction.id) + this.transactionStack.push(transaction) + } + + unregisterTransaction(transaction: Transaction): void { + try { + transactionScopedScheduler.flush(transaction.id) + } finally { + this.transactionStack = this.transactionStack.filter( + (candidate) => candidate.id !== transaction.id, + ) + } + } + + removeTransaction(transaction: Transaction): void { + const index = this.transactions.findIndex( + (candidate) => candidate.id === transaction.id, + ) + if (index !== -1) { + this.transactions.splice(index, 1) + } + } + + rollbackConflictingTransactions( + transaction: Transaction, + mutationIds: Set, + ): void { + for (const candidate of this.transactions) { + if ( + candidate !== transaction && + candidate.state === `pending` && + candidate.mutations.some((mutation) => + mutationIds.has(mutation.globalKey), + ) + ) { + candidate.rollback({ isSecondaryRollback: true }) + } + } + } -let sequenceNumber = 0 + clear(): void { + const transactionIds = new Set([ + ...this.transactions.map((transaction) => transaction.id), + ...this.transactionStack.map((transaction) => transaction.id), + ]) + for (const transactionId of transactionIds) { + transactionScopedScheduler.clear(transactionId) + } + this.transactions = [] + this.transactionStack = [] + } +} + +const defaultTransactionScope = new TransactionScope() +const transactionScopes = new WeakMap() + +function getTransactionScope(transaction: object): TransactionScope { + const scope = transactionScopes.get(transaction) + if (!scope) { + throw new Error(`Transaction is not associated with a TransactionScope.`) + } + return scope +} /** * Merges two pending mutations for the same item within a transaction @@ -157,9 +274,7 @@ function mergePendingMutations( export function createTransaction>( config: TransactionConfig, ): Transaction { - const newTransaction = new Transaction(config) - transactions.push(newTransaction) - return newTransaction + return defaultTransactionScope.createTransaction(config) } /** @@ -174,36 +289,7 @@ export function createTransaction>( * } */ export function getActiveTransaction(): Transaction | undefined { - if (transactionStack.length > 0) { - return transactionStack.slice(-1)[0] - } else { - return undefined - } -} - -function registerTransaction(tx: Transaction) { - // Clear any stale work that may have been left behind if a previous mutate - // scope aborted before we could flush. - transactionScopedScheduler.clear(tx.id) - transactionStack.push(tx) -} - -function unregisterTransaction(tx: Transaction) { - // Always flush pending work for this transaction before removing it from - // the ambient stack – this runs even if the mutate callback throws. - // If flush throws (e.g., due to a job error), we still clean up the stack. - try { - transactionScopedScheduler.flush(tx.id) - } finally { - transactionStack = transactionStack.filter((t) => t.id !== tx.id) - } -} - -function removeFromPendingList(tx: Transaction) { - const index = transactions.findIndex((t) => t.id === tx.id) - if (index !== -1) { - transactions.splice(index, 1) - } + return defaultTransactionScope.getActiveTransaction() } class Transaction> { @@ -233,7 +319,11 @@ class Transaction> { error: Error } - constructor(config: TransactionConfig) { + constructor( + config: TransactionConfig, + scope: TransactionScope, + sequenceNumber: number, + ) { if (typeof config.mutationFn === `undefined`) { throw new MissingMutationFunctionError() } @@ -244,15 +334,16 @@ class Transaction> { this.isPersisted = createDeferred>() this.autoCommit = config.autoCommit ?? true this.createdAt = new Date() - this.sequenceNumber = sequenceNumber++ + this.sequenceNumber = sequenceNumber this.metadata = config.metadata ?? {} + transactionScopes.set(this, scope) } setState(newState: TransactionState) { this.state = newState if (newState === `completed` || newState === `failed`) { - removeFromPendingList(this) + getTransactionScope(this).removeTransaction(this) } } @@ -310,12 +401,17 @@ class Transaction> { throw new TransactionNotPendingMutateError() } - registerTransaction(this) + const initialScope = getTransactionScope(this) + initialScope.registerTransaction(this) try { callback() } finally { - unregisterTransaction(this) + const finalScope = getTransactionScope(this) + if (finalScope !== initialScope) { + finalScope.unregisterTransaction(this) + } + initialScope.unregisterTransaction(this) } if (this.autoCommit) { @@ -430,13 +526,13 @@ class Transaction> { // See if there's any other transactions w/ mutations on the same ids // and roll them back as well. if (!isSecondaryRollback) { - const mutationIds = new Set() - this.mutations.forEach((m) => mutationIds.add(m.globalKey)) - for (const t of transactions) { - t.state === `pending` && - t.mutations.some((m) => mutationIds.has(m.globalKey)) && - t.rollback({ isSecondaryRollback: true }) - } + const mutationIds = new Set( + this.mutations.map((mutation) => mutation.globalKey), + ) + getTransactionScope(this).rollbackConflictingTransactions( + this, + mutationIds, + ) } // Reject the promise diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 6087e234ec..bae05a943f 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -349,6 +349,22 @@ export interface SyncConfig< */ getSyncMetadata?: () => Record + /** + * Export adapter-specific metadata that lets hydration/persistence resume sync. + * The payload shape is owned by the adapter. + */ + exportSyncMeta?: () => unknown + + /** + * Import adapter-specific metadata produced by exportSyncMeta. + */ + importSyncMeta?: (meta: unknown) => void + + /** + * Merge two adapter-specific metadata payloads during hydration. + */ + mergeSyncMeta?: (current: unknown, incoming: unknown) => unknown + /** * The row update mode used to sync to the collection. * @default `partial` diff --git a/packages/db/tests/collection.test-d.ts b/packages/db/tests/collection.test-d.ts index 32edff2f8d..8a29fa3ff8 100644 --- a/packages/db/tests/collection.test-d.ts +++ b/packages/db/tests/collection.test-d.ts @@ -1,6 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { createCollection } from '../src/collection/index.js' +import { DbClient, collectionOptions } from '../src/index.js' import type { OutputWithVirtual } from './utils' import type { OperationConfig } from '../src/types' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -162,6 +163,42 @@ describe(`Collection type resolution tests`, () => { }) }) +describe(`DbClient type tests`, () => { + type Todo = { id: string; text: string } + + it(`materializes typed collection options`, () => { + const todos = collectionOptions({ + id: `todos`, + getKey: (todo) => todo.id, + sync: { sync: () => {} }, + }) + + const client = new DbClient() + const collection = client.collection(todos) + + expectTypeOf(collection.get(`1`)).toEqualTypeOf< + OutputWithVirtual | undefined + >() + }) + + it(`accepts materialization initialData`, () => { + const todos = collectionOptions({ + id: `todos`, + getKey: (todo) => todo.id, + sync: { sync: () => {} }, + }) + + const client = new DbClient() + const collection = client.collection(todos, { + initialData: [{ id: `1`, text: `Write tests` }], + }) + + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + }) +}) + describe(`Schema Input/Output Type Distinction`, () => { // Define schema with different input/output types const userSchemaWithDefaults = z.object({ diff --git a/packages/db/tests/db-client.test-d.ts b/packages/db/tests/db-client.test-d.ts new file mode 100644 index 0000000000..1a1c75bb18 --- /dev/null +++ b/packages/db/tests/db-client.test-d.ts @@ -0,0 +1,92 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { DbClient, collectionOptions } from '../src' +import type { DehydratedCollectionChunk, DehydratedDbState } from '../src' + +type Todo = { + id: string + title: string +} + +describe(`DbClient type assertions`, () => { + it(`types explicit dependencies`, () => { + const queryClient = { + invalidateQueries: () => Promise.resolve(), + } + const client = new DbClient({ queryClient }) + + expectTypeOf( + client.getDependency(`queryClient`), + ).toEqualTypeOf() + expectTypeOf( + client.requireDependency(`queryClient`), + ).toEqualTypeOf() + }) + + it(`infers collections from client-aware descriptor factories`, () => { + const descriptor = collectionOptions(`todos`, (client) => { + expectTypeOf(client).toEqualTypeOf() + + return { + id: `todos`, + getKey: (todo: Todo) => todo.id, + sync: { + sync: () => {}, + }, + } + }) + const client = new DbClient() + const collection = client.collection(descriptor, { + initialData: [{ id: `1`, title: `Ship SSR` }], + }) + + expectTypeOf(collection.get(`1`)).toMatchTypeOf() + collection.insert({ id: `2`, title: `Keep inference` }) + }) + + it(`types holistic and incremental hydration payloads`, () => { + const client = new DbClient() + const state: DehydratedDbState = { + collections: [ + { + collectionId: `todos`, + rows: [ + { + key: `1`, + value: { id: `1`, title: `Ship SSR` }, + }, + ], + }, + ], + } + const chunk: DehydratedCollectionChunk = state + .collections[0] as DehydratedCollectionChunk + + client.hydrate(state) + client.applyCollectionChunk(chunk) + + expectTypeOf(client.dehydrate()).toEqualTypeOf() + }) + + it(`preserves schema input and output through descriptor factories`, () => { + const schema = z.object({ + id: z.string(), + createdAt: z.string().transform((value) => new Date(value)), + }) + const descriptor = collectionOptions(`schema-items`, () => ({ + id: `schema-items`, + schema, + getKey: (item: z.output) => item.id, + sync: { + sync: () => {}, + }, + })) + const collection = new DbClient().collection(descriptor, { + initialData: [{ id: `1`, createdAt: `2026-01-01T00:00:00.000Z` }], + }) + + expectTypeOf(collection.get(`1`)?.createdAt).toEqualTypeOf< + Date | undefined + >() + }) +}) diff --git a/packages/db/tests/db-client.test.ts b/packages/db/tests/db-client.test.ts new file mode 100644 index 0000000000..2da6a2a6da --- /dev/null +++ b/packages/db/tests/db-client.test.ts @@ -0,0 +1,705 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { + DbClient, + collectionOptions, + createLiveQueryCollection, + createTransaction, + eq, + liveQueryCollectionOptions, + localOnlyCollectionOptions, +} from '../src' +import { mockSyncCollectionOptions } from './utils' + +type Person = { + id: string + name: string + status?: string +} + +const people: Array = [ + { id: `1`, name: `Tanner`, status: `active` }, + { id: `2`, name: `Kyle`, status: `inactive` }, +] + +describe(`DbClient`, () => { + it(`memoizes materialized collections per client and isolates clients`, () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + + const clientA = new DbClient() + const clientB = new DbClient() + + const peopleA1 = clientA.collection(descriptor) + const peopleA2 = clientA.collection(descriptor) + const peopleB = clientB.collection(descriptor) + + expect(peopleA1).toBe(peopleA2) + expect(peopleA1).not.toBe(peopleB) + expect(peopleA1.toArray).toHaveLength(2) + expect(peopleB.toArray).toHaveLength(2) + }) + + it(`materializes independent adapter state for each client`, async () => { + const descriptor = collectionOptions( + localOnlyCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + }), + ) + const clientA = new DbClient() + const clientB = new DbClient() + const peopleA = clientA.collection(descriptor) + const peopleB = clientB.collection(descriptor) + + const transaction = peopleA.insert(people[0]!) + await transaction.isPersisted.promise + + expect(peopleA.get(`1`)).toMatchObject(people[0]!) + expect(peopleB.get(`1`)).toBeUndefined() + }) + + it(`does not reuse concrete configs across clients`, () => { + const descriptor = collectionOptions({ + id: `people`, + getKey: (person: Person) => person.id, + sync: { sync: () => {} }, + }) + + new DbClient().collection(descriptor) + + expect(() => new DbClient().collection(descriptor)).toThrow( + /cannot be safely reused across DbClient instances/, + ) + }) + + it(`isolates ambient transactions between clients`, async () => { + const descriptor = collectionOptions( + localOnlyCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + }), + ) + const clientA = new DbClient() + const clientB = new DbClient() + const peopleA = clientA.collection(descriptor) + const peopleB = clientB.collection(descriptor) + const transactionA = clientA.createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + const rolledBack = transactionA.isPersisted.promise.catch(() => undefined) + let transactionB: ReturnType | undefined + + transactionA.mutate(() => { + expect(peopleA.insert(people[0]!)).toBe(transactionA) + transactionB = peopleB.insert(people[1]!) + expect(transactionB).not.toBe(transactionA) + expect(clientA.activeTransaction).toBe(transactionA) + expect(clientB.activeTransaction).toBeUndefined() + }) + + await transactionB!.isPersisted.promise + transactionA.rollback() + await rolledBack + + expect(peopleA.get(`1`)).toBeUndefined() + expect(peopleB.get(`2`)).toMatchObject(people[1]!) + }) + + it(`binds the backwards-compatible createTransaction API to one client`, async () => { + const descriptor = collectionOptions( + localOnlyCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + }), + ) + const clientA = new DbClient() + const clientB = new DbClient() + const peopleA = clientA.collection(descriptor) + const peopleB = clientB.collection(descriptor) + const transaction = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + const rolledBack = transaction.isPersisted.promise.catch(() => undefined) + + transaction.mutate(() => { + expect(peopleA.insert(people[0]!)).toBe(transaction) + expect(() => peopleB.insert(people[1]!)).toThrow( + /cannot mutate collections from multiple DbClient instances/, + ) + }) + + transaction.rollback() + await rolledBack + + expect(peopleA.get(`1`)).toBeUndefined() + expect(peopleB.get(`2`)).toBeUndefined() + }) + + it(`cleans up materialized collections and allows rematerialization`, async () => { + const cleanup = vi.fn() + const descriptor = collectionOptions(`people`, () => ({ + id: `people`, + getKey: (person: Person) => person.id, + startSync: true, + sync: { + sync: () => ({ cleanup }), + }, + })) + const client = new DbClient() + const first = client.collection(descriptor) + + await client.cleanup() + + expect(cleanup).toHaveBeenCalledOnce() + expect(client.dehydrate()).toEqual({ collections: [] }) + expect(client.collection(descriptor)).not.toBe(first) + }) + + it(`serializes collection rows and sync metadata from explicit ids`, () => { + let syncMeta = { version: 1, cursor: `a` } + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: people[0]!, + metadata: { source: `server` }, + }) + commit() + markReady() + }, + exportSyncMeta: () => syncMeta, + importSyncMeta: (meta) => { + syncMeta = meta as typeof syncMeta + }, + mergeSyncMeta: (_current, incoming) => incoming, + }, + }), + ) + + const client = new DbClient() + client.collection(descriptor) + + const dehydrated = client.dehydrate() + + expect(dehydrated).toEqual({ + collections: [ + { + collectionId: `people`, + rows: [ + { + key: `1`, + value: people[0], + metadata: { source: `server` }, + }, + ], + syncMeta: { version: 1, cursor: `a` }, + }, + ], + }) + }) + + it(`serializes only collections materialized through the client`, () => { + const peopleDescriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + collectionOptions( + mockSyncCollectionOptions({ + id: `unused-people`, + getKey: (person) => person.id, + initialData: [{ id: `3`, name: `Unused` }], + }), + ) + + const client = new DbClient() + + expect(client.dehydrate()).toEqual({ collections: [] }) + + client.collection(peopleDescriptor) + + expect( + client.dehydrate().collections.map((chunk) => chunk.collectionId), + ).toEqual([`people`]) + }) + + it(`requires collection ids to be unique per client`, () => { + const firstDescriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [people[0]!], + }), + ) + const secondDescriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [people[1]!], + }), + ) + + const client = new DbClient() + client.collection(firstDescriptor) + + expect(() => client.collection(secondDescriptor)).toThrow( + /collection ids to be unique per DbClient/, + ) + }) + + it(`requires a stable explicit collection id when creating a descriptor`, () => { + expect(() => + collectionOptions( + mockSyncCollectionOptions({ + id: undefined as unknown as string, + getKey: (person) => person.id, + initialData: people, + }), + ), + ).toThrow(/collectionOptions requires a non-empty explicit id/) + }) + + it(`rejects an empty collection descriptor id`, () => { + expect(() => + collectionOptions( + mockSyncCollectionOptions({ + id: ``, + getKey: (person) => person.id, + initialData: people, + }), + ), + ).toThrow(/collectionOptions requires a non-empty explicit id/) + }) + + it(`hydrates pending collection rows when the collection materializes`, () => { + const importedMeta = vi.fn() + const lifecycleOrder: Array = [] + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [], + sync: { + sync: ({ markReady }) => { + lifecycleOrder.push(`sync`) + markReady() + }, + importSyncMeta: (meta) => { + lifecycleOrder.push(`import`) + importedMeta(meta) + }, + }, + }), + ) + + const client = new DbClient() + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [ + { + key: `1`, + value: people[0]!, + metadata: { source: `ssr` }, + }, + ], + syncMeta: { version: 1, cursor: `ssr` }, + }, + ], + }) + + const collection = client.collection(descriptor) + + expect(collection.get(`1`)).toMatchObject(people[0]!) + expect(collection._state.syncedMetadata.get(`1`)).toEqual({ + source: `ssr`, + }) + expect(importedMeta).toHaveBeenCalledWith({ version: 1, cursor: `ssr` }) + expect(lifecycleOrder).toEqual([`import`, `sync`]) + expect(collection.status).toBe(`ready`) + }) + + it(`defers adapter sync and replays subset loads after hydrated rows render`, () => { + const lifecycleOrder: Array = [] + const descriptor = collectionOptions(`people`, () => ({ + id: `people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ begin, write, commit, markReady }) => { + lifecycleOrder.push(`sync`) + markReady() + return { + loadSubset: () => { + lifecycleOrder.push(`load`) + begin({ immediate: true }) + write({ + type: `insert`, + value: { id: `1`, name: `fresh` }, + }) + commit() + return true + }, + } + }, + }, + })) + const client = new DbClient() + + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [{ key: `1`, value: { id: `1`, name: `stale` } }], + }, + ], + }) + + const collection = client._materializeCollectionForRender(descriptor) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: true, + }) + + expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `stale` }) + expect(lifecycleOrder).toEqual([]) + + collection._resumeSyncStart() + + expect(lifecycleOrder).toEqual([`sync`, `load`]) + expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `fresh` }) + subscription.unsubscribe() + }) + + it(`lets the first sync snapshot replace stale hydrated rows`, () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [{ id: `1`, name: `fresh` }], + }), + ) + const client = new DbClient() + + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [{ key: `1`, value: { id: `1`, name: `stale` } }], + }, + ], + }) + + const collection = client.collection(descriptor) + + expect(collection.get(`1`)).toMatchObject({ id: `1`, name: `fresh` }) + }) + + it(`merges sync metadata before importing hydration metadata`, () => { + let syncMeta: unknown = { version: 1, cursor: `client` } + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [], + sync: { + sync: ({ markReady }) => { + markReady() + }, + exportSyncMeta: () => syncMeta, + importSyncMeta: (meta) => { + syncMeta = meta + }, + mergeSyncMeta: (current, incoming) => ({ current, incoming }), + }, + }), + ) + + const client = new DbClient() + client.collection(descriptor) + + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [], + syncMeta: { version: 1, cursor: `server` }, + }, + ], + }) + + expect(syncMeta).toEqual({ + current: { version: 1, cursor: `client` }, + incoming: { version: 1, cursor: `server` }, + }) + }) + + it(`applies streaming collection chunks and live queries react from collection state`, async () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [], + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + }), + ) + + const client = new DbClient() + const collection = client.collection(descriptor) + const activePeople = createLiveQueryCollection((q) => + q + .from({ person: collection }) + .where(({ person }) => eq(person.status, `active`)), + ) + await activePeople.preload() + + client.applyCollectionChunk({ + collectionId: `people`, + rows: [{ key: `1`, value: people[0]! }], + }) + + expect(activePeople.toArray.map((person) => person.id)).toEqual([`1`]) + }) + + it(`live query preload dehydrates source collection rows instead of live query snapshots`, async () => { + const descriptor = collectionOptions({ + id: `people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + + return { + loadSubset: () => { + begin({ immediate: true }) + for (const person of people) { + write({ + type: `insert`, + value: person, + }) + } + commit() + return true + }, + } + }, + }, + }) + + const client = new DbClient() + const collection = client.collection(descriptor) + const activePeople = createLiveQueryCollection((q) => + q + .from({ person: collection }) + .where(({ person }) => eq(person.status, `active`)), + ) + + await activePeople.preload() + + expect(activePeople.toArray.map((person) => person.id)).toEqual([`1`]) + expect(client.dehydrate()).toEqual({ + collections: [ + { + collectionId: `people`, + rows: people.map((person) => ({ + key: person.id, + value: person, + })), + syncMeta: undefined, + }, + ], + }) + }) + + it(`does not dehydrate explicitly client-bound live query result collections`, async () => { + const peopleDescriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const activePeopleDescriptor = collectionOptions( + `active-people`, + (client) => + liveQueryCollectionOptions({ + id: `active-people`, + query: (q) => + q + .from({ person: client.collection(peopleDescriptor) }) + .where(({ person }) => eq(person.status, `active`)), + }), + ) + const client = new DbClient() + const activePeople = client.collection(activePeopleDescriptor) + + await activePeople.preload() + + expect(activePeople.toArray.map((person) => person.id)).toEqual([`1`]) + expect( + client.dehydrate().collections.map((chunk) => chunk.collectionId), + ).toEqual([`people`]) + }) + + it(`hydrates rows without running mutation handlers or creating optimistic state`, () => { + const onInsert = vi.fn() + const descriptor = collectionOptions({ + id: `people`, + getKey: (person: Person) => person.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + onInsert, + }) + + const client = new DbClient() + const collection = client.collection(descriptor) + + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [{ key: `1`, value: people[0]! }], + }, + ], + }) + + expect(onInsert).not.toHaveBeenCalled() + expect(collection._state.optimisticUpserts.size).toBe(0) + expect(collection._state.optimisticDeletes.size).toBe(0) + expect(collection.get(`1`)).toMatchObject(people[0]!) + }) + + it(`does not serialize optimistic pending mutations`, async () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [people[0]!], + }), + ) + + const client = new DbClient() + const collection = client.collection(descriptor) + const tx = collection.insert({ id: `3`, name: `Pending` }) + + expect(collection._state.optimisticUpserts.has(`3`)).toBe(true) + expect(client.dehydrate()).toEqual({ + collections: [ + { + collectionId: `people`, + rows: [ + { + key: `1`, + value: people[0], + }, + ], + syncMeta: undefined, + }, + ], + }) + + collection.utils.resolveSync() + await tx.isPersisted.promise + }) + + it(`applies initialData precedence before hydrated rows`, () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: [], + }), + ) + + const client = new DbClient() + const collection = client.collection(descriptor, { + initialData: [{ id: `1`, name: `materialized` }], + }) + + expect(collection.get(`1`)).toMatchObject({ + id: `1`, + name: `materialized`, + }) + + client.hydrate({ + collections: [ + { + collectionId: `people`, + rows: [{ key: `1`, value: { id: `1`, name: `hydrated` } }], + }, + ], + }) + + expect(collection.get(`1`)).toMatchObject({ + id: `1`, + name: `hydrated`, + }) + }) + + it(`seeds initialData without marking adapter sync as ready`, () => { + const descriptor = collectionOptions({ + id: `people`, + getKey: (person) => person.id, + sync: { + sync: () => {}, + }, + }) + + const client = new DbClient() + const collection = client.collection(descriptor, { + initialData: [people[0]!], + }) + + expect(collection.get(`1`)).toMatchObject(people[0]!) + expect(collection.status).not.toBe(`ready`) + }) + + it(`validates and transforms materialization initialData before keying`, () => { + const personSchema = z.object({ + id: z.string().transform((id) => `person:${id}`), + name: z.string(), + }) + const descriptor = collectionOptions({ + id: `people`, + schema: personSchema, + getKey: (person) => person.id, + sync: { + sync: () => {}, + }, + }) + + const collection = new DbClient().collection(descriptor, { + initialData: [{ id: `1`, name: `Tanner` }], + }) + + expect(collection.get(`person:1`)).toMatchObject({ + id: `person:1`, + name: `Tanner`, + }) + expect(collection.get(`1`)).toBeUndefined() + }) +}) diff --git a/packages/db/tests/query/ir-stable-identity.test.ts b/packages/db/tests/query/ir-stable-identity.test.ts new file mode 100644 index 0000000000..e315fdcd1b --- /dev/null +++ b/packages/db/tests/query/ir-stable-identity.test.ts @@ -0,0 +1,512 @@ +import { describe, expect, it } from 'vitest' +import { CollectionImpl } from '../../src/collection/index.js' +import { Query, getQueryIR } from '../../src/query/builder/index.js' +import { + add, + and, + avg, + caseWhen, + coalesce, + concat, + count, + eq, + gt, + gte, + inArray, + isNull, + isUndefined, + length, + like, + lower, + max, + not, + or, + sum, + upper, +} from '../../src/query/builder/functions.js' +import { + UnhashableQueryIRError, + getStableQueryIRHash, +} from '../../src/query/ir-stable-identity.js' +import type { QueryIR } from '../../src/query/ir.js' + +interface User { + id: number + name: string + email?: string | null + active: boolean + age: number + salary: number + status: `active` | `inactive` + teamId: string + departmentId: number | null + createdAt: Date + profile?: { + skills: Array + experience: { + years: number + } + } + blob?: Uint8Array + largeViewCount?: bigint +} + +interface Post { + id: number + userId: number + title: string + published: boolean + views: number + createdAt: Date +} + +const usersCollection = new CollectionImpl({ + id: `users`, + getKey: (item) => item.id, + sync: { sync: () => {} }, +}) + +const postsCollection = new CollectionImpl({ + id: `posts`, + getKey: (item) => item.id, + sync: { sync: () => {} }, +}) + +const structuredQueries: Array<[string, () => QueryIR]> = [ + [ + `basic collection source`, + () => getQueryIR(new Query().from({ user: usersCollection })), + ], + [ + `captured primitive where value`, + () => { + const status = `active` as const + return getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, status)), + ) + }, + ], + [ + `boolean expression tree`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => + and( + eq(user.active, true), + or(gt(user.age, 30), not(isNull(user.email))), + ), + ), + ), + ], + [ + `array membership and undefined checks`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => + and( + inArray(user.teamId, [`eng`, `design`]), + not(isUndefined(user.profile)), + ), + ), + ), + ], + [ + `date bigint and typed array values`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => + and( + gte(user.createdAt, new Date(`2024-01-01T00:00:00.000Z`)), + gt(user.largeViewCount, 9007199254740993n), + eq(user.blob, new Uint8Array([1, 2, 3])), + ), + ), + ), + ], + [ + `plain object values`, + () => + getQueryIR( + new Query().from({ user: usersCollection }).where(({ user }) => + eq(user.profile, { + experience: { years: 5 }, + skills: [`ts`, `db`], + }), + ), + ), + ], + [ + `nested select and computed expressions`, + () => + getQueryIR( + new Query().from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + displayName: concat(upper(user.name), ` <`, lower(user.email), `>`), + score: add(user.salary, 1000), + fallbackEmail: coalesce(user.email, `missing@example.com`), + meta: { + active: user.active, + nameLength: length(user.name), + }, + })), + ), + ], + [ + `conditional projection select`, + () => + getQueryIR( + new Query().from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + profile: caseWhen( + gt(user.age, 18), + { + label: `adult`, + email: user.email, + }, + { + label: `minor`, + email: null, + }, + ), + })), + ), + ], + [ + `top-level alias spread select`, + () => + getQueryIR( + new Query().from({ user: usersCollection }).select(({ user }) => user), + ), + ], + [ + `locale orderBy options`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, { + direction: `asc`, + nulls: `last`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { sensitivity: `base`, numeric: true }, + }), + ), + ], + [ + `groupBy aggregates and selected orderBy`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.teamId) + .select(({ user }) => ({ + teamId: user.teamId, + userCount: count(user.id), + avgAge: avg(user.age), + totalSalary: sum(user.salary), + latestSignup: max(user.createdAt), + })) + .having(({ $selected }) => gt($selected.userCount, 1)) + .orderBy(({ $selected }) => $selected.avgAge, `desc`), + ), + ], + [ + `join query`, + () => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .join( + { post: postsCollection }, + ({ user, post }) => eq(user.id, post.userId), + `left`, + ) + .where(({ post }) => eq(post.published, true)) + .select(({ user, post }) => ({ + userId: user.id, + postTitle: post.title, + })), + ), + ], + [ + `subquery join`, + () => + getQueryIR( + new Query() + .from({ + post: new Query() + .from({ post: postsCollection }) + .where(({ post }) => gt(post.views, 100)), + }) + .join( + { + activeUser: new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)), + }, + ({ post, activeUser }) => eq(post.userId, activeUser.id), + `inner`, + ), + ), + ], + [ + `unioned source object`, + () => + getQueryIR( + new Query().unionAll({ user: usersCollection, post: postsCollection }), + ), + ], + [ + `unioned query branches`, + () => + getQueryIR( + new Query().unionAll( + new Query().from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + label: user.name, + })), + new Query().from({ post: postsCollection }).select(({ post }) => ({ + id: post.id, + label: post.title, + })), + ), + ), + ], + [ + `includes subquery`, + () => + getQueryIR( + new Query().from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + posts: new Query() + .from({ post: postsCollection }) + .where(({ post }) => eq(post.userId, user.id)) + .select(({ post }) => ({ + id: post.id, + title: post.title, + })), + })), + ), + ], + [ + `pagination shape`, + () => + getQueryIR( + new Query() + .from({ post: postsCollection }) + .where(({ post }) => like(post.title, `%db%`)) + .orderBy(({ post }) => post.createdAt, `desc`) + .offset(20) + .limit(10), + ), + ], +] + +describe(`stable QueryIR identity smoke test`, () => { + it(`can derive identity for representative structured query shapes`, () => { + expect(structuredQueries).toHaveLength(17) + + const hashes = structuredQueries.map(([name, createQuery]) => { + const hash = getStableQueryIRHash(createQuery()) + expect(hash, name).toContain(`"type":"query"`) + expect(() => JSON.parse(hash), name).not.toThrow() + return hash + }) + + expect(new Set(hashes).size).toBe(hashes.length) + }) + + it(`does not depend on collection object identity when ids match`, () => { + const otherUsersCollection = new CollectionImpl({ + id: `users`, + getKey: (item) => item.id, + sync: { sync: () => {} }, + }) + + const createQuery = (collection: CollectionImpl) => + getQueryIR( + new Query() + .from({ user: collection }) + .where(({ user }) => eq(user.status, `active`)), + ) + + expect(getStableQueryIRHash(createQuery(usersCollection))).toBe( + getStableQueryIRHash(createQuery(otherUsersCollection)), + ) + }) + + it(`changes identity when captured structured values change`, () => { + const createQuery = (status: User[`status`]) => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, status)), + ) + + expect(getStableQueryIRHash(createQuery(`active`))).not.toBe( + getStableQueryIRHash(createQuery(`inactive`)), + ) + }) + + it(`normalizes object property ordering inside values`, () => { + const left = getQueryIR( + new Query().from({ user: usersCollection }).where(({ user }) => + eq(user.profile, { + skills: [`ts`, `db`], + experience: { years: 5 }, + }), + ), + ) + + const right = getQueryIR( + new Query().from({ user: usersCollection }).where(({ user }) => + eq(user.profile, { + experience: { years: 5 }, + skills: [`ts`, `db`], + }), + ), + ) + + expect(getStableQueryIRHash(left)).toBe(getStableQueryIRHash(right)) + }) + + it(`keeps runtime values disjoint from internal identity tags`, () => { + const createQuery = (value: unknown) => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.profile, value as never)), + ) + + const hashes = [ + undefined, + { type: `undefined` }, + [`undefined`], + Number.NaN, + { type: `number`, value: `NaN` }, + new Date(`2024-01-01T00:00:00.000Z`), + { type: `Date`, value: `2024-01-01T00:00:00.000Z` }, + ].map((value) => getStableQueryIRHash(createQuery(value))) + + expect(new Set(hashes).size).toBe(hashes.length) + }) + + it(`preserves __proto__ as a normal object key`, () => { + const withProtoKey = JSON.parse(`{"__proto__":{"value":true}}`) as object + const withoutProtoKey = {} + const createQuery = (value: object) => + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.profile, value as never)), + ) + + expect(getStableQueryIRHash(createQuery(withProtoKey))).not.toBe( + getStableQueryIRHash(createQuery(withoutProtoKey)), + ) + }) + + it(`rejects functional query variants`, () => { + const queries = [ + getQueryIR( + new Query() + .from({ user: usersCollection }) + .fn.where(({ user }) => user.active), + ), + getQueryIR( + new Query() + .from({ user: usersCollection }) + .fn.select(({ user }) => ({ id: user.id })), + ), + getQueryIR( + new Query() + .from({ user: usersCollection }) + .groupBy(({ user }) => user.teamId) + .select(({ user }) => ({ + teamId: user.teamId, + userCount: count(user.id), + })) + .fn.having(({ $selected }) => $selected.userCount > 1), + ), + ] + + for (const query of queries) { + expect(() => getStableQueryIRHash(query)).toThrow(UnhashableQueryIRError) + } + }) + + it(`rejects opaque runtime values inside otherwise structured expressions`, () => { + const circularValue: Record = {} + circularValue.self = circularValue + + class OpaqueValue { + value = `Tanner` + } + + const queries = [ + [ + `function value`, + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.name, (() => `Tanner`) as never)), + ), + /function value/, + ], + [ + `symbol value`, + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.name, Symbol(`name`) as never)), + ), + /symbol value/, + ], + [ + `circular value`, + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.profile, circularValue as never)), + ), + /circular value/, + ], + [ + `invalid date`, + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => + eq(user.createdAt, new Date(`invalid`) as never), + ), + ), + /invalid Date/, + ], + [ + `class instance`, + getQueryIR( + new Query() + .from({ user: usersCollection }) + .where(({ user }) => eq(user.name, new OpaqueValue() as never)), + ), + /non-plain object value/, + ], + ] as const + + for (const [name, query, message] of queries) { + expect(() => getStableQueryIRHash(query), name).toThrow( + UnhashableQueryIRError, + ) + expect(() => getStableQueryIRHash(query), name).toThrow(message) + } + }) +}) diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 41fc65a9d8..6cc7cc3ad6 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -1,5 +1,6 @@ import { expect } from 'vitest' import { BTreeIndex } from '../src/indexes/btree-index' +import { withCollectionConfigFactory } from '../src/client' import type { CollectionConfig, MutationFnParams, @@ -219,9 +220,21 @@ type MockSyncCollectionConfig> = { defaultIndexType?: IndexConstructor } +type MockSyncCollectionUtils = { + begin: () => void + write: Parameters[`sync`]>[0][`write`] + commit: () => void + resolveSync: () => void + rejectSync: (error: Error) => void +} + export function mockSyncCollectionOptions< T extends object = Record, ->(config: MockSyncCollectionConfig) { +>( + config: MockSyncCollectionConfig, +): CollectionConfig & { + utils: MockSyncCollectionUtils +} { let begin: () => void let write: Parameters[`sync`]>[0][`write`] let commit: () => void @@ -304,7 +317,9 @@ export function mockSyncCollectionOptions< (config.autoIndex === `eager` ? BTreeIndex : undefined), } - return options + return withCollectionConfigFactory(options, () => + mockSyncCollectionOptions(config), + ) } type MockSyncCollectionConfigNoInitialState = { diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 1b63237cff..9cb6038024 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -6,7 +6,11 @@ import { } from '@electric-sql/client' import { Store } from '@tanstack/store' import DebugModule from 'debug' -import { DeduplicatedLoadSubset, and } from '@tanstack/db' +import { + DeduplicatedLoadSubset, + and, + withCollectionConfigFactory, +} from '@tanstack/db' import { ExpectedNumberInAwaitTxIdError, StreamAbortedError, @@ -87,6 +91,125 @@ export interface ElectricTestHooks { */ export type Txid = number +type ElectricResumeState = + | { + kind: `resume` + offset: string + handle: string + shapeId: string + updatedAt: number + } + | { + kind: `reset` + updatedAt: number + } + +type ElectricSyncMeta = { + version: 1 + resume?: ElectricResumeState + seenTxids: Array +} + +function parseElectricResumeState( + value: unknown, +): ElectricResumeState | undefined { + if (!value || typeof value !== `object`) { + return undefined + } + + const record = value as Record + if ( + record.kind === `resume` && + typeof record.offset === `string` && + typeof record.handle === `string` && + typeof record.shapeId === `string` && + typeof record.updatedAt === `number` && + Number.isFinite(record.updatedAt) + ) { + return { + kind: `resume`, + offset: record.offset, + handle: record.handle, + shapeId: record.shapeId, + updatedAt: record.updatedAt, + } + } + + if ( + record.kind === `reset` && + typeof record.updatedAt === `number` && + Number.isFinite(record.updatedAt) + ) { + return { + kind: `reset`, + updatedAt: record.updatedAt, + } + } + + return undefined +} + +function parseElectricSyncMeta(value: unknown): ElectricSyncMeta | undefined { + if (!value || typeof value !== `object`) { + return undefined + } + + const record = value as Record + if ( + record.version !== 1 || + !Array.isArray(record.seenTxids) || + !record.seenTxids.every( + (txid) => typeof txid === `number` && Number.isFinite(txid), + ) + ) { + return undefined + } + + const resume = + record.resume === undefined + ? undefined + : parseElectricResumeState(record.resume) + if (record.resume !== undefined && resume === undefined) { + return undefined + } + + return { + version: 1, + ...(resume ? { resume } : {}), + seenTxids: Array.from(new Set(record.seenTxids)).sort((a, b) => a - b), + } +} + +function mergeElectricSyncMeta( + current: unknown, + incoming: unknown, +): ElectricSyncMeta | unknown { + const currentMeta = parseElectricSyncMeta(current) + const incomingMeta = parseElectricSyncMeta(incoming) + + if (!incomingMeta) { + return current + } + if (!currentMeta) { + return incomingMeta + } + + const resume = + !currentMeta.resume || + (incomingMeta.resume && + incomingMeta.resume.updatedAt >= currentMeta.resume.updatedAt) + ? incomingMeta.resume + : currentMeta.resume + + return { + version: 1, + ...(resume ? { resume } : {}), + seenTxids: Array.from( + new Set([...currentMeta.seenTxids, ...incomingMeta.seenTxids]), + ).sort((a, b) => a - b), + } +} + /** * Custom match function type - receives stream messages and returns boolean * indicating if the mutation has been synchronized @@ -633,6 +756,9 @@ export function electricCollectionOptions>( } { const seenTxids = new Store>(new Set([])) const seenSnapshots = new Store>([]) + const hydratedResumeState = new Store( + undefined, + ) const internalSyncMode = config.syncMode ?? `eager` const finalSyncMode = internalSyncMode === `progressive` ? `on-demand` : internalSyncMode @@ -690,6 +816,7 @@ export function electricCollectionOptions>( const sync = createElectricSync(config.shapeOptions, { seenTxids, seenSnapshots, + hydratedResumeState, syncMode: internalSyncMode, pendingMatches, currentBatchMessages, @@ -941,10 +1068,29 @@ export function electricCollectionOptions>( ...restConfig } = config - return { + const options = { ...restConfig, syncMode: finalSyncMode, - sync, + sync: { + ...sync, + exportSyncMeta: (): ElectricSyncMeta => ({ + version: 1, + ...(hydratedResumeState.state + ? { resume: hydratedResumeState.state } + : {}), + seenTxids: Array.from(seenTxids.state).sort((a, b) => a - b), + }), + importSyncMeta: (meta: unknown): void => { + const parsed = parseElectricSyncMeta(meta) + if (!parsed) { + return + } + + hydratedResumeState.setState(() => parsed.resume) + seenTxids.setState(() => new Set(parsed.seenTxids)) + }, + mergeSyncMeta: mergeElectricSyncMeta, + }, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, @@ -953,6 +1099,14 @@ export function electricCollectionOptions>( awaitMatch, }, } + + return withCollectionConfigFactory(options, () => + ( + electricCollectionOptions as ( + nextConfig: ElectricCollectionConfig, + ) => typeof options + )(config), + ) } /** @@ -964,6 +1118,7 @@ function createElectricSync>( syncMode: ElectricSyncMode seenTxids: Store> seenSnapshots: Store> + hydratedResumeState: Store pendingMatches: Store< Map< string, @@ -987,6 +1142,7 @@ function createElectricSync>( const { seenTxids, seenSnapshots, + hydratedResumeState, syncMode, pendingMatches, currentBatchMessages, @@ -1319,40 +1475,13 @@ function createElectricSync>( collection, metadata, } = params - const readPersistedResumeState = () => { + const readPersistedResumeState = (): ElectricResumeState | undefined => { const persistedResumeState = metadata?.collection.get(`electric:resume`) - if (!persistedResumeState || typeof persistedResumeState !== `object`) { - return undefined - } - - const record = persistedResumeState as Record - if ( - record.kind === `resume` && - typeof record.offset === `string` && - typeof record.handle === `string` && - typeof record.shapeId === `string` && - typeof record.updatedAt === `number` - ) { - return { - kind: `resume` as const, - offset: record.offset, - handle: record.handle, - shapeId: record.shapeId, - updatedAt: record.updatedAt, - } - } - - if (record.kind === `reset` && typeof record.updatedAt === `number`) { - return { - kind: `reset` as const, - updatedAt: record.updatedAt, - } - } - - return undefined + return parseElectricResumeState(persistedResumeState) } - const persistedResumeState = readPersistedResumeState() + const persistedResumeState = + hydratedResumeState.state ?? readPersistedResumeState() const shapeIdentity = getStableShapeIdentity({ url: shapeOptions.url, params: shapeOptions.params as Record | undefined, @@ -1476,35 +1605,35 @@ function createElectricSync>( const syncedKeys = new Set() const stageResumeMetadata = () => { - if (!metadata) { - return - } const shapeHandle = stream.shapeHandle const lastOffset = stream.lastOffset if (!shapeHandle || lastOffset === `-1`) { return } - metadata.collection.set(`electric:resume`, { + const resumeState: ElectricResumeState = { kind: `resume`, offset: lastOffset, handle: shapeHandle, shapeId: shapeIdentity, updatedAt: Date.now(), - }) + } + hydratedResumeState.setState(() => resumeState) + metadata?.collection.set(`electric:resume`, resumeState) } const commitResetResumeMetadataImmediately = () => { - if (!metadata) { - return - } - - begin({ immediate: true }) - metadata.collection.set(`electric:resume`, { + const resetState: ElectricResumeState = { kind: `reset`, updatedAt: Date.now(), - }) - commit() + } + hydratedResumeState.setState(() => resetState) + + if (metadata) { + begin({ immediate: true }) + metadata.collection.set(`electric:resume`, resetState) + commit() + } } if (hasIncompatiblePersistedResume) { diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index c48121a9c1..c8ae7f18d0 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ShapeStream } from '@electric-sql/client' import { CollectionImpl, createCollection, @@ -442,6 +443,100 @@ describe(`Electric Integration`, () => { await expect(collection.utils.awaitTxId(txid2)).resolves.not.toThrow() }) + it(`exports and imports versioned hydration sync metadata`, async () => { + mockStream.shapeHandle = `shape-handle` + mockStream.lastOffset = `42_0` + + subscriber([ + { + key: `1`, + value: { id: 1, name: `Test User` }, + headers: { + operation: `insert`, + txids: [100, 200], + }, + }, + { + headers: { control: `up-to-date` }, + }, + ]) + + const exported = collection.config.sync.exportSyncMeta?.() + expect(exported).toMatchObject({ + version: 1, + resume: { + kind: `resume`, + offset: `42_0`, + handle: `shape-handle`, + }, + seenTxids: [100, 200], + }) + + const resumedOptions = electricCollectionOptions({ + id: `resumed`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + startSync: false, + getKey: (item) => item.id as number, + }) + + const merged = resumedOptions.sync.mergeSyncMeta?.( + { + version: 1, + seenTxids: [50], + }, + exported, + ) + resumedOptions.sync.importSyncMeta?.(merged) + + await expect(resumedOptions.utils.awaitTxId(50)).resolves.toBe(true) + await expect(resumedOptions.utils.awaitTxId(200)).resolves.toBe(true) + + const resumedCollection = createCollection({ + ...resumedOptions, + startSync: true, + }) + + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: `42_0`, + handle: `shape-handle`, + }) + + await resumedCollection.cleanup() + }) + + it(`ignores non-finite hydration sync metadata`, () => { + const options = electricCollectionOptions({ + id: `invalid-hydration-sync-meta`, + shapeOptions: { + url: `http://test-url`, + params: { + table: `test_table`, + }, + }, + startSync: false, + getKey: (item) => item.id as number, + }) + + options.sync.importSyncMeta?.({ + version: 1, + resume: { + kind: `reset`, + updatedAt: Number.POSITIVE_INFINITY, + }, + seenTxids: [Number.NaN], + }) + + expect(options.sync.exportSyncMeta?.()).toEqual({ + version: 1, + seenTxids: [], + }) + }) + it(`should reject with timeout when waiting for unknown txid`, async () => { // Set a short timeout for the test const unknownTxid = 0 diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index c11c1d2698..7b42714001 100644 --- a/packages/powersync-db-collection/src/powersync.ts +++ b/packages/powersync-db-collection/src/powersync.ts @@ -1,5 +1,5 @@ import { DiffTriggerOperation, sanitizeSQL } from '@powersync/common' -import { or } from '@tanstack/db' +import { or, withCollectionConfigFactory } from '@tanstack/db' import { compileSQLite } from './sqlite-compiler' import { PendingOperationStore } from './PendingOperationStore' import { PowerSyncTransactor } from './PowerSyncTransactor' @@ -226,6 +226,16 @@ export function powerSyncCollectionOptions< export function powerSyncCollectionOptions< TTable extends Table, TSchema extends StandardSchemaV1 = never, +>(config: PowerSyncCollectionConfig): unknown { + const outputConfig = createPowerSyncCollectionConfig(config) + return withCollectionConfigFactory(outputConfig, () => + createPowerSyncCollectionConfig(config), + ) +} + +function createPowerSyncCollectionConfig< + TTable extends Table, + TSchema extends StandardSchemaV1 = never, >(config: PowerSyncCollectionConfig) { const { database, diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 82f2a8dde5..7632e537e4 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1,5 +1,5 @@ import { QueryObserver, hashKey } from '@tanstack/query-core' -import { deepEquals } from '@tanstack/db' +import { deepEquals, withCollectionConfigFactory } from '@tanstack/db' import { GetKeyRequiredError, InitialDataInOnDemandModeError, @@ -2176,7 +2176,7 @@ export function queryCollectionOptions( // Create utils instance with state and dependencies passed explicitly const utils: any = new QueryCollectionUtilsImpl(state, refetch, writeUtils) - return { + const options = { ...baseCollectionConfig, getKey, syncMode, @@ -2186,4 +2186,16 @@ export function queryCollectionOptions( onDelete: wrappedOnDelete, utils, } + + return withCollectionConfigFactory( + options, + (client) => + queryCollectionOptions({ + ...config, + queryClient: + client.getDependency(`queryClient`) ?? + config.queryClient, + id: options.id, + }) as typeof options, + ) } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index a4e4350d05..d400c4357d 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -8,6 +8,8 @@ import { } from '@tanstack/query-core' import { BTreeIndex, + DbClient, + collectionOptions, createCollection, createLiveQueryCollection, eq, @@ -218,6 +220,66 @@ describe(`QueryCollection`, () => { }) }) + it(`materializes against each DbClient QueryClient dependency`, async () => { + const constructionClient = new QueryClient() + const queryClientA = new QueryClient() + const queryClientB = new QueryClient() + const queryKey = [`db-client-query-dependency`] as const + const descriptor = collectionOptions( + queryCollectionOptions({ + id: `db-client-query-dependency`, + queryClient: constructionClient, + queryKey, + queryFn: async () => [{ id: `1`, name: `Item` }], + getKey, + }), + ) + const dbClientA = new DbClient({ queryClient: queryClientA }) + const dbClientB = new DbClient({ queryClient: queryClientB }) + const collectionA = dbClientA.collection(descriptor) + const collectionB = dbClientB.collection(descriptor) + + await Promise.all([collectionA.preload(), collectionB.preload()]) + + expect(queryClientA.getQueryData(queryKey)).toEqual([ + { id: `1`, name: `Item` }, + ]) + expect(queryClientB.getQueryData(queryKey)).toEqual([ + { id: `1`, name: `Item` }, + ]) + expect(constructionClient.getQueryData(queryKey)).toBeUndefined() + + await Promise.all([dbClientA.cleanup(), dbClientB.cleanup()]) + constructionClient.clear() + queryClientA.clear() + queryClientB.clear() + }) + + it(`uses the configured QueryClient when DbClient has no override`, async () => { + const constructionClient = new QueryClient() + const queryKey = [`db-client-query-fallback`] as const + const descriptor = collectionOptions( + queryCollectionOptions({ + id: `db-client-query-fallback`, + queryClient: constructionClient, + queryKey, + queryFn: async () => [{ id: `1`, name: `Item` }], + getKey, + }), + ) + const dbClient = new DbClient() + const collection = dbClient.collection(descriptor) + + await collection.preload() + + expect(constructionClient.getQueryData(queryKey)).toEqual([ + { id: `1`, name: `Item` }, + ]) + + await dbClient.cleanup() + constructionClient.clear() + }) + afterEach(() => { // Ensure all queries are properly cleaned up after each test queryClient.clear() diff --git a/packages/react-db/README.md b/packages/react-db/README.md index 12c86adeff..0e136dfea7 100644 --- a/packages/react-db/README.md +++ b/packages/react-db/README.md @@ -1,3 +1,15 @@ # @tanstack/react-db React hooks for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details. + +```tsx +import { useLiveQuery } from '@tanstack/react-db' + +function TodoList() { + const { data: todos } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) + + return todos.map((todo) =>
              {todo.text}
              ) +} +``` diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index fb864ac8f7..eda3577c3f 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -1,9 +1,10 @@ --- name: react-db description: > - React bindings for TanStack DB. useLiveQuery hook with dependency arrays - (8 overloads: query function, config object, pre-created collection, - disabled state via returning undefined/null). useLiveSuspenseQuery for + React bindings for TanStack DB. Prefer useLiveQuery({ query }) with + derived structured query identity. Provide queryKey only for opaque + functional query variants or very hot render paths. Dependency arrays are + legacy and warn before 1.0 removal. useLiveSuspenseQuery for React Suspense with Error Boundaries (data always defined). useLiveInfiniteQuery for cursor-based pagination (pageSize, fetchNextPage, hasNextPage, isFetchingNextPage). usePacedMutations for debounced React @@ -30,15 +31,16 @@ This skill builds on db-core. Read it first for collection setup, query builder, ## Setup ```tsx -import { useLiveQuery, eq, not } from '@tanstack/react-db' +import { eq, not, useLiveQuery } from '@tanstack/react-db' function TodoList() { - const { data: todos, isLoading } = useLiveQuery((q) => - q - .from({ todo: todoCollection }) - .where(({ todo }) => not(todo.completed)) - .orderBy(({ todo }) => todo.created_at, 'asc'), - ) + const { data: todos, isLoading } = useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => not(todo.completed)) + .orderBy(({ todo }) => todo.created_at, 'asc'), + }) if (isLoading) return
              Loading...
              @@ -59,7 +61,7 @@ function TodoList() { ### useLiveQuery ```tsx -// Query function with dependency array +// Preferred config object with derived query identity const { data, state, @@ -70,15 +72,14 @@ const { isError, isIdle, isCleanedUp, -} = useLiveQuery( - (q) => +} = useLiveQuery({ + query: (q) => q .from({ todo: todoCollection }) .where(({ todo }) => eq(todo.userId, userId)), - [userId], -) +}) -// Config object +// Static query const { data } = useLiveQuery({ query: (q) => q.from({ todo: todoCollection }), gcTime: 60000, @@ -87,16 +88,13 @@ const { data } = useLiveQuery({ // Pre-created collection (from route loader) const { data } = useLiveQuery(preloadedCollection) -// Conditional query — return undefined/null to disable -const { data, status } = useLiveQuery( - (q) => { - if (!userId) return undefined - return q - .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.userId, userId)) - }, - [userId], -) +// Conditional query — derived identity handles enabled/disabled transitions +const { data, status } = useLiveQuery((q) => { + if (!userId) return undefined + return q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.userId, userId)) +}) // When disabled: status='disabled', data=undefined ``` @@ -106,9 +104,9 @@ const { data, status } = useLiveQuery( // data is ALWAYS defined — never undefined // Must wrap in and function TodoList() { - const { data: todos } = useLiveSuspenseQuery((q) => - q.from({ todo: todoCollection }), - ) + const { data: todos } = useLiveSuspenseQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) return (
                @@ -119,14 +117,13 @@ function TodoList() { ) } -// With deps — re-suspends when deps change -const { data } = useLiveSuspenseQuery( - (q) => +// Structured captured values are part of the derived identity and re-suspend when changed +const { data } = useLiveSuspenseQuery({ + query: (q) => q .from({ todo: todoCollection }) .where(({ todo }) => eq(todo.category, category)), - [category], -) +}) ``` ### useLiveInfiniteQuery @@ -137,9 +134,11 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = (q) => q .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) .orderBy(({ posts }) => posts.createdAt, 'desc'), - { pageSize: 20 }, - [category], + { + pageSize: 20, + }, ) // data is the flat array of all loaded pages @@ -174,16 +173,17 @@ When a query uses includes (subqueries in `select`), each child field is a live ```tsx 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 (
                  @@ -217,18 +217,19 @@ With `toArray()`, child results are plain arrays and the parent re-renders on ch ```tsx import { toArray, eq } from '@tanstack/react-db' -const { data: projects } = useLiveQuery((q) => - q.from({ p: projectsCollection }).select(({ p }) => ({ - id: p.id, - name: p.name, - issues: toArray( - 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: toArray( + q + .from({ i: issuesCollection }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ id: i.id, title: i.title })), + ), + })), +}) // project.issues is string[] — no subcomponent needed ``` @@ -246,38 +247,54 @@ Live query results include computed, read-only virtual properties on every row: These props are added automatically and can be used in `where`, `select`, and `orderBy` clauses. Do not persist them back to storage. ```tsx -const { data } = useLiveQuery( - (q) => +const { data } = useLiveQuery({ + query: (q) => q .from({ todo: todoCollection }) .where(({ todo }) => eq(todo.$synced, false)), - [], -) +}) // Shows only optimistic (unconfirmed) todos ``` ## React-Specific Patterns -### Dependency arrays +### Query identity ```tsx -// Include ALL external reactive values -const { data } = useLiveQuery( - (q) => +// Structured captured values are included in the derived identity +const { data } = useLiveQuery({ + query: (q) => q .from({ todo: todoCollection }) .where(({ todo }) => and(eq(todo.userId, userId), eq(todo.status, filter)), ), - [userId, filter], -) +}) + +// Static query +const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), +}) +``` -// Empty array = static query, never re-runs -const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), []) +Use `queryKey` only when DB cannot derive identity from structured IR, such as +`.fn.where`, `.fn.select`, `.fn.having`, or as a deliberate performance escape +hatch on a hot render path: -// No array = re-runs on every render (usually wrong) +```tsx +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q.from({ todo: todoCollection }).fn.where(({ todo }) => { + return fuzzyMatch(todo.title, search) + }), +}) ``` +Before 1.0, opaque IR warns and keeps legacy mount-stable identity. Slow or +repeated derived identity work also warns once. Both point to the same +`queryKey` escape hatch; unhashable IR without a key will throw in 1.0. + ### Suspense + Error Boundary ```tsx @@ -295,36 +312,42 @@ const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), []) await todoCollection.preload() // In component — data available immediately: -const { data } = useLiveQuery((q) => q.from({ todo: todoCollection })) +const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), +}) ``` See meta-framework/SKILL.md for full preloading patterns. ## Common Mistakes -### CRITICAL Missing external values in dependency array +### CRITICAL Using opaque query logic without queryKey Wrong: ```tsx -const { data } = useLiveQuery((q) => - q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)), -) +const { data } = useLiveQuery({ + query: (q) => + q.from({ todo: todoCollection }).fn.where(({ todo }) => { + return fuzzyMatch(todo.title, search) + }), +}) ``` Correct: ```tsx -const { data } = useLiveQuery( - (q) => - q - .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.userId, userId)), - [userId], -) +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q.from({ todo: todoCollection }).fn.where(({ todo }) => { + return fuzzyMatch(todo.title, search) + }), +}) ``` -When the query uses external state not in the deps array, the query won't re-run when that value changes, showing stale results. +Structured expressions are hashable by default. Functional query variants are +opaque runtime code, so they need an explicit key to say when identity changes. Source: docs/framework/react/overview.md diff --git a/packages/react-db/src/DbProvider.tsx b/packages/react-db/src/DbProvider.tsx new file mode 100644 index 0000000000..b048844563 --- /dev/null +++ b/packages/react-db/src/DbProvider.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react' +import type { DbClient } from '@tanstack/db' +import type { ReactNode } from 'react' + +const DbContext = createContext(undefined) + +export type DbProviderProps = { + client: DbClient + children?: ReactNode +} + +export function DbProvider(props: DbProviderProps) { + return ( + + {props.children} + + ) +} + +export function useDbClient(): DbClient { + const client = useContext(DbContext) + if (!client) { + throw new Error(`useDbClient must be used within a DbProvider.`) + } + return client +} + +export function useOptionalDbClient(): DbClient | undefined { + return useContext(DbContext) +} diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts index 96db7e2796..4683db0583 100644 --- a/packages/react-db/src/index.ts +++ b/packages/react-db/src/index.ts @@ -1,5 +1,6 @@ // Re-export all public APIs export * from './useLiveQuery' +export * from './DbProvider' export * from './useLiveSuspenseQuery' export * from './usePacedMutations' export * from './useLiveInfiniteQuery' diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 99c77c7397..82c33e76be 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CollectionImpl } from '@tanstack/db' import { useLiveQuery } from './useLiveQuery' +import type { LiveQueryKey } from './useLiveQuery' import type { Collection, Context, @@ -21,6 +22,12 @@ function isLiveQueryCollectionUtils( } export type UseLiveInfiniteQueryConfig = { + /** + * Explicit identity for queries that contain opaque functional variants or + * are hot enough that deriving identity from structured IR is too expensive. + * Structured queries should omit this so DB can derive identity directly. + */ + queryKey?: LiveQueryKey pageSize?: number initialPageParam?: number /** @@ -56,7 +63,7 @@ export type UseLiveInfiniteQueryReturn = Omit< * * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work. * @param config - Configuration including pageSize and getNextPageParam - * @param deps - Array of dependencies that trigger query re-execution when changed + * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with pages, data, and pagination controls * * @example @@ -77,7 +84,7 @@ export type UseLiveInfiniteQueryReturn = Omit< * ) * * @example - * // With dependencies + * // With values that recreate the query * const { pages, fetchNextPage } = useLiveInfiniteQuery( * (q) => q * .from({ posts: postsCollection }) @@ -87,8 +94,7 @@ export type UseLiveInfiniteQueryReturn = Omit< * pageSize: 10, * getNextPageParam: (lastPage) => * lastPage.length === 10 ? lastPage.length : undefined - * }, - * [category] + * } * ) * * @example @@ -135,10 +141,11 @@ export function useLiveInfiniteQuery( export function useLiveInfiniteQuery( queryFnOrCollection: any, config: UseLiveInfiniteQueryConfig, - deps: Array = [], + deps?: Array, ): UseLiveInfiniteQueryReturn { const pageSize = config.pageSize || 20 const initialPageParam = config.initialPageParam ?? 0 + const identityDeps = config.queryKey ?? deps ?? [] // Detect if input is a collection or query function const isCollection = queryFnOrCollection instanceof CollectionImpl @@ -159,18 +166,45 @@ export function useLiveInfiniteQuery( const collectionRef = useRef(isCollection ? queryFnOrCollection : null) const hasValidatedCollectionRef = useRef(false) - // Track deps for query functions (stringify for comparison) + // Track query identity for query functions (stringify for comparison) let depsKey: string try { - depsKey = JSON.stringify(deps) + depsKey = JSON.stringify(identityDeps) } catch { throw new Error( - `useLiveInfiniteQuery: dependency array contains values that cannot be serialized (e.g. circular references). ` + - `Ensure all dependency values are JSON-serializable.`, + `useLiveInfiniteQuery: queryKey/dependency array contains values that cannot be serialized (e.g. circular references). ` + + `Ensure all identity values are JSON-serializable.`, ) } const prevDepsKeyRef = useRef(depsKey) + // Create a live query with initial limit and offset + // Either pass collection directly or wrap query function + // Use pageSize + 1 for peek-ahead detection (to know if there are more pages) + const queryResult = isCollection + ? useLiveQuery(queryFnOrCollection) + : config.queryKey + ? useLiveQuery({ + queryKey: config.queryKey, + query: (q) => + queryFnOrCollection(q) + .limit(pageSize + 1) + .offset(0), + }) + : deps === undefined + ? useLiveQuery((q) => + queryFnOrCollection(q) + .limit(pageSize + 1) + .offset(0), + ) + : useLiveQuery( + (q) => + queryFnOrCollection(q) + .limit(pageSize + 1) + .offset(0), + deps, + ) + // Reset pagination when inputs change useEffect(() => { let shouldReset = false @@ -183,30 +217,23 @@ export function useLiveInfiniteQuery( shouldReset = true } } else { - // Reset if deps changed (for query functions) + // Reset if explicit identity changed if (prevDepsKeyRef.current !== depsKey) { prevDepsKeyRef.current = depsKey shouldReset = true } + + // Reset if derived query identity changed and useLiveQuery rebuilt the collection + if (collectionRef.current !== queryResult.collection) { + collectionRef.current = queryResult.collection + shouldReset = true + } } if (shouldReset) { setLoadedPageCount(1) } - }, [isCollection, queryFnOrCollection, depsKey]) - - // Create a live query with initial limit and offset - // Either pass collection directly or wrap query function - // Use pageSize + 1 for peek-ahead detection (to know if there are more pages) - const queryResult = isCollection - ? useLiveQuery(queryFnOrCollection) - : useLiveQuery( - (q) => - queryFnOrCollection(q) - .limit(pageSize + 1) - .offset(0), - deps, - ) + }, [isCollection, queryFnOrCollection, depsKey, queryResult.collection]) // Adjust window when pagination changes useEffect(() => { diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 8dc3d0a31b..c3df3ccc73 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,15 +1,22 @@ import { useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, + UnhashableQueryIRError, createLiveQueryCollection, + deepEquals, getLiveQueryStatusFlags, + getStableQueryBuilderHash, isCollection, isSingleResultCollection, } from '@tanstack/db' +import { useOptionalDbClient } from './DbProvider' import type { Collection, + CollectionImpl, + CollectionOptions, CollectionStatus, Context, + DbClient, GetResult, InferResultType, InitialQueryBuilder, @@ -20,57 +27,362 @@ import type { } from '@tanstack/db' const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) +const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16 +const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10 +const DERIVED_IDENTITY_TOTAL_WARN_MS = 50 +const warnedDepsCallsites = new Set() +const warnedDerivedIdentityCallsites = new Set() +const warnedUnhashableIdentityCallsites = new Set() +const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) + +type DerivedIdentityProfiler = { + renderCount: number + totalMs: number + maxMs: number + warned: boolean +} export type UseLiveQueryStatus = CollectionStatus | `disabled` +export type LiveQueryKey = ReadonlyArray +export type UseLiveQueryConfig = + LiveQueryCollectionConfig & { + /** + * Explicit identity for queries that contain opaque functional variants or + * are hot enough that deriving identity from structured IR is too expensive. + * Structured queries should omit this so DB can derive identity directly. + */ + queryKey?: LiveQueryKey + /** Override the nearest DbProvider for this query. */ + client?: DbClient + } + +function warnDeprecatedDepsArray(): void { + if (!shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_DEPRECATION_WARNINGS`)) { + return + } + + const callsite = getWarningCallsite(4) + if (warnedDepsCallsites.has(callsite)) { + return + } + warnedDepsCallsites.add(callsite) + console.warn( + `[useLiveQuery] The dependency-array form useLiveQuery(query, deps) is deprecated and will be removed in 1.0. Use useLiveQuery({ query }) instead. Provide queryKey only for functional/opaque queries or to avoid deriving identity from structured query IR on render.`, + ) +} + +function shouldWarnInDevelopment(disableEnvVar: string): boolean { + if (typeof process === `undefined`) { + return false + } + + return ( + process.env.NODE_ENV !== `production` && process.env[disableEnvVar] !== `1` + ) +} + +function getCurrentTime(): number { + return typeof performance !== `undefined` && + typeof performance.now === `function` + ? performance.now() + : Date.now() +} + +function getWarningCallsite(stackIndex: number): string { + const stack = new Error().stack ?? `unknown` + return stack.split(`\n`)[stackIndex]?.trim() ?? stack +} + +function warnDerivedIdentityHotPath( + profiler: DerivedIdentityProfiler, + durationMs: number, +): void { + if ( + profiler.warned || + !shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) + ) { + return + } + + const isSlowSingleRender = + durationMs >= DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS + const isHotRenderPath = + profiler.renderCount >= DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD && + profiler.totalMs >= DERIVED_IDENTITY_TOTAL_WARN_MS + + if (!isSlowSingleRender && !isHotRenderPath) { + return + } + + const callsite = getWarningCallsite(5) + if (warnedDerivedIdentityCallsites.has(callsite)) { + profiler.warned = true + return + } + + warnedDerivedIdentityCallsites.add(callsite) + profiler.warned = true + + const reason = isSlowSingleRender + ? `one render took ${durationMs.toFixed(1)}ms` + : `${profiler.renderCount} renders took ${profiler.totalMs.toFixed(1)}ms` + + console.warn( + `[useLiveQuery] Deriving live query identity from structured query IR is running on a hot render path (${reason}, max ${profiler.maxMs.toFixed(1)}ms). ` + + `Provide an explicit queryKey to skip rebuilding and hashing the IR on every render: useLiveQuery({ queryKey: [...], query }).`, + ) +} + +function createInitialQueryBuilder( + dbClient: DbClient | undefined, + deferredCollections: Set>, +) { + return new BaseQueryBuilder( + {}, + dbClient + ? (options: CollectionOptions) => { + const collection = dbClient._materializeCollectionForRender( + options as any, + ) as CollectionImpl + if (collection._deferSyncStart()) { + deferredCollections.add(collection) + } + return collection + } + : undefined, + ) as InitialQueryBuilder +} + +function getExplicitQueryKey(value: unknown): LiveQueryKey | undefined { + return value && + typeof value === `object` && + Array.isArray((value as { queryKey?: unknown }).queryKey) + ? (value as { queryKey: LiveQueryKey }).queryKey + : undefined +} + +function getExplicitDbClient(value: unknown): DbClient | undefined { + return value && + typeof value === `object` && + `client` in value && + (value as { client?: unknown }).client !== undefined + ? (value as { client: DbClient }).client + : undefined +} + +function prepareQueryValue( + value: unknown, + dbClient: DbClient | undefined, + deferredCollections: Set>, +): unknown { + if (typeof value === `function`) { + return prepareQueryValue( + 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 +} + +type DerivedQueryPreparation = + | { + status: `hashable` + value: unknown + identityDeps: Array + } + | { + status: `unhashable` + value: unknown + error: UnhashableQueryIRError + } + +function prepareDerivedQuery( + value: unknown, + dbClient: DbClient | undefined, + profiler: DerivedIdentityProfiler, + deferredCollections: Set>, +): DerivedQueryPreparation { + const shouldProfile = shouldWarnInDevelopment( + `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, + ) + const start = shouldProfile ? getCurrentTime() : 0 + const preparedValue = prepareQueryValue(value, dbClient, deferredCollections) + + try { + const identity = getPreparedQueryResultIdentity(preparedValue) + return { + status: `hashable`, + value: preparedValue, + identityDeps: [`derived`, identity], + } + } catch (error) { + if (error instanceof UnhashableQueryIRError) { + return { status: `unhashable`, value: preparedValue, error } + } + + throw error + } finally { + if (shouldProfile) { + const durationMs = getCurrentTime() - start + profiler.renderCount += 1 + profiler.totalMs += durationMs + profiler.maxMs = Math.max(profiler.maxMs, durationMs) + warnDerivedIdentityHotPath(profiler, durationMs) + } + } +} + +function derivePreparedQueryIdentity(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`, derivePreparedQueryIdentity(config.query)] + } + + return [`value`, value] +} + +function warnUnhashableDerivedIdentity(error: UnhashableQueryIRError): void { + if (!shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`)) { + return + } + + const callsite = getWarningCallsite(4) + if (warnedUnhashableIdentityCallsites.has(callsite)) { + return + } + warnedUnhashableIdentityCallsites.add(callsite) + + console.warn( + `[useLiveQuery] This query cannot derive a stable identity because ${error.reason} at ${error.path}. ` + + `It will keep the legacy mount-stable behavior for now. Add queryKey: [...] to make captured values reactive. ` + + `Unhashable queries without queryKey will throw in 1.0.`, + ) +} + +function createCollectionFromPreparedQuery(value: unknown) { + if (value === undefined || value === null) { + return null + } + + if (isCollection(value)) { + value.startSyncImmediate() + return value + } + + if (value instanceof BaseQueryBuilder) { + return createLiveQueryCollection({ + query: value, + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + }) + } + + if (typeof value === `object`) { + return createLiveQueryCollection({ + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + ...(value as LiveQueryCollectionConfig), + }) + } + + throw new Error( + `useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof value}`, + ) +} + +function getPreparedQueryResultIdentity(result: unknown): unknown { + if (result === undefined || result === null) { + return [`disabled`] + } + + return derivePreparedQueryIdentity(result) +} /** - * Create a live query using a query function + * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch - * @param deps - Array of dependencies that trigger query re-execution when changed + * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data, state, and status information * @example - * // Basic query with object syntax - * const { data, isLoading } = useLiveQuery((q) => - * q.from({ todos: todosCollection }) - * .where(({ todos }) => eq(todos.completed, false)) - * .select(({ todos }) => ({ id: todos.id, text: todos.text })) - * ) + * // Prefer config object syntax + * const { data, isLoading } = useLiveQuery({ + * query: (q) => + * q.from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.completed, false)) + * .select(({ todos }) => ({ id: todos.id, text: todos.text })) + * }) * * @example * // Single result query - * const { data } = useLiveQuery( - * (q) => q.from({ todos: todosCollection }) + * const { data } = useLiveQuery({ + * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => eq(todos.id, 1)) * .findOne() - * ) + * }) * * @example - * // With dependencies that trigger re-execution - * const { data, state } = useLiveQuery( - * (q) => q.from({ todos: todosCollection }) + * // Structured captured values are included in derived query identity + * const { data, state } = useLiveQuery({ + * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => gt(todos.priority, minPriority)), - * [minPriority] // Re-run when minPriority changes - * ) + * }) * * @example * // Join pattern - * const { data } = useLiveQuery((q) => - * q.from({ issues: issueCollection }) - * .join({ persons: personCollection }, ({ issues, persons }) => - * eq(issues.userId, persons.id) - * ) - * .select(({ issues, persons }) => ({ - * id: issues.id, - * title: issues.title, - * userName: persons.name - * })) - * ) + * const { data } = useLiveQuery({ + * query: (q) => + * q.from({ issues: issueCollection }) + * .join({ persons: personCollection }, ({ issues, persons }) => + * eq(issues.userId, persons.id) + * ) + * .select(({ issues, persons }) => ({ + * id: issues.id, + * title: issues.title, + * userName: persons.name + * })) + * }) * * @example * // Handle loading and error states - * const { data, isLoading, isError, status } = useLiveQuery((q) => - * q.from({ todos: todoCollection }) - * ) + * const { data, isLoading, isError, status } = useLiveQuery({ + * query: (q) => q.from({ todos: todoCollection }) + * }) * * if (isLoading) return
                  Loading...
                  * if (isError) return
                  Error: {status}
                  @@ -197,7 +509,7 @@ export function useLiveQuery< /** * Create a live query using configuration object * @param config - Configuration object with query and options - * @param deps - Array of dependencies that trigger query re-execution when changed + * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data, state, and status information * @example * // Basic config object usage @@ -213,7 +525,9 @@ export function useLiveQuery< * .where(({ persons }) => gt(persons.age, 30)) * .select(({ persons }) => ({ id: persons.id, name: persons.name })) * - * const { data, isReady } = useLiveQuery({ query: queryBuilder }) + * const { data, isReady } = useLiveQuery({ + * query: queryBuilder, + * }) * * @example * // Handle all states uniformly @@ -228,6 +542,22 @@ export function useLiveQuery< * return
                  {data.length} items loaded
                  */ // Overload 6: Accept config object +export function useLiveQuery( + config: UseLiveQueryConfig, +): { + state: Map> + data: InferResultType + collection: Collection, string | number, {}> + status: CollectionStatus // Can't be disabled for config objects + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: true // Always true for config objects +} + +// Overload 7: Accept config object with legacy deps export function useLiveQuery( config: LiveQueryCollectionConfig, deps?: Array, @@ -273,7 +603,7 @@ export function useLiveQuery( * * return
                  {data.map(item => )}
                  */ -// Overload 7: Accept pre-created live query collection +// Overload 8: Accept pre-created live query collection export function useLiveQuery< TResult extends object, TKey extends string | number, @@ -293,7 +623,7 @@ export function useLiveQuery< isEnabled: true // Always true for pre-created live query collections } -// Overload 8: Accept pre-created live query collection with singleResult: true +// Overload 9: Accept pre-created live query collection with singleResult: true export function useLiveQuery< TResult extends object, TKey extends string | number, @@ -316,10 +646,15 @@ export function useLiveQuery< // Implementation - use function overloads to infer the actual collection type export function useLiveQuery( configOrQueryOrCollection: any, - deps: Array = [], + deps?: Array, ) { + const contextDbClient = useOptionalDbClient() // Check if it's already a collection const inputIsCollection = isCollection(configOrQueryOrCollection) + const dbClient = inputIsCollection + ? contextDbClient + : (getExplicitDbClient(configOrQueryOrCollection) ?? contextDbClient) + const resolvedDeps = deps ?? [] // Use refs to cache collection and track dependencies const collectionRef = useRef | null>( @@ -327,6 +662,10 @@ export function useLiveQuery( ) const depsRef = useRef | null>(null) const configRef = useRef(null) + const clientRef = useRef(dbClient) + const legacyUnhashableIdentityRef = useRef>([ + `legacy-unhashable`, + ]) // Use refs to track version and memoized snapshot const versionRef = useRef(0) @@ -334,15 +673,57 @@ export function useLiveQuery( collection: Collection | null version: number } | null>(null) + const derivedIdentityProfilerRef = useRef({ + renderCount: 0, + totalMs: 0, + maxMs: 0, + warned: false, + }) + const deferredCollectionsRef = useRef( + new Set>(), + ) + + const queryKey = !inputIsCollection + ? getExplicitQueryKey(configOrQueryOrCollection) + : undefined + let preparedQueryValue: unknown | typeof unpreparedQueryValue = + unpreparedQueryValue + let identityDeps: ReadonlyArray + + if (queryKey) { + identityDeps = queryKey + } else if (deps !== undefined) { + identityDeps = resolvedDeps + } else if (inputIsCollection) { + identityDeps = [] + } else { + const preparation = prepareDerivedQuery( + configOrQueryOrCollection, + dbClient, + derivedIdentityProfilerRef.current, + deferredCollectionsRef.current, + ) + preparedQueryValue = preparation.value + if (preparation.status === `hashable`) { + identityDeps = preparation.identityDeps + } else { + warnUnhashableDerivedIdentity(preparation.error) + identityDeps = legacyUnhashableIdentityRef.current + } + } + + if (deps !== undefined) { + warnDeprecatedDepsArray() + } // Check if we need to create/recreate the collection const needsNewCollection = !collectionRef.current || (inputIsCollection && configRef.current !== configOrQueryOrCollection) || (!inputIsCollection && - (depsRef.current === null || - depsRef.current.length !== deps.length || - depsRef.current.some((dep, i) => dep !== deps[i]))) + (clientRef.current !== dbClient || + depsRef.current === null || + !deepEquals(depsRef.current, identityDeps))) if (needsNewCollection) { if (inputIsCollection) { @@ -352,12 +733,15 @@ export function useLiveQuery( const syncMode = ( configOrQueryOrCollection as { config?: { syncMode?: string } } ).config?.syncMode - if (syncMode === `on-demand`) { + if ( + syncMode === `on-demand` && + shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) + ) { console.warn( `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + `Instead, use a query builder function:\n` + - ` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` + + ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + `Or switch to syncMode "eager" if you want all data to sync automatically.`, ) } @@ -366,51 +750,20 @@ export function useLiveQuery( collectionRef.current = configOrQueryOrCollection configRef.current = configOrQueryOrCollection } else { - // Handle different callback return types - if (typeof configOrQueryOrCollection === `function`) { - // Call the function with a query builder to see what it returns - const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder - const result = configOrQueryOrCollection(queryBuilder) - - if (result === undefined || result === null) { - // Callback returned undefined/null - disabled query - collectionRef.current = null - } else if (isCollection(result)) { - // Callback returned a Collection instance - use it directly - result.startSyncImmediate() - collectionRef.current = result - } else if (result instanceof BaseQueryBuilder) { - // Callback returned QueryBuilder - create live query collection using the original callback - // (not the result, since the result might be from a different query builder instance) - collectionRef.current = createLiveQueryCollection({ - query: configOrQueryOrCollection, - startSync: true, - gcTime: DEFAULT_GC_TIME_MS, - }) - } else if (result && typeof result === `object`) { - // Assume it's a LiveQueryCollectionConfig - collectionRef.current = createLiveQueryCollection({ - startSync: true, - gcTime: DEFAULT_GC_TIME_MS, - ...result, - }) - } else { - // Unexpected return type - throw new Error( - `useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof result}`, - ) - } - depsRef.current = [...deps] - } else { - // Original logic for config objects - collectionRef.current = createLiveQueryCollection({ - startSync: true, - gcTime: DEFAULT_GC_TIME_MS, - ...configOrQueryOrCollection, - }) - depsRef.current = [...deps] + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + deferredCollectionsRef.current, + ) } + collectionRef.current = createCollectionFromPreparedQuery( + preparedQueryValue, + ) as Collection + configRef.current = configOrQueryOrCollection + depsRef.current = [...identityDeps] } + clientRef.current = dbClient } // Reset refs when collection changes @@ -439,6 +792,10 @@ export function useLiveQuery( versionRef.current += 1 onStoreChange() }) + for (const collection of deferredCollectionsRef.current) { + collection._resumeSyncStart() + } + deferredCollectionsRef.current.clear() // Already-ready collections won't emit an initial change. Notify React // ourselves, but defer to a microtask — calling onStoreChange synchronously // here lands during the render-to-commit window and trips React's @@ -490,6 +847,7 @@ export function useLiveQuery( const snapshot = useSyncExternalStore( subscribeRef.current, getSnapshotRef.current, + getSnapshotRef.current, ) // Track last snapshot (from useSyncExternalStore) and the returned value separately diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 162bf1f3fe..e51b62e917 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -1,5 +1,6 @@ import { useRef } from 'react' import { useLiveQuery } from './useLiveQuery' +import type { UseLiveQueryConfig } from './useLiveQuery' import type { Collection, Context, @@ -15,18 +16,19 @@ import type { /** * Create a live query with React Suspense support * @param queryFn - Query function that defines what data to fetch - * @param deps - Array of dependencies that trigger query re-execution when changed + * @param deps - Deprecated array of dependencies that trigger query re-execution when changed * @returns Object with reactive data and state - data is guaranteed to be defined * @throws Promise when data is loading (caught by Suspense boundary) * @throws Error when collection fails (caught by Error boundary) * @example * // Basic usage with Suspense * function TodoList() { - * const { data } = useLiveSuspenseQuery((q) => - * q.from({ todos: todosCollection }) - * .where(({ todos }) => eq(todos.completed, false)) - * .select(({ todos }) => ({ id: todos.id, text: todos.text })) - * ) + * const { data } = useLiveSuspenseQuery({ + * query: (q) => + * q.from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.completed, false)) + * .select(({ todos }) => ({ id: todos.id, text: todos.text })) + * }) * * return ( *
                    @@ -53,12 +55,11 @@ import type { * // data is guaranteed to be the single item (or undefined if not found) * * @example - * // With dependencies that trigger re-suspension - * const { data } = useLiveSuspenseQuery( - * (q) => q.from({ todos: todosCollection }) + * // Structured captured values are included in derived query identity and trigger re-suspension + * const { data } = useLiveSuspenseQuery({ + * query: (q) => q.from({ todos: todosCollection }) * .where(({ todos }) => gt(todos.priority, minPriority)), - * [minPriority] // Re-suspends when minPriority changes - * ) + * }) * * @example * // With Error boundary @@ -87,9 +88,9 @@ import type { * ✅ **Use conditional rendering instead:** * ```ts * function Profile({ userId }: { userId: string }) { - * const { data } = useLiveSuspenseQuery( - * (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)) - * ) + * const { data } = useLiveSuspenseQuery({ + * query: (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)), + * }) * return
                    {data.name}
                    * } * @@ -97,12 +98,9 @@ import type { * {userId ? :
                    No user
                    } * ``` * - * ✅ **Or use useLiveQuery for conditional queries:** + * ✅ **For optional inputs, conditionally render a component with complete query inputs:** * ```ts - * const { data, isEnabled } = useLiveQuery( - * (q) => userId ? q.from({ users }) : undefined, // ✅ Supported! - * [userId] - * ) + * {userId ? :
                    No user
                    } * ``` */ // Overload 1: Accept query function that always returns QueryBuilder @@ -116,6 +114,15 @@ export function useLiveSuspenseQuery( } // Overload 2: Accept config object +export function useLiveSuspenseQuery( + config: UseLiveQueryConfig, +): { + state: Map> + data: InferResultType + collection: Collection, string | number, {}> +} + +// Overload 3: Accept legacy config object export function useLiveSuspenseQuery( config: LiveQueryCollectionConfig, deps?: Array, @@ -125,7 +132,7 @@ export function useLiveSuspenseQuery( collection: Collection, string | number, {}> } -// Overload 3: Accept pre-created live query collection +// Overload 4: Accept pre-created live query collection export function useLiveSuspenseQuery< TResult extends object, TKey extends string | number, @@ -138,7 +145,7 @@ export function useLiveSuspenseQuery< collection: Collection } -// Overload 4: Accept pre-created live query collection with singleResult: true +// Overload 5: Accept pre-created live query collection with singleResult: true export function useLiveSuspenseQuery< TResult extends object, TKey extends string | number, @@ -154,16 +161,19 @@ export function useLiveSuspenseQuery< // Implementation - uses useLiveQuery internally and adds Suspense logic export function useLiveSuspenseQuery( configOrQueryOrCollection: any, - deps: Array = [], + deps?: Array, ) { const promiseRef = useRef | null>(null) const collectionRef = useRef | null>(null) const hasBeenReadyRef = useRef(false) // Use useLiveQuery to handle collection management and reactivity - const result = useLiveQuery(configOrQueryOrCollection, deps) + const result = + deps === undefined + ? useLiveQuery(configOrQueryOrCollection) + : useLiveQuery(configOrQueryOrCollection, deps) - // Reset promise and ready state when collection changes (deps changed) + // Reset promise and ready state when query identity changes if (collectionRef.current !== result.collection) { promiseRef.current = null collectionRef.current = result.collection diff --git a/packages/react-db/tests/DbProvider.test.tsx b/packages/react-db/tests/DbProvider.test.tsx new file mode 100644 index 0000000000..59e1a88eb5 --- /dev/null +++ b/packages/react-db/tests/DbProvider.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import { DbClient } from '@tanstack/db' +import { DbProvider, useDbClient } from '../src/DbProvider' +import type { ReactNode } from 'react' + +describe(`DbProvider`, () => { + it(`provides a DbClient to hooks`, () => { + const client = new DbClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const { result } = renderHook(() => useDbClient(), { wrapper }) + + expect(result.current).toBe(client) + }) + + it(`throws without a provider`, () => { + expect(() => renderHook(() => useDbClient())).toThrow( + /useDbClient must be used within a DbProvider/, + ) + }) +}) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 9aa63244e7..f4bd5e6842 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { BTreeIndex } from '@tanstack/db' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' @@ -695,6 +699,60 @@ describe(`useLiveInfiniteQuery`, () => { }) }) + it(`should derive query identity from structured captured values`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `derived-identity-change-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { result, rerender } = renderHook( + ({ category }: { category: string }) => { + return useLiveInfiniteQuery( + (q) => + q + .from({ posts: collection }) + .where(({ posts: p }) => eq(p.category, category)) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { + pageSize: 5, + getNextPageParam: (lastPage) => + lastPage.length === 5 ? lastPage.length : undefined, + }, + ) + }, + { initialProps: { category: `tech` } }, + ) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + + act(() => { + result.current.fetchNextPage() + }) + + await waitFor(() => { + expect(result.current.pages).toHaveLength(2) + }) + + act(() => { + rerender({ category: `life` }) + }) + + await waitFor(() => { + expect(result.current.pages).toHaveLength(1) + }) + + result.current.pages[0]!.forEach((post) => { + expect(post.category).toBe(`life`) + }) + }) + it(`should track pageParams correctly`, async () => { const posts = createMockPosts(30) const collection = createCollection( diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 43747284d8..0af16ec53f 100644 --- a/packages/react-db/tests/useLiveQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveQuery.test-d.tsx @@ -1,6 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { renderHook } from '@testing-library/react' import { createCollection } from '../../db/src/collection/index' +import { collectionOptions } from '../../db/src/index' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createLiveQueryCollection, @@ -8,6 +9,10 @@ import { liveQueryCollectionOptions, } from '../../db/src/query/index' import { useLiveQuery } from '../src/useLiveQuery' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { useDbClient } from '../src/DbProvider' +import type { DbClient } from '../../db/src/index' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' @@ -21,6 +26,11 @@ type Person = { } describe(`useLiveQuery type assertions`, () => { + it(`should type useDbClient as DbClient`, () => { + const client = useDbClient() + expectTypeOf(client).toEqualTypeOf() + }) + it(`should type findOne query builder to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -68,6 +78,148 @@ describe(`useLiveQuery type assertions`, () => { >() }) + it(`should type config object to return query rows without queryKey`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-query-key`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { result } = renderHook(() => { + return useLiveQuery({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.team, `team-1`)), + }) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`should type queryKey and a per-call DbClient override`, () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `test-persons-client-override`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const client = null as unknown as DbClient + + const { result } = renderHook(() => { + return useLiveQuery({ + client, + queryKey: [descriptor.id, `team`, `team-1`], + query: (q) => + q + .from({ person: descriptor }) + .where(({ person }) => eq(person.team, `team-1`)), + }) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`keeps the deprecated dependency-array overload typed`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-deprecated-deps`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { result } = renderHook(() => + useLiveQuery( + (q) => + q + .from({ person: collection }) + .where(({ person }) => eq(person.team, `team-1`)), + [`team-1`], + ), + ) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`should type collection descriptors in query sources`, () => { + const collection = collectionOptions( + mockSyncCollectionOptions({ + id: `test-persons-descriptor-query-source`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { result } = renderHook(() => { + return useLiveQuery({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.team, `team-1`)), + }) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`should type suspense config object to return query rows without queryKey`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-suspense-query-key`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { result } = renderHook(() => { + return useLiveSuspenseQuery({ + query: (q) => + q + .from({ collection }) + .where(({ collection: c }) => eq(c.team, `team-1`)), + }) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + + it(`should type infinite config object to return query rows without queryKey`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-infinite-query-key`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + + const { result } = renderHook(() => { + return useLiveInfiniteQuery( + (q) => q.from({ collection }).orderBy(({ collection: c }) => c.name), + { + pageSize: 10, + }, + ) + }) + + expectTypeOf(result.current.data).toMatchTypeOf< + Array> + >() + }) + it(`should type findOne collection using liveQueryCollectionOptions to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index fbb48d882c..0b96ffc0f3 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1,8 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' import { + DbClient, Query, coalesce, + collectionOptions, count, createCollection, createLiveQueryCollection, @@ -14,10 +16,13 @@ import { } from '@tanstack/db' import { useEffect } from 'react' import { useLiveQuery } from '../src/useLiveQuery' +import { DbProvider } from '../src/DbProvider' import { mockSyncCollectionOptions, stripVirtualProps, } from '../../db/tests/utils' +import type { DehydratedDbState } from '@tanstack/db' +import type { ReactNode } from 'react' type Person = { id: string @@ -1976,7 +1981,7 @@ describe(`Query Collections`, () => { }) describe(`callback variants with conditional returns`, () => { - it(`should handle callback returning undefined with proper state`, async () => { + it(`should handle callback returning undefined without a dependency array`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `undefined-callback-test`, @@ -1987,20 +1992,17 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => { - return useLiveQuery( - (q) => { - if (!enabled) return undefined - return q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) - }, - [enabled], - ) + return useLiveQuery((q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }) }, { initialProps: { enabled: false } }, ) @@ -2056,7 +2058,7 @@ describe(`Query Collections`, () => { expect(result.current.isCleanedUp).toBe(false) }) - it(`should handle callback returning null with proper state`, async () => { + it(`should handle callback returning null without a dependency array`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `null-callback-test`, @@ -2067,20 +2069,17 @@ describe(`Query Collections`, () => { const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => { - return useLiveQuery( - (q) => { - if (!enabled) return null - return q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) - }, - [enabled], - ) + return useLiveQuery((q) => { + if (!enabled) return null + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }) }, { initialProps: { enabled: false } }, ) @@ -2604,4 +2603,563 @@ describe(`Query Collections`, () => { ) }) }) + + describe(`SSR hydration`, () => { + it(`round-trips collection rows into React and applies streamed chunks incrementally`, async () => { + const peopleCollectionId = `ssr-react-people` + const peopleCollection = collectionOptions(peopleCollectionId, () => ({ + id: peopleCollectionId, + getKey: (person: Person) => person.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + + return { + loadSubset: () => { + begin({ immediate: true }) + for (const person of initialPersons) { + write({ + type: `insert`, + value: person, + }) + } + commit() + return true + }, + } + }, + }, + })) + const serverClient = new DbClient() + const serverPeople = serverClient.collection(peopleCollection) + const serverLiveQuery = createLiveQueryCollection((q) => + q + .from({ people: serverPeople }) + .where(({ people }) => eq(people.team, `team1`)), + ) + + await serverLiveQuery.preload() + + expect(serverLiveQuery.toArray.map((person) => person.id)).toEqual([ + `1`, + `3`, + ]) + const dehydratedState = serverClient.dehydrate() + expect( + dehydratedState.collections + .flatMap((collection) => collection.rows.map((row) => row.key)) + .sort(), + ).toEqual([`1`, `2`, `3`]) + + const transferredState = JSON.parse( + JSON.stringify(dehydratedState), + ) as DehydratedDbState + const clientClient = new DbClient() + clientClient.hydrate(transferredState) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const { result } = renderHook( + () => + useLiveQuery({ + query: (q) => + q + .from({ people: peopleCollection }) + .where(({ people }) => eq(people.team, `team1`)), + }), + { wrapper }, + ) + + const resultIds = () => result.current.data.map((person) => person.id) + + await waitFor(() => { + expect(resultIds()).toEqual([`1`, `3`]) + }) + const hydratedLiveQuery = result.current.collection + + act(() => { + clientClient.applyCollectionChunk({ + collectionId: peopleCollectionId, + rows: [ + { + key: `4`, + value: { + id: `4`, + name: `Kyle Doe`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }, + }, + ], + }) + }) + + await waitFor(() => { + expect(resultIds()).toEqual([`1`, `3`, `4`]) + }) + expect(result.current.collection).toBe(hydratedLiveQuery) + }) + }) + + describe(`derived query identity`, () => { + it(`resolves collection descriptors from DbProvider`, async () => { + const dbClient = new DbClient() + const peopleCollection = collectionOptions( + mockSyncCollectionOptions({ + id: `descriptor-people`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const { result } = renderHook( + () => + useLiveQuery({ + query: (q) => + q + .from({ people: peopleCollection }) + .where(({ people }) => eq(people.team, `team1`)), + }), + { wrapper }, + ) + + await waitFor(() => { + expect(result.current.data).toHaveLength(2) + }) + + const people = dbClient.collection(peopleCollection) + + act(() => { + people.insert({ + id: `4`, + name: `Kyle Doe`, + age: 40, + email: `kyle.doe@example.com`, + isActive: true, + team: `team1`, + }) + }) + + await waitFor(() => { + expect(result.current.data).toHaveLength(3) + }) + }) + + it(`keeps the same live query collection when derived identity is stable`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `query-key-stable`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ minAge }) => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, minAge)), + }), + { initialProps: { minAge: 30 } }, + ) + + await waitFor(() => { + expect(result.current.data).toHaveLength(1) + }) + const firstCollection = result.current.collection + + rerender({ minAge: 30 }) + + expect(result.current.collection).toBe(firstCollection) + }) + + it(`evaluates a derived query once per render`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-single-evaluation`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + let queryExecutions = 0 + + const { result, rerender } = renderHook( + ({ minAge }) => + useLiveQuery({ + query: (q) => { + queryExecutions += 1 + return q + .from({ people: collection }) + .where(({ people }) => gt(people.age, minAge)) + }, + }), + { initialProps: { minAge: 25 } }, + ) + + expect(queryExecutions).toBe(1) + const firstCollection = result.current.collection + + rerender({ minAge: 25 }) + + expect(queryExecutions).toBe(2) + expect(result.current.collection).toBe(firstCollection) + + rerender({ minAge: 30 }) + + expect(queryExecutions).toBe(3) + expect(result.current.collection).not.toBe(firstCollection) + }) + + it(`rebinds descriptors when the DbProvider client changes`, async () => { + const peopleCollection = collectionOptions( + `provider-swap-people`, + (client) => + mockSyncCollectionOptions({ + id: `provider-swap-people`, + getKey: (person) => person.id, + initialData: client.requireDependency>(`people`), + }), + ) + const clientA = new DbClient({ + people: [{ ...initialPersons[0]!, name: `Client A` }], + }) + const clientB = new DbClient({ + people: [{ ...initialPersons[0]!, name: `Client B` }], + }) + let currentClient = clientA + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const { result, rerender } = renderHook( + () => + useLiveQuery({ + query: (q) => q.from({ people: peopleCollection }), + }), + { wrapper }, + ) + + await waitFor(() => { + expect(result.current.data[0]?.name).toBe(`Client A`) + }) + const firstCollection = result.current.collection + + currentClient = clientB + rerender() + + await waitFor(() => { + expect(result.current.data[0]?.name).toBe(`Client B`) + }) + expect(result.current.collection).not.toBe(firstCollection) + }) + + it(`recreates the live query collection when derived identity changes`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `query-key-change`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ minAge }) => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, minAge)), + }), + { initialProps: { minAge: 25 } }, + ) + + await waitFor(() => { + expect(result.current.data).toHaveLength(2) + }) + const firstCollection = result.current.collection + + rerender({ minAge: 30 }) + + await waitFor(() => { + expect(result.current.data).toHaveLength(1) + }) + expect(result.current.collection).not.toBe(firstCollection) + }) + + it(`warns and preserves legacy behavior when a functional query has no queryKey`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-functional-missing-key`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + expect(() => + renderHook( + ({ minAge }) => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .fn.where(({ people }) => people.age > minAge), + }), + { initialProps: { minAge: 25 } }, + ), + ).not.toThrow() + + const warnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes(`cannot derive a stable identity`), + ) + expect(warnings).toHaveLength(1) + expect(warnings[0]![0]).toContain(`queryKey`) + expect(warnings[0]![0]).toContain(`1.0`) + warnSpy.mockRestore() + }) + + it(`warns when a structured query captures an opaque runtime value without queryKey`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-opaque-value`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + expect(() => + renderHook(() => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => + eq(people.name, (() => `John Doe`) as never), + ), + }), + ), + ).not.toThrow() + + const warnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes(`function value`), + ) + expect(warnings).toHaveLength(1) + expect(warnings[0]![0]).toContain(`queryKey`) + warnSpy.mockRestore() + }) + + it(`does not emit identity warnings in production`, () => { + const previousNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = `production` + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-production-warning`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + let unmount: (() => void) | undefined + try { + ;({ unmount } = renderHook(() => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .fn.where(({ people }) => people.age > 25), + }), + )) + + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes(`cannot derive a stable identity`), + ), + ).toBe(false) + } finally { + unmount?.() + process.env.NODE_ENV = previousNodeEnv + warnSpy.mockRestore() + } + }) + + it(`uses explicit queryKey for functional query variants`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-functional-explicit-key`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ minAge }) => + useLiveQuery({ + queryKey: [collection.id, `fn`, minAge], + query: (q) => + q + .from({ people: collection }) + .fn.where(({ people }) => people.age > minAge), + }), + { initialProps: { minAge: 25 } }, + ) + + await waitFor(() => { + expect(result.current.data).toHaveLength(2) + }) + const firstCollection = result.current.collection + + rerender({ minAge: 30 }) + + await waitFor(() => { + expect(result.current.data).toHaveLength(1) + }) + expect(result.current.collection).not.toBe(firstCollection) + }) + + it(`warns when derived query identity is slow enough to need queryKey`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const nowSpy = vi.spyOn(globalThis.performance, `now`) + let currentTime = 0 + nowSpy.mockImplementation(() => { + const value = currentTime + currentTime += 20 + return value + }) + + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-slow-warning`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { rerender } = renderHook(() => + useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, 25)), + }), + ) + + rerender() + + const hotPathWarnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes(`hot render path`), + ) + expect(hotPathWarnings).toHaveLength(1) + expect(hotPathWarnings[0]![0]).toContain(`queryKey`) + + nowSpy.mockRestore() + warnSpy.mockRestore() + }) + + it(`warns when repeated derived query identity work accumulates on a hot render path`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const nowSpy = vi.spyOn(globalThis.performance, `now`) + let currentTime = 0 + nowSpy.mockImplementation(() => { + const value = currentTime + currentTime += 6 + return value + }) + + const collection = createCollection( + mockSyncCollectionOptions({ + id: `derived-identity-accumulated-warning`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { rerender } = renderHook( + ({ renderCount }) => { + void renderCount + return useLiveQuery({ + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, 25)), + }) + }, + { initialProps: { renderCount: 0 } }, + ) + + for (let renderCount = 1; renderCount < 10; renderCount++) { + rerender({ renderCount }) + } + + const hotPathWarnings = warnSpy.mock.calls.filter(([message]) => + String(message).includes(`renders took`), + ) + expect(hotPathWarnings).toHaveLength(1) + expect(hotPathWarnings[0]![0]).toContain(`queryKey`) + + nowSpy.mockRestore() + warnSpy.mockRestore() + }) + + it(`warns once for the deprecated dependency-array form`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `deps-warning`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { rerender } = renderHook( + ({ minAge }) => + useLiveQuery( + (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, minAge)), + [minAge], + ), + { initialProps: { minAge: 25 } }, + ) + + rerender({ minAge: 30 }) + rerender({ minAge: 30 }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`will be removed in 1.0`), + ) + + warnSpy.mockRestore() + }) + + it(`warns for an explicitly passed empty dependency array`, () => { + const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const collection = createCollection( + mockSyncCollectionOptions({ + id: `empty-deps-warning`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + renderHook(() => useLiveQuery((q) => q.from({ people: collection }), [])) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`useLiveQuery({ query })`), + ) + + warnSpy.mockRestore() + }) + }) }) diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index fc78b07968..ce7a78d2fe 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -185,7 +185,7 @@ describe(`useLiveSuspenseQuery`, () => { }) }) - it(`should re-suspend when deps change`, async () => { + it(`should re-suspend when derived query identity changes`, async () => { const collection = createCollection( mockSyncCollectionOptions({ id: `test-persons-suspense-5`, @@ -196,13 +196,12 @@ describe(`useLiveSuspenseQuery`, () => { const { result, rerender } = renderHook( ({ minAge }) => { - return useLiveSuspenseQuery( - (q) => + return useLiveSuspenseQuery({ + query: (q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, minAge)), - [minAge], - ) + }) }, { wrapper: SuspenseWrapper, @@ -216,7 +215,7 @@ describe(`useLiveSuspenseQuery`, () => { }) expect(result.current.data[0]?.age).toBe(35) - // Change deps - age > 20 + // Change derived identity - age > 20 rerender({ minAge: 20 }) // Should re-suspend and load new data diff --git a/packages/rxdb-db-collection/src/rxdb.ts b/packages/rxdb-db-collection/src/rxdb.ts index 6ec59cfb69..9eb6b260b0 100644 --- a/packages/rxdb-db-collection/src/rxdb.ts +++ b/packages/rxdb-db-collection/src/rxdb.ts @@ -7,6 +7,7 @@ import { rxStorageWriteErrorToRxError, } from 'rxdb/plugins/core' import DebugModule from 'debug' +import { withCollectionConfigFactory } from '@tanstack/db' import { stripRxdbFields } from './helper' import type { FilledMangoQuery, @@ -101,7 +102,9 @@ export function rxdbCollectionOptions( schema?: never // no schema in the result } -export function rxdbCollectionOptions(config: RxDBCollectionConfig) { +export function rxdbCollectionOptions( + config: RxDBCollectionConfig, +): CollectionConfig { type Row = Record type Key = string // because RxDB primary keys must be strings @@ -309,5 +312,7 @@ export function rxdbCollectionOptions(config: RxDBCollectionConfig) { }) }, } - return collectionConfig + return withCollectionConfigFactory(collectionConfig, () => + rxdbCollectionOptions(config), + ) } diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index b47728d861..f76afabd29 100644 --- a/packages/trailbase-db-collection/src/trailbase.ts +++ b/packages/trailbase-db-collection/src/trailbase.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ import { Store } from '@tanstack/store' +import { withCollectionConfigFactory } from '@tanstack/db' import { ExpectedDeleteTypeError, ExpectedInsertTypeError, @@ -363,7 +364,7 @@ export function trailBaseCollectionOptions< }) as const, } - return { + const options = { ...config, sync, getKey, @@ -428,6 +429,11 @@ export function trailBaseCollectionOptions< cancel: cancelEventReader, }, } + + return withCollectionConfigFactory( + options, + () => trailBaseCollectionOptions(config) as typeof options, + ) } function buildOrder(opts: LoadSubsetOptions): undefined | Array { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 210a2dbfa8..00a73d0445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,6 +722,49 @@ importers: specifier: ^5.1.0 version: 5.1.0 + examples/react/start-ssr-e2e: + dependencies: + '@tanstack/react-db': + specifier: ^0.1.95 + version: link:../../../packages/react-db + '@tanstack/react-router': + specifier: ^1.159.5 + version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start': + specifier: ^1.159.5 + version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.3.2(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)))(vite@7.3.2(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.3.2(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 + '@types/node': + specifier: ^25.2.2 + version: 25.2.2 + '@types/react': + specifier: ^19.2.13 + version: 19.2.13 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.13) + '@vitejs/plugin-react': + specifier: ^5.1.3 + version: 5.1.3(vite@7.3.2(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vite: + specifier: ^7.3.0 + version: 7.3.2(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + examples/react/todo: dependencies: '@tanstack/electric-db-collection': @@ -4819,6 +4862,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -8586,6 +8634,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -10701,6 +10754,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -16466,6 +16529,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@poppinss/colors@4.1.6': @@ -21315,6 +21382,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -23639,6 +23709,14 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11