diff --git a/.changeset/modern-dbs-hydrate.md b/.changeset/modern-dbs-hydrate.md new file mode 100644 index 0000000000..87d98466e3 --- /dev/null +++ b/.changeset/modern-dbs-hydrate.md @@ -0,0 +1,25 @@ +--- +'@tanstack/db': minor +'@tanstack/react-db': minor +'@tanstack/svelte-db': minor +'@tanstack/react-router-with-db': minor +'@tanstack/electric-db-collection': minor +'@tanstack/query-db-collection': patch +'@tanstack/powersync-db-collection': patch +'@tanstack/rxdb-db-collection': patch +'@tanstack/trailbase-db-collection': patch +'@tanstack/db-sqlite-persistence-core': patch +--- + +Add SSR through request-scoped `DbClient` instances, collection descriptors, +explicit collection-row hydration, live-query result snapshots, adapter sync +metadata, and React and Svelte descriptor resolution. + +React live queries now derive identity from structured query IR. Opaque queries +can provide `queryKey`; legacy dependency arrays and unkeyed opaque queries keep +working with development warnings until 1.0. + +Add TanStack Router integration that streams live queries discovered during a +Suspense render as pending promises which resolve to ordered result snapshots. +The browser starts normal source sync and atomically replaces the snapshot when +its live result is ready. diff --git a/.github/SSR_RELEASE_PLAN.md b/.github/SSR_RELEASE_PLAN.md new file mode 100644 index 0000000000..8df2f4fbbf --- /dev/null +++ b/.github/SSR_RELEASE_PLAN.md @@ -0,0 +1,69 @@ +# TanStack DB SSR Release Plan + +## Release Goal + +Ship TanStack DB SSR as a single coherent story: + +- explicit collection-row hydration and live-query result snapshots through + `DbClient` +- React and Svelte provider and descriptor resolution +- derived live query identity with `queryKey` only when necessary +- backwards-compatible dependency arrays with dev warnings until 1.0 +- a working TanStack Start demo and E2E proof + +## Pre-release Validation + +- Run `pnpm --filter @tanstack/db test`. +- Run `pnpm --filter @tanstack/react-db test`. +- Run `pnpm --filter @tanstack/svelte-db test`. +- Run `pnpm --filter @tanstack/react-router-with-db test` (includes type + tests). +- Run `pnpm --filter @tanstack/query-db-collection test`. +- Run `pnpm --filter @tanstack/db-sqlite-persistence-core test`. +- Run `pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e`. +- Run `pnpm --filter @tanstack/db-example-react-next-ssr-e2e test:e2e`. +- Run `pnpm test:docs`. +- Run `pnpm test:sherif`. +- Run `pnpm build`. + +## Demo + +- Live URL: https://tanstack-db-ssr-demo.netlify.app/ssr-db +- Deploy `examples/react/start-ssr-e2e` to an SSR-capable host. +- Verify the deployed `/ssr-db` route serves SSR HTML with hydrated rows. +- Verify browser hydration succeeds without console/page errors. +- Verify the streamed collection chunk updates the live query. +- Verify `/ssr-db-stream` streams a projected result, omits source-only data, + and hands off to browser sync. +- Run `PLAYWRIGHT_BASE_URL=https://tanstack-db-ssr-demo.netlify.app pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e:hosted`. +- Add the live URL to the PR description and release notes. + +## Docs + +- Publish the [SSR and Hydration guide](../docs/guides/ssr.md). +- Link the guide from overview, quick start, live queries, and React overview. +- Regenerate API reference docs in a dedicated docs-maintenance pass if broad + TypeDoc output churn is acceptable. +- Confirm docs explain when `queryKey` is necessary and when it should be + omitted. +- Confirm docs say dependency arrays warn now and are removed in 1.0. + +## Migration Messaging + +- Lead with: explicit collection preloads transport normalized rows; live-query + preloads transport only their result snapshot. +- Emphasize that existing apps keep working. +- State that `createCollection(...)` remains available, but SSR apps should use + `collectionOptions(...)` plus `DbClient`. +- Explain that React dependency arrays are deprecated with a 1.0 removal path. +- Show `queryKey` only for opaque functional query logic or hot render paths. + +## Announcement Checklist + +- PR description includes high-level summary, migration cheat sheet, and test + commands. +- Release notes include a "No removals in this release" compatibility section. +- Discord announcement links the SSR guide and live demo. +- Example migration diff is available from the Start SSR demo. +- Follow-up issues are filed for the remaining framework adapters and API + reference generation if they are not part of the shipping PR. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3678cd19f2..a340502b46 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -45,6 +45,7 @@ jobs: run: | pnpm --filter @tanstack/db-ivm build pnpm --filter @tanstack/db build + pnpm --filter @tanstack/react-db build pnpm --filter @tanstack/electric-db-collection build pnpm --filter @tanstack/offline-transactions build pnpm --filter @tanstack/query-db-collection build @@ -68,6 +69,21 @@ jobs: env: ELECTRIC_URL: http://localhost:3000 + - name: Install Playwright browsers + run: | + cd examples/react/start-ssr-e2e + pnpm exec playwright install --with-deps chromium + + - name: Run React Start SSR E2E tests + run: | + cd examples/react/start-ssr-e2e + pnpm test:e2e + + - name: Run Next.js SSR E2E tests + run: | + cd examples/react/next-ssr-e2e + pnpm test:e2e + - name: Run Node SQLite persisted collection E2E tests run: | cd packages/node-db-sqlite-persistence diff --git a/.gitignore b/.gitignore index 4ad9ee25d4..3bfb31bb9b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ yarn.lock build coverage dist +playwright-report +test-results # misc .DS_Store @@ -19,6 +21,7 @@ dist .env.test.local .env.production.local .next +next-env.d.ts npm-debug.log* yarn-debug.log* diff --git a/docs/collections/local-only-collection.md b/docs/collections/local-only-collection.md index 17bf51ef4b..2f016baac5 100644 --- a/docs/collections/local-only-collection.md +++ b/docs/collections/local-only-collection.md @@ -192,10 +192,12 @@ export const modalStateCollection = createCollection( // Use in component function UserProfileModal() { - const { data: modals } = useLiveQuery((q) => - q.from({ modal: modalStateCollection }) - .where(({ modal }) => eq(modal.id, 'user-profile')) - ) + const { data: modals } = useLiveQuery({ + query: (q) => + q + .from({ modal: modalStateCollection }) + .where(({ modal }) => eq(modal.id, 'user-profile')), + }) const modalState = modals[0] @@ -248,10 +250,12 @@ export const formDraftsCollection = createCollection( // Use in component function CreatePostForm() { - const { data: drafts } = useLiveQuery((q) => - q.from({ draft: formDraftsCollection }) - .where(({ draft }) => eq(draft.id, 'new-post')) - ) + const { data: drafts } = useLiveQuery({ + query: (q) => + q + .from({ draft: formDraftsCollection }) + .where(({ draft }) => eq(draft.id, 'new-post')), + }) const currentDraft = drafts[0] diff --git a/docs/collections/local-storage-collection.md b/docs/collections/local-storage-collection.md index 171e5cb9b4..59d3a981c0 100644 --- a/docs/collections/local-storage-collection.md +++ b/docs/collections/local-storage-collection.md @@ -263,10 +263,12 @@ export const userPreferencesCollection = createCollection( // Use in component function SettingsPanel() { - const { data: prefs } = useLiveQuery((q) => - q.from({ pref: userPreferencesCollection }) - .where(({ pref }) => eq(pref.id, 'current-user')) - ) + const { data: prefs } = useLiveQuery({ + query: (q) => + q + .from({ pref: userPreferencesCollection }) + .where(({ pref }) => eq(pref.id, 'current-user')), + }) const currentPrefs = prefs[0] diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 7389c77caa..a5f4f13680 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -25,22 +25,26 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db ```typescript import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { DbClient, collectionOptions } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" const queryClient = new QueryClient() +const db = new DbClient({ queryClient }) -const todosCollection = createCollection( +const todosCollection = collectionOptions("todos", (client) => queryCollectionOptions({ + id: "todos", queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") return response.json() }, - queryClient, + queryClient: client.requireDependency("queryClient"), getKey: (item) => item.id, }) ) + +const todos = db.collection(todosCollection) ``` ## Configuration Options @@ -54,15 +58,15 @@ The `queryCollectionOptions` function accepts the following options: - `queryClient`: TanStack Query client instance - `getKey`: Function to extract the unique key from an item -### Creating Collection Options from a Runtime QueryClient - -`queryCollectionOptions` needs a `queryClient` when the collection options are created. In SSR, TanStack Start, tests, or multi-tenant apps, that `QueryClient` is often request-local or route-local rather than module-global. +### Request-scoped QueryClient -Keep shared collection configuration in a factory function that accepts the runtime `QueryClient`: +`queryCollectionOptions` needs a `queryClient`. In SSR, TanStack Start, tests, +or multi-tenant apps, that client is request-local rather than module-global. +Put it on `DbClient`, then resolve it inside the collection descriptor factory: ```typescript import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { DbClient, collectionOptions } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" interface Todo { @@ -70,53 +74,42 @@ interface Todo { title: string } -export function todoCollectionOptions(queryClient: QueryClient) { - return queryCollectionOptions({ +export const todoCollection = collectionOptions("todos", (client) => + queryCollectionOptions({ + id: "todos", queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") return response.json() as Promise> }, - queryClient, + queryClient: client.requireDependency("queryClient"), getKey: (todo) => todo.id, }) -} - -function createTodosCollection(queryClient: QueryClient) { - return createCollection(todoCollectionOptions(queryClient)) -} -``` - -Create the collection once for each scoped `QueryClient` and parameter set, then reuse that `Collection` instance. Creating multiple collections with the same `QueryClient` and `queryKey` gives each collection its own materialized state, lifecycle, subscriptions, and optimistic mutations. - -In request-scoped environments, store the collection in request or router context. For client-side scopes, memoize by `QueryClient`: - -```typescript -type TodosCollection = ReturnType - -const collectionsByClient = new WeakMap() - -export function getTodosCollection( - queryClient: QueryClient, -): TodosCollection { - let collection = collectionsByClient.get(queryClient) - - if (!collection) { - collection = createTodosCollection(queryClient) - collectionsByClient.set(queryClient, collection) - } +) - return collection +export function createRequestClients() { + const queryClient = new QueryClient() + const dbClient = new DbClient({ queryClient }) + return { queryClient, dbClient } } ``` -Avoid calling `createCollection(todoCollectionOptions(queryClient))` independently during render or in each consumer. Share the stable collection instance for the lifetime of that `QueryClient` scope. +`dbClient.collection(todoCollection)` memoizes one collection instance for that +descriptor and client. A second `DbClient` materializes fresh adapter state and +uses its own `QueryClient`. -This keeps SSR and request-scoped code from sharing a global `QueryClient` while keeping each collection instance stable within its scope. +Passing `queryClient` directly to `queryCollectionOptions` remains supported for +`createCollection(...)` and existing apps. When a descriptor is materialized, +an explicit `DbClient` dependency takes precedence; the configured +`queryClient` is the backwards-compatible fallback. ### Business-Scoped Collection Factories -A tenant, project, account, or route parameter can define a **business scope**: the server resource that a collection represents. Include the scope in both the Query key and `queryFn`. This extends the [runtime `QueryClient` factory pattern](#creating-collection-options-from-a-runtime-queryclient) with an explicit scope parameter: +A tenant, project, account, or route parameter can define a **business scope**: +the server resource that a collection represents. Include the scope in the +descriptor id, Query key, and `queryFn`. This extends the +[request-scoped QueryClient pattern](#request-scoped-queryclient) with an +explicit scope parameter: ```typescript interface Todo { @@ -130,54 +123,48 @@ async function fetchProjectTodos(projectId: string): Promise> { return response.json() } -export function createProjectTodosCollection( - queryClient: QueryClient, +function createProjectTodosDescriptor( projectId: string, ) { - return createCollection( + return collectionOptions(`project:${projectId}:todos`, (client) => queryCollectionOptions({ + id: `project:${projectId}:todos`, queryKey: ["projects", projectId, "todos"], queryFn: () => fetchProjectTodos(projectId), - queryClient, + queryClient: client.requireDependency("queryClient"), getKey: (todo) => todo.id, }) ) } ``` -The scope is part of the collection's identity. Memoize by both the `QueryClient` and a stable scope key so consumers of the same project share one collection: +The scope is part of the descriptor identity. Memoize descriptors by a stable +scope key; `DbClient` handles collection memoization and QueryClient ownership: ```typescript -type ProjectTodosCollection = ReturnType +type ProjectTodosDescriptor = ReturnType -const projectCollections = new WeakMap< - QueryClient, - Map ->() +const projectDescriptors = new Map() -export function getProjectTodosCollection( - queryClient: QueryClient, +export function getProjectTodosDescriptor( projectId: string, -): ProjectTodosCollection { - let collectionsByProject = projectCollections.get(queryClient) - - if (!collectionsByProject) { - collectionsByProject = new Map() - projectCollections.set(queryClient, collectionsByProject) +): ProjectTodosDescriptor { + let descriptor = projectDescriptors.get(projectId) + if (!descriptor) { + descriptor = createProjectTodosDescriptor(projectId) + projectDescriptors.set(projectId, descriptor) } - - let collection = collectionsByProject.get(projectId) - - if (!collection) { - collection = createProjectTodosCollection(queryClient, projectId) - collectionsByProject.set(projectId, collection) - } - - return collection + return descriptor } + +const todos = dbClient.collection(getProjectTodosDescriptor(projectId)) ``` -For multiple scope values, use nested maps or a collision-safe stable key that includes every value. Do not call the factory on each render. In a long-lived client, user-selected scopes can make the map grow without bound. Remove unused entries and call `await collection.cleanup()` when your application owns their lifecycle. Request-local maps can be discarded with the request. +For multiple scope values, use nested maps or a collision-safe stable key that +includes every value. Do not create a descriptor on each render. In a +long-lived app, user-selected scopes can make the map grow without bound; remove +unused descriptors and call `await dbClient.cleanup()` when the client scope +ends. Request-local maps can be discarded with the request. A business scope is separate from a **relational subset** requested by a live query. With `syncMode: "on-demand"`, `LoadSubsetOptions` describes predicates, ordering, limits, and offsets within one business-scoped collection. These options reach `queryFn` through `ctx.meta.loadSubsetOptions` and determine the subset Query keys. See [QueryFn and Predicate Push-Down](#queryfn-and-predicate-push-down). @@ -298,11 +285,12 @@ If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tan ```typescript import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { DbClient, collectionOptions } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" import { queryOptions } from "@tanstack/react-query" const queryClient = new QueryClient() +const db = new DbClient({ queryClient }) const listOptions = queryOptions({ queryKey: ["todos"], @@ -312,14 +300,17 @@ const listOptions = queryOptions({ }, }) -const todosCollection = createCollection( +const todosCollection = collectionOptions("todos", (client) => queryCollectionOptions({ + id: "todos", ...listOptions, queryFn: (context) => listOptions.queryFn!(context), - queryClient, + queryClient: client.requireDependency("queryClient"), getKey: (item) => item.id, }), ) + +const todos = db.collection(todosCollection) ``` If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`. diff --git a/docs/collections/trailbase-collection.md b/docs/collections/trailbase-collection.md index 938e714a52..741cd8d80d 100644 --- a/docs/collections/trailbase-collection.md +++ b/docs/collections/trailbase-collection.md @@ -194,11 +194,13 @@ export const todosCollection = createCollection( // Use in component function TodoList() { - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todosCollection }) - .where(({ todo }) => not(todo.completed)) - .orderBy(({ todo }) => todo.created_at, 'desc') - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: todosCollection }) + .where(({ todo }) => not(todo.completed)) + .orderBy(({ todo }) => todo.created_at, 'desc'), + }) const addTodo = (text: string) => { todosCollection.insert({ diff --git a/docs/config.json b/docs/config.json index 09ca7c62ba..94a08ef0fd 100644 --- a/docs/config.json +++ b/docs/config.json @@ -30,6 +30,10 @@ "label": "Live Queries", "to": "guides/live-queries" }, + { + "label": "SSR and Hydration", + "to": "guides/ssr" + }, { "label": "Mutations", "to": "guides/mutations" diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index f68d013d2f..857559e4f6 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -17,19 +17,34 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio ## Basic Usage +Create a `DbClient` and provide it to your React tree: + +```tsx +import { DbClient, DbProvider } from '@tanstack/react-db' + +const dbClient = new DbClient() + +root.render( + + + +) +``` + ### useLiveQuery The `useLiveQuery` hook creates a live query that automatically updates your component when data changes: ```tsx -import { useLiveQuery, eq } from '@tanstack/react-db' +import { and, eq, gt, useDbClient, useLiveQuery } from '@tanstack/react-db' function TodoList() { - const { data, isLoading } = useLiveQuery((q) => - q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)) - .select(({ todos }) => ({ id: todos.id, text: todos.text })) - ) + const { data, isLoading } = useLiveQuery({ + query: (q) => + q.from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })), + }) if (isLoading) return
Loading...
@@ -41,29 +56,61 @@ function TodoList() { } ``` -### Dependency Arrays +### Query Identity -All query hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array as their last parameter. This array works similarly to React's `useEffect` dependencies - when any value in the array changes, the query is recreated and re-executed. +React live query hooks derive the live query identity from structured query IR by default. The hook runs the query builder, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created. -#### When to Use Dependency Arrays - -Use dependency arrays when your query depends on external reactive values (props, state, or other hooks): +That means normal structured queries do not need a separate `queryKey`. Collection descriptors provide stable collection IDs, and captured values inside structured expressions become part of the derived identity: ```tsx function FilteredTodos({ minPriority }: { minPriority: number }) { - const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) + const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => gt(todos.priority, minPriority)), - [minPriority] // Re-run when minPriority changes - ) + }) return
{data.length} high-priority todos
} ``` -#### What Happens When Dependencies Change +#### Collection Hooks + +`useLiveQuery` resolves collection descriptors from `DbProvider` automatically. Create small collection hooks when components need imperative collection methods like `insert`, `update`, `delete`, or `preload`: + +```tsx +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} +``` + +#### When to Use Query Keys -When a dependency value changes: +Use `queryKey` only when DB cannot derive identity from structured IR, or when you intentionally want to avoid deriving identity on a hot render path. The common case is a functional query variant such as `.fn.where`, `.fn.select`, or `.fn.having`: + +```tsx +function SearchTodos({ search }: { search: string }) { + const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => q.from({ todos: todoCollection }) + .fn.where(({ todos }) => + todos.text.toLowerCase().includes(search.toLowerCase()) + ), + }) + + return
{data.length} matching todos
+} +``` + +Before 1.0, an unhashable query warns in development and keeps its legacy +mount-stable identity. The query still runs, but captured values inside opaque +logic only become reactive when they are represented in `queryKey`. In 1.0, an +unhashable query without `queryKey` will throw. If deriving identity becomes +expensive across renders, the hook warns once and suggests adding a `queryKey` +as a performance escape hatch. + +#### What Happens When Identity Changes + +When the derived identity or explicit query key changes: 1. The previous live query collection is cleaned up 2. A new query is created with the updated values 3. The component re-renders with the new data @@ -71,46 +118,41 @@ When a dependency value changes: #### Best Practices -**Include all external values used in the query:** +**Use structured expressions when possible:** ```tsx -// Good - all external values in deps -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) +// Good - DB can derive identity from this structured IR +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => and( eq(todos.userId, userId), eq(todos.status, status) )), - [userId, status] -) - -// Bad - missing dependencies -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.userId, userId)), - [] // Missing userId! -) +}) ``` -**Empty array for static queries:** +**Add a query key for opaque runtime logic:** ```tsx -// No external dependencies - query never changes -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }), - [] -) +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'by-user-fn', userId], + query: (q) => q.from({ todos: todoCollection }) + .fn.where(({ todos }) => todos.userId === userId), +}) ``` -**Omit the array for queries with no external dependencies:** +**Omit query keys for static structured queries:** ```tsx -// Same as above - no deps needed -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) -) +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }), +}) ``` +Dependency arrays are still accepted for backwards compatibility, but they warn in development and will be removed in 1.0. + +For SSR setup, collection hydration, and migration details, see the [SSR and Hydration guide](../../guides/ssr.md). + ### useLiveInfiniteQuery For paginated data with live updates, use `useLiveInfiniteQuery`: @@ -125,14 +167,13 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( pageSize: 20, getNextPageParam: (lastPage, allPages) => lastPage.length === 20 ? allPages.length : undefined - }, - [category] // Re-run when category changes + } ) ``` `fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` value and do not reject the promise. -**Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection. +The deprecated dependency array is only available when using the query function variant, not when passing a pre-created collection. ### useLiveSuspenseQuery @@ -140,11 +181,10 @@ For React Suspense integration, use `useLiveSuspenseQuery`: ```tsx function TodoList({ filter }: { filter: string }) { - const { data } = useLiveSuspenseQuery( - (q) => q.from({ todos: todosCollection }) + const { data } = useLiveSuspenseQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => eq(todos.filter, filter)), - [filter] // Re-suspends when filter changes - ) + }) return (
    @@ -162,4 +202,4 @@ function App() { } ``` -When dependencies change, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready. +When the derived identity or explicit `queryKey` changes, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready. diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index e0515b7d9d..5eeea14c81 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -17,6 +17,27 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio ## Basic Usage +### DbProvider + +Use one `DbClient` for each browser app and one per server request. `DbProvider` +lets queries resolve collection descriptors against that client: + +```svelte + + + + + +``` + +See [SSR and Hydration](../../guides/ssr.md) for server preloading, +dehydration, and snapshot handoff. + ### useLiveQuery The `useLiveQuery` utility creates a live query that automatically updates your component when data changes. It returns reactive values powered by Svelte 5 runes: @@ -26,11 +47,12 @@ The `useLiveQuery` utility creates a live query that automatically updates your import { useLiveQuery } from '@tanstack/svelte-db' import { eq } from '@tanstack/db' - const query = useLiveQuery((q) => - q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)) - .select(({ todos }) => ({ id: todos.id, text: todos.text })) - ) + const query = useLiveQuery({ + query: (q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + }) {#if query.isLoading} @@ -87,13 +109,13 @@ The query must include `orderBy`. The dependency array is available only with the query-function form. You can also pass an ordered, pre-created live query collection directly. -### Dependency Arrays - -The `useLiveQuery` utility accepts an optional dependency array as its last parameter. When any value in the array changes, the query is recreated and re-executed. +### Query Identity -#### When to Use Dependency Arrays +`useLiveQuery` derives identity from structured query IR. Svelte also tracks +reactive values read while building the query, so normal builder queries do not +need a dependency array or `queryKey`. -Use dependency arrays when your query depends on external reactive values (props or state): +Captured props and state become part of the derived identity: ```svelte
    {query.data.length} high-priority todos
    ``` -**Note:** When using props or reactive state in the query, wrap them in a function for the dependency array. - -#### What Happens When Dependencies Change - -When a dependency value changes: +When the derived identity changes: 1. The previous live query collection is cleaned up 2. A new query is created with the updated values 3. The component re-renders with the new data 4. The utility shows loading state again -#### Best Practices - -**Include all external values used in the query:** +Use `queryKey` for opaque functional variants such as `.fn.where`, because DB +cannot inspect their closed-over values. Pass reactive key values through a +getter: ```svelte - -
    {query.data.length} todos
    -``` - -**Empty array for static queries:** -```svelte - - -
    {query.data.length} todos
    ``` -**Omit the array for queries with no external dependencies:** - -```svelte - - -
    {query.data.length} todos
    -``` +The legacy dependency array remains supported. Prefer derived identity for +structured queries and `queryKey` for opaque ones. ### Accessing Multiple Properties diff --git a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md index 05a023791e..8c0fae9760 100644 --- a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md @@ -48,8 +48,8 @@ controller. The query must include an `orderBy` clause. ```ts function useLiveInfiniteQuery( - queryFn, - config, + queryFn, + config, deps?): UseLiveInfiniteQueryReturn; ``` diff --git a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md index ace644e59e..37894a398e 100644 --- a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md @@ -48,8 +48,8 @@ controller. The query must include an `orderBy` clause. ```ts function useLiveInfiniteQuery( - queryFn, - config, + queryFn, + config, deps?): UseLiveInfiniteQueryReturn; ``` diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index d1f4d55c4a..000585ccab 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -709,18 +709,23 @@ export function webSocketCollectionOptions( ## Usage Example ```typescript -import { createCollection } from '@tanstack/react-db' +import { DbClient, collectionOptions } from '@tanstack/react-db' import { webSocketCollectionOptions } from './websocket-collection' -const todos = createCollection( +const db = new DbClient() + +const todosCollection = collectionOptions('todos', () => webSocketCollectionOptions({ + id: 'todos', url: 'ws://localhost:8080/todos', getKey: (todo) => todo.id, - schema: todoSchema + schema: todoSchema, // Note: No onInsert/onUpdate/onDelete - handled by WebSocket automatically }) ) +const todos = db.collection(todosCollection) + // Use the collection todos.insert({ id: '1', text: 'Buy milk', completed: false }) diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index dfd4fa0b80..40bb758beb 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -91,7 +91,9 @@ const syncedCollection = createCollection( // Component can check error state function DataList() { - const { data } = useLiveQuery((q) => q.from({ item: syncedCollection })) + const { data } = useLiveQuery({ + query: (q) => q.from({ item: syncedCollection }), + }) const isError = syncedCollection.utils.isError const errorCount = syncedCollection.utils.errorCount diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 9b6ee18b06..eb2bf08f04 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -164,14 +164,15 @@ bindings and reactive updates, use live queries instead. In React, you can use the `useLiveQuery` hook: ```tsx -import { useLiveQuery } from '@tanstack/react-db' +import { eq, useLiveQuery } from '@tanstack/react-db' function UserList() { - const activeUsers = useLiveQuery((q) => - q - .from({ user: usersCollection }) - .where(({ user }) => eq(user.active, true)) - ) + const { data: activeUsers } = useLiveQuery({ + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)), + }) return (
      @@ -206,7 +207,44 @@ export class UserListComponent { } ``` -> **Note:** React hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array parameter to re-execute queries when values change, similar to React's `useEffect`. See the [React Adapter documentation](../framework/react/overview#dependency-arrays) for details on when and how to use dependency arrays. +> **Note:** React hooks derive query identity from structured query IR by +> default. Dependency arrays are still accepted for backwards compatibility, +> but warn in development and will be removed in 1.0. Unhashable queries also +> warn and keep legacy mount-stable identity until 1.0; add `queryKey` to make +> captured opaque values reactive. See the [React Adapter +> documentation](../framework/react/overview#query-identity) for details. + +For server rendering and hydration, live query preloading transports the ordered +query result without implicitly serializing its source collections. Explicit +collection preloading still transports normalized collection rows. See the +[SSR and Hydration guide](./ssr.md). + +#### When React Needs a Query Key + +Use `queryKey` when the query contains opaque runtime logic that cannot be represented in structured IR, such as `.fn.where`, `.fn.select`, or `.fn.having`. The key becomes the explicit identity for that query: + +```tsx +function UserSearch({ search }: { search: string }) { + const { data } = useLiveQuery({ + queryKey: [usersCollection.id, 'search', search], + query: (q) => + q + .from({ user: usersCollection }) + .fn.where(({ user }) => + user.name.toLowerCase().includes(search.toLowerCase()) + ), + }) + + return
      {data.length} users
      +} +``` + +You can also provide a `queryKey` as a performance escape hatch for a very hot render path, but normal structured queries should omit it. + +React development builds detect both cases. Before 1.0, opaque, unhashable IR +warns and keeps legacy mount-stable identity; repeated expensive identity +derivation also warns once. Both warnings point to the same `queryKey` escape +hatch. For more details on framework integration, see the [React](../framework/react/overview), [Vue](../framework/vue/overview), and [Angular](../framework/angular/overview) adapter documentation. @@ -220,11 +258,12 @@ import { Suspense } from 'react' function UserList() { // This will suspend until data is ready - const { data } = useLiveSuspenseQuery((q) => - q - .from({ user: usersCollection }) - .where(({ user }) => eq(user.active, true)) - ) + const { data } = useLiveSuspenseQuery({ + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)), + }) // data is always defined - no need for optional chaining return ( @@ -251,9 +290,9 @@ The key difference from `useLiveQuery` is that `data` is always defined (never ` ```tsx function UserStats() { - const { data } = useLiveSuspenseQuery((q) => - q.from({ user: usersCollection }) - ) + const { data } = useLiveSuspenseQuery({ + query: (q) => q.from({ user: usersCollection }), + }) // TypeScript knows data is Array, not Array | undefined return
      Total users: {data.length}
      @@ -284,9 +323,9 @@ After the initial load, data updates stream in without re-suspending: ```tsx function UserList() { - const { data } = useLiveSuspenseQuery((q) => - q.from({ user: usersCollection }) - ) + const { data } = useLiveSuspenseQuery({ + query: (q) => q.from({ user: usersCollection }), + }) // Suspends once during initial load // After that, data updates automatically when users change @@ -301,19 +340,18 @@ function UserList() { } ``` -#### Re-suspending on Dependency Changes +#### Re-suspending on Query Identity Changes -When dependencies change, the hook re-suspends to load new data: +When the derived query identity changes, the hook re-suspends to load new data: ```tsx function FilteredUsers({ minAge }: { minAge: number }) { - const { data } = useLiveSuspenseQuery( - (q) => + const { data } = useLiveSuspenseQuery({ + query: (q) => q .from({ user: usersCollection }) .where(({ user }) => gt(user.age, minAge)), - [minAge] // Re-suspend when minAge changes - ) + }) return (
        @@ -334,7 +372,7 @@ function FilteredUsers({ minAge }: { minAge: number }) { - The query always needs to run (not conditional) - **Use `useLiveQuery`** when: - - You need conditional/disabled queries + - You prefer conditional rendering for optional query inputs - You prefer handling loading/error states within your component - You want to show loading states inline without Suspense - You need access to `status` and `isLoading` flags @@ -343,9 +381,9 @@ function FilteredUsers({ minAge }: { minAge: number }) { ```tsx // useLiveQuery - handle states in component function UserList() { - const { data, status, isLoading } = useLiveQuery((q) => - q.from({ user: usersCollection }) - ) + const { data, status, isLoading } = useLiveQuery({ + query: (q) => q.from({ user: usersCollection }), + }) if (isLoading) return
        Loading...
        if (status === 'error') return
        Error loading users
        @@ -355,9 +393,9 @@ function UserList() { // useLiveSuspenseQuery - handle states with Suspense/ErrorBoundary function UserList() { - const { data } = useLiveSuspenseQuery((q) => - q.from({ user: usersCollection }) - ) + const { data } = useLiveSuspenseQuery({ + query: (q) => q.from({ user: usersCollection }), + }) return
          {data.map(user =>
        • {user.name}
        • )}
        } @@ -377,9 +415,9 @@ const route = { // In your component: function UserList() { // Collection is already loaded, so data is immediately available - const { data } = useLiveQuery((q) => - q.from({ user: usersCollection }) - ) + const { data } = useLiveQuery({ + query: (q) => q.from({ user: usersCollection }), + }) return
          {data?.map(user =>
        • {user.name}
        • )}
        } @@ -387,28 +425,28 @@ function UserList() { ### Conditional Queries -In React, you can conditionally disable a query by returning `undefined` or `null` from the `useLiveQuery` callback. When disabled, the hook returns a special state indicating the query is not active. +For optional inputs, prefer rendering the query component only after the inputs exist. That avoids creating a live query before all required values exist. ```tsx import { useLiveQuery } from '@tanstack/react-db' -function TodoList({ userId }: { userId?: string }) { - const { data, isEnabled, status } = useLiveQuery((q) => { - // Disable the query when userId is not available - if (!userId) return undefined +function TodosPanel({ userId }: { userId?: string }) { + if (!userId) return
        Please select a user
        - return q - .from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.userId, userId)) - }, [userId]) + return +} - if (!isEnabled) { - return
        Please select a user
        - } +function TodoList({ userId }: { userId: string }) { + const { data } = useLiveQuery({ + query: (q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)), + }) return (
          - {data?.map(todo => ( + {data.map(todo => (
        • {todo.text}
        • ))}
        @@ -416,32 +454,29 @@ function TodoList({ userId }: { userId?: string }) { } ``` -When the query is disabled (callback returns `undefined` or `null`): +The callback form can also return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array. When the query is disabled: - `status` is `'disabled'` - `data`, `state`, and `collection` are `undefined` - `isEnabled` is `false` - `isLoading`, `isReady`, `isIdle`, and `isError` are all `false` -This pattern is useful for "wait until inputs exist" flows without needing to conditionally render the hook itself or manage an external enabled flag. - -### Alternative Callback Return Types - -The `useLiveQuery` callback can return different types depending on your use case: +### Alternative Input Forms #### Returning a Query Builder (Standard) -The most common pattern is to return a query builder: +The standard React pattern is an object with a query builder. For structured queries, React derives the identity from the query IR: ```tsx -const { data } = useLiveQuery((q) => - q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)) -) +const { data } = useLiveQuery({ + query: (q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +}) ``` #### Returning a Pre-created Collection -You can return an existing collection directly: +You can also subscribe to an existing collection directly: ```tsx const activeUsersCollection = createLiveQueryCollection((q) => @@ -449,15 +484,10 @@ const activeUsersCollection = createLiveQueryCollection((q) => .where(({ users }) => eq(users.active, true)) ) -function UserList({ usePrebuilt }: { usePrebuilt: boolean }) { - const { data } = useLiveQuery((q) => { - // Toggle between pre-created collection and ad-hoc query - if (usePrebuilt) return activeUsersCollection - - return q.from({ users: usersCollection }) - }, [usePrebuilt]) +function UserList() { + const { data } = useLiveQuery(activeUsersCollection) - return
          {data?.map(user =>
        • {user.name}
        • )}
        + return
          {data.map(user =>
        • {user.name}
        • )}
        } ``` @@ -466,13 +496,12 @@ function UserList({ usePrebuilt }: { usePrebuilt: boolean }) { You can return a configuration object to specify additional options like a custom ID: ```tsx -const { data } = useLiveQuery((q) => { - return { - query: q.from({ items: itemsCollection }) - .select(({ items }) => ({ id: items.id })), - id: 'items-view', // Custom ID for debugging - gcTime: 10000 // Custom garbage collection time - } +const { data } = useLiveQuery({ + query: (q) => + q.from({ items: itemsCollection }) + .select(({ items }) => ({ id: items.id })), + id: 'items-view', // Custom ID for debugging + gcTime: 10000 // Custom garbage collection time }) ``` @@ -1360,19 +1389,20 @@ import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' function ProjectList() { - const { data: projects } = useLiveQuery((q) => - q.from({ p: projectsCollection }).select(({ p }) => ({ - id: p.id, - name: p.name, - issues: q - .from({ i: issuesCollection }) - .where(({ i }) => eq(i.projectId, p.id)) - .select(({ i }) => ({ - id: i.id, - title: i.title, - })), - })), - ) + const { data: projects } = useLiveQuery({ + query: (q) => + q.from({ p: projectsCollection }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: q + .from({ i: issuesCollection }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + })), + }) return (
          @@ -1613,12 +1643,13 @@ import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' function UserProfile({ userId }: { userId: string }) { - const { data: user, isLoading } = useLiveQuery((q) => - q - .from({ users: usersCollection }) - .where(({ users }) => eq(users.id, userId)) - .findOne() - , [userId]) + const { data: user, isLoading } = useLiveQuery({ + query: (q) => + q + .from({ users: usersCollection }) + .where(({ users }) => eq(users.id, userId)) + .findOne(), + }) if (isLoading) return
          Loading...
          if (!user) return
          User not found
          @@ -2014,14 +2045,15 @@ You can chain multiple reusable filters: ```tsx import { useLiveQuery } from '@tanstack/react-db' -const { data } = useLiveQuery((q) => { - return q - .from({ item: itemsCollection }) - .where(({ item }) => eq(item.id, 1)) - .where(activeItemFilter) // Reusable filter 1 - .where(verifiedItemFilter) // Reusable filter 2 - .select(({ item }) => ({ ...item })) -}, []) +const { data } = useLiveQuery({ + query: (q) => + q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.id, 1)) + .where(activeItemFilter) // Reusable filter 1 + .where(verifiedItemFilter) // Reusable filter 2 + .select(({ item }) => ({ ...item })), +}) ``` #### Using with Different Aliases @@ -2399,7 +2431,9 @@ createEffect({ ### Using with React -The `useLiveQueryEffect` hook manages the effect lifecycle automatically — creating on mount, disposing on unmount, and recreating when dependencies change: +The `useLiveQueryEffect` hook manages the effect lifecycle automatically — +creating on mount, disposing on unmount, and recreating when effect dependencies +change: ```tsx import { useLiveQueryEffect } from '@tanstack/react-db' @@ -2424,7 +2458,10 @@ function ChatComponent({ channelId }: { channelId: string }) { } ``` -The second argument is a dependency array (like `useEffect`). When dependencies change, the old effect is disposed and a new one is created with the updated config. +The second argument is still a React-style dependency array for the effect +lifecycle. This is separate from `useLiveQuery` identity: React live query hooks +derive identity from structured IR by default and use `queryKey` only for opaque +or hot-path queries. ### Complete Example diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 9a60c47dc5..7a269aa36c 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -1653,9 +1653,9 @@ todoCollection.insert({ // Use view key for rendering const TodoList = () => { - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todoCollection }) - ) + const { data: todos } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) return (
            diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md new file mode 100644 index 0000000000..abb14c9062 --- /dev/null +++ b/docs/guides/ssr.md @@ -0,0 +1,826 @@ +--- +title: SSR and Hydration +id: ssr +--- + +# SSR and Hydration + +TanStack DB SSR transports the smallest useful snapshot for the work the server +performed: + +- Explicitly preloaded collections dehydrate as normalized collection rows. +- Preloaded or render-discovered live queries dehydrate as ordered query-result + snapshots, without serializing all of their source collections. + +The browser renders either snapshot immediately, starts its normal collection +sync and live-query pipeline, then atomically replaces a live-query snapshot +when the browser result becomes authoritative. + +## High-level Summary + +The SSR-friendly API adds six concepts: + +- `DbClient` owns materialized collection instances for one request, browser app, + test, or script. +- `collectionOptions(...)` creates a stable collection descriptor. Reusable + descriptors create fresh adapter config for each `DbClient`. +- `dbClient.dehydrate()`, `dbClient.hydrate(state)`, and + `dbClient.applyCollectionChunk(chunk)` move explicit collection state across + the server/client boundary. +- `dbClient.preloadLiveQuery(options)` captures only the ordered result of a + live query for hydration or streaming. +- React and Svelte apps use `DbProvider` so hooks can resolve collection + descriptors against the current client. +- `@tanstack/react-router-with-db` streams live queries discovered by Suspense + during a TanStack Start server render. + +Existing apps continue to work. `createCollection(...)` and direct collection +instances still exist. The migration is required when you want SSR-safe request +isolation, hydration, incremental chunks, Suspense streaming, or the 1.0-ready +React hook shape. + +The old dependency-array form now warns: + +```tsx +useLiveQuery((q) => q.from({ todos }).where(...), [status]) +``` + +It still works, but warns in development and will be removed in 1.0. Prefer: + +```tsx +useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }).where(...), +}) +``` + +React derives live query identity from structured query IR by default. Add +`queryKey` only for opaque functional query logic or for a hot render path where +you want to skip derived identity work. + +## Cheat Sheet + +| Task | Before | SSR-friendly | +| --- | --- | --- | +| Define a collection | `createCollection(options)` | `collectionOptions(id, factory)` | +| Materialize a collection | module-level singleton | `dbClient.collection(todoCollection)` | +| Scope collection state | module lifetime | `new DbClient()` per request/browser/test | +| Provide React context | none | `` | +| Query from React | direct collection instance | descriptor in `from`, resolved by `DbProvider` | +| Mutate from React | import singleton collection | `useDbClient().collection(todoCollection)` | +| Server preload | ad hoc collection preload | `collection.preload()` or `dbClient.preloadLiveQuery(...)` | +| Serialize SSR state | none | `const state = dbClient.dehydrate()` | +| Hydrate in browser | none | `dbClient.hydrate(state)` before hooks read it | +| Apply rows incrementally | custom app state | `dbClient.applyCollectionChunk(chunk)` | +| Stream render-time results | none | `routerWithDbClient(router, dbClient)` | +| React query identity | dependency array | derived IR, or `queryKey` when needed | + +### Minimal React Pattern + +```tsx +import { + DbClient, + DbProvider, + collectionOptions, + eq, + useDbClient, + useLiveQuery, +} from '@tanstack/react-db' + +const todoCollection = collectionOptions('todos', () => ({ + id: 'todos', + getKey: (todo: Todo) => todo.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, +})) + +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} + +function Todos({ status }: { status: string }) { + const todos = useTodoCollection() + + const { data } = useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, status)), + }) + + return ( +
              + {data.map((todo) => ( +
            • todos.update(todo.id, (draft) => { + draft.done = true + })} + > + {todo.title} +
            • + ))} +
            + ) +} + +const dbClient = new DbClient() + +root.render( + + + +) +``` + +The factory matters when config contains mutable adapter state or closures. +Every `DbClient` gets a fresh config and collection instance. First-party +adapter option creators already attach an equivalent factory, so this is also +safe: + +```tsx +const todoCollection = collectionOptions( + localOnlyCollectionOptions({ + id: 'todos', + getKey: (todo) => todo.id, + }) +) +``` + +A descriptor created from an arbitrary concrete config can be materialized by +one `DbClient` only. Use the explicit factory form for custom adapters and +request-scoped dependencies. + +## SSR Flow + +The server and browser use the same descriptors, but different `DbClient` +instances. + +```txt +server request + -> new DbClient() + -> preload an explicit collection or live-query result + -> dbClient.dehydrate() + -> send state through framework loader + +browser + -> new DbClient() + -> dbClient.hydrate(loaderState) + -> + -> useLiveQuery({ query }) + -> start source sync + -> atomically replace any query snapshot with the live result +``` + +During React hydration, descriptor-backed queries read either hydrated +collection rows or their matching query-result snapshot for the first browser +render. Adapter sync and queued on-demand loads start when React commits the +external-store subscription, so the initial markup still matches the server. +The snapshot remains visible while the source is loading. Once the browser live +query is ready, DB publishes one handoff from the snapshot to the live result. + +### Server + +Create a fresh `DbClient` for each request. Materialize descriptors through that +client, preload the data needed for the route, and dehydrate the client. + +```tsx +import { DbClient, collectionOptions, eq } from '@tanstack/db' + +export const todoCollection = collectionOptions('todos', () => ({ + id: 'todos', + getKey: (todo: Todo) => todo.id, + syncMode: 'on-demand', + sync: { + sync: ({ markReady, begin, write, commit }) => { + markReady() + + return { + loadSubset: async () => { + const todos = await api.todos.list() + begin({ immediate: true }) + for (const todo of todos) { + write({ type: 'insert', value: todo }) + } + commit() + return true + }, + } + }, + }, +})) + +export async function loadTodosForSsr() { + const dbClient = new DbClient() + const todos = dbClient.collection(todoCollection) + await todos.preload() + + return dbClient.dehydrate() +} +``` + +This explicit collection preload dehydrates normalized source rows. Use it when +multiple browser queries need the same source data. + +If the source is much larger than the rendered result, preload the query instead: + +```tsx +const dbClient = new DbClient() + +await dbClient.preloadLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, 'open')) + .select(({ todo }) => ({ id: todo.id, title: todo.title })), +}) + +const state = dbClient.dehydrate() +``` + +This payload contains the projected query result and no source collection rows +unless that collection was also materialized explicitly. + +### Browser + +Hydrate the browser client before rendering components that read from DB. + +```tsx +import { + DbClient, + DbProvider, + HydrationBoundary, +} from '@tanstack/react-db' + +function App({ dehydratedDbState }: { dehydratedDbState: DehydratedDbState }) { + const [dbClient] = React.useState(() => new DbClient()) + + return ( + + + + + + ) +} +``` + +Frameworks differ in how loader data reaches the client, but the DB handoff is +the same: `DbClient` on the server, `dehydrate()`, then `hydrate()` into the +browser client. + +### Svelte + +Svelte resolves descriptors from its own `DbProvider` and reads hydrated query +snapshots synchronously during server rendering: + +```svelte + + + + + +``` + +Inside `Todos.svelte`, `useLiveQuery({ query })` can use collection descriptors +directly. The browser subscription starts source sync and performs the same +snapshot-to-live-result handoff as React. + +Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db + +## Suspense Streaming with TanStack Start + +`@tanstack/react-router-with-db` follows the same integration pattern as +`@tanstack/react-router-with-query`: + +```tsx +import { DbClient } from '@tanstack/react-db' +import { createRouter } from '@tanstack/react-router' +import { routerWithDbClient } from '@tanstack/react-router-with-db' + +export type RouterContext = { + dbClient: DbClient +} + +export function getRouter() { + const dbClient = new DbClient() + const router = createRouter({ + routeTree, + context: { dbClient }, + }) + + return routerWithDbClient(router, dbClient) +} +``` + +The adapter adds `dbClient` to router context, wraps the app in `DbProvider`, +dehydrates critical state, and opens a stream for query results discovered later +during rendering. + +```tsx +function RouteComponent() { + return ( + Loading todos

            }> + +
            + ) +} + +function TodoList() { + const { data } = useLiveSuspenseQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, 'open')), + }) + + return data.map((todo) => ) +} +``` + +When `TodoList` suspends on the server, the adapter streams the pending query +promise. That promise resolves to the ordered live-query result snapshot inside +the streamed `DehydratedDbState`. The source collections and D2 graph do not +cross the wire. The browser shows the snapshot, starts the source collections +and live query normally, then replaces the snapshot when the browser result is +ready. + +The server and browser must derive the same live-query identity. Structured +queries do this automatically. An opaque query must provide a serializable +`queryKey`; render-time streaming throws if it cannot derive an identity. + +## Suspense Streaming with Next.js + +Next.js App Router can transport the same pending query promise through React +Server Components. Start the preload without awaiting it, dehydrate the pending +result, and pass that state to a client hydration boundary: + +```tsx +export default function Page() { + const dbClient = new DbClient() + void dbClient.preloadLiveQuery(openTodosQuery) + + const state = dbClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + + return ( + + Loading todos

            }> + +
            +
            + ) +} +``` + +`DbHydration` is a client component that creates one browser `DbClient`, wraps +children in `DbProvider`, and passes `state` to `HydrationBoundary`. React streams +the promise result into that boundary. The full working integration is in +`examples/react/next-ssr-e2e`. + +## Incremental Collection Hydration + +Applications can also apply collection rows received through their own stream. +Incremental hydration uses the same collection chunk shape as holistic +dehydration: + +```ts +dbClient.applyCollectionChunk({ + collectionId: 'todos', + rows: [ + { + key: 'todo-1', + value: { + id: 'todo-1', + title: 'Streamed row', + status: 'open', + }, + metadata: { source: 'stream' }, + }, + ], + syncMeta: { version: 1, cursor: 'abc' }, +}) +``` + +If the target collection is already materialized, the rows apply immediately and +existing live queries react from collection state. If the collection is not +materialized yet, the chunk is stored and applied when that `collectionId` +materializes. + +## What Gets Serialized + +`dbClient.dehydrate()` can emit two independent snapshot types. + +Serialized: + +- explicit collection snapshots: collection id, synced row keys and values, row + metadata, and adapter sync metadata from `exportSyncMeta` +- live-query snapshots: query hash and ordered result rows; completed explicit + preloads are included by default, while framework integrations opt pending + promises into streaming + +Not serialized: + +- mutation handlers +- pending optimistic mutations +- pending subscriptions +- D2 graphs or compiled pipelines +- transaction stacks +- module-level runtime state +- source collection rows for a query-result snapshot, unless that collection was + also explicitly materialized for dehydration + +Choose the payload unit according to what the browser needs. Explicit collection +preloading preserves normalized rows for reuse across queries. Live-query +preloading avoids shipping a 50-100x larger source when the rendered projection +is small. Neither mode serializes executable query state. + +## Sync Metadata + +Adapters can participate in resumable sync with three optional hooks: + +```ts +type SyncConfig = { + exportSyncMeta?: () => unknown + importSyncMeta?: (meta: unknown) => void + mergeSyncMeta?: (current: unknown, incoming: unknown) => unknown +} +``` + +The metadata shape is adapter-owned. Version it inside the adapter payload. If an +adapter cannot understand incoming metadata, it should ignore it and restart +sync from a safe point. + +During hydration, DB imports `syncMeta` into the materialized collection. If the +collection already has current metadata, DB calls `mergeSyncMeta(current, +incoming)` when provided and imports the merged result. + +If an adapter does not implement sync metadata hooks, row snapshots still hydrate +and the adapter can restart sync normally. + +## Initial Data + +`initialData` is a startup seed, not a sync-ready signal. + +Before adapter sync starts, current `DbClient` precedence from lowest to highest +is: + +1. per-materialization `initialData` +2. persisted rows +3. hydrated rows + +Fresh adapter sync is authoritative over all three. Hydrated and initial rows +are provisional base state, so the adapter's first insert for the same key is +reconciled as an update instead of raising a duplicate-key error. + +Hydrated rows and `initialData` never mark adapter sync as ready by themselves. +The adapter still owns readiness through its sync lifecycle. + +## React Query Identity + +React hooks derive live query identity from structured query IR by default: + +```tsx +function Todos({ status }: { status: string }) { + return useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, status)), + }) +} +``` + +The captured `status` value is represented in the structured IR, so no +dependency array or `queryKey` is required. + +Use `queryKey` when the query contains opaque runtime logic that DB cannot +stably represent: + +```tsx +function SearchTodos({ search }: { search: string }) { + return useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => + todo.title.toLowerCase().includes(search.toLowerCase()) + ), + }) +} +``` + +Common reasons to add `queryKey`: + +- `.fn.where(...)` +- `.fn.select(...)` +- `.fn.having(...)` +- function values, symbols, class instances, or circular objects captured inside + the structured query +- a render path where derived identity becomes measurably expensive + +Before 1.0, DB warns when structured IR cannot be hashed and preserves the +legacy mount-stable identity. The query still works, but captured values inside +opaque logic are not reactive unless they are represented in `queryKey`. In 1.0, +an unhashable query without `queryKey` will throw. + +DB also warns once in development if deriving identity becomes expensive enough +that an explicit `queryKey` would be better. + +Dependency arrays are accepted for backwards compatibility: + +```tsx +useLiveQuery((q) => q.from({ todo: todoCollection }), [status]) +``` + +They warn in development and will be removed in 1.0. Migrate to the config +object form: + +```tsx +useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), +}) +``` + +Add `queryKey` only if the query uses opaque logic or trips the performance +warning. + +## Migration Guide + +### 1. Create descriptors instead of SSR singletons + +For collections that need SSR, replace module-level `createCollection(...)` +with a reusable `collectionOptions(...)` descriptor. + +```tsx +// Before +export const todoCollection = createCollection({ + id: 'todos', + getKey: (todo) => todo.id, + sync: todoSync, +}) + +// After +export const todoCollection = collectionOptions('todos', () => ({ + id: 'todos', + getKey: (todo: Todo) => todo.id, + sync: createTodoSync(), +})) +``` + +Put mutable state and closures inside the factory. First-party adapter option +creators can also be passed directly because they provide a fresh config +factory. Collections that never participate in SSR can keep using +`createCollection`. + +### 2. Add a `DbClient` + +Use a new client for every server request and a stable client for each browser +app instance. + +```tsx +const dbClient = new DbClient() +``` + +In tests, create a new client per test unless the test is explicitly covering +shared state. + +### 3. Wrap React with `DbProvider` + +```tsx +root.render( + + + +) +``` + +Hooks that resolve collection descriptors need this provider. Without it, DB +throws instead of falling back to hidden global state. + +### 4. Use collection hooks for imperative operations + +Use descriptors directly in live query sources, and materialize only when you +need collection methods: + +```tsx +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} + +function TodoActions({ id }: { id: string }) { + const todos = useTodoCollection() + + return ( + + ) +} +``` + +This keeps request/client scoping in one place and avoids reintroducing +module-level collections. + +### 5. Replace dependency arrays + +Most queries can drop the dependency array entirely: + +```tsx +// Before +useLiveQuery( + (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, status)), + [status], +) + +// After +useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.status, status)), +}) +``` + +If the query uses opaque functional variants, add `queryKey`: + +```tsx +useLiveQuery({ + queryKey: [todoCollection.id, 'status-fn', status], + query: (q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => todo.status === status), +}) +``` + +### 6. Preload and dehydrate on the server + +Preload a collection when the browser should receive normalized source rows: + +```tsx +const dbClient = new DbClient() +const todos = dbClient.collection(todoCollection) +await todos.preload() + +return { + dbState: dbClient.dehydrate(), +} +``` + +Preload a live query when the browser only needs the rendered result: + +```tsx +const dbClient = new DbClient() +await dbClient.preloadLiveQuery(openTodosQuery) + +return { + dbState: dbClient.dehydrate(), +} +``` + +### 7. Hydrate before client hooks read DB + +```tsx + + + + + +``` + +Imperative integrations can call `client.hydrate(loaderData.dbState)` before +rendering instead. + +## Compatibility + +No existing public API is removed by this change. + +Still supported: + +- `createCollection(...)` +- passing collection instances to `useLiveQuery(...)` +- `useLiveQuery(queryFn, deps)` +- `useLiveSuspenseQuery(queryFn, deps)` +- mutation APIs such as `insert`, `update`, `delete`, `subscribe`, and + optimistic mutation helpers + +Warnings: + +- React dependency arrays warn in development and will be removed in 1.0. +- Opaque query IR without `queryKey` warns in development and keeps legacy + mount-stable identity until 1.0. In 1.0 it will throw. +- Expensive derived identity warns in development and suggests `queryKey`. + +Required for SSR: + +- stable explicit collection ids +- request-scoped server `DbClient` +- browser-scoped client `DbClient` +- `DbProvider` for descriptor resolution in React +- `dehydrate()` on the server and `hydrate()` in the browser + +Required for render-time Suspense streaming: + +- `routerWithDbClient(router, dbClient)` +- `useLiveSuspenseQuery(...)` inside a Suspense boundary +- a stable derived query identity or explicit serializable `queryKey` + +## Detailed Changelog + +### Added + +- `DbClient` +- `collectionOptions(...)` +- `CollectionOptions` descriptor type +- `CollectionMaterializeOptions` +- `DehydratedDbState` +- `DehydratedCollectionChunk` +- `DehydratedCollectionRow` +- `dbClient.collection(descriptor, options?)` +- `dbClient.dehydrate()` +- `dbClient.hydrate(state)` +- `dbClient.applyCollectionChunk(chunk)` +- `dbClient.subscribe(listener)` +- `dbClient.createTransaction(config)` +- `dbClient.cleanup()` +- React `DbProvider` +- React `useDbClient()` +- React `useOptionalDbClient()` +- React `HydrationBoundary` +- React descriptor resolution inside live query builders +- React derived structured query identity +- React `queryKey` escape hatch for opaque or hot-path queries +- React per-query `client` override +- SSR-capable `useSyncExternalStore` server snapshot support +- `dbClient.preloadLiveQuery(...)` +- Svelte `DbProvider`, `useDbClient()`, descriptor resolution, and synchronous + server snapshot support +- TanStack Start and Next.js Playwright SSR E2E coverage +- `@tanstack/react-router-with-db` +- render-time `useLiveSuspenseQuery` promise streaming + +### Changed + +- React `useLiveQuery({ query })` can use collection descriptors directly in + `from`, `join`, `leftJoin`, and `unionAll` sources when a `DbProvider` is + present. +- React live query identity is derived from normalized structured IR when no + explicit `queryKey` or legacy dependency array is supplied. +- Explicit collection preloading serializes normalized collection rows. +- Live-query preloading and render-time discovery serialize ordered result + snapshots without implicitly serializing source collections. +- Browser observers keep the hydrated result visible while normal source sync + starts, then publish one authoritative handoff. +- Hydration applies rows as committed synced state without invoking mutation + handlers or creating optimistic state. +- Hydration and adapter sync begin in a deterministic order: pending rows and + sync metadata are imported before sync starts. +- `DbClient` owns collection instances and ambient transaction scope; cleanup + releases both. +- Incremental chunks use the same collection payload shape as full dehydration. +- Streamed live-query promises resolve to live-query result snapshots. + +### Deprecated + +- React dependency arrays for `useLiveQuery` and wrappers that delegate to it. + They still work and warn in development. They are planned for removal in 1.0. + +### Not Changed + +- `createCollection(...)` remains available. +- Direct collection runtime APIs remain available. +- Vue, Solid, and Angular keep their existing dependency/reactivity model until + they get their own SSR/client-provider work. Svelte is covered by this change. +- Query collection `queryKey` is still TanStack Query's cache key. It is + separate from React live query identity. + +## Validation + +The SSR strategy is covered by: + +- core `DbClient` tests for hydration, streaming chunks, sync metadata, + initial data precedence, explicit ids, and no optimistic serialization +- React tests for `DbProvider`, descriptor resolution, derived query identity, + `queryKey`, deprecation warnings, SSR result snapshots, and atomic handoff +- Svelte tests for provider ownership, server snapshot rendering, and browser + handoff +- query adapter tests to ensure Query cache behavior still holds +- persistence core tests to ensure persisted row behavior remains intact +- TanStack Start and Next.js Playwright E2Es that verify a Suspense fallback, + streamed query result, omitted source-only data, clean hydration, and atomic + replacement by browser sync diff --git a/docs/overview.md b/docs/overview.md index 63f0965d96..d129103a1d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -35,6 +35,7 @@ It extends TanStack Query with collections, live queries and optimistic mutation ## Contents - [How it works](#how-it-works) — understand the TanStack DB development model and how the pieces fit together +- [SSR and hydration](./guides/ssr.md) — use `DbClient` to transport explicit collection rows or live-query result snapshots - [API reference](#api-reference) — for the primitives and function interfaces - [Usage examples](#usage-examples) — examples of common usage patterns - [More info](#more-info) — where to find support and more information @@ -48,21 +49,38 @@ TanStack DB works by: - [making optimistic mutations](#making-optimistic-mutations) using transactional mutators ```tsx -// Define collections to load data into -const todoCollection = createCollection({ +import { + DbClient, + DbProvider, + collectionOptions, + not, + useDbClient, + useLiveQuery, +} from '@tanstack/react-db' + +// Define stable collection descriptors to load data into +const todoCollection = collectionOptions('todos', () => ({ + id: 'todos', // ...your config onUpdate: updateMutationFn, -}) +})) + +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} const Todos = () => { + const todosCollection = useTodoCollection() + // Bind data using live queries - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todoCollection }).where(({ todo }) => not(todo.completed)) - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q.from({ todo: todoCollection }).where(({ todo }) => not(todo.completed)), + }) const complete = (todo) => { // Instantly applies optimistic state - todoCollection.update(todo.id, (draft) => { + todosCollection.update(todo.id, (draft) => { draft.completed = true }) } @@ -77,6 +95,14 @@ const Todos = () => {
          ) } + +const dbClient = new DbClient() + +const App = () => ( + + + +) ``` ### Defining collections @@ -103,14 +129,17 @@ Collections support three sync modes to optimize data loading: With on-demand mode, your component's query becomes the API call: ```tsx -const productsCollection = createCollection( +const productsCollection = collectionOptions('products', (client) => queryCollectionOptions({ + id: 'products', queryKey: ['products'], + queryClient: client.requireDependency('queryClient'), queryFn: async (ctx) => { // Query predicates passed automatically in ctx.meta const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) return api.getProducts(params) // e.g., GET /api/products?category=electronics&price_lt=100 }, + getKey: (product) => product.id, syncMode: 'on-demand', // ← Enable query-driven sync }) ) @@ -143,17 +172,18 @@ Collections support `insert`, `update` and `delete` operations. When called, by ```ts // Define collection with persistence handlers -const todoCollection = createCollection({ +const todoCollection = collectionOptions('todos', () => ({ id: "todos", // ... other config onUpdate: async ({ transaction }) => { const { original, changes } = transaction.mutations[0] await api.todos.update(original.id, changes) }, -}) +})) +const todosCollection = dbClient.collection(todoCollection) // Immediately applies optimistic state -todoCollection.update(todo.id, (draft) => { +todosCollection.update(todo.id, (draft) => { draft.completed = true }) ``` @@ -227,12 +257,14 @@ const todoSchema = z.object({ priority: z.number().default(0) }) -const collection = createCollection( +const todoCollection = collectionOptions( queryCollectionOptions({ + id: "todos", schema: todoSchema, // ... }) ) +const collection = dbClient.collection(todoCollection) // Users provide simple inputs collection.insert({ @@ -269,16 +301,17 @@ import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' const Todos = () => { - const { data: todos } = useLiveQuery((q) => - q - .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.completed, false)) - .orderBy(({ todo }) => todo.created_at, 'asc') - .select(({ todo }) => ({ - id: todo.id, - text: todo.text - })) - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)) + .orderBy(({ todo }) => todo.created_at, 'asc') + .select(({ todo }) => ({ + id: todo.id, + text: todo.text + })), + }) return } @@ -291,21 +324,22 @@ import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' const Todos = () => { - const { data: todos } = useLiveQuery((q) => - q - .from({ todos: todoCollection }) - .join( - { lists: listCollection }, - ({ todos, lists }) => eq(lists.id, todos.listId), - 'inner' - ) - .where(({ lists }) => eq(lists.active, true)) - .select(({ todos, lists }) => ({ - id: todos.id, - title: todos.title, - listName: lists.name - })) - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todos: todoCollection }) + .join( + { lists: listCollection }, + ({ todos, lists }) => eq(lists.id, todos.listId), + 'inner' + ) + .where(({ lists }) => eq(lists.active, true)) + .select(({ todos, lists }) => ({ + id: todos.id, + title: todos.title, + listName: lists.name + })), + }) return } @@ -321,11 +355,12 @@ import { Suspense } from 'react' const Todos = () => { // data is always defined - no need for optional chaining - const { data: todos } = useLiveSuspenseQuery((q) => - q - .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.completed, false)) - ) + const { data: todos } = useLiveSuspenseQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)), + }) return } @@ -397,15 +432,26 @@ The steps are to: 2. implement mutation handlers that handle mutations by posting them to your API endpoints ```tsx -import { useLiveQuery, createCollection } from "@tanstack/react-db" +import { + DbClient, + DbProvider, + collectionOptions, + useLiveQuery, +} from "@tanstack/react-db" import { queryCollectionOptions } from "@tanstack/query-db-collection" +import { QueryClient } from "@tanstack/query-core" + +const queryClient = new QueryClient() +const dbClient = new DbClient({ queryClient }) // Load data into collections using TanStack Query. // It's common to define these in a `collections` module. -const todoCollection = createCollection( +const todoCollection = collectionOptions("todos", (client) => queryCollectionOptions({ + id: "todos", queryKey: ["todos"], - queryFn: async () => fetch("/api/todos"), + queryClient: client.requireDependency("queryClient"), + queryFn: async () => fetch("/api/todos").then((response) => response.json()), getKey: (item) => item.id, schema: todoSchema, // any standard schema onInsert: async ({ transaction }) => { @@ -417,10 +463,13 @@ const todoCollection = createCollection( // also add onUpdate, onDelete as needed. }) ) -const listCollection = createCollection( +const listCollection = collectionOptions("todo-lists", (client) => queryCollectionOptions({ + id: "todo-lists", queryKey: ["todo-lists"], - queryFn: async () => fetch("/api/todo-lists"), + queryClient: client.requireDependency("queryClient"), + queryFn: async () => + fetch("/api/todo-lists").then((response) => response.json()), getKey: (item) => item.id, schema: todoListSchema, onInsert: async ({ transaction }) => { @@ -436,25 +485,32 @@ const listCollection = createCollection( const Todos = () => { // Read the data using live queries. Here we show a live // query that joins across two collections. - const { data: todos } = useLiveQuery((q) => - q - .from({ todo: todoCollection }) - .join( - { list: listCollection }, - ({ todo, list }) => eq(list.id, todo.list_id), - "inner" - ) - .where(({ list }) => eq(list.active, true)) - .select(({ todo, list }) => ({ - id: todo.id, - text: todo.text, - status: todo.status, - listName: list.name, - })) - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .join( + { list: listCollection }, + ({ todo, list }) => eq(list.id, todo.list_id), + "inner" + ) + .where(({ list }) => eq(list.active, true)) + .select(({ todo, list }) => ({ + id: todo.id, + text: todo.text, + status: todo.status, + listName: list.name, + })), + }) // ... } + +const App = () => ( + + + +) ``` This pattern allows you to extend an existing TanStack Query application, or any application built on a REST API, with blazing fast, cross-collection live queries and local optimistic mutations with automatically managed optimistic state. @@ -476,15 +532,14 @@ This pattern enables the "load everything once" approach that makes apps like Li Here, we illustrate this pattern using [ElectricSQL](https://electric-sql.com) as the sync engine, but this pattern also works with other sync engines like [PowerSync](https://www.powersync.com/?utm_source=tanstack&utm_campaign=tanstack_partner), [RxDB](https://rxdb.info/), and [TrailBase](https://trailbase.io/). ```tsx -import type { Collection } from "@tanstack/db" import type { MutationFn, PendingMutation, - createCollection, } from "@tanstack/react-db" +import { collectionOptions, useDbClient } from "@tanstack/react-db" import { electricCollectionOptions } from "@tanstack/electric-db-collection" -export const todoCollection = createCollection( +export const todoCollection = collectionOptions( electricCollectionOptions({ id: "todos", schema: todoSchema, @@ -497,7 +552,6 @@ export const todoCollection = createCollection( }, }, getKey: (item) => item.id, - schema: todoSchema, onInsert: async ({ transaction }) => { const response = await api.todos.create(transaction.mutations[0].modified) @@ -508,9 +562,11 @@ export const todoCollection = createCollection( ) const AddTodo = () => { + const todosCollection = useDbClient().collection(todoCollection) + return (
        ) } + +function App() { + return ( + + + + ) +} ``` You now have collections, live queries, and optimistic mutations! Let's break this down further. +If you are building with SSR, see the [SSR and Hydration guide](./guides/ssr.md) +after this quick start. The short version is that SSR apps use stable +`collectionOptions(...)` descriptors, materialize them through a request-scoped +`DbClient` on the server, then hydrate a browser `DbClient` with explicit +collection rows or a preloaded live-query result before React hooks read from +DB. + ## Installation ```bash -npm install @tanstack/react-db @tanstack/query-db-collection +npm install @tanstack/react-db @tanstack/query-db-collection @tanstack/query-core ``` ## 1. Create a Collection @@ -72,9 +107,11 @@ npm install @tanstack/react-db @tanstack/query-db-collection Collections store your data and handle persistence. The `queryCollectionOptions` loads data using TanStack Query and defines mutation handlers for server sync: ```tsx -const todoCollection = createCollection( +const todoCollection = collectionOptions('todos', (client) => queryCollectionOptions({ + id: 'todos', queryKey: ['todos'], + queryClient: client.requireDependency('queryClient'), queryFn: async () => { const response = await fetch('/api/todos') return response.json() @@ -103,41 +140,58 @@ const todoCollection = createCollection( ) ``` -## 2. Query with Live Queries +The `queryKey` above is TanStack Query's cache key for loading the collection. +React live queries below derive their own identity from structured query IR. + +## 2. Materialize the Collection -Live queries reactively update when data changes. They support filtering, sorting, joins, and transformations: +Use the `DbClient` from context to materialize the descriptor. A tiny collection hook keeps components from repeating the client lookup: + +```tsx +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} +``` + +## 3. Query with Live Queries + +Live queries reactively update when data changes. They support filtering, sorting, joins, and transformations. React hooks derive query identity from the structured query by default, so normal builder queries do not need a separate `queryKey`: ```tsx function TodoList() { // Basic filtering and sorting - const { data: incompleteTodos } = useLiveQuery((q) => - q.from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.completed, false)) - .orderBy(({ todo }) => todo.createdAt, 'desc') - ) + const { data: incompleteTodos } = useLiveQuery({ + query: (q) => + q.from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)) + .orderBy(({ todo }) => todo.createdAt, 'desc'), + }) // Transform the data - const { data: todoSummary } = useLiveQuery((q) => - q.from({ todo: todoCollection }) - .select(({ todo }) => ({ - id: todo.id, - summary: `${todo.text} (${todo.completed ? 'done' : 'pending'})`, - priority: todo.priority || 'normal' - })) - ) + const { data: todoSummary } = useLiveQuery({ + query: (q) => + q.from({ todo: todoCollection }) + .select(({ todo }) => ({ + id: todo.id, + summary: `${todo.text} (${todo.completed ? 'done' : 'pending'})`, + priority: todo.priority || 'normal' + })), + }) return
        {/* Render todos */}
        } ``` -## 3. Optimistic Mutations +## 4. Optimistic Mutations Mutations apply instantly and sync to your server. If the server request fails, changes automatically roll back: ```tsx function TodoActions({ todo }) { + const todosCollection = useTodoCollection() + const addTodo = () => { - todoCollection.insert({ + todosCollection.insert({ id: crypto.randomUUID(), text: 'New todo', completed: false, @@ -146,19 +200,19 @@ function TodoActions({ todo }) { } const toggleComplete = () => { - todoCollection.update(todo.id, (draft) => { + todosCollection.update(todo.id, (draft) => { draft.completed = !draft.completed }) } const updateText = (newText) => { - todoCollection.update(todo.id, (draft) => { + todosCollection.update(todo.id, (draft) => { draft.text = newText }) } const deleteTodo = () => { - todoCollection.delete(todo.id) + todosCollection.delete(todo.id) } return ( diff --git a/docs/reference/interfaces/LiveQuerySnapshot.md b/docs/reference/interfaces/LiveQuerySnapshot.md index fcbe2436f1..5f77269038 100644 --- a/docs/reference/interfaces/LiveQuerySnapshot.md +++ b/docs/reference/interfaces/LiveQuerySnapshot.md @@ -29,7 +29,7 @@ an older snapshot cannot expose rows from a later revision. ### collection ```ts -collection: +collection: | Collection, T> | undefined; ``` diff --git a/docs/reference/interfaces/LiveQueryWindowSnapshot.md b/docs/reference/interfaces/LiveQueryWindowSnapshot.md index 33302cc3af..c2e4386fb3 100644 --- a/docs/reference/interfaces/LiveQueryWindowSnapshot.md +++ b/docs/reference/interfaces/LiveQueryWindowSnapshot.md @@ -28,7 +28,7 @@ A page-windowed view of a live query at a point in time. ### collection ```ts -collection: +collection: | Collection, T> | undefined; ``` diff --git a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md index f01025d905..86a83ad338 100644 --- a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md +++ b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md @@ -6,7 +6,7 @@ title: ResolvedLiveQueryWindowInput # Type Alias: ResolvedLiveQueryWindowInput\ ```ts -type ResolvedLiveQueryWindowInput = +type ResolvedLiveQueryWindowInput = | { collection: Collection; kind: "collection"; diff --git a/examples/react-native/offline-transactions/src/components/TodoList.tsx b/examples/react-native/offline-transactions/src/components/TodoList.tsx index f5aa666c2b..aaf3fe8d2c 100644 --- a/examples/react-native/offline-transactions/src/components/TodoList.tsx +++ b/examples/react-native/offline-transactions/src/components/TodoList.tsx @@ -30,9 +30,12 @@ export function TodoList({ collection, executor }: TodoListProps) { [executor, collection], ) - const { data: todoList = [], isLoading } = useLiveQuery((q) => - q.from({ todo: collection }).orderBy(({ todo }) => todo.createdAt, `desc`), - ) + const { data: todoList = [], isLoading } = useLiveQuery({ + query: (q) => + q + .from({ todo: collection }) + .orderBy(({ todo }) => todo.createdAt, `desc`), + }) // Monitor network status for UI display // (The executor's ReactNativeOnlineDetector handles sync retries internally) diff --git a/examples/react-native/shopping-list/app/list/[id].tsx b/examples/react-native/shopping-list/app/list/[id].tsx index 5e97589059..5d9d1f9c45 100644 --- a/examples/react-native/shopping-list/app/list/[id].tsx +++ b/examples/react-native/shopping-list/app/list/[id].tsx @@ -1,7 +1,6 @@ -import { useLocalSearchParams, Stack } from 'expo-router' +import { Stack, useLocalSearchParams } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' -import { useLiveQuery } from '@tanstack/react-db' -import { eq } from '@tanstack/react-db' +import { eq, useLiveQuery } from '@tanstack/react-db' import { listsCollection } from '../../src/db/collections' import { ListDetail } from '../../src/components/ListDetail' @@ -9,15 +8,14 @@ export default function ListScreen() { const { id } = useLocalSearchParams<{ id: string }>() as { id: string } // Get the list name for the header - const listResult = useLiveQuery((q) => - q - .from({ list: listsCollection }) - .where(({ list }) => eq(list.id, id)) - .select(({ list }) => ({ id: list.id, name: list.name })), - ) - const list = (listResult.data ?? [])[0] as - | { id: string; name: string } - | undefined + const listResult = useLiveQuery({ + query: (q) => + q + .from({ list: listsCollection }) + .where(({ list }) => eq(list.id, id)) + .select(({ list }) => ({ id: list.id, name: list.name })), + }) + const list = listResult.data[0] as { id: string; name: string } | undefined return ( <> diff --git a/examples/react-native/shopping-list/src/components/ListDetail.tsx b/examples/react-native/shopping-list/src/components/ListDetail.tsx index 6bcd31788b..2b3e300e3a 100644 --- a/examples/react-native/shopping-list/src/components/ListDetail.tsx +++ b/examples/react-native/shopping-list/src/components/ListDetail.tsx @@ -82,12 +82,13 @@ export function ListDetail({ listId }: ListDetailProps) { const { itemActions } = useShopping() // Get items for this list - const itemsResult = useLiveQuery((q) => - q - .from({ item: itemsCollection }) - .where(({ item }) => eq(item.listId, listId)) - .orderBy(({ item }) => item.createdAt, `asc`), - ) + const itemsResult = useLiveQuery({ + query: (q) => + q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, listId)) + .orderBy(({ item }) => item.createdAt, `asc`), + }) const items = itemsResult.data as Array const handleAddItem = async () => { diff --git a/examples/react-native/shopping-list/src/components/ListsScreen.tsx b/examples/react-native/shopping-list/src/components/ListsScreen.tsx index 1a5b501c3d..d46b277644 100644 --- a/examples/react-native/shopping-list/src/components/ListsScreen.tsx +++ b/examples/react-native/shopping-list/src/components/ListsScreen.tsx @@ -107,37 +107,38 @@ export function ListsScreen() { // ★ Includes query with aggregate subqueries: each list gets child collections // with computed counts. ListCard subscribes to them via useLiveQuery. - const queryResult = useLiveQuery((q) => - q - .from({ list: listsCollection }) - .select(({ list }) => ({ - id: list.id, - name: list.name, - createdAt: list.createdAt, - $synced: list.$synced, - totalItems: q - .from({ item: itemsCollection }) - .where(({ item }) => eq(item.listId, list.id)) - .select(({ item }) => ({ n: count(item.id) })), - uncheckedPreview: q - .from({ item: itemsCollection }) - .where(({ item }) => eq(item.listId, list.id)) - .where(({ item }) => eq(item.checked, false)) - .select(({ item }) => ({ - id: item.id, - text: item.text, - createdAt: item.createdAt, - })) - .orderBy(({ item }) => item.createdAt, `asc`) - .limit(3), - checkedItems: q - .from({ item: itemsCollection }) - .where(({ item }) => eq(item.listId, list.id)) - .where(({ item }) => eq(item.checked, true)) - .select(({ item }) => ({ n: count(item.id) })), - })) - .orderBy(({ list }) => list.createdAt, `desc`), - ) + const queryResult = useLiveQuery({ + query: (q) => + q + .from({ list: listsCollection }) + .select(({ list }) => ({ + id: list.id, + name: list.name, + createdAt: list.createdAt, + $synced: list.$synced, + totalItems: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .select(({ item }) => ({ n: count(item.id) })), + uncheckedPreview: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .where(({ item }) => eq(item.checked, false)) + .select(({ item }) => ({ + id: item.id, + text: item.text, + createdAt: item.createdAt, + })) + .orderBy(({ item }) => item.createdAt, `asc`) + .limit(3), + checkedItems: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .where(({ item }) => eq(item.checked, true)) + .select(({ item }) => ({ n: count(item.id) })), + })) + .orderBy(({ list }) => list.createdAt, `desc`), + }) const lists = queryResult.data as unknown as Array<{ id: string name: string diff --git a/examples/react/next-ssr-e2e/app/db-hydration.tsx b/examples/react/next-ssr-e2e/app/db-hydration.tsx new file mode 100644 index 0000000000..2bbe8b42cf --- /dev/null +++ b/examples/react/next-ssr-e2e/app/db-hydration.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useState } from 'react' +import { DbClient } from '@tanstack/db' +import { DbProvider, HydrationBoundary } from '@tanstack/react-db' +import type { DehydratedDbState } from '@tanstack/db' +import type { ReactNode } from 'react' + +export function DbHydration({ + state, + children, +}: { + state: DehydratedDbState + children: ReactNode +}) { + const [client] = useState(() => new DbClient({ runtime: `browser` })) + + return ( + + {children} + + ) +} diff --git a/examples/react/next-ssr-e2e/app/layout.tsx b/examples/react/next-ssr-e2e/app/layout.tsx new file mode 100644 index 0000000000..d7cbe9fc83 --- /dev/null +++ b/examples/react/next-ssr-e2e/app/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +export const metadata: Metadata = { + title: `TanStack DB Next.js SSR E2E`, +} + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/examples/react/next-ssr-e2e/app/page.tsx b/examples/react/next-ssr-e2e/app/page.tsx new file mode 100644 index 0000000000..7f6f18bb3d --- /dev/null +++ b/examples/react/next-ssr-e2e/app/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react' +import { DbClient } from '@tanstack/db' +import { DbHydration } from './db-hydration' +import { streamedTodoQuery } from './ssr-fixture' +import { StreamedTodos } from './streamed-todos' + +export const dynamic = `force-dynamic` + +export default function Page() { + const dbClient = new DbClient({ runtime: `server` }) + void dbClient.preloadLiveQuery(streamedTodoQuery) + const state = dbClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + + return ( +
        +

        TanStack DB Next.js SSR

        + + Loading todos

        }> + +
        +
        +
        + ) +} diff --git a/examples/react/next-ssr-e2e/app/ssr-fixture.ts b/examples/react/next-ssr-e2e/app/ssr-fixture.ts new file mode 100644 index 0000000000..c3001cc6f5 --- /dev/null +++ b/examples/react/next-ssr-e2e/app/ssr-fixture.ts @@ -0,0 +1,70 @@ +import { collectionOptions, eq } from '@tanstack/db' +import type { InitialQueryBuilder } from '@tanstack/db' + +export type StreamedTodo = { + id: string + text: string + status: `open` | `done` + sourcePayload: string +} + +const serverTodo: StreamedTodo = { + id: `next-server-1`, + text: `Streamed from Next.js`, + status: `open`, + sourcePayload: `NEXT_SOURCE_ONLY_DO_NOT_TRANSPORT`, +} + +const browserTodo: StreamedTodo = { + id: `next-browser-1`, + text: `Reconciled by Next.js browser sync`, + status: `open`, + sourcePayload: `NEXT_BROWSER_SOURCE_ONLY_DO_NOT_TRANSPORT`, +} + +export const streamedTodoCollection = collectionOptions( + `next-ssr-stream-todos`, + (client) => { + const runtime = client.requireDependency<`server` | `browser`>(`runtime`) + + return { + id: `next-ssr-stream-todos`, + getKey: (todo: StreamedTodo) => todo.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => + new Promise((resolve) => { + setTimeout( + () => { + begin({ immediate: true }) + write({ + type: `insert`, + value: runtime === `server` ? serverTodo : browserTodo, + }) + commit() + resolve() + }, + runtime === `server` ? 1000 : 1500, + ) + }), + } + }, + }, + } + }, +) + +export const streamedTodoQuery = { + query: (q: InitialQueryBuilder) => + q + .from({ todo: streamedTodoCollection }) + .where(({ todo }) => eq(todo.status, `open`)) + .select(({ todo }) => ({ + id: todo.id, + text: todo.text, + status: todo.status, + })), +} diff --git a/examples/react/next-ssr-e2e/app/streamed-todos.tsx b/examples/react/next-ssr-e2e/app/streamed-todos.tsx new file mode 100644 index 0000000000..b893421cae --- /dev/null +++ b/examples/react/next-ssr-e2e/app/streamed-todos.tsx @@ -0,0 +1,18 @@ +'use client' + +import { useLiveSuspenseQuery } from '@tanstack/react-db' +import { streamedTodoQuery } from './ssr-fixture' + +export function StreamedTodos() { + const { data: todos } = useLiveSuspenseQuery(streamedTodoQuery) + + return ( +
          + {todos.map((todo) => ( +
        • + {todo.text} +
        • + ))} +
        + ) +} diff --git a/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts b/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts new file mode 100644 index 0000000000..02d046cc62 --- /dev/null +++ b/examples/react/next-ssr-e2e/e2e/ssr-db.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +test(`Next.js streams a DB result snapshot and hands off to browser sync`, async ({ + page, + request, +}) => { + const response = await request.get(`/`) + expect(response.ok()).toBe(true) + const html = await response.text() + expect(html).toContain(`Streamed from Next.js`) + expect(html).not.toContain(`NEXT_SOURCE_ONLY_DO_NOT_TRANSPORT`) + + const browserErrors: Array = [] + page.on(`console`, (message) => { + if (message.type() === `error`) browserErrors.push(message.text()) + }) + page.on(`pageerror`, (error) => { + browserErrors.push(error.message) + }) + + await page.goto(`/`, { waitUntil: `commit` }) + + await expect(page.getByTestId(`stream-fallback`)).toBeVisible() + await expect(page.getByTestId(`streamed-todo-next-server-1`)).toHaveText( + `Streamed from Next.js`, + ) + await expect(page.getByTestId(`stream-fallback`)).not.toBeVisible() + await expect( + page.getByTestId(`streamed-todo-next-server-1`), + ).not.toBeVisible() + await expect(page.getByTestId(`streamed-todo-next-browser-1`)).toHaveText( + `Reconciled by Next.js browser sync`, + ) + expect(browserErrors).toEqual([]) +}) diff --git a/examples/react/next-ssr-e2e/next.config.ts b/examples/react/next-ssr-e2e/next.config.ts new file mode 100644 index 0000000000..6491458f0a --- /dev/null +++ b/examples/react/next-ssr-e2e/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + reactStrictMode: true, + transpilePackages: [`@tanstack/db`, `@tanstack/react-db`], +} + +export default nextConfig diff --git a/examples/react/next-ssr-e2e/package.json b/examples/react/next-ssr-e2e/package.json new file mode 100644 index 0000000000..c5987333f6 --- /dev/null +++ b/examples/react/next-ssr-e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tanstack/db-example-react-next-ssr-e2e", + "private": true, + "version": "0.0.0", + "scripts": { + "build": "next build", + "dev": "next dev", + "start": "next start", + "test:e2e": "pnpm --filter @tanstack/db build && pnpm --filter @tanstack/react-db build && playwright test" + }, + "dependencies": { + "@tanstack/db": "workspace:*", + "@tanstack/react-db": "workspace:*", + "next": "^16.3.1", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^25.2.2", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.2" + } +} diff --git a/examples/react/next-ssr-e2e/playwright.config.ts b/examples/react/next-ssr-e2e/playwright.config.ts new file mode 100644 index 0000000000..7a6ae0db52 --- /dev/null +++ b/examples/react/next-ssr-e2e/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:4176` +const shouldStartWebServer = process.env.PLAYWRIGHT_BASE_URL === undefined + +export default defineConfig({ + testDir: `./e2e`, + timeout: 30000, + expect: { + timeout: 10000, + }, + fullyParallel: false, + use: { + baseURL, + trace: `on-first-retry`, + }, + webServer: shouldStartWebServer + ? { + command: `pnpm dev --hostname 127.0.0.1 --port 4176`, + reuseExistingServer: !process.env.CI, + timeout: 120000, + url: baseURL, + } + : undefined, + projects: [ + { + name: `chromium`, + use: { ...devices[`Desktop Chrome`] }, + }, + ], +}) diff --git a/examples/react/next-ssr-e2e/tsconfig.json b/examples/react/next-ssr-e2e/tsconfig.json new file mode 100644 index 0000000000..b134f6f799 --- /dev/null +++ b/examples/react/next-ssr-e2e/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": ["node_modules"] +} diff --git a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx index e7252f5f4e..c7f25cd99c 100644 --- a/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx +++ b/examples/react/offline-transactions/src/components/PersistedTodoDemo.tsx @@ -11,9 +11,12 @@ export function PersistedTodoDemo({ collection }: PersistedTodoDemoProps) { const [newTodoText, setNewTodoText] = useState(``) const [error, setError] = useState(null) - const { data: todoList = [] } = useLiveQuery((q) => - q.from({ todo: collection }).orderBy(({ todo }) => todo.createdAt, `desc`), - ) + const { data: todoList = [] } = useLiveQuery({ + query: (q) => + q + .from({ todo: collection }) + .orderBy(({ todo }) => todo.createdAt, `desc`), + }) const handleAddTodo = () => { if (!newTodoText.trim()) return diff --git a/examples/react/offline-transactions/src/components/TodoDemo.tsx b/examples/react/offline-transactions/src/components/TodoDemo.tsx index fcdf088da1..4f95b79a92 100644 --- a/examples/react/offline-transactions/src/components/TodoDemo.tsx +++ b/examples/react/offline-transactions/src/components/TodoDemo.tsx @@ -25,11 +25,12 @@ export function TodoDemo({ console.log({ offline, actions }) // Use live query to get todos - const { data: todoList = [], isLoading } = useLiveQuery((q) => - q - .from({ todo: todoCollection }) - .orderBy(({ todo }) => todo.createdAt, `desc`), - ) + const { data: todoList = [], isLoading } = useLiveQuery({ + query: (q) => + q + .from({ todo: todoCollection }) + .orderBy(({ todo }) => todo.createdAt, `desc`), + }) // Monitor online status useEffect(() => { diff --git a/examples/react/projects/src/routes/_authenticated.tsx b/examples/react/projects/src/routes/_authenticated.tsx index 17ed734276..43142ee5ca 100644 --- a/examples/react/projects/src/routes/_authenticated.tsx +++ b/examples/react/projects/src/routes/_authenticated.tsx @@ -20,7 +20,9 @@ function AuthenticatedLayout() { const [showNewProjectForm, setShowNewProjectForm] = useState(false) const [newProjectName, setNewProjectName] = useState(``) - const { data: projects } = useLiveQuery((q) => q.from({ projectCollection })) + const { data: projects } = useLiveQuery({ + query: (q) => q.from({ projectCollection }), + }) const handleLogout = async () => { await authClient.signOut() diff --git a/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx b/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx index 0ac0be409e..a60c4848d3 100644 --- a/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx +++ b/examples/react/projects/src/routes/_authenticated/project/$projectId.tsx @@ -25,41 +25,40 @@ export const Route = createFileRoute(`/_authenticated/project/$projectId`)({ function ProjectPage() { const { projectId } = Route.useParams() + const projectIdNumber = parseInt(projectId, 10) const { data: session } = authClient.useSession() const [newTodoText, setNewTodoText] = useState(``) - const { data: todos } = useLiveQuery( - (q) => + const { data: todos } = useLiveQuery({ + query: (q) => q .from({ todo: todoCollection }) - .where(({ todo }) => eq(todo.project_id, parseInt(projectId, 10))) + .where(({ todo }) => eq(todo.project_id, projectIdNumber)) .orderBy(({ todo }) => todo.created_at), - [projectId] - ) - - const { data: users } = useLiveQuery((q) => - q.from({ users: usersCollection }) - ) - const { data: usersInProjects } = useLiveQuery( - (q) => + }) + + const { data: users } = useLiveQuery({ + query: (q) => q.from({ users: usersCollection }), + }) + const { data: usersInProjects } = useLiveQuery({ + queryKey: [projectCollection.id, `users-in-project`, projectIdNumber], + query: (q) => q .from({ projects: projectCollection }) - .where(({ projects }) => eq(projects.id, parseInt(projectId, 10))) + .where(({ projects }) => eq(projects.id, projectIdNumber)) .fn.select(({ projects }) => ({ users: projects.shared_user_ids.concat(projects.owner_id), owner: projects.owner_id, })), - [projectId] - ) + }) const usersInProject = usersInProjects[0] - const { data: projects } = useLiveQuery( - (q) => + const { data: projects } = useLiveQuery({ + query: (q) => q .from({ p: projectCollection }) - .where(({ p }) => eq(p.id, parseInt(projectId, 10))), - [projectId] - ) + .where(({ p }) => eq(p.id, projectIdNumber)), + }) const project = projects[0] const addTodo = () => { @@ -69,7 +68,7 @@ function ProjectPage() { id: Math.floor(Math.random() * 100000), text: newTodoText.trim(), completed: false, - project_id: parseInt(projectId), + project_id: projectIdNumber, user_ids: [], created_at: new Date(), }) diff --git a/examples/react/start-ssr-e2e/README.md b/examples/react/start-ssr-e2e/README.md new file mode 100644 index 0000000000..3fbfd0d042 --- /dev/null +++ b/examples/react/start-ssr-e2e/README.md @@ -0,0 +1,55 @@ +# TanStack DB Start SSR Demo + +This example is a minimal TanStack Start app that demonstrates TanStack DB SSR +with collection-row hydration. + +It verifies five things: + +- server HTML contains rows loaded through a request-scoped `DbClient` +- the browser hydrates those rows into a client `DbClient` +- fresh adapter sync replaces a stale hydrated row with the same key +- an incremental collection chunk updates an existing live query +- critical collection rows hydrate while a query discovered later in the same + render streams through a Suspense boundary + +Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db + +## Run Locally + +```sh +pnpm --filter @tanstack/db build +pnpm --filter @tanstack/react-db build +pnpm --filter @tanstack/db-example-react-start-ssr-e2e dev +``` + +Open `/ssr-db` for holistic and incremental hydration, or `/ssr-db-stream` for +render-time Suspense streaming. + +## Run E2E + +```sh +pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e +``` + +The Playwright tests cover raw SSR HTML, browser hydration, fresh-sync +reconciliation, incremental collection hydration, and critical hydration plus a +render-time query in the same request. The streaming route shows its Suspense +fallback before the streamed server rows arrive. + +## Deploy Demo + +The demo requires an SSR-capable host for TanStack Start. + +Netlify deployment is configured through `netlify.toml` and +`netlify/functions/server.mjs`. Deploy with: + +```sh +cd examples/react/start-ssr-e2e +netlify deploy --prod --site-name tanstack-db-ssr-demo --team tanstack +``` + +After deployment, verify the live URL with: + +```sh +PLAYWRIGHT_BASE_URL=https://your-demo-url pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e:hosted +``` diff --git a/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts new file mode 100644 index 0000000000..f9461d6fac --- /dev/null +++ b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test' + +test(`TanStack Start hydrates, reconciles, and incrementally applies DB rows`, async ({ + page, + request, +}) => { + const response = await request.get(`/ssr-db`) + expect(response.ok()).toBe(true) + + const html = await response.text() + expect(html).toContain(`Pay invoices`) + expect(html).not.toContain(`Pay invoices (reconciled from sync)`) + expect(html).toContain(`Review pull requests`) + expect(html).toContain(`ssr`) + expect(html).not.toContain(`Streamed from collection chunk`) + + const browserErrors: Array = [] + page.on(`console`, (message) => { + if (message.type() === `error`) { + browserErrors.push(message.text()) + } + }) + page.on(`pageerror`, (error) => { + browserErrors.push(error.message) + }) + + await page.goto(`/ssr-db`) + + await expect(page.getByTestId(`hydration-state`)).toHaveText(`hydrated`) + await expect(page.getByTestId(`ready-state`)).toHaveText(`ready`) + await expect(page.getByTestId(`streamed-status`)).toHaveText(`waiting`) + await expect(page.getByTestId(`ssr-row-count`)).toHaveText(`2`) + await expect(page.getByTestId(`ssr-todo-list`)).toContainText( + `Pay invoices (reconciled from sync)`, + ) + await expect(page.getByTestId(`ssr-todo-server-1`)).toContainText(`(sync)`) + await expect(page.getByTestId(`ssr-todo-list`)).toContainText( + `Review pull requests`, + ) + await expect(page.getByTestId(`ssr-todo-list`)).not.toContainText( + `Archived roadmap`, + ) + + await page.getByTestId(`apply-stream-chunk`).click() + + await expect(page.getByTestId(`streamed-status`)).toHaveText(`streamed`) + await expect(page.getByTestId(`ssr-row-count`)).toHaveText(`3`) + await expect(page.getByTestId(`ssr-todo-streamed-1`)).toBeVisible() + await expect(page.getByTestId(`ssr-todo-streamed-1`)).toContainText( + `Streamed from collection chunk`, + ) + expect(browserErrors).toEqual([]) +}) + +test(`TanStack Start streams a DB result snapshot and hands off to browser sync`, async ({ + page, + request, +}) => { + const response = await request.get(`/ssr-db-stream`) + expect(response.ok()).toBe(true) + const html = await response.text() + expect(html).toContain(`Streamed while rendering`) + expect(html).not.toContain(`SOURCE_ONLY_DO_NOT_TRANSPORT`) + + const browserErrors: Array = [] + page.on(`console`, (message) => { + if (message.type() === `error`) { + browserErrors.push(message.text()) + } + }) + page.on(`pageerror`, (error) => { + browserErrors.push(error.message) + }) + + await page.goto(`/ssr-db-stream`, { waitUntil: `commit` }) + + await expect(page.getByTestId(`critical-todo-server-1`)).toContainText( + `Pay invoices`, + ) + await expect(page.getByTestId(`stream-fallback`)).toBeVisible() + await expect(page.getByTestId(`streamed-todo-list`)).not.toBeVisible() + await expect(page.getByTestId(`streamed-todo-streamed-server-1`)).toHaveText( + `Streamed while rendering`, + ) + await expect(page.getByTestId(`stream-fallback`)).not.toBeVisible() + await expect( + page.getByTestId(`streamed-todo-streamed-server-1`), + ).not.toBeVisible() + await expect(page.getByTestId(`streamed-todo-streamed-browser-1`)).toHaveText( + `Reconciled from browser sync`, + ) + expect(browserErrors).toEqual([]) +}) diff --git a/examples/react/start-ssr-e2e/netlify.toml b/examples/react/start-ssr-e2e/netlify.toml new file mode 100644 index 0000000000..8b7d900e4c --- /dev/null +++ b/examples/react/start-ssr-e2e/netlify.toml @@ -0,0 +1,8 @@ +[build] +command = "pnpm build" +publish = "dist/client" +functions = "netlify/functions" + +[functions] +node_bundler = "esbuild" +included_files = ["dist/server/**"] diff --git a/examples/react/start-ssr-e2e/netlify/functions/server.mjs b/examples/react/start-ssr-e2e/netlify/functions/server.mjs new file mode 100644 index 0000000000..ce2ee0d83d --- /dev/null +++ b/examples/react/start-ssr-e2e/netlify/functions/server.mjs @@ -0,0 +1,10 @@ +import server from '../../dist/server/server.js' + +export const config = { + path: '/*', + preferStatic: true, +} + +export default function handler(request) { + return server.fetch(request) +} diff --git a/examples/react/start-ssr-e2e/package.json b/examples/react/start-ssr-e2e/package.json new file mode 100644 index 0000000000..8f8788a9be --- /dev/null +++ b/examples/react/start-ssr-e2e/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tanstack/db-example-react-start-ssr-e2e", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite dev", + "test:e2e": "pnpm --filter @tanstack/db build && pnpm --filter @tanstack/react-db build && pnpm --filter @tanstack/react-router-with-db build && playwright test", + "test:e2e:hosted": "playwright test" + }, + "dependencies": { + "@tanstack/react-db": "^0.2.1", + "@tanstack/react-router": "^1.159.5", + "@tanstack/react-router-with-db": "workspace:*", + "@tanstack/react-start": "^1.159.5", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vite-tsconfig-paths": "^5.1.4" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^25.2.2", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.3", + "typescript": "^5.9.2", + "vite": "^7.3.0" + } +} diff --git a/examples/react/start-ssr-e2e/playwright.config.ts b/examples/react/start-ssr-e2e/playwright.config.ts new file mode 100644 index 0000000000..ef3628ad29 --- /dev/null +++ b/examples/react/start-ssr-e2e/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:4175` +const shouldStartWebServer = process.env.PLAYWRIGHT_BASE_URL === undefined + +export default defineConfig({ + testDir: `./e2e`, + timeout: 30000, + expect: { + timeout: 10000, + }, + fullyParallel: false, + use: { + baseURL, + trace: `on-first-retry`, + }, + webServer: shouldStartWebServer + ? { + command: `pnpm dev --host 127.0.0.1 --port 4175`, + reuseExistingServer: !process.env.CI, + timeout: 120000, + url: baseURL, + } + : undefined, + projects: [ + { + name: `chromium`, + use: { ...devices[`Desktop Chrome`] }, + }, + ], +}) diff --git a/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts b/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts new file mode 100644 index 0000000000..affaeda9bd --- /dev/null +++ b/examples/react/start-ssr-e2e/src/lib/ssr-fixture.ts @@ -0,0 +1,108 @@ +import { DbClient, collectionOptions, eq } from '@tanstack/react-db' +import type { DehydratedDbState } from '@tanstack/react-db' + +export type SsrTodo = { + id: string + text: string + status: `open` | `done` + source: `server` | `sync` | `stream` +} + +export const ssrTodoCollectionId = `ssr-e2e-todos` + +const serverTodos: Array = [ + { + id: `server-1`, + text: `Pay invoices`, + status: `open`, + source: `server`, + }, + { + id: `server-2`, + text: `Review pull requests`, + status: `open`, + source: `server`, + }, + { + id: `server-3`, + text: `Archived roadmap`, + status: `done`, + source: `server`, + }, +] + +const browserTodos: Array = serverTodos.map((todo) => + todo.id === `server-1` + ? { + ...todo, + text: `Pay invoices (reconciled from sync)`, + source: `sync`, + } + : { ...todo, source: `sync` }, +) + +export const streamedTodo: SsrTodo = { + id: `streamed-1`, + text: `Streamed from collection chunk`, + status: `open`, + source: `stream`, +} + +export const ssrTodoCollection = collectionOptions(ssrTodoCollectionId, () => ({ + id: ssrTodoCollectionId, + getKey: (todo: SsrTodo) => todo.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + + return { + loadSubset: () => { + const todos = + typeof window === `undefined` ? serverTodos : browserTodos + + begin({ immediate: true }) + for (const todo of todos) { + write({ + type: `insert`, + value: todo, + }) + } + commit() + return true + }, + } + }, + }, +})) + +export async function preloadSsrTodos(dbClient: DbClient): Promise { + await dbClient.preloadLiveQuery({ + query: (q) => + q + .from({ todo: ssrTodoCollection }) + .where(({ todo }) => eq(todo.status, `open`)), + }) +} + +export async function createDehydratedSsrTodoState(): Promise { + const dbClient = new DbClient() + try { + await preloadSsrTodos(dbClient) + return dbClient.dehydrate() + } finally { + await dbClient.cleanup() + } +} + +export function applyStreamedTodo(dbClient: DbClient): void { + dbClient.applyCollectionChunk({ + collectionId: ssrTodoCollectionId, + rows: [ + { + key: streamedTodo.id, + value: streamedTodo, + }, + ], + }) +} diff --git a/examples/react/start-ssr-e2e/src/main.tsx b/examples/react/start-ssr-e2e/src/main.tsx new file mode 100644 index 0000000000..7c9866dcd3 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider } from '@tanstack/react-router' +import { getRouter } from './router' + +const router = getRouter() + +createRoot(document.getElementById(`root`)!).render( + + + , +) diff --git a/examples/react/start-ssr-e2e/src/routeTree.gen.ts b/examples/react/start-ssr-e2e/src/routeTree.gen.ts new file mode 100644 index 0000000000..b6d5a30439 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routeTree.gen.ts @@ -0,0 +1,105 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SsrDbStreamRouteImport } from './routes/ssr-db-stream' +import { Route as SsrDbRouteImport } from './routes/ssr-db' +import { Route as IndexRouteImport } from './routes/index' + +const SsrDbStreamRoute = SsrDbStreamRouteImport.update({ + id: '/ssr-db-stream', + path: '/ssr-db-stream', + getParentRoute: () => rootRouteImport, +} as any) +const SsrDbRoute = SsrDbRouteImport.update({ + id: '/ssr-db', + path: '/ssr-db', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/ssr-db': typeof SsrDbRoute + '/ssr-db-stream': typeof SsrDbStreamRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/ssr-db': typeof SsrDbRoute + '/ssr-db-stream': typeof SsrDbStreamRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/ssr-db': typeof SsrDbRoute + '/ssr-db-stream': typeof SsrDbStreamRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/ssr-db' | '/ssr-db-stream' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/ssr-db' | '/ssr-db-stream' + id: '__root__' | '/' | '/ssr-db' | '/ssr-db-stream' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + SsrDbRoute: typeof SsrDbRoute + SsrDbStreamRoute: typeof SsrDbStreamRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/ssr-db-stream': { + id: '/ssr-db-stream' + path: '/ssr-db-stream' + fullPath: '/ssr-db-stream' + preLoaderRoute: typeof SsrDbStreamRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr-db': { + id: '/ssr-db' + path: '/ssr-db' + fullPath: '/ssr-db' + preLoaderRoute: typeof SsrDbRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + SsrDbRoute: SsrDbRoute, + SsrDbStreamRoute: SsrDbStreamRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.tsx' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/examples/react/start-ssr-e2e/src/router.tsx b/examples/react/start-ssr-e2e/src/router.tsx new file mode 100644 index 0000000000..b091853cb9 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/router.tsx @@ -0,0 +1,27 @@ +import { createRouter as createTanstackRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { DbClient } from '@tanstack/react-db' +import { routerWithDbClient } from '@tanstack/react-router-with-db' +import { routeTree } from './routeTree.gen' +import './styles.css' + +export type RouterContext = { + dbClient: DbClient +} + +const getRuntime = createIsomorphicFn() + .server(() => `server` as const) + .client(() => `browser` as const) + +export function getRouter() { + const dbClient = new DbClient({ + runtime: getRuntime(), + }) + const router = createTanstackRouter({ + routeTree, + context: { dbClient }, + scrollRestoration: true, + }) + + return routerWithDbClient(router, dbClient) +} diff --git a/examples/react/start-ssr-e2e/src/routes/__root.tsx b/examples/react/start-ssr-e2e/src/routes/__root.tsx new file mode 100644 index 0000000000..ad71067417 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/__root.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import { + HeadContent, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/react-router' +import appCss from '../styles.css?url' +import type { RouterContext } from '../router' + +export const Route = createRootRouteWithContext()({ + head: () => ({ + meta: [ + { + charSet: `utf-8`, + }, + { + name: `viewport`, + content: `width=device-width, initial-scale=1`, + }, + { + title: `TanStack DB Start SSR E2E`, + }, + ], + links: [ + { + rel: `stylesheet`, + href: appCss, + }, + ], + }), + shellComponent: RootDocument, + component: () => , +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ) +} diff --git a/examples/react/start-ssr-e2e/src/routes/index.tsx b/examples/react/start-ssr-e2e/src/routes/index.tsx new file mode 100644 index 0000000000..1131099207 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute(`/`)({ + component: HomePage, +}) + +function HomePage() { + return ( +
        +

        TanStack DB Start SSR E2E

        + Open SSR DB route +
        + ) +} diff --git a/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx b/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx new file mode 100644 index 0000000000..14c3aea8e1 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/ssr-db-stream.tsx @@ -0,0 +1,131 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + collectionOptions, + eq, + useLiveQuery, + useLiveSuspenseQuery, +} from '@tanstack/react-db' +import { preloadSsrTodos, ssrTodoCollection } from '../lib/ssr-fixture' + +type StreamedTodo = { + id: string + text: string + status: `open` | `done` + sourcePayload: string +} + +const serverTodo: StreamedTodo = { + id: `streamed-server-1`, + text: `Streamed while rendering`, + status: `open`, + sourcePayload: `SOURCE_ONLY_DO_NOT_TRANSPORT`, +} + +const browserTodo: StreamedTodo = { + id: `streamed-browser-1`, + text: `Reconciled from browser sync`, + status: `open`, + sourcePayload: `BROWSER_SOURCE_ONLY_DO_NOT_TRANSPORT`, +} + +const streamedTodoCollection = collectionOptions( + `ssr-suspense-stream-todos`, + (client) => { + const runtime = client.requireDependency<`server` | `browser`>(`runtime`) + + return { + id: `ssr-suspense-stream-todos`, + getKey: (todo: StreamedTodo) => todo.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + + return { + loadSubset: () => { + return new Promise((resolve) => { + setTimeout( + () => { + begin({ immediate: true }) + write({ + type: `insert`, + value: runtime === `server` ? serverTodo : browserTodo, + }) + commit() + resolve() + }, + runtime === `server` ? 1000 : 1500, + ) + }) + }, + } + }, + }, + } + }, +) + +export const Route = createFileRoute(`/ssr-db-stream`)({ + loader: async ({ context }) => { + await preloadSsrTodos(context.dbClient) + }, + component: SsrDbStreamRoute, +}) + +function SsrDbStreamRoute() { + return ( +
        +

        TanStack DB Suspense Streaming

        + + Loading todos

        } + > + +
        +
        + ) +} + +function CriticalTodoList() { + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: ssrTodoCollection }) + .where(({ todo }) => eq(todo.status, `open`)), + }) + + return ( +
          + {todos.map((todo) => ( +
        • + {todo.text} +
        • + ))} +
        + ) +} + +function StreamedTodoList() { + const { data: todos } = useLiveSuspenseQuery({ + query: (q) => + q + .from({ todo: streamedTodoCollection }) + .where(({ todo }) => eq(todo.status, `open`)) + .select(({ todo }) => ({ + id: todo.id, + text: todo.text, + status: todo.status, + })), + }) + + return ( +
          + {todos.map((todo) => ( +
        • + {todo.text} +
        • + ))} +
        + ) +} diff --git a/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx new file mode 100644 index 0000000000..6b31a87460 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/ssr-db.tsx @@ -0,0 +1,110 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { eq, useLiveQuery } from '@tanstack/react-db' +import { + applyStreamedTodo, + createDehydratedSsrTodoState, + ssrTodoCollection, +} from '../lib/ssr-fixture' +import type { DbClient } from '@tanstack/react-db' + +export const Route = createFileRoute(`/ssr-db`)({ + loader: async () => { + return { + dbState: await createDehydratedSsrTodoState(), + } + }, + component: SsrDbRoute, +}) + +function SsrDbRoute() { + const { dbState } = Route.useLoaderData() + const { dbClient } = Route.useRouteContext() + const [hydratedDbClient] = React.useState(() => { + dbClient.hydrate(dbState) + return dbClient + }) + + return +} + +function SsrDbTodos({ dbClient }: { dbClient: DbClient }) { + const [hydrated, setHydrated] = React.useState(false) + const [streamed, setStreamed] = React.useState(false) + const { data: todos, isReady } = useLiveQuery({ + query: (q) => + q + .from({ todo: ssrTodoCollection }) + .where(({ todo }) => eq(todo.status, `open`)) + .orderBy(({ todo }) => todo.id, `asc`), + }) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( +
        +
        +

        TanStack DB SSR

        + +
        + + {hydrated ? `hydrated` : `ssr`} + + {isReady ? `ready` : `loading`} + + {streamed ? `streamed` : `waiting`} + + + rows: {todos.length} + +
        + +
          + {todos.map((todo) => ( +
        • + {todo.text} ({todo.source}) +
        • + ))} +
        + + +
        +
        + ) +} diff --git a/examples/react/start-ssr-e2e/src/start.tsx b/examples/react/start-ssr-e2e/src/start.tsx new file mode 100644 index 0000000000..bb197cafb1 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/start.tsx @@ -0,0 +1,7 @@ +import { createStart } from '@tanstack/react-start' + +export const startInstance = createStart(() => { + return { + defaultSsr: true, + } +}) diff --git a/examples/react/start-ssr-e2e/src/styles.css b/examples/react/start-ssr-e2e/src/styles.css new file mode 100644 index 0000000000..251e05865f --- /dev/null +++ b/examples/react/start-ssr-e2e/src/styles.css @@ -0,0 +1,15 @@ +body { + margin: 0; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +button { + font: inherit; +} diff --git a/examples/react/start-ssr-e2e/tsconfig.json b/examples/react/start-ssr-e2e/tsconfig.json new file mode 100644 index 0000000000..19dcb2d948 --- /dev/null +++ b/examples/react/start-ssr-e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "e2e/**/*.ts", + "playwright.config.ts", + "src/**/*.ts", + "src/**/*.tsx", + "vite.config.ts" + ], + "exclude": ["dist", "node_modules"] +} diff --git a/examples/react/start-ssr-e2e/vite.config.ts b/examples/react/start-ssr-e2e/vite.config.ts new file mode 100644 index 0000000000..856428d501 --- /dev/null +++ b/examples/react/start-ssr-e2e/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteTsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [ + viteTsConfigPaths({ + projects: [`./tsconfig.json`], + }), + tanstackStart({ + srcDirectory: `src`, + start: { entry: `./start.tsx` }, + }), + react(), + ], +}) diff --git a/examples/react/todo/src/routes/electric.tsx b/examples/react/todo/src/routes/electric.tsx index 61629b81f2..16da41b9ff 100644 --- a/examples/react/todo/src/routes/electric.tsx +++ b/examples/react/todo/src/routes/electric.tsx @@ -24,15 +24,16 @@ export const Route = createFileRoute(`/electric`)({ function ElectricPage() { // Get data using live queries with Electric collections - const { data: todos } = useLiveQuery((q) => - q - .from({ todo: electricTodoCollection }) - .orderBy(({ todo }) => todo.created_at, `asc`), - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: electricTodoCollection }) + .orderBy(({ todo }) => todo.created_at, `asc`), + }) - const { data: configData } = useLiveQuery((q) => - q.from({ config: electricConfigCollection }), - ) + const { data: configData } = useLiveQuery({ + query: (q) => q.from({ config: electricConfigCollection }), + }) // Electric collections use txid to track sync const configMutationFn = async ({ diff --git a/examples/react/todo/src/routes/query.tsx b/examples/react/todo/src/routes/query.tsx index 62c0ad37dc..5cbf4f28de 100644 --- a/examples/react/todo/src/routes/query.tsx +++ b/examples/react/todo/src/routes/query.tsx @@ -21,15 +21,16 @@ export const Route = createFileRoute(`/query`)({ function QueryPage() { // Get data using live queries with Query collections - const { data: todos } = useLiveQuery((q) => - q - .from({ todo: queryTodoCollection }) - .orderBy(({ todo }) => todo.created_at, `asc`), - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: queryTodoCollection }) + .orderBy(({ todo }) => todo.created_at, `asc`), + }) - const { data: configData } = useLiveQuery((q) => - q.from({ config: queryConfigCollection }), - ) + const { data: configData } = useLiveQuery({ + query: (q) => q.from({ config: queryConfigCollection }), + }) // Query collections automatically refetch after handler completes const configMutationFn = async ({ diff --git a/examples/react/todo/src/routes/trailbase.tsx b/examples/react/todo/src/routes/trailbase.tsx index 96e05e11ac..d4b5b46556 100644 --- a/examples/react/todo/src/routes/trailbase.tsx +++ b/examples/react/todo/src/routes/trailbase.tsx @@ -22,15 +22,16 @@ export const Route = createFileRoute(`/trailbase`)({ function TrailBasePage() { // Get data using live queries with TrailBase collections - const { data: todos } = useLiveQuery((q) => - q - .from({ todo: trailBaseTodoCollection }) - .orderBy(({ todo }) => todo.created_at, `asc`), - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: trailBaseTodoCollection }) + .orderBy(({ todo }) => todo.created_at, `asc`), + }) - const { data: configData } = useLiveQuery((q) => - q.from({ config: trailBaseConfigCollection }), - ) + const { data: configData } = useLiveQuery({ + query: (q) => q.from({ config: trailBaseConfigCollection }), + }) // Note: TrailBase collections use recordApi internally, which is not exposed // as a collection utility. For this example, we're not using serialized diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index ca60a050ab..be32f3eb00 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -2,6 +2,7 @@ import { compileSingleRowExpression, safeRandomUUID, toBooleanPredicate, + withCollectionConfigFactory, } from '@tanstack/db' import { InvalidPersistedCollectionConfigError, @@ -1244,6 +1245,9 @@ class PersistedCollectionRuntime< this.syncControls.begin?.({ immediate: true }) for (const row of rows) { + if (this.collection?._hasHydratedKey(row.key)) { + continue + } this.syncControls.write?.({ type: `update`, value: row.value, @@ -2641,12 +2645,21 @@ export function persistedCollectionOptions< collectionId, ) - return { + const result = { ...syncOptions, id: collectionId, sync: createWrappedSyncConfig(syncOptions.sync, runtime), persistence, } + + return withCollectionConfigFactory( + result, + () => + persistedCollectionOptions({ + ...options, + id: collectionId, + }) as typeof result, + ) } const { schemaVersion, ...localOnlyOptions } = options @@ -2734,7 +2747,7 @@ export function persistedCollectionOptions< ...persistedUtils, } - return { + const result = { ...localOnlyOptions, id: collectionId, persistence, @@ -2746,6 +2759,15 @@ export function persistedCollectionOptions< startSync: true, gcTime: localOnlyOptions.gcTime ?? 0, } + + return withCollectionConfigFactory( + result, + () => + persistedCollectionOptions({ + ...options, + id: collectionId, + }) as typeof result, + ) } export function encodePersistedStorageKey(key: string | number): string { diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 42bbcf5633..78087419fa 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { BasicIndex, + DbClient, IR, + collectionOptions, createCollection, createTransaction, } from '@tanstack/db' @@ -912,6 +914,51 @@ describe(`persistedCollectionOptions`, () => { expect(adapter.loadSubsetCalls[0]?.collectionId).toBe(collection.id) }) + it(`keeps hydrated rows ahead of persisted startup rows`, async () => { + const adapter = createRecordingAdapter([ + { id: `1`, title: `Persisted title` }, + ]) + const descriptor = collectionOptions( + persistedCollectionOptions({ + id: `hydration-precedence`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + persistence: { + adapter, + }, + }), + ) + const client = new DbClient() + client.hydrate({ + collections: [ + { + collectionId: descriptor.id, + rows: [ + { + key: `1`, + value: { id: `1`, title: `SSR title` }, + }, + ], + }, + ], + }) + + const collection = client.collection(descriptor) + await collection.stateWhenReady() + await flushAsyncWork() + + expect(collection.get(`1`)).toMatchObject({ + id: `1`, + title: `SSR title`, + }) + + await client.cleanup() + }) + it(`bootstraps and tracks persisted index lifecycle in sync-present mode`, async () => { const adapter = createRecordingAdapter() const collection = createCollection( diff --git a/packages/db/skills/db-core/live-queries/SKILL.md b/packages/db/skills/db-core/live-queries/SKILL.md index 00da8635ff..4152253383 100644 --- a/packages/db/skills/db-core/live-queries/SKILL.md +++ b/packages/db/skills/db-core/live-queries/SKILL.md @@ -415,15 +415,18 @@ JS `.filter()` / `.map()` on the result array throws away incremental maintenanc ```ts // WRONG -- re-runs filter on every change -const { data } = useLiveQuery((q) => q.from({ todos: todosCollection })) +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }), +}) const active = data.filter((t) => t.completed === false) // CORRECT -- incrementally maintained -const { data } = useLiveQuery((q) => - q - .from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)), -) +const { data } = useLiveQuery({ + query: (q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +}) ``` ### HIGH: Not using the full operator set diff --git a/packages/db/skills/meta-framework/SKILL.md b/packages/db/skills/meta-framework/SKILL.md index d8aef668f6..fbeacc1c50 100644 --- a/packages/db/skills/meta-framework/SKILL.md +++ b/packages/db/skills/meta-framework/SKILL.md @@ -62,7 +62,9 @@ export const Route = createFileRoute('/todos')({ }) function TodoPage() { - const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection })) + const { data: todos } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) return (
          {todos.map((t) => ( @@ -127,9 +129,9 @@ import { useEffect, useState } from 'react' import { useLiveQuery } from '@tanstack/react-db' export default function TodoPage() { - const { data: todos, isLoading } = useLiveQuery((q) => - q.from({ todo: todoCollection }), - ) + const { data: todos, isLoading } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) if (isLoading) return
          Loading...
          return ( @@ -157,7 +159,9 @@ import { useLiveQuery } from '@tanstack/react-db' const preloadPromise = todoCollection.preload() export default function TodoPage() { - const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection })) + const { data: todos } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) return (
            {todos.map((t) => ( @@ -186,7 +190,9 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { export const loader = () => null export default function TodoPage() { - const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection })) + const { data: todos } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) return (
              {todos.map((t) => ( @@ -296,7 +302,9 @@ export const Route = createFileRoute('/todos')({ return null }, component: () => { - const { data } = useLiveQuery((q) => q.from({ todo: todoCollection })) + const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) // ... }, }) @@ -387,7 +395,9 @@ export const Route = createFileRoute('/todos')({ ssr: false, loader: async () => { await todoCollection.preload() }, component: () => { - const { data } = useLiveQuery((q) => q.from({ todo: todoCollection })) + const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + }) }, }) ``` diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000000..7ba774aa6c --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,898 @@ +import { createCollection } from './collection/index.js' +import { + collectionOptionsBrand, + collectionOptionsFactory, + hasCollectionOptionsBrand, +} from './collection-options.js' +import { TransactionScope } from './transactions.js' +import { getBuilderFromConfig } from './query/live/collection-registry.js' +import { createLiveQueryCollection } from './query/live-query-collection.js' +import { createLiveQueryObserver } from './live-query-observer.js' +import { + getLiveQueryHash, + prepareLiveQueryValue, +} from './live-query-options.js' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { Collection } from './collection/index.js' +import type { CollectionOptionsIdentity } from './collection-options.js' +import type { + CollectionConfig, + InferSchemaInput, + InferSchemaOutput, + NonSingleResult, + SingleResult, + TransactionConfig, + UtilsRecord, +} from './types.js' +import type { + DeferredLiveQueryCollections, + LiveQueryOptions, +} from './live-query-options.js' + +const collectionConfigFactory: unique symbol = Symbol.for( + `@tanstack/db.collectionConfig.factory`, +) as never + +type AnyCollectionConfig = CollectionConfig + +export type CollectionOptions< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TUtils extends UtilsRecord = UtilsRecord, +> = CollectionOptionsIdentity + +type AnyCollectionOptions = CollectionOptions +type AnyCollection = Collection + +type DescriptorFromConfig = + TConfig extends { + getKey: (item: infer T) => infer TKey + } + ? CollectionOptions< + Extract, + Extract, + TConfig extends { + schema: infer TSchema extends StandardSchemaV1 + } + ? TSchema + : never, + TConfig extends { + utils: infer TUtils extends UtilsRecord + } + ? TUtils + : UtilsRecord + > & + (TConfig extends SingleResult ? SingleResult : NonSingleResult) + : never + +type CollectionConfigWithFactory = + TConfig & { + readonly [collectionConfigFactory]: (client: DbClient) => TConfig + } + +/** + * Adds a fresh-config materializer to an adapter options object. + * + * Adapter option creators should use this so a module-scoped descriptor can be + * materialized safely by more than one DbClient. + */ +export function withCollectionConfigFactory< + TConfig extends AnyCollectionConfig, +>( + config: TConfig, + factory: (client: DbClient) => TConfig, +): CollectionConfigWithFactory { + Object.defineProperty(config, collectionConfigFactory, { + value: factory, + enumerable: false, + }) + return config as CollectionConfigWithFactory +} + +export type CollectionMaterializeOptions = { + initialData?: Array +} + +export type DehydratedCollectionRow< + T extends object = Record, + TKey extends string | number = string | number, +> = { + key: TKey + value: T + metadata?: unknown +} + +export type DehydratedCollectionChunk< + T extends object = Record, + TKey extends string | number = string | number, +> = { + collectionId: string + rows: Array> + syncMeta?: unknown +} + +export type DehydratedLiveQuery = { + queryHash: string + dehydratedAt: number + snapshot?: DehydratedLiveQueryResult + promise?: Promise +} + +export type DehydratedLiveQueryResult< + T extends object = object, + TKey extends string | number = string | number, +> = { + rows: Array> +} + +export type DehydratedDbState = { + collections: Array + liveQueries?: Array +} + +export type DbClientLiveQueryState = `pending` | `success` | `error` + +export type DbClientLiveQuery = { + readonly queryHash: string + readonly dehydratedAt: number + readonly status: DbClientLiveQueryState + readonly promise: Promise + readonly snapshot?: DehydratedLiveQueryResult + readonly error?: unknown +} + +export type DbClientEvent = + | { + type: `liveQueryAdded` | `liveQueryUpdated` + query: DbClientLiveQuery + } + | { + type: `liveQueryStreamError` + error: unknown + } + +export type DehydrateDbClientOptions = { + shouldDehydrateCollection?: (collection: Collection) => boolean + shouldDehydrateLiveQuery?: (query: DbClientLiveQuery) => boolean +} + +type CollectionRecord = { + collection: AnyCollection + shouldDehydrate: boolean +} + +type LiveQueryRecord = { + queryHash: string + dehydratedAt: number + status: DbClientLiveQueryState + promise: Promise + resultPromise: Promise + succeed: (snapshot: DehydratedLiveQueryResult) => void + fail: (error: unknown) => void + snapshot?: DehydratedLiveQueryResult + error?: unknown +} + +export type DbClientOptions = Record + +export function collectionOptions< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + options: CollectionConfig, TKey, T, TUtils> & { + schema: T + } & NonSingleResult, +): CollectionOptions, TKey, T, TUtils> & NonSingleResult +export function collectionOptions< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, +>( + options: CollectionConfig, TKey, T, TUtils> & { + schema: T + } & SingleResult, +): CollectionOptions, TKey, T, TUtils> & SingleResult +export function collectionOptions< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = UtilsRecord, +>( + options: CollectionConfig & { + schema?: never + } & NonSingleResult, +): CollectionOptions & NonSingleResult +export function collectionOptions< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = UtilsRecord, +>( + options: CollectionConfig & { + schema?: never + } & SingleResult, +): CollectionOptions & SingleResult +export function collectionOptions( + id: string, + factory: (client: DbClient) => TConfig, +): DescriptorFromConfig +export function collectionOptions( + optionsOrId: AnyCollectionConfig | string, + explicitFactory?: (client: DbClient) => unknown, +): any { + const config = typeof optionsOrId === `string` ? undefined : optionsOrId + const id = typeof optionsOrId === `string` ? optionsOrId : optionsOrId.id + + if (!id) { + throw new Error( + `collectionOptions requires a non-empty explicit id so the descriptor is stable across DbClient instances and SSR boundaries.`, + ) + } + + if (typeof optionsOrId === `string` && !explicitFactory) { + throw new Error( + `collectionOptions("${id}") requires a factory as its second argument.`, + ) + } + + const reusableFactory: + | ((client: DbClient) => AnyCollectionConfig) + | undefined = config + ? (config as CollectionConfigWithFactory)[ + collectionConfigFactory + ] + : (explicitFactory as + | ((client: DbClient) => AnyCollectionConfig) + | undefined) + + let owner: DbClient | undefined + const materialize = (client: DbClient): AnyCollectionConfig => { + let materialized: AnyCollectionConfig + + if (reusableFactory) { + materialized = reusableFactory(client) + } else { + if (owner && owner !== client) { + throw new Error( + `Collection descriptor "${id}" was created from a concrete config that cannot be safely reused across DbClient instances. ` + + `Use collectionOptions("${id}", (client) => adapterCollectionOptions(...)) or an adapter options creator that supports DbClient materialization.`, + ) + } + owner = client + materialized = config! + } + + if (materialized.id !== undefined && materialized.id !== id) { + throw new Error( + `Collection descriptor "${id}" materialized a config with id "${materialized.id}". Descriptor and collection ids must match.`, + ) + } + + return materialized.id === id ? materialized : { ...materialized, id } + } + + const descriptor = { + id, + ...((config as { singleResult?: boolean } | undefined)?.singleResult === + true + ? { singleResult: true as const } + : {}), + } as Record + + Object.defineProperties(descriptor, { + [collectionOptionsBrand]: { + value: true, + enumerable: false, + }, + [collectionOptionsFactory]: { + value: materialize, + enumerable: false, + }, + }) + + return Object.freeze(descriptor) as CollectionOptions< + any, + string | number, + any, + UtilsRecord + > +} + +export function isCollectionOptions( + value: unknown, +): value is CollectionOptions { + return hasCollectionOptionsBrand(value) +} + +export class DbClient { + private collectionsByOptions = new WeakMap() + private collectionsById = new Map() + private pendingHydration = new Map>() + private liveQueries = new Map() + private preloadedLiveQueries = new Map< + string, + { + collection: AnyCollection + observer: { dispose: () => void } + } + >() + private liveQueryResources = new Map Promise>() + private listeners = new Set<(event: DbClientEvent) => void>() + private ssrStreamingEnabled = false + private ssrServerCleanupEnabled = false + private lastLiveQueryTimestamp = 0 + private readonly transactionScope = new TransactionScope() + + constructor(private readonly options: DbClientOptions = {}) {} + + getDependency(key: string): T | undefined { + return this.options[key] as T | undefined + } + + requireDependency(key: string): T { + const dependency = this.getDependency(key) + if (dependency === undefined) { + throw new Error( + `DbClient is missing the required "${key}" dependency. Pass it explicitly when constructing the client: new DbClient({ ${key} }).`, + ) + } + return dependency + } + + get activeTransaction() { + return this.transactionScope.getActiveTransaction() + } + + createTransaction>( + config: TransactionConfig, + ) { + return this.transactionScope.createTransaction(config) + } + + preloadLiveQuery(options: LiveQueryOptions): Promise { + const deferredCollections: DeferredLiveQueryCollections = new Set() + try { + const prepared = prepareLiveQueryValue(options, this, deferredCollections) + const queryHash = getLiveQueryHash(prepared, options.queryKey) + const existing = this.liveQueries.get(queryHash) + if (existing && existing.status !== `error`) return existing.promise + + const failedPreload = this.preloadedLiveQueries.get(queryHash) + if (failedPreload) { + failedPreload.observer.dispose() + void failedPreload.collection.cleanup().catch(() => {}) + this.preloadedLiveQueries.delete(queryHash) + } + + const collection = createLiveQueryCollection({ + ...(prepared as LiveQueryOptions), + startSync: true, + }) as AnyCollection + const observer = createLiveQueryObserver(collection, { + client: this, + queryHash, + mode: `wholesale`, + }) + this.preloadedLiveQueries.set(queryHash, { collection, observer }) + + return this._registerLiveQuery( + queryHash, + collection.preload().then(() => observer.dehydrate()), + ) + } finally { + for (const source of deferredCollections) source._resumeSyncStart() + deferredCollections.clear() + } + } + + collection< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, + >( + options: CollectionOptions, TKey, T, TUtils> & + NonSingleResult, + materializeOptions?: CollectionMaterializeOptions>, + ): Collection, TKey, TUtils, T, InferSchemaInput> & + NonSingleResult + collection< + T extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord, + >( + options: CollectionOptions, TKey, T, TUtils> & + SingleResult, + materializeOptions?: CollectionMaterializeOptions>, + ): Collection, TKey, TUtils, T, InferSchemaInput> & + SingleResult + collection< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = UtilsRecord, + >( + options: CollectionOptions & NonSingleResult, + materializeOptions?: CollectionMaterializeOptions, + ): Collection & NonSingleResult + collection< + T extends object, + TKey extends string | number = string | number, + TUtils extends UtilsRecord = UtilsRecord, + >( + options: CollectionOptions & SingleResult, + materializeOptions?: CollectionMaterializeOptions, + ): Collection & SingleResult + collection( + options: AnyCollectionOptions, + materializeOptions?: CollectionMaterializeOptions, + ): AnyCollection { + return this.materializeCollection(options, materializeOptions, false) + } + + /** @internal */ + _materializeCollectionForRender< + T extends object, + TKey extends string | number, + TSchema extends StandardSchemaV1, + TUtils extends UtilsRecord, + >( + options: CollectionOptions, + ): Collection< + T, + TKey, + TUtils, + TSchema, + [TSchema] extends [never] ? T : InferSchemaInput + > { + return this.materializeCollection(options, undefined, true) + } + + private materializeCollection( + options: AnyCollectionOptions, + materializeOptions: CollectionMaterializeOptions | undefined, + deferSyncStart: boolean, + ): AnyCollection { + const existing = this.collectionsByOptions.get(options) + if (existing) { + if (!deferSyncStart) { + this.collectionsById.get(existing.id)!.shouldDehydrate = true + } + if (deferSyncStart) { + existing._deferSyncStart() + } + return existing + } + + if (this.collectionsById.has(options.id)) { + throw new Error( + `Cannot materialize collection "${options.id}" because this DbClient already has a different collection with that id. SSR hydration requires collection ids to be unique per DbClient.`, + ) + } + + const config = options[collectionOptionsFactory](this) + const shouldStartSync = config.startSync === true + const collection = createCollection({ + ...config, + startSync: false, + } as any) + collection._setTransactionScope(this.transactionScope) + if (deferSyncStart) { + collection._deferSyncStart() + } + + this.collectionsByOptions.set(options, collection) + this.collectionsById.set(collection.id, { + collection, + shouldDehydrate: !deferSyncStart, + }) + + if (materializeOptions?.initialData?.length) { + this.applyRows( + collection, + { + collectionId: collection.id, + rows: materializeOptions.initialData.map((value) => ({ value })), + }, + `initialData`, + ) + } + + const pendingChunks = this.pendingHydration.get(collection.id) + if (pendingChunks) { + for (const chunk of pendingChunks) { + this.applyRows(collection, chunk, `hydration`) + } + this.pendingHydration.delete(collection.id) + } + + if (shouldStartSync) { + collection.startSyncImmediate() + } + + return collection + } + + dehydrate(options: DehydrateDbClientOptions = {}): DehydratedDbState { + const collections: Array = [] + + for (const { + collection, + shouldDehydrate, + } of this.collectionsById.values()) { + const collectionDecision = options.shouldDehydrateCollection?.(collection) + if ( + getBuilderFromConfig(collection.config) || + collectionDecision === false || + (!shouldDehydrate && collectionDecision !== true) + ) { + continue + } + + const rows = Array.from(collection._state.syncedData.entries()).map( + ([key, value]) => { + const metadata = collection._state.syncedMetadata.get(key) + return { + key, + value, + ...(metadata === undefined ? {} : { metadata }), + } + }, + ) + + collections.push({ + collectionId: collection.id, + rows, + syncMeta: collection.config.sync.exportSyncMeta?.(), + }) + } + + const liveQueries = Array.from(this.liveQueries.values()).flatMap( + (query): Array => { + const shouldDehydrate = + options.shouldDehydrateLiveQuery?.(query) ?? + query.status === `success` + if (!shouldDehydrate || query.status === `error`) { + return [] + } + + return [ + { + queryHash: query.queryHash, + dehydratedAt: query.dehydratedAt, + ...(query.snapshot + ? { snapshot: query.snapshot } + : { promise: query.resultPromise }), + }, + ] + }, + ) + + return { + collections, + ...(liveQueries.length > 0 ? { liveQueries } : {}), + } + } + + hydrate(state: DehydratedDbState): void { + for (const chunk of state.collections) { + const record = this.collectionsById.get(chunk.collectionId) + if (record) { + this.applyRows(record.collection, chunk, `hydration`) + continue + } + + const pendingChunks = this.pendingHydration.get(chunk.collectionId) ?? [] + pendingChunks.push(chunk) + this.pendingHydration.set(chunk.collectionId, pendingChunks) + } + + for (const dehydratedQuery of state.liveQueries ?? []) { + this.hydrateLiveQuery(dehydratedQuery) + } + } + + applyCollectionChunk(chunk: DehydratedCollectionChunk): void { + this.hydrate({ collections: [chunk] }) + } + + subscribe(listener: (event: DbClientEvent) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + /** @internal */ + _setSsrStreamingEnabled(enabled: boolean): void { + this.ssrStreamingEnabled = enabled + } + + /** @internal */ + _isSsrStreamingEnabled(): boolean { + return this.ssrStreamingEnabled + } + + /** @internal */ + _setSsrServerCleanupEnabled(enabled: boolean): void { + this.ssrServerCleanupEnabled = enabled + } + + /** @internal */ + _isSsrServerCleanupEnabled(): boolean { + return this.ssrServerCleanupEnabled + } + + /** @internal */ + _getLiveQuery(queryHash: string): DbClientLiveQuery | undefined { + return this.liveQueries.get(queryHash) + } + + /** @internal */ + _consumeLiveQueryResult(queryHash: string, dehydratedAt: number): void { + const record = this.liveQueries.get(queryHash) + if (record?.dehydratedAt === dehydratedAt) { + this.liveQueries.delete(queryHash) + } + } + + /** @internal */ + _registerLiveQuery( + queryHash: string, + promise: Promise, + ): Promise { + const existing = this.liveQueries.get(queryHash) + if (existing && existing.status !== `error`) { + void Promise.resolve(promise).catch(() => {}) + return existing.promise + } + + const record = this.createLiveQueryRecord( + queryHash, + this.nextLiveQueryTimestamp(), + ) + + this.liveQueries.set(queryHash, record) + this.emit({ type: `liveQueryAdded`, query: record }) + Promise.resolve(promise).then(record.succeed, record.fail) + return record.promise + } + + /** @internal */ + _registerLiveQueryResource( + owner: object, + cleanup: () => Promise, + ): () => void { + this.liveQueryResources.set(owner, cleanup) + return () => { + if (this.liveQueryResources.get(owner) === cleanup) { + this.liveQueryResources.delete(owner) + } + } + } + + /** @internal */ + _failPendingLiveQueries(error: unknown): void { + for (const record of this.liveQueries.values()) { + if (record.status === `pending`) record.fail(error) + } + this.emit({ type: `liveQueryStreamError`, error }) + } + + async cleanup(): Promise { + try { + const materializedCollections = Array.from( + this.collectionsById.values(), + ({ collection }) => collection, + ) + const preloadedQueries = Array.from(this.preloadedLiveQueries.values()) + const liveQueryCollections = new Set([ + ...preloadedQueries.map(({ collection }) => collection), + ...materializedCollections.filter((collection) => + getBuilderFromConfig(collection.config), + ), + ]) + + for (const { observer } of preloadedQueries) observer.dispose() + + const cleanupResults = [ + ...(await Promise.allSettled( + Array.from(this.liveQueryResources.values(), (cleanup) => cleanup()), + )), + ...(await Promise.allSettled( + Array.from(liveQueryCollections, (collection) => + collection.cleanup(), + ), + )), + ...(await Promise.allSettled( + materializedCollections + .filter((collection) => !liveQueryCollections.has(collection)) + .map((collection) => collection.cleanup()), + )), + ] + const failure = cleanupResults.find( + (result): result is PromiseRejectedResult => + result.status === `rejected`, + ) + if (failure) throw failure.reason + } finally { + this.transactionScope.clear() + this.collectionsByOptions = new WeakMap() + this.collectionsById.clear() + this.pendingHydration.clear() + this.liveQueries.clear() + this.preloadedLiveQueries.clear() + this.liveQueryResources.clear() + this.listeners.clear() + this.ssrStreamingEnabled = false + this.ssrServerCleanupEnabled = false + this.lastLiveQueryTimestamp = 0 + } + } + + private hydrateLiveQuery(dehydratedQuery: DehydratedLiveQuery): void { + const existing = this.liveQueries.get(dehydratedQuery.queryHash) + if (existing && existing.dehydratedAt >= dehydratedQuery.dehydratedAt) { + return + } + + const record = this.createLiveQueryRecord( + dehydratedQuery.queryHash, + dehydratedQuery.dehydratedAt, + ) + this.liveQueries.set(record.queryHash, record) + if (existing?.status === `pending`) { + void record.resultPromise.then(existing.succeed, existing.fail) + } + this.emit({ type: `liveQueryAdded`, query: record }) + + if (dehydratedQuery.snapshot) { + record.succeed(dehydratedQuery.snapshot) + } else if (dehydratedQuery.promise) { + Promise.resolve(dehydratedQuery.promise).then(record.succeed, record.fail) + } else { + record.fail( + new Error( + `Dehydrated live query "${dehydratedQuery.queryHash}" has neither a snapshot nor a promise.`, + ), + ) + } + } + + private nextLiveQueryTimestamp(): number { + this.lastLiveQueryTimestamp = Math.max( + Date.now(), + this.lastLiveQueryTimestamp + 1, + ) + return this.lastLiveQueryTimestamp + } + + private createLiveQueryRecord( + queryHash: string, + dehydratedAt: number, + ): LiveQueryRecord { + this.lastLiveQueryTimestamp = Math.max( + this.lastLiveQueryTimestamp, + dehydratedAt, + ) + + let resolveResult!: (snapshot: DehydratedLiveQueryResult) => void + let rejectResult!: (error: unknown) => void + let settled = false + const resultPromise = new Promise( + (resolve, reject) => { + resolveResult = resolve + rejectResult = reject + }, + ) + const promise = resultPromise.then(() => undefined) + resultPromise.catch(() => {}) + promise.catch(() => {}) + + const record: LiveQueryRecord = { + queryHash, + dehydratedAt, + status: `pending`, + promise, + resultPromise, + succeed: (snapshot) => { + if (settled) return + settled = true + record.status = `success` + record.snapshot = snapshot + resolveResult(snapshot) + if (this.liveQueries.get(queryHash) === record) { + this.emit({ type: `liveQueryUpdated`, query: record }) + } + }, + fail: (error) => { + if (settled) return + settled = true + record.status = `error` + record.error = error + rejectResult(error) + if (this.liveQueries.get(queryHash) === record) { + this.emit({ type: `liveQueryUpdated`, query: record }) + } + }, + } + + return record + } + + private emit(event: DbClientEvent): void { + for (const listener of this.listeners) { + listener(event) + } + } + + private applyRows( + collection: Collection, + chunk: Omit & { + rows: Array< + Omit & { + key?: string | number + } + > + }, + seedKind?: `initialData` | `hydration`, + ): void { + const rows = chunk.rows.flatMap((row) => { + const value = collection.validateData(row.value, `insert`) + const key = collection.config.getKey(value) + const isAdapterAuthoritative = + seedKind === `hydration` && + collection._state.syncedData.has(key) && + !collection._state.hydrationSeedKeys.has(key) + + return isAdapterAuthoritative ? [] : [{ ...row, key, value }] + }) + const rowMetadataWrites = new Map< + string | number, + { type: `set`; value: unknown } | { type: `delete` } + >() + + for (const row of rows) { + if (row.metadata !== undefined) { + rowMetadataWrites.set(row.key, { type: `set`, value: row.metadata }) + } + } + + if (seedKind) { + for (const row of rows) { + collection._state.hydrationSeedKeys.add(row.key) + if (seedKind === `hydration`) { + collection._state.hydratedKeys.add(row.key) + } + } + } + + if (rows.length > 0) { + collection._state.pendingSyncedTransactions.push({ + committed: true, + layoutChanged: false, + operations: rows.map((row) => ({ + type: collection._state.syncedData.has(row.key) + ? (`update` as const) + : (`insert` as const), + key: row.key, + value: row.value, + })), + deletedKeys: new Set(), + rowMetadataWrites, + collectionMetadataWrites: new Map(), + immediate: true, + preserveHydrationSeedKeys: seedKind !== undefined, + }) + collection._state.commitPendingTransactions() + } + + if (chunk.syncMeta !== undefined) { + const currentMeta = collection.config.sync.exportSyncMeta?.() + const mergedMeta = + currentMeta === undefined + ? chunk.syncMeta + : (collection.config.sync.mergeSyncMeta?.( + currentMeta, + chunk.syncMeta, + ) ?? chunk.syncMeta) + collection.config.sync.importSyncMeta?.(mergedMeta) + } + } +} diff --git a/packages/db/src/collection-options.ts b/packages/db/src/collection-options.ts new file mode 100644 index 0000000000..148841c4a6 --- /dev/null +++ b/packages/db/src/collection-options.ts @@ -0,0 +1,34 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { CollectionConfig, UtilsRecord } from './types.js' + +export const collectionOptionsBrand: unique symbol = Symbol.for( + `@tanstack/db.collectionOptions`, +) as never + +export const collectionOptionsFactory: unique symbol = Symbol.for( + `@tanstack/db.collectionOptions.factory`, +) as never + +export type CollectionOptionsIdentity< + T extends object = Record, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TUtils extends UtilsRecord = UtilsRecord, + TClient = unknown, +> = { + readonly id: string + readonly [collectionOptionsBrand]: true + readonly [collectionOptionsFactory]: ( + client: TClient, + ) => CollectionConfig +} + +export function hasCollectionOptionsBrand( + value: unknown, +): value is CollectionOptionsIdentity { + return ( + typeof value === `object` && + value !== null && + (value as Record)[collectionOptionsBrand] === true + ) +} diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index f9bb465fe0..a23dec7648 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -42,6 +42,7 @@ import type { import type { SingleRowRefProxy } from '../query/builder/ref-proxy' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { WithVirtualProps } from '../virtual-props.js' +import type { TransactionScope } from '../transactions.js' export type { CollectionIndexMetadata } from './events.js' @@ -493,6 +494,26 @@ export class CollectionImpl< this._sync.startSync() } + /** @internal */ + public _setTransactionScope(transactionScope: TransactionScope): void { + this._mutations.setTransactionScope(transactionScope) + } + + /** @internal */ + public _hasHydratedKey(key: TKey): boolean { + return this._state.hydratedKeys.has(key) + } + + /** @internal */ + public _deferSyncStart(): boolean { + return this._sync.deferStart() + } + + /** @internal */ + public _resumeSyncStart(): void { + this._sync.resumeStart() + } + /** * Preload the collection data by starting sync if not already started * Multiple concurrent calls will share the same promise diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index abfb6693eb..9c91789780 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -27,11 +27,13 @@ import type { OperationConfig, PendingMutation, StandardSchema, + TransactionConfig, Transaction as TransactionType, TransactionWithMutations, UtilsRecord, WritableDeep, } from '../types' +import type { TransactionScope } from '../transactions' import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionStateManager } from './state' @@ -46,6 +48,7 @@ export class CollectionMutationsManager< private state!: CollectionStateManager private collection!: CollectionImpl private config!: CollectionConfig + private transactionScope?: TransactionScope private id: string constructor(config: CollectionConfig, id: string) { @@ -63,6 +66,22 @@ export class CollectionMutationsManager< this.collection = deps.collection } + setTransactionScope(transactionScope: TransactionScope): void { + this.transactionScope = transactionScope + } + + private getActiveTransaction() { + return this.transactionScope + ? this.transactionScope.getActiveTransactionForCollection() + : getActiveTransaction() + } + + private createTransaction(config: TransactionConfig) { + return this.transactionScope + ? this.transactionScope.createTransaction(config) + : createTransaction(config) + } + private ensureStandardSchema(schema: unknown): StandardSchema { // If the schema already implements the standard-schema interface, return it if (schema && `~standard` in (schema as {})) { @@ -169,7 +188,7 @@ export class CollectionMutationsManager< insert = (data: TInput | Array, config?: InsertConfig) => { this.lifecycle.validateCollectionUsable(`insert`) const state = this.state - const ambientTransaction = getActiveTransaction() + const ambientTransaction = this.getActiveTransaction() // If no ambient transaction exists, check for an onInsert handler early if (!ambientTransaction && !this.config.onInsert) { @@ -231,7 +250,7 @@ export class CollectionMutationsManager< return ambientTransaction } else { // Create a new transaction with a mutation function that calls the onInsert handler - const directOpTransaction = createTransaction({ + const directOpTransaction = this.createTransaction({ metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { // Call the onInsert handler with the transaction and collection @@ -281,7 +300,7 @@ export class CollectionMutationsManager< const state = this.state this.lifecycle.validateCollectionUsable(`update`) - const ambientTransaction = getActiveTransaction() + const ambientTransaction = this.getActiveTransaction() // If no ambient transaction exists, check for an onUpdate handler early if (!ambientTransaction && !this.config.onUpdate) { @@ -404,7 +423,7 @@ export class CollectionMutationsManager< // If no changes were made, return an empty transaction early if (mutations.length === 0) { - const emptyTransaction = createTransaction({ + const emptyTransaction = this.createTransaction({ mutationFn: async () => {}, }) // Errors still propagate through tx.isPersisted.promise; suppress the background commit from warning @@ -428,7 +447,7 @@ export class CollectionMutationsManager< // No need to check for onUpdate handler here as we've already checked at the beginning // Create a new transaction with a mutation function that calls the onUpdate handler - const directOpTransaction = createTransaction({ + const directOpTransaction = this.createTransaction({ metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { // Call the onUpdate handler with the transaction and collection @@ -468,7 +487,7 @@ export class CollectionMutationsManager< const state = this.state this.lifecycle.validateCollectionUsable(`delete`) - const ambientTransaction = getActiveTransaction() + const ambientTransaction = this.getActiveTransaction() // If no ambient transaction exists, check for an onDelete handler early if (!ambientTransaction && !this.config.onDelete) { @@ -531,7 +550,7 @@ export class CollectionMutationsManager< } // Create a new transaction with a mutation function that calls the onDelete handler - const directOpTransaction = createTransaction({ + const directOpTransaction = this.createTransaction({ autoCommit: true, metadata: { [DIRECT_TRANSACTION_METADATA_KEY]: true }, mutationFn: async (params) => { diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 1eeb92b2d4..69b8b6f9cf 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -35,6 +35,7 @@ interface PendingSyncedTransaction< upserts: Map deletes: Set } + preserveHydrationSeedKeys?: boolean /** * When true, this transaction should be processed immediately even if there * are persisting user transactions. Used by manual write operations (writeInsert, @@ -76,6 +77,8 @@ export class CollectionStateManager< public syncedData: SortedMap public syncedMetadata = new Map() public syncedCollectionMetadata = new Map() + public hydrationSeedKeys = new Set() + public hydratedKeys = new Set() // Optimistic state tracking - make public for testing public optimisticUpserts = new Map() @@ -979,6 +982,8 @@ export class CollectionStateManager< this.syncedData.clear() this.syncedMetadata.clear() this.syncedKeys.clear() + this.hydrationSeedKeys.clear() + this.hydratedKeys.clear() this.clearOriginTrackingState() // 3) Clear currentVisibleState for truncated keys to ensure subsequent operations @@ -1056,6 +1061,10 @@ export class CollectionStateManager< this.pendingOptimisticDirectDeletes.delete(key) break } + if (!transaction.preserveHydrationSeedKeys) { + this.hydrationSeedKeys.delete(key) + this.hydratedKeys.delete(key) + } } for (const [key, metadataWrite] of transaction.rowMetadataWrites) { @@ -1439,6 +1448,8 @@ export class CollectionStateManager< this.pendingOptimisticDeletes.clear() this.pendingOptimisticDirectUpserts.clear() this.pendingOptimisticDirectDeletes.clear() + this.hydrationSeedKeys.clear() + this.hydratedKeys.clear() this.clearOriginTrackingState() this.isLocalOnly = false this.size = 0 diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index d717106110..c47de23a92 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -8,6 +8,7 @@ import { SyncTransactionAlreadyCommittedError, SyncTransactionAlreadyCommittedWriteError, } from '../errors' +import { createDeferred } from '../deferred' import { deepEquals } from '../utils' import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -25,6 +26,12 @@ import type { CollectionStateManager } from './state' import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionEventsManager } from './events.js' import type { LiveQueryCollectionUtils } from '../query/live/collection-config-builder.js' +import type { Deferred } from '../deferred' + +type DeferredLoadSubset = { + options: LoadSubsetOptions + deferred: Deferred +} export class CollectionSyncManager< TOutput extends object = Record, @@ -49,6 +56,9 @@ export class CollectionSyncManager< null private pendingLoadSubsetPromises: Set> = new Set() + private syncStartDeferred = false + private syncStartRequested = false + private deferredLoadSubsets: Array = [] /** * Creates a new CollectionSyncManager instance @@ -88,6 +98,11 @@ export class CollectionSyncManager< return // Already started or in progress } + if (this.syncStartDeferred) { + this.syncStartRequested = true + return + } + this.lifecycle.setStatus(`loading`) try { @@ -151,10 +166,11 @@ export class CollectionSyncManager< const valuesEqual = existingValue !== undefined && deepEquals(existingValue, messageWithOptionalKey.value) - if (valuesEqual) { + if (valuesEqual || this.state.hydrationSeedKeys.has(key)) { // The "insert" is an echo of a value we already have locally. - // Treat it as an update so we preserve optimistic intent without - // throwing a duplicate-key error during reconciliation. + // Hydration and initialData are also provisional base state, so + // accept the adapter's first authoritative value as an update + // using the configured rowUpdateMode semantics. messageType = `update` } else { const utils = this.config.utils as @@ -278,6 +294,58 @@ export class CollectionSyncManager< } } + public deferStart(): boolean { + if ( + this.lifecycle.status !== `idle` && + this.lifecycle.status !== `cleaned-up` + ) { + return false + } + + this.syncStartDeferred = true + return true + } + + public resumeStart(): void { + if (!this.syncStartDeferred) { + return + } + + this.syncStartDeferred = false + const shouldStart = + this.syncStartRequested || this.deferredLoadSubsets.length > 0 + this.syncStartRequested = false + const deferredLoadSubsets = this.deferredLoadSubsets + this.deferredLoadSubsets = [] + + try { + if (shouldStart) { + this.startSync() + } + } catch (error) { + for (const { deferred } of deferredLoadSubsets) { + deferred.reject(error) + } + throw error + } + + for (const { options, deferred } of deferredLoadSubsets) { + try { + const result = this.syncLoadSubsetFn?.(options) ?? true + if (result instanceof Promise) { + void result.then( + () => deferred.resolve(undefined), + (error: unknown) => deferred.reject(error), + ) + } else { + deferred.resolve(undefined) + } + } catch (error) { + deferred.reject(error) + } + } + } + private getActivePendingSyncTransaction() { const pendingTransaction = this.state.pendingSyncedTransactions[ @@ -505,6 +573,14 @@ export class CollectionSyncManager< return true } + if (this.syncStartDeferred) { + this.syncStartRequested = true + const deferred = createDeferred() + this.deferredLoadSubsets.push({ options, deferred }) + this.trackLoadPromise(deferred.promise) + return deferred.promise + } + if (this.syncLoadSubsetFn) { const result = this.syncLoadSubsetFn(options) // If the result is a promise, track it @@ -522,6 +598,18 @@ export class CollectionSyncManager< * @param options Options that identify what data is being unloaded */ public unloadSubset(options: LoadSubsetOptions): void { + if (this.syncStartDeferred) { + this.deferredLoadSubsets = this.deferredLoadSubsets.filter((request) => { + if (request.options !== options) { + return true + } + + request.deferred.resolve(undefined) + return false + }) + return + } + if (this.syncUnloadSubsetFn) { this.syncUnloadSubsetFn(options) } @@ -548,6 +636,13 @@ export class CollectionSyncManager< }) } this.preloadPromise = null + this.syncStartDeferred = false + this.syncStartRequested = false + const deferredLoadSubsets = this.deferredLoadSubsets + this.deferredLoadSubsets = [] + for (const { deferred } of deferredLoadSubsets) { + deferred.resolve(undefined) + } } } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 80814640dd..8c4d7258e0 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -6,12 +6,15 @@ import * as IR from './query/ir.js' export * from './collection/index.js' export * from './SortedMap' export * from './transactions' +export * from './client.js' +export { withCollectionConfigFactory } from './client.js' export * from './types' export * from './proxy' export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' +export * from './live-query-options' /** @internal Unstable adapter primitive for RFC #1623. */ export * from './live-query-window-controller' export * from './local-only' diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 1cb0cba28a..735195816a 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -3,7 +3,9 @@ import { getLiveQueryStatusFlags, isSingleResultCollection, } from './live-query-adapter.js' +import { getBuilderFromConfig } from './query/live/collection-registry.js' import type { Collection } from './collection/index.js' +import type { DbClient, DehydratedLiveQueryResult } from './client.js' import type { ChangeMessage, CollectionStatus } from './types.js' /** @@ -73,6 +75,8 @@ export interface LiveQueryObserver< > { /** Stable per-revision snapshot for wholesale materialization. */ getSnapshot: () => LiveQuerySnapshot + /** Stable server snapshot used by useSyncExternalStore-style adapters. */ + getServerSnapshot: () => LiveQuerySnapshot /** * Subscribe to changes. The listener receives the change set (or `undefined` * for the synthetic notify a ready collection emits on attach). Granular @@ -82,6 +86,10 @@ export interface LiveQueryObserver< subscribe: (listener: LiveQueryObserverListener) => () => void /** Resolve once the collection has loaded its first data. */ preload: () => Promise + /** The transport or preload error for this query, if it has not produced data. */ + getError: () => unknown + /** Capture the ordered query result without serializing its source collections. */ + dehydrate: () => DehydratedLiveQueryResult /** Idempotent teardown. */ dispose: () => void } @@ -126,6 +134,9 @@ class LiveQueryObserverImpl< > implements LiveQueryObserver { private readonly collection: Collection | null private readonly wholesale: boolean + private readonly client: DbClient | undefined + private readonly queryHash: string | undefined + private readonly onPreload: (() => void) | undefined private visibleStatus: CollectionStatus | undefined private cachedEntries: Array<[TKey, T]> | undefined private cachedCollectionRevision: number | undefined @@ -144,29 +155,58 @@ class LiveQueryObserverImpl< private blockDelivery = false private attached = false private collectionUnsub: (() => void) | null = null + private unregisterClientResource: (() => void) | undefined + private hydrationSeed: + | { + dehydratedAt: number + entries: Array<[TKey, T]> + } + | undefined + private hydrationError: unknown + private hasHydrationError = false + private liveResultIsAuthoritative = false + private handoffScheduled = false + private preloadPromise: Promise | undefined private disposed = false - // Construction is side-effect-free: sync activation belongs to the first - // subscription (attach), so building an observer — e.g. in a React render - // that may be abandoned — cannot activate resources on its own. - constructor(collection: Collection | null, wholesale: boolean) { + // Sync activation belongs to the first subscription (attach), so building + // an observer cannot activate collection resources on its own. Server + // request clients still record ownership here because React may render an + // observer without ever subscribing to it. + constructor( + collection: Collection | null, + wholesale: boolean, + client: DbClient | undefined, + queryHash: string | undefined, + onPreload: (() => void) | undefined, + ) { this.collection = collection this.wholesale = wholesale + this.client = client + this.queryHash = queryHash + this.onPreload = onPreload + this.registerClientResource() } getSnapshot(): LiveQuerySnapshot { const collection = this.collection if (!collection) return DISABLED_SNAPSHOT + this.syncHydrationState() if (!this.attached) this.refreshDetachedState(collection) if (this.snapshotDirty) { - const entries = - this.cachedEntries ?? this.captureEntries(collection).entries + const entries = this.getVisibleEntries(collection) const state = new Map(entries) const data = entries.map(([, value]) => value) const singleResult = isSingleResultCollection(collection) - const status = this.visibleStatus ?? collection.status + const liveStatus = this.visibleStatus ?? collection.status + const status = + this.hasHydrationError || liveStatus === `error` + ? (`error` as const) + : this.hasHydrationSeed() + ? (`ready` as const) + : liveStatus // Bump the layout revision when the ordered key sequence changes // (membership, ordering, or an order-only move). Compare the key sequence @@ -205,6 +245,180 @@ class LiveQueryObserverImpl< return this.cachedSnapshot } + getServerSnapshot(): LiveQuerySnapshot { + return this.getSnapshot() + } + + getError(): unknown { + this.syncHydrationState() + return this.hasHydrationError ? this.hydrationError : undefined + } + + dehydrate(): DehydratedLiveQueryResult { + const collection = this.collection + if (!collection) return { rows: [] } + + const entries = this.hasHydrationSeed() + ? this.hydrationSeed!.entries + : this.readEntries(collection).entries + + return { + rows: entries.map(([key, value]) => ({ + key, + value, + })), + } + } + + private hasHydrationSeed(): boolean { + return this.hydrationSeed !== undefined && !this.liveResultIsAuthoritative + } + + private getVisibleEntries( + collection: Collection, + ): Array<[TKey, T]> { + if (this.hasHydrationSeed()) return this.hydrationSeed!.entries + return this.cachedEntries ?? this.captureEntries(collection).entries + } + + private syncHydrationState(): boolean { + if (!this.client || !this.queryHash || this.liveResultIsAuthoritative) { + return false + } + + const query = this.client._getLiveQuery(this.queryHash) + if (!query) return false + + if ( + this.attached && + !this.hydrationSeed && + this.collection?.status === `ready` && + !this.collection.isLoadingSubset + ) { + return this.markLiveResultAuthoritative(query.dehydratedAt) + } + + if (query.status === `error`) { + const changed = + !this.hasHydrationError || this.hydrationError !== query.error + this.hydrationError = query.error + this.hasHydrationError = true + if (changed) this.snapshotDirty = true + return changed + } + + if ( + query.status !== `success` || + !query.snapshot || + (this.hydrationSeed && + this.hydrationSeed.dehydratedAt >= query.dehydratedAt) + ) { + return false + } + + this.hydrationSeed = { + dehydratedAt: query.dehydratedAt, + entries: query.snapshot.rows.map((row) => [ + row.key as TKey, + row.value as T, + ]), + } + this.hydrationError = undefined + this.hasHydrationError = false + this.snapshotDirty = true + return true + } + + private diffEntries( + previous: Array<[TKey, T]>, + next: Array<[TKey, T]>, + ): Array> { + const previousByKey = new Map(previous) + const nextByKey = new Map(next) + const changes: Array> = [] + + for (const [key, value] of previous) { + if (!nextByKey.has(key)) changes.push({ type: `delete`, key, value }) + } + for (const [key, value] of next) { + const previousValue = previousByKey.get(key) + if (previousValue === undefined) { + changes.push({ type: `insert`, key, value }) + } else if (previousValue !== value) { + changes.push({ + type: `update`, + key, + value, + previousValue, + }) + } + } + + return changes + } + + private handoffHydrationSeed(collection: Collection): { + changes: Array> + entries: Array<[TKey, T]> + revision?: number + } { + const previous = this.hydrationSeed?.entries ?? [] + const dehydratedAt = this.hydrationSeed?.dehydratedAt + const { entries, revision } = this.readEntries(collection) + this.hydrationSeed = undefined + this.markLiveResultAuthoritative(dehydratedAt) + this.updateCachedEntries(entries, revision) + this.snapshotDirty = true + return { + changes: this.diffEntries(previous, entries), + entries, + revision, + } + } + + private markLiveResultAuthoritative(dehydratedAt?: number): boolean { + const changed = this.hasHydrationError + this.hydrationError = undefined + this.hasHydrationError = false + this.liveResultIsAuthoritative = true + if (dehydratedAt !== undefined && this.queryHash) { + this.client?._consumeLiveQueryResult(this.queryHash, dehydratedAt) + } + if (changed) this.snapshotDirty = true + return changed + } + + private scheduleHydrationHandoff(): void { + if (this.handoffScheduled) return + this.handoffScheduled = true + + queueMicrotask(() => { + this.handoffScheduled = false + const collection = this.collection + if ( + this.disposed || + !this.attached || + !collection || + !this.hasHydrationSeed() || + collection.status !== `ready` || + collection.isLoadingSubset + ) { + return + } + + const handoff = this.handoffHydrationSeed(collection) + this.emit( + this.wholesale ? undefined : handoff.changes, + undefined, + handoff.entries, + collection.status, + handoff.revision, + this.getCollectionLayoutRevision(collection), + true, + ) + }) + } + private getCollectionRevision( collection: Collection, ): number | undefined { @@ -325,9 +539,7 @@ class LiveQueryObserverImpl< if (!collection) return const seedChanges: Array> = [] - for (const [key, value] of collection.entries() as IterableIterator< - [TKey, T] - >) { + for (const [key, value] of this.getVisibleEntries(collection)) { seedChanges.push({ type: `insert`, key, value }) } if (seedChanges.length === 0) return @@ -338,11 +550,14 @@ class LiveQueryObserverImpl< private attach(): void { const collection = this.collection if (!collection || this.disposed) return + this.registerClientResource() + this.syncHydrationState() this.refreshDetachedState(collection) this.attached = true this.visibleStatus ??= collection.status this.deliveredLayoutRevision = this.getCollectionLayoutRevision(collection) - this.blockDelivery = this.wholesale + const attachedWithHydrationSeed = this.hasHydrationSeed() + this.blockDelivery = this.wholesale || attachedWithHydrationSeed // Sync activation happens inside subscribeChanges (addSubscriber starts // an idle/cleaned-up collection) — the same startSync path the old @@ -363,6 +578,23 @@ class LiveQueryObserverImpl< explicitLayoutChange = false, ) => { if (this.disposed || this.subscriptions.size === 0) return + + if (this.hasHydrationSeed()) { + if (status === `ready`) this.scheduleHydrationHandoff() + if (status !== `error`) return + } + + if ( + status === `ready` && + !collection.isLoadingSubset && + !this.liveResultIsAuthoritative && + this.client && + this.queryHash + ) { + const query = this.client._getLiveQuery(this.queryHash) + this.markLiveResultAuthoritative(query?.dehydratedAt) + } + const layoutRevision = this.getCollectionLayoutRevision(collection) let layoutChanged = explicitLayoutChange if ( @@ -427,7 +659,28 @@ class LiveQueryObserverImpl< // ran during that replay (collectionUnsub no longer points at our hook), // undo the subscription as soon as the call returns. let subscription: { unsubscribe: () => void } | null = null + const clientUnsub = + this.client && this.queryHash + ? this.client.subscribe((event) => { + if ( + event.type === `liveQueryStreamError` || + event.query.queryHash !== this.queryHash + ) { + return + } + + const previousEntries = this.getVisibleEntries(collection) + if (!this.syncHydrationState()) return + const nextEntries = this.getVisibleEntries(collection) + this.emit( + this.wholesale + ? undefined + : this.diffEntries(previousEntries, nextEntries), + ) + }) + : () => {} const release = () => { + clientUnsub() statusUnsub() layoutUnsub() subscription?.unsubscribe() @@ -435,22 +688,26 @@ class LiveQueryObserverImpl< this.collectionUnsub = release subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), - { includeInitialState: !this.wholesale }, + { includeInitialState: !this.wholesale && !attachedWithHydrationSeed }, ) this.blockDelivery = false if (this.collectionUnsub !== release) { subscription.unsubscribe() return } - if (this.wholesale) { + if (this.wholesale || attachedWithHydrationSeed) { // Publications raised while subscribeChanges starts sync are part of the // subscribe handshake. Apply their final snapshot state now, but suppress // listener delivery: useSyncExternalStore performs its consistency read // immediately after subscribe returns. - this.flushPublications(false) + this.flushPublications(!this.wholesale) const { entries, revision } = this.readEntries(collection) this.updateCachedEntries(entries, revision) } + if (this.hasHydrationSeed()) { + if (!this.wholesale) this.seed(Array.from(this.subscriptions)[0]!) + if (collection.status === `ready`) this.scheduleHydrationHandoff() + } } private detach(): void { @@ -459,6 +716,28 @@ class LiveQueryObserverImpl< this.attached = false this.blockDelivery = false this.publicationQueue.length = 0 + this.unregisterClientResource?.() + this.unregisterClientResource = undefined + } + + private registerClientResource(): void { + if ( + this.unregisterClientResource || + !this.client?._isSsrServerCleanupEnabled() || + !this.collection || + !getBuilderFromConfig(this.collection.config) + ) { + return + } + + this.unregisterClientResource = this.client._registerLiveQueryResource( + this, + async () => { + const collection = this.collection + this.dispose() + await collection?.cleanup() + }, + ) } private emit( @@ -524,8 +803,33 @@ class LiveQueryObserverImpl< } } - async preload(): Promise { - await this.collection?.preload() + preload(): Promise { + if (this.preloadPromise) return this.preloadPromise + + if (this.client && this.queryHash) { + const query = this.client._getLiveQuery(this.queryHash) + if (query?.status === `pending`) return query.promise + if (query?.status === `success`) return Promise.resolve() + } + + this.registerClientResource() + this.onPreload?.() + const collectionPromise = this.collection?.preload() ?? Promise.resolve() + const preloadPromise = + this.client?._isSsrStreamingEnabled() && this.queryHash + ? this.client._registerLiveQuery( + this.queryHash, + collectionPromise.then(() => this.dehydrate()), + ) + : collectionPromise + this.preloadPromise = preloadPromise + const clearPreload = () => { + if (this.preloadPromise === preloadPromise) { + this.preloadPromise = undefined + } + } + void preloadPromise.then(clearPreload, clearPreload) + return preloadPromise } dispose(): void { @@ -554,6 +858,12 @@ export interface CreateLiveQueryObserverOptions { * `useSyncExternalStore`-style consumers safe by construction. */ mode?: `granular` | `wholesale` + /** DbClient cache that owns SSR snapshots for this query identity. */ + client?: DbClient + /** Stable live-query identity used for dehydration and hydration. */ + queryHash?: string + /** Resume framework-deferred query sources before a server preload. */ + onPreload?: () => void } /** @@ -575,5 +885,8 @@ export function createLiveQueryObserver< return new LiveQueryObserverImpl( collection ?? null, options.mode === `wholesale`, + options.client, + options.queryHash, + options.onPreload, ) } diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts new file mode 100644 index 0000000000..4005c3f9bf --- /dev/null +++ b/packages/db/src/live-query-options.ts @@ -0,0 +1,124 @@ +import { BaseQueryBuilder } from './query/builder/index.js' +import { isCollection } from './live-query-adapter.js' +import { + getStableQueryBuilderHash, + getStableValueHash, +} from './query/ir-stable-identity.js' +import type { CollectionImpl } from './collection/index.js' +import type { CollectionOptionsIdentity } from './collection-options.js' +import type { CollectionOptions, DbClient } from './client.js' +import type { + InitialQueryBuilder, + LiveQueryCollectionConfig, +} from './query/index.js' + +export type LiveQueryKey = ReadonlyArray + +export type LiveQueryOptions = LiveQueryCollectionConfig & { + queryKey?: LiveQueryKey +} + +export type DeferredLiveQueryCollections = Set< + CollectionImpl +> + +function createInitialQueryBuilder( + dbClient: DbClient | undefined, + deferredCollections: DeferredLiveQueryCollections, +): InitialQueryBuilder { + return new BaseQueryBuilder( + {}, + dbClient + ? ( + options: CollectionOptionsIdentity< + any, + string | number, + any, + any, + any + >, + ) => { + const collection = dbClient._materializeCollectionForRender( + options as CollectionOptions, + ) as CollectionImpl + if (collection._deferSyncStart()) deferredCollections.add(collection) + return collection + } + : undefined, + ) as InitialQueryBuilder +} + +export function prepareLiveQueryValue( + value: unknown, + dbClient: DbClient | undefined, + deferredCollections: DeferredLiveQueryCollections, +): unknown { + if (typeof value === `function`) { + return prepareLiveQueryValue( + value(createInitialQueryBuilder(dbClient, deferredCollections)), + dbClient, + deferredCollections, + ) + } + + if ( + value && + typeof value === `object` && + !isCollection(value) && + !(value instanceof BaseQueryBuilder) && + `query` in value + ) { + const { + query, + queryKey: _queryKey, + client: _client, + ...config + } = value as LiveQueryCollectionConfig & { + queryKey?: LiveQueryKey + client?: DbClient + } + + return { + ...config, + query: + typeof query === `function` + ? query(createInitialQueryBuilder(dbClient, deferredCollections)) + : query, + } + } + + return value +} + +export function getPreparedLiveQueryIdentity(value: unknown): unknown { + if (isCollection(value)) return [`collection`, value.id] + if (value instanceof BaseQueryBuilder) { + return [`query`, getStableQueryBuilderHash(value)] + } + if (value && typeof value === `object` && `query` in value) { + const config = value as LiveQueryCollectionConfig + return [ + `config`, + getPreparedLiveQueryIdentity(config.query), + [`getKey`, config.getKey], + [`schema`, config.schema], + [`singleResult`, config.singleResult === true], + [`defaultStringCollation`, config.defaultStringCollation], + ] + } + if (value === undefined || value === null) return [`disabled`] + return [`value`, value] +} + +export function getLiveQueryHash( + preparedValue: unknown, + queryKey?: LiveQueryKey, +): string { + const identity = queryKey?.length + ? [`queryKey`, queryKey] + : isCollection(preparedValue) + ? [`collection`, preparedValue.id] + : [`derived`, getPreparedLiveQueryIdentity(preparedValue)] + + return getStableValueHash(identity, `queryKey`) +} diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index afcf3c9a76..911b0cb924 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -1,4 +1,5 @@ import { safeRandomUUID } from './utils/uuid' +import { withCollectionConfigFactory } from './client.js' import type { BaseCollectionConfig, CollectionConfig, @@ -264,7 +265,7 @@ export function localOnlyCollectionOptions< ) } - return { + const options = { ...restConfig, id: collectionId, sync: syncResult.sync, @@ -279,6 +280,17 @@ export function localOnlyCollectionOptions< } as LocalOnlyCollectionOptionsResult & { schema?: StandardSchemaV1 } + + return withCollectionConfigFactory(options, () => + ( + localOnlyCollectionOptions as ( + nextConfig: LocalOnlyCollectionConfig, + ) => typeof options + )({ + ...config, + id: collectionId, + }), + ) } /** diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 05ad388d7c..7ab6b98449 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -1,4 +1,5 @@ import { safeRandomUUID } from './utils/uuid' +import { withCollectionConfigFactory } from './client.js' import { InvalidStorageDataFormatError, InvalidStorageObjectFormatError, @@ -607,7 +608,7 @@ export function localStorageCollectionOptions( sync.confirmOperationsSync(collectionMutations) } - return { + const options = { ...restConfig, id: collectionId, sync, @@ -620,6 +621,15 @@ export function localStorageCollectionOptions( acceptMutations, }, } + + return withCollectionConfigFactory( + options, + () => + localStorageCollectionOptions({ + ...config, + id: collectionId, + }) as unknown as typeof options, + ) } /** diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index e8f370228f..0291c204cd 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -1,4 +1,5 @@ import { CollectionImpl } from '../../collection/index.js' +import { hasCollectionOptionsBrand } from '../../collection-options.js' import { Aggregate as AggregateExpr, CollectionRef, @@ -36,6 +37,7 @@ import { } from './functions.js' import type { SourceClauseContext } from '../../errors.js' import type { NamespacedRow, SingleResult } from '../../types.js' +import type { CollectionOptionsIdentity } from '../../collection-options.js' import type { Aggregate, BasicExpression, @@ -75,13 +77,26 @@ import type { const UNION_ALL_SOURCE_CONTEXT = `unionAll clause` satisfies SourceClauseContext +type CollectionResolver = ( + options: CollectionOptionsIdentity, +) => CollectionImpl + export class BaseQueryBuilder { private readonly query: Partial = {} - constructor(query: Partial = {}) { + constructor( + query: Partial = {}, + private readonly resolveCollection?: CollectionResolver, + ) { this.query = { ...query } } + private _clone( + query: Partial, + ): BaseQueryBuilder { + return new BaseQueryBuilder(query, this.resolveCollection) + } + /** * Creates a CollectionRef or QueryRef from a source object * @param source - An object with a single key-value pair @@ -140,6 +155,13 @@ export class BaseQueryBuilder { if (sourceValue instanceof CollectionImpl) { ref = new CollectionRef(sourceValue, alias) + } else if (hasCollectionOptionsBrand(sourceValue)) { + if (!this.resolveCollection) { + throw new Error( + `Cannot use collection descriptor "${alias}" as a query source without a DbClient resolver. In React, wrap your tree in .`, + ) + } + ref = new CollectionRef(this.resolveCollection(sourceValue), alias) } else if (sourceValue instanceof BaseQueryBuilder) { const subQuery = sourceValue._getQuery() if (!(subQuery as Partial).from) { @@ -177,7 +199,7 @@ export class BaseQueryBuilder { ): QueryBuilder> { const [, from] = this._createRefForSource(source, `from clause`) - return new BaseQueryBuilder({ + return this._clone({ ...this.query, from, }) as any @@ -209,7 +231,7 @@ export class BaseQueryBuilder { ...branches: Array> ): QueryBuilder { if (sourceOrBranch instanceof BaseQueryBuilder) { - return new BaseQueryBuilder({ + return this._clone({ ...this.query, from: new UnionAll( [sourceOrBranch, ...branches].map((branch) => @@ -226,7 +248,7 @@ export class BaseQueryBuilder { const from = refs.length === 1 ? refs[0]![1] : new UnionFrom(refs.map((r) => r[1])) - return new BaseQueryBuilder({ + return this._clone({ ...this.query, from, }) as any @@ -308,7 +330,7 @@ export class BaseQueryBuilder { const existingJoins = this.query.join || [] - return new BaseQueryBuilder({ + return this._clone({ ...this.query, join: [...existingJoins, joinClause], }) as any @@ -467,7 +489,7 @@ export class BaseQueryBuilder { const existingWhere = this.query.where || [] - return new BaseQueryBuilder({ + return this._clone({ ...this.query, where: [...existingWhere, expression], }) as any @@ -527,7 +549,7 @@ export class BaseQueryBuilder { const existingHaving = this.query.having || [] - return new BaseQueryBuilder({ + return this._clone({ ...this.query, having: [...existingHaving, expression], }) as any @@ -593,7 +615,7 @@ export class BaseQueryBuilder { const select = buildNestedSelect(selectObject, aliases) - return new BaseQueryBuilder({ + return this._clone({ ...this.query, select: select, fnSelect: undefined, // remove the fnSelect clause if it exists @@ -668,7 +690,7 @@ export class BaseQueryBuilder { const existingOrderBy: OrderBy = this.query.orderBy || [] - return new BaseQueryBuilder({ + return this._clone({ ...this.query, orderBy: [...existingOrderBy, ...orderByClauses], }) as any @@ -713,7 +735,7 @@ export class BaseQueryBuilder { // Extend existing groupBy expressions (multiple groupBy calls should accumulate) const existingGroupBy = this.query.groupBy || [] - return new BaseQueryBuilder({ + return this._clone({ ...this.query, groupBy: [...existingGroupBy, ...newExpressions], }) as any @@ -736,7 +758,7 @@ export class BaseQueryBuilder { * ``` */ limit(count: number): QueryBuilder { - return new BaseQueryBuilder({ + return this._clone({ ...this.query, limit: count, }) as any @@ -760,7 +782,7 @@ export class BaseQueryBuilder { * ``` */ offset(count: number): QueryBuilder { - return new BaseQueryBuilder({ + return this._clone({ ...this.query, offset: count, }) as any @@ -781,7 +803,7 @@ export class BaseQueryBuilder { * ``` */ distinct(): QueryBuilder { - return new BaseQueryBuilder({ + return this._clone({ ...this.query, distinct: true, }) as any @@ -801,7 +823,7 @@ export class BaseQueryBuilder { *``` */ findOne(): QueryBuilder { - return new BaseQueryBuilder({ + return this._clone({ ...this.query, // TODO: enforcing return only one result with also a default orderBy if none is specified // limit: 1, @@ -871,7 +893,7 @@ export class BaseQueryBuilder { select( callback: (row: TContext[`schema`]) => TFuncSelectResult, ): QueryBuilder> { - return new BaseQueryBuilder({ + return builder._clone({ ...builder.query, select: undefined, // remove the select clause if it exists fnSelect: callback, @@ -895,7 +917,7 @@ export class BaseQueryBuilder { where( callback: (row: TContext[`schema`]) => any, ): QueryBuilder { - return new BaseQueryBuilder({ + return builder._clone({ ...builder.query, fnWhere: [ ...(builder.query.fnWhere || []), @@ -923,7 +945,7 @@ export class BaseQueryBuilder { having( callback: (row: FunctionalHavingRow) => any, ): QueryBuilder { - return new BaseQueryBuilder({ + return builder._clone({ ...builder.query, fnHaving: [ ...(builder.query.fnHaving || []), diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index f8cdbcabe4..db20942b59 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -1,4 +1,5 @@ import type { Collection, CollectionImpl } from '../../collection/index.js' +import type { CollectionOptionsIdentity } from '../../collection-options.js' import type { SingleResult, StringCollationConfig } from '../../types.js' import type { Aggregate, @@ -89,7 +90,10 @@ export type ContextSchema = Record * Example: `{ users: usersCollection }` */ export type Source = { - [alias: string]: CollectionImpl | QueryBuilder + [alias: string]: + | CollectionImpl + | CollectionOptionsIdentity + | QueryBuilder } /** @@ -101,7 +105,15 @@ export type Source = { export type InferCollectionType = T extends CollectionImpl ? WithVirtualProps - : never + : T extends CollectionOptionsIdentity< + infer TOutput, + infer TKey, + any, + any, + any + > + ? WithVirtualProps + : never /** * SchemaFromSource - Converts a Source definition into a ContextSchema @@ -116,9 +128,11 @@ export type InferCollectionType = export type SchemaFromSource = Prettify<{ [K in keyof T]: T[K] extends CollectionImpl ? InferCollectionType - : T[K] extends QueryBuilder - ? GetRawResult - : never + : T[K] extends CollectionOptionsIdentity + ? InferCollectionType + : T[K] extends QueryBuilder + ? GetRawResult + : never }> export type UnionRefsSchema = Prettify<{ diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 8cda812b3f..c330b56b95 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -95,6 +95,13 @@ export { queryOnce, type QueryOnceConfig } from './query-once.js' export { type LiveQueryCollectionConfig } from './live/types.js' export { type LiveQueryCollectionUtils } from './live/collection-config-builder.js' +export { + UnhashableQueryIRError, + canonicalizeQueryIR, + getStableQueryBuilderHash, + getStableQueryIRHash, + getStableValueHash, +} from './ir-stable-identity.js' // Predicate utilities for predicate push-down export { diff --git a/packages/db/src/query/ir-stable-identity.ts b/packages/db/src/query/ir-stable-identity.ts new file mode 100644 index 0000000000..a479536fb2 --- /dev/null +++ b/packages/db/src/query/ir-stable-identity.ts @@ -0,0 +1,576 @@ +import { isRefProxy, toExpression } from './builder/ref-proxy.js' +import { getQueryIR } from './builder/index.js' +import type { + Aggregate, + BasicExpression, + ConditionalSelect, + From, + Having, + IncludesSubquery, + JoinClause, + OrderByClause, + QueryIR, + Select, + Where, +} from './ir.js' +import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js' + +type StableIdentityValue = + | null + | boolean + | number + | string + | Array + | { [key: string]: StableIdentityValue } + +export class UnhashableQueryIRError extends Error { + constructor( + public readonly path: string, + public readonly reason: string, + ) { + super(`Query IR is not stably hashable at ${path}: ${reason}`) + this.name = `UnhashableQueryIRError` + } +} + +export function getStableQueryIRHash(query: QueryIR): string { + return JSON.stringify(canonicalizeQueryIR(query)) +} + +export function getStableQueryBuilderHash( + query: InitialQueryBuilder | QueryBuilder, +): string { + return getStableQueryIRHash(getQueryIR(query)) +} + +export function getStableValueHash(value: unknown, path = `value`): string { + return JSON.stringify(canonicalizeRuntimeValue(value, path, new WeakSet())) +} + +export function canonicalizeQueryIR(query: QueryIR): StableIdentityValue { + return canonicalizeQuery(query, `query`, new WeakSet()) +} + +function canonicalizeQuery( + query: QueryIR, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (query.fnSelect) { + throw new UnhashableQueryIRError(`${path}.fnSelect`, `function select`) + } + + if (query.fnWhere?.length) { + throw new UnhashableQueryIRError(`${path}.fnWhere`, `function where`) + } + + if (query.fnHaving?.length) { + throw new UnhashableQueryIRError(`${path}.fnHaving`, `function having`) + } + + const result: Record = { + type: `query`, + from: canonicalizeSource(query.from, `${path}.from`, seen), + } + + if (query.select) { + result.select = canonicalizeSelect(query.select, `${path}.select`, seen) + } + + if (query.join) { + result.join = query.join.map((join, index) => + canonicalizeJoin(join, `${path}.join[${index}]`, seen), + ) + } + + if (query.where) { + result.where = query.where.map((where, index) => + canonicalizeWhere(where, `${path}.where[${index}]`, seen), + ) + } + + if (query.groupBy) { + result.groupBy = query.groupBy.map((expression, index) => + canonicalizeExpression(expression, `${path}.groupBy[${index}]`, seen), + ) + } + + if (query.having) { + result.having = query.having.map((having, index) => + canonicalizeWhere(having, `${path}.having[${index}]`, seen), + ) + } + + if (query.orderBy) { + result.orderBy = query.orderBy.map((orderBy, index) => + canonicalizeOrderBy(orderBy, `${path}.orderBy[${index}]`, seen), + ) + } + + if (query.limit !== undefined) { + result.limit = canonicalizeRuntimeValue(query.limit, `${path}.limit`, seen) + } + + if (query.offset !== undefined) { + result.offset = canonicalizeRuntimeValue( + query.offset, + `${path}.offset`, + seen, + ) + } + + if (query.distinct) { + result.distinct = true + } + + if (query.singleResult) { + result.singleResult = true + } + + return result +} + +function canonicalizeJoin( + join: JoinClause, + path: string, + seen: WeakSet, +): StableIdentityValue { + return { + type: join.type, + from: canonicalizeSource(join.from, `${path}.from`, seen), + left: canonicalizeExpression(join.left, `${path}.left`, seen), + right: canonicalizeExpression(join.right, `${path}.right`, seen), + } +} + +function canonicalizeSource( + source: From, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (source.type === `collectionRef`) { + return { + type: `collectionRef`, + alias: source.alias, + collectionId: canonicalizeRuntimeValue( + source.collection.id, + `${path}.collection.id`, + seen, + ), + } + } + + if (source.type === `unionFrom`) { + return { + type: `unionFrom`, + sources: source.sources.map((unionSource, index) => + canonicalizeSource(unionSource, `${path}.sources[${index}]`, seen), + ), + } + } + + if (source.type === `unionAll`) { + return { + type: `unionAll`, + queries: source.queries.map((query, index) => + canonicalizeQuery(query, `${path}.queries[${index}]`, seen), + ), + } + } + + return { + type: `queryRef`, + alias: source.alias, + query: canonicalizeQuery(source.query, `${path}.query`, seen), + } +} + +function canonicalizeSelect( + select: Select, + path: string, + seen: WeakSet, +): StableIdentityValue { + return { + type: `select`, + fields: Object.keys(select) + .sort() + .map((key) => [ + key, + canonicalizeSelectValue(select[key]!, `${path}.${key}`, seen), + ]), + } +} + +function canonicalizeSelectValue( + value: unknown, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (isRefProxy(value)) { + return canonicalizeExpression(toExpression(value), path, seen) + } + + if (isExpression(value)) { + return canonicalizeExpression(value, path, seen) + } + + if (isPlainObject(value)) { + return canonicalizeSelect(value as Select, path, seen) + } + + return canonicalizeRuntimeValue(value, path, seen) +} + +function canonicalizeWhere( + where: Where | Having, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (isWhereObject(where)) { + const result: Record = { + type: `where`, + expression: canonicalizeExpression( + where.expression, + `${path}.expression`, + seen, + ), + } + + if (where.residual === true) { + result.residual = true + } + + return result + } + + return canonicalizeExpression(where, path, seen) +} + +function canonicalizeOrderBy( + orderBy: OrderByClause, + path: string, + seen: WeakSet, +): StableIdentityValue { + return { + expression: canonicalizeExpression( + orderBy.expression, + `${path}.expression`, + seen, + ), + compareOptions: canonicalizeRuntimeValue( + orderBy.compareOptions, + `${path}.compareOptions`, + seen, + ), + } +} + +function canonicalizeExpression( + expression: + | BasicExpression + | Aggregate + | IncludesSubquery + | ConditionalSelect, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (expression.type === `ref`) { + return { + type: `ref`, + path: expression.path.map((segment, index) => + canonicalizeRuntimeValue(segment, `${path}.path[${index}]`, seen), + ), + } + } + + if (expression.type === `val`) { + return { + type: `val`, + value: canonicalizeRuntimeValue(expression.value, `${path}.value`, seen), + } + } + + if (expression.type === `func`) { + return { + type: `func`, + name: expression.name, + args: expression.args.map((arg, index) => + canonicalizeExpression(arg, `${path}.args[${index}]`, seen), + ), + } + } + + if (expression.type === `agg`) { + return { + type: `agg`, + name: expression.name, + args: expression.args.map((arg, index) => + canonicalizeExpression(arg, `${path}.args[${index}]`, seen), + ), + } + } + + if (expression.type === `conditionalSelect`) { + const result: Record = { + type: `conditionalSelect`, + branches: expression.branches.map((branch, index) => ({ + condition: canonicalizeExpression( + branch.condition, + `${path}.branches[${index}].condition`, + seen, + ), + value: canonicalizeSelectValue( + branch.value, + `${path}.branches[${index}].value`, + seen, + ), + })), + } + + if (expression.defaultValue !== undefined) { + result.defaultValue = canonicalizeSelectValue( + expression.defaultValue, + `${path}.defaultValue`, + seen, + ) + } + + return result + } + + const result: Record = { + type: `includesSubquery`, + query: canonicalizeQuery(expression.query, `${path}.query`, seen), + correlationField: canonicalizeExpression( + expression.correlationField, + `${path}.correlationField`, + seen, + ), + childCorrelationField: canonicalizeExpression( + expression.childCorrelationField, + `${path}.childCorrelationField`, + seen, + ), + fieldName: expression.fieldName, + materialization: expression.materialization, + } + + if (expression.parentFilters) { + result.parentFilters = expression.parentFilters.map((where, index) => + canonicalizeWhere(where, `${path}.parentFilters[${index}]`, seen), + ) + } + + if (expression.parentProjection) { + result.parentProjection = expression.parentProjection.map( + (projection, index) => + canonicalizeExpression( + projection, + `${path}.parentProjection[${index}]`, + seen, + ), + ) + } + + if (expression.scalarField !== undefined) { + result.scalarField = expression.scalarField + } + + return result +} + +function canonicalizeRuntimeValue( + value: unknown, + path: string, + seen: WeakSet, +): StableIdentityValue { + if (value === null) return [`null`] + + if (typeof value === `string`) { + return [`string`, value] + } + + if (typeof value === `boolean`) { + return [`boolean`, value] + } + + if (typeof value === `number`) { + if (Number.isNaN(value)) { + return [`number`, `NaN`] + } + + if (value === Infinity) { + return [`number`, `Infinity`] + } + + if (value === -Infinity) { + return [`number`, `-Infinity`] + } + + if (Object.is(value, -0)) { + return [`number`, `-0`] + } + + return [`number`, value] + } + + if (typeof value === `undefined`) { + return [`undefined`] + } + + if (typeof value === `bigint`) { + return [`bigint`, value.toString()] + } + + if (typeof value === `function`) { + throw new UnhashableQueryIRError(path, `function value`) + } + + if (typeof value === `symbol`) { + throw new UnhashableQueryIRError(path, `symbol value`) + } + + if (isRefProxy(value)) { + return canonicalizeExpression(toExpression(value), path, seen) + } + + if (Array.isArray(value)) { + return withCircularGuard(value, path, seen, () => [ + `array`, + value.map((item, index) => + canonicalizeRuntimeValue(item, `${path}[${index}]`, seen), + ), + ]) + } + + if (value instanceof Date) { + const timestamp = value.getTime() + if (Number.isNaN(timestamp)) { + throw new UnhashableQueryIRError(path, `invalid Date`) + } + + return [`Date`, value.toISOString()] + } + + if (value instanceof ArrayBuffer) { + return [`binary`, `ArrayBuffer`, Array.from(new Uint8Array(value))] + } + + if (ArrayBuffer.isView(value)) { + return [ + `binary`, + value.constructor.name, + Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + ), + ] + } + + if (value instanceof Map) { + return withCircularGuard(value, path, seen, () => { + const entries = Array.from( + value.entries(), + ([key, entryValue], index) => [ + canonicalizeRuntimeValue(key, `${path}.key[${index}]`, seen), + canonicalizeRuntimeValue(entryValue, `${path}.value[${index}]`, seen), + ], + ) + entries.sort(compareStableIdentityValues) + return [`Map`, entries] + }) + } + + if (value instanceof Set) { + return withCircularGuard(value, path, seen, () => { + const entries = Array.from(value, (entry, index) => + canonicalizeRuntimeValue(entry, `${path}[${index}]`, seen), + ) + entries.sort(compareStableIdentityValues) + return [`Set`, entries] + }) + } + + if (isPlainObject(value)) { + return canonicalizeObject(value, path, seen) + } + + throw new UnhashableQueryIRError(path, `non-plain object value`) +} + +function compareStableIdentityValues( + left: StableIdentityValue, + right: StableIdentityValue, +): number { + const serializedLeft = JSON.stringify(left) + const serializedRight = JSON.stringify(right) + return serializedLeft < serializedRight + ? -1 + : serializedLeft > serializedRight + ? 1 + : 0 +} + +function canonicalizeObject( + value: Record, + path: string, + seen: WeakSet, +): StableIdentityValue { + return withCircularGuard(value, path, seen, () => [ + `object`, + Object.keys(value) + .sort() + .map((key) => [ + key, + canonicalizeRuntimeValue(value[key], `${path}.${key}`, seen), + ]), + ]) +} + +function withCircularGuard( + 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..c30ee78f05 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -17,10 +17,136 @@ 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) + } + } -let sequenceNumber = 0 + 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 }) + } + } + } + + 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() +const transactionAmbientScopes = 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 +} + +function getTransactionAmbientScope(transaction: object): TransactionScope { + const scope = transactionAmbientScopes.get(transaction) + if (!scope) { + throw new Error(`Transaction is not associated with an ambient scope.`) + } + return scope +} /** * Merges two pending mutations for the same item within a transaction @@ -157,9 +283,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 +298,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 +328,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 +343,17 @@ 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) + transactionAmbientScopes.set(this, scope) } setState(newState: TransactionState) { this.state = newState if (newState === `completed` || newState === `failed`) { - removeFromPendingList(this) + getTransactionScope(this).removeTransaction(this) } } @@ -310,12 +411,22 @@ class Transaction> { throw new TransactionNotPendingMutateError() } - registerTransaction(this) + const initialScope = getTransactionScope(this) + const registeredScopes = new Set([ + initialScope, + getTransactionAmbientScope(this), + ]) + for (const scope of registeredScopes) { + scope.registerTransaction(this) + } try { callback() } finally { - unregisterTransaction(this) + registeredScopes.add(getTransactionScope(this)) + for (const scope of registeredScopes) { + scope.unregisterTransaction(this) + } } if (this.autoCommit) { @@ -430,13 +541,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..74cb1943f0 --- /dev/null +++ b/packages/db/tests/db-client.test-d.ts @@ -0,0 +1,115 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { DbClient, collectionOptions, eq } from '../src' +import type { + DehydratedCollectionChunk, + DehydratedDbState, + DehydratedLiveQueryResult, +} 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(`types live-query preload and result snapshots`, () => { + const descriptor = collectionOptions(`live-todos`, () => ({ + id: `live-todos`, + getKey: (todo: Todo) => todo.id, + sync: { sync: () => {} }, + })) + const client = new DbClient() + const preload = client.preloadLiveQuery({ + query: (q) => + q.from({ todo: descriptor }).where(({ todo }) => eq(todo.id, `1`)), + }) + const snapshot: DehydratedLiveQueryResult = { + rows: [{ key: `1`, value: { id: `1`, title: `Ship SSR` } }], + } + + expectTypeOf(preload).toEqualTypeOf>() + expectTypeOf(snapshot.rows[0]!.value).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..46b67a970c --- /dev/null +++ b/packages/db/tests/db-client.test.ts @@ -0,0 +1,1132 @@ +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' +import type { DehydratedLiveQueryResult, InitialQueryBuilder } from '../src' + +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(`requires a factory when using the explicit id overload`, () => { + expect(() => + Reflect.apply(collectionOptions, undefined, [`people`]), + ).toThrow(/collectionOptions\("people"\) requires a factory/) + }) + + 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(`keeps deferred subset loads pending until the replayed adapter load finishes`, async () => { + let resolveLoad!: () => void + const adapterLoad = new Promise((resolve) => { + resolveLoad = resolve + }) + const loadSubset = vi.fn(() => adapterLoad) + const descriptor = collectionOptions(`people`, () => ({ + id: `people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset } + }, + }, + })) + const client = new DbClient() + const collection = client._materializeCollectionForRender(descriptor) + + const deferredLoad = collection._sync.loadSubset({}) + expect(deferredLoad).toBeInstanceOf(Promise) + expect(collection.isLoadingSubset).toBe(true) + expect(loadSubset).not.toHaveBeenCalled() + + collection._resumeSyncStart() + expect(loadSubset).toHaveBeenCalledOnce() + expect(collection.isLoadingSubset).toBe(true) + + resolveLoad() + await deferredLoad + expect(collection.isLoadingSubset).toBe(false) + }) + + 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(`streams pending live queries as result snapshots`, async () => { + const descriptor = collectionOptions(`people`, () => ({ + id: `people`, + getKey: (person: Person) => person.id, + sync: { + sync: ({ markReady }) => markReady(), + }, + })) + const serverClient = new DbClient() + serverClient.collection(descriptor) + const listener = vi.fn() + serverClient.subscribe(listener) + + let resolveLoad!: (snapshot: { + rows: Array<{ key: string; value: Person }> + }) => void + const loadPromise = new Promise<{ + rows: Array<{ key: string; value: Person }> + }>((resolve) => { + resolveLoad = resolve + }) + serverClient._registerLiveQuery(`active-people`, loadPromise) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: `liveQueryAdded`, + query: expect.objectContaining({ + queryHash: `active-people`, + status: `pending`, + }), + }), + ) + expect(serverClient.dehydrate().liveQueries).toBeUndefined() + + const dehydrated = serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + expect(dehydrated.collections).toEqual([]) + expect(dehydrated.liveQueries).toHaveLength(1) + + const browserClient = new DbClient() + browserClient.hydrate(dehydrated) + const browserQuery = browserClient._getLiveQuery(`active-people`) + expect(browserQuery?.status).toBe(`pending`) + + resolveLoad({ + rows: [{ key: `1`, value: people[0]! }], + }) + await browserQuery?.promise + + expect(browserClient._getLiveQuery(`active-people`)?.status).toBe(`success`) + expect(browserQuery?.snapshot).toEqual({ + rows: [{ key: `1`, value: people[0]! }], + }) + expect(browserClient.collection(descriptor).get(`1`)).toBeUndefined() + }) + + it(`propagates streamed live query failures to the hydrated client`, async () => { + const serverClient = new DbClient() + let rejectLoad!: (error: Error) => void + const loadPromise = new Promise<{ + rows: Array<{ key: string; value: Person }> + }>((_resolve, reject) => { + rejectLoad = reject + }) + serverClient._registerLiveQuery(`active-people`, loadPromise) + const dehydrated = serverClient.dehydrate({ + shouldDehydrateLiveQuery: () => true, + }) + const browserClient = new DbClient() + + browserClient.hydrate(dehydrated) + const browserQuery = browserClient._getLiveQuery(`active-people`) + const error = new Error(`Server load failed`) + rejectLoad(error) + + await expect(browserQuery?.promise).rejects.toBe(error) + expect(browserQuery?.status).toBe(`error`) + expect(browserQuery?.error).toBe(error) + }) + + it(`settles waiters when newer hydration supersedes a pending live query`, async () => { + const client = new DbClient() + let resolveOriginal!: (snapshot: { + rows: Array<{ key: string; value: Person }> + }) => void + const originalResult = new Promise<{ + rows: Array<{ key: string; value: Person }> + }>((resolve) => { + resolveOriginal = resolve + }) + const originalPromise = client._registerLiveQuery( + `active-people`, + originalResult, + ) + const originalRecord = client._getLiveQuery(`active-people`)! + + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `active-people`, + dehydratedAt: originalRecord.dehydratedAt + 1, + snapshot: { + rows: [{ key: `1`, value: people[0]! }], + }, + }, + ], + }) + + await expect(originalPromise).resolves.toBeUndefined() + expect(client._getLiveQuery(`active-people`)).toMatchObject({ + status: `success`, + snapshot: { rows: [{ key: `1`, value: people[0]! }] }, + }) + + resolveOriginal({ rows: [] }) + }) + + it(`handles a rejected duplicate live-query registration`, async () => { + const client = new DbClient() + await client._registerLiveQuery( + `active-people`, + Promise.resolve({ rows: [] }), + ) + + let rejectDuplicate!: (error: Error) => void + const duplicate = new Promise( + (_resolve, reject) => { + rejectDuplicate = reject + }, + ) + + await expect( + client._registerLiveQuery(`active-people`, duplicate), + ).resolves.toBeUndefined() + rejectDuplicate(new Error(`duplicate failed`)) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + it(`explicit collection preload dehydrates source collection rows`, 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 collections materialized only as query sources`, () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const client = new DbClient() + + client._materializeCollectionForRender(descriptor) + expect(client.dehydrate()).toEqual({ collections: [] }) + + client.collection(descriptor) + expect(client.dehydrate().collections).toHaveLength(1) + }) + + it(`preloads and dehydrates a live query result without its source rows`, async () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const client = new DbClient() + + await client.preloadLiveQuery({ + query: (q) => + q + .from({ person: descriptor }) + .where(({ person }) => eq(person.status, `active`)), + }) + + const dehydrated = client.dehydrate() + expect(dehydrated.collections).toEqual([]) + expect(dehydrated.liveQueries).toHaveLength(1) + expect(dehydrated.liveQueries![0]!.promise).toBeUndefined() + expect(dehydrated.liveQueries![0]!.snapshot?.rows).toEqual([ + { + key: `1`, + value: expect.objectContaining(people[0]!), + }, + ]) + expect( + client.dehydrate({ shouldDehydrateLiveQuery: () => false }).liveQueries, + ).toBeUndefined() + }) + + it(`cleans up a failed live-query preload before retrying it`, async () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `retry-people`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const client = new DbClient() + const options = { + query: (q: InitialQueryBuilder) => + q + .from({ person: descriptor }) + .where(({ person }) => eq(person.status, `active`)), + } + + await client.preloadLiveQuery(options) + const internals = client as unknown as { + liveQueries: Map + preloadedLiveQueries: Map< + string, + { collection: { cleanup: () => Promise } } + > + } + const failedQuery = Array.from(internals.liveQueries.values())[0]! + failedQuery.status = `error` + failedQuery.error = new Error(`failed`) + const failedCollection = Array.from( + internals.preloadedLiveQueries.values(), + )[0]!.collection + const originalCleanup = failedCollection.cleanup.bind(failedCollection) + const cleanup = vi + .spyOn(failedCollection, `cleanup`) + .mockImplementationOnce(async () => { + await originalCleanup() + throw new Error(`cleanup failed`) + }) + + await expect(client.preloadLiveQuery(options)).resolves.toBeUndefined() + + expect(cleanup).toHaveBeenCalledOnce() + expect(client.dehydrate().liveQueries?.[0]?.snapshot?.rows).toHaveLength(1) + }) + + it(`releases source deferrals when preload returns an existing result`, async () => { + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `preload-existing-source`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const client = new DbClient() + const options = { + query: (q: InitialQueryBuilder) => q.from({ person: descriptor }), + } + + await client.preloadLiveQuery(options) + const source = client.collection(descriptor) + await source.cleanup() + await client.preloadLiveQuery(options) + + await expect( + Promise.race([ + source.preload().then(() => `ready`), + new Promise<`timeout`>((resolve) => + setTimeout(() => resolve(`timeout`), 20), + ), + ]), + ).resolves.toBe(`ready`) + }) + + it(`cleans up live queries before their source collections`, async () => { + const errorSpy = vi.spyOn(console, `error`).mockImplementation(() => {}) + const descriptor = collectionOptions( + mockSyncCollectionOptions({ + id: `cleanup-order-source`, + getKey: (person) => person.id, + initialData: people, + }), + ) + const client = new DbClient() + + try { + await client.preloadLiveQuery({ + query: (q: InitialQueryBuilder) => q.from({ person: descriptor }), + }) + await client.cleanup() + + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`was manually cleaned up while live query`), + ) + } finally { + errorSpy.mockRestore() + } + }) + + 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(`validates and transforms hydrated rows through the collection schema`, () => { + const descriptor = collectionOptions({ + id: `schema-hydration`, + schema: z.object({ + id: z.string().transform((id) => `person:${id}`), + createdAt: z.coerce.date(), + }), + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const client = new DbClient() + const collection = client.collection(descriptor) + + client.hydrate({ + collections: [ + { + collectionId: `schema-hydration`, + rows: [ + { + key: `1`, + value: { id: `1`, createdAt: `2026-08-14T00:00:00.000Z` }, + }, + ], + }, + ], + }) + + expect(collection.get(`person:1`)?.createdAt).toBeInstanceOf(Date) + expect(collection.get(`1`)).toBeUndefined() + }) + + it(`lets adapter inserts replace hydration applied to a ready collection`, async () => { + let adapterWrite!: (person: Person) => void + const descriptor = collectionOptions({ + id: `ready-hydration-seed`, + getKey: (person: Person) => person.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + adapterWrite = (person) => { + begin() + write({ type: `insert`, value: person }) + commit() + } + markReady() + }, + }, + }) + const client = new DbClient() + const collection = client.collection(descriptor) + await collection.preload() + + client.hydrate({ + collections: [ + { + collectionId: `ready-hydration-seed`, + rows: [{ key: `1`, value: { id: `1`, name: `hydrated` } }], + }, + ], + }) + + expect(() => adapterWrite({ id: `1`, name: `adapter` })).not.toThrow() + expect(collection.get(`1`)?.name).toBe(`adapter`) + }) + + it(`does not let a late stream chunk overwrite adapter rows or metadata`, async () => { + const descriptor = collectionOptions({ + id: `adapter-authority`, + getKey: (person: Person) => person.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `1`, name: `adapter` }, + metadata: { source: `adapter` }, + }) + commit() + markReady() + }, + }, + }) + const client = new DbClient() + const collection = client.collection(descriptor) + await collection.preload() + + expect(collection._state.syncedData.has(`1`)).toBe(true) + expect(collection._state.hydrationSeedKeys.has(`1`)).toBe(false) + + client.applyCollectionChunk({ + collectionId: `adapter-authority`, + rows: [{ key: `1`, value: { id: `1`, name: `stale stream` } }], + }) + + expect(collection.get(`1`)?.name).toBe(`adapter`) + expect(collection._state.syncedMetadata.get(`1`)).toEqual({ + source: `adapter`, + }) + }) + + 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/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index bb0ac8ff50..0ff8963811 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { DbClient, collectionOptions } from '../src/client.js' +import { createLiveQueryCollection } from '../src/query/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { mockSyncCollectionOptions, @@ -126,6 +128,396 @@ function makeControlledTruncateSource() { } describe(`createLiveQueryObserver`, () => { + it(`registers SSR live-query resources for client-owned cleanup`, async () => { + const errorSpy = vi.spyOn(console, `error`).mockImplementation(() => {}) + const client = new DbClient() + const source = client.collection( + collectionOptions({ + id: `observer-client-cleanup-source`, + getKey: (row: Row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: SEED[0]! }) + commit() + markReady() + }, + }, + }), + ) + const liveQuery = createLiveQueryCollection((q) => q.from({ source })) + client._setSsrServerCleanupEnabled(true) + const sourceObserver = createLiveQueryObserver(source, { + client, + queryHash: `observer-source-cleanup`, + }) + const liveQueryObserver = createLiveQueryObserver(liveQuery, { + client, + queryHash: `observer-client-cleanup`, + }) + sourceObserver.getServerSnapshot() + liveQueryObserver.getServerSnapshot() + liveQuery.startSyncImmediate() + + try { + await client.cleanup() + + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`was manually cleaned up while live query`), + ) + } finally { + errorSpy.mockRestore() + } + }) + + it(`publishes a live error instead of pinning a hydration seed as ready`, () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `seeded-error`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `server` } }], + }, + }, + ], + }) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: `seeded-error`, + mode: `wholesale`, + }) + const listener = vi.fn() + observer.subscribe(listener) + + collection._lifecycle.setStatus(`error`) + + expect(listener).toHaveBeenCalled() + expect(observer.getSnapshot().status).toBe(`error`) + observer.dispose() + }) + + it(`exposes a streamed query error while a hydration seed is active`, async () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `seeded-stream-error`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `server` } }], + }, + }, + ], + }) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: `seeded-stream-error`, + mode: `wholesale`, + }) + observer.subscribe(() => {}) + const failure = new Error(`stream failed`) + + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `seeded-stream-error`, + dehydratedAt: 2, + promise: Promise.reject(failure), + }, + ], + }) + await Promise.resolve() + + expect(observer.getError()).toBe(failure) + observer.dispose() + }) + + it(`retries preload after settlement and replaces cached error records`, async () => { + const collection = makeSource() + const preload = vi.spyOn(collection, `preload`).mockResolvedValue(undefined) + const client = new DbClient() + const failure = new Error(`first preload failed`) + await expect( + client._registerLiveQuery(`retry-preload`, Promise.reject(failure)), + ).rejects.toBe(failure) + const observer = createLiveQueryObserver(collection, { + client, + queryHash: `retry-preload`, + }) + + await expect(observer.preload()).resolves.toBeUndefined() + await expect(observer.preload()).resolves.toBeUndefined() + + expect(preload).toHaveBeenCalledTimes(2) + observer.dispose() + }) + + it(`shows a hydrated result until the live collection is authoritative`, async () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `people`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `From server` } }], + }, + }, + ], + }) + const observer = createLiveQueryObserver(collection as any, { + client, + queryHash: `people`, + mode: `wholesale`, + }) + + expect(observer.getSnapshot()).toMatchObject({ + status: `ready`, + data: [{ id: `1`, name: `From server` }], + }) + + const visibleSnapshots: Array> = [] + observer.subscribe(() => { + visibleSnapshots.push(observer.getSnapshot().data as ReadonlyArray) + }) + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: `2`, name: `From live sync` }, + }) + collection.utils.commit() + + expect(observer.getSnapshot().data).toEqual([ + { id: `1`, name: `From server` }, + ]) + expect(visibleSnapshots).toEqual([]) + + collection.utils.markReady() + await Promise.resolve() + + expect(observer.getSnapshot().data).toEqual([ + expect.objectContaining({ id: `2`, name: `From live sync` }), + ]) + expect(visibleSnapshots).toHaveLength(1) + expect(visibleSnapshots[0]).toEqual([ + expect.objectContaining({ id: `2`, name: `From live sync` }), + ]) + observer.dispose() + }) + + it(`delivers an atomic hydrated-to-live diff to granular consumers`, async () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `people`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `From server` } }], + }, + }, + ], + }) + const observer = createLiveQueryObserver(collection as any, { + client, + queryHash: `people`, + }) + const changes: Array> = [] + observer.subscribe((batch) => changes.push(...(batch ?? []))) + + expect(changes).toEqual([ + { + type: `insert`, + key: `1`, + value: { id: `1`, name: `From server` }, + }, + ]) + changes.length = 0 + + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: `2`, name: `From live sync` }, + }) + collection.utils.commit() + expect(changes).toEqual([]) + + collection.utils.markReady() + await Promise.resolve() + expect(changes).toEqual([ + { + type: `delete`, + key: `1`, + value: { id: `1`, name: `From server` }, + }, + { + type: `insert`, + key: `2`, + value: expect.objectContaining({ id: `2`, name: `From live sync` }), + }, + ]) + observer.dispose() + }) + + it(`does not replay a consumed server snapshot to a later observer`, async () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `people`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `From server` } }], + }, + }, + ], + }) + const observer = createLiveQueryObserver(collection as any, { + client, + queryHash: `people`, + mode: `wholesale`, + }) + observer.subscribe(() => {}) + + collection.utils.begin() + collection.utils.write({ + type: `insert`, + value: { id: `2`, name: `From live sync` }, + }) + collection.utils.commit() + collection.utils.markReady() + await Promise.resolve() + + const laterObserver = createLiveQueryObserver( + collection as any, + { client, queryHash: `people`, mode: `wholesale` }, + ) + expect(laterObserver.getSnapshot().data).toEqual([ + expect.objectContaining({ id: `2`, name: `From live sync` }), + ]) + expect(client._getLiveQuery(`people`)).toBeUndefined() + observer.dispose() + laterObserver.dispose() + }) + + it(`does not consume a shared hydration result during an abandoned render read`, () => { + const collection = makeLoadingSource() + const client = new DbClient() + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `shared-render-result`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `1`, value: { id: `1`, name: `From server` } }], + }, + }, + ], + }) + const abandoned = createLiveQueryObserver(collection, { + client, + queryHash: `shared-render-result`, + mode: `wholesale`, + }) + + expect(abandoned.getSnapshot().data).toEqual([ + { id: `1`, name: `From server` }, + ]) + abandoned.dispose() + + const sibling = createLiveQueryObserver(collection, { + client, + queryHash: `shared-render-result`, + mode: `wholesale`, + }) + expect(sibling.getSnapshot().data).toEqual([ + { id: `1`, name: `From server` }, + ]) + expect(client._getLiveQuery(`shared-render-result`)).toBeDefined() + sibling.dispose() + }) + + it(`ignores a server snapshot that arrives after browser sync is ready`, () => { + const collection = makeSource() + const client = new DbClient() + const observer = createLiveQueryObserver(collection as any, { + client, + queryHash: `people`, + mode: `wholesale`, + }) + observer.subscribe(() => {}) + + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `people`, + dehydratedAt: 1, + snapshot: { + rows: [{ key: `server`, value: { id: `server`, name: `Stale` } }], + }, + }, + ], + }) + + expect(observer.getSnapshot().data).toEqual([ + expect.objectContaining({ id: `1`, name: `A` }), + expect.objectContaining({ id: `2`, name: `B` }), + ]) + expect(client._getLiveQuery(`people`)).toBeUndefined() + observer.dispose() + }) + + it(`ignores a server failure that arrives after browser sync is ready`, async () => { + const collection = makeSource() + const client = new DbClient() + const observer = createLiveQueryObserver(collection as any, { + client, + queryHash: `people`, + mode: `wholesale`, + }) + observer.subscribe(() => {}) + let rejectServerResult!: (error: Error) => void + const serverResult = new Promise<{ rows: [] }>((_resolve, reject) => { + rejectServerResult = reject + }) + + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: `people`, + dehydratedAt: 1, + promise: serverResult, + }, + ], + }) + rejectServerResult(new Error(`Stale server failure`)) + await Promise.resolve() + + expect(observer.getError()).toBeUndefined() + expect(observer.getSnapshot().data).toEqual([ + expect.objectContaining({ id: `1`, name: `A` }), + expect.objectContaining({ id: `2`, name: `B` }), + ]) + expect(client._getLiveQuery(`people`)).toBeUndefined() + observer.dispose() + }) + it(`exposes a stable snapshot of a ready collection (wholesale path)`, () => { const observer = createLiveQueryObserver(makeSource() as any) diff --git a/packages/db/tests/live-query-options.test.ts b/packages/db/tests/live-query-options.test.ts new file mode 100644 index 0000000000..5283d0cff4 --- /dev/null +++ b/packages/db/tests/live-query-options.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { + getLiveQueryHash, + getPreparedLiveQueryIdentity, +} from '../src/live-query-options.js' +import { BaseQueryBuilder } from '../src/query/builder/index.js' + +describe(`live query identity`, () => { + it(`hashes Map values in an explicit queryKey deterministically`, () => { + const first = getLiveQueryHash(undefined, [ + new Map([ + [`b`, 2], + [`a`, 1], + ]), + ]) + const second = getLiveQueryHash(undefined, [ + new Map([ + [`a`, 1], + [`b`, 2], + ]), + ]) + + expect(first).toBe(second) + }) + + it(`hashes Set values in an explicit queryKey deterministically`, () => { + const first = getLiveQueryHash(undefined, [new Set([`b`, `a`])]) + const second = getLiveQueryHash(undefined, [new Set([`a`, `b`])]) + + expect(first).toBe(second) + }) + + it(`treats an empty queryKey as absent`, () => { + const first = createCollection<{ id: string }>({ + id: `empty-query-key-first`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const second = createCollection<{ id: string }>({ + id: `empty-query-key-second`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + + expect(getLiveQueryHash(first, [])).not.toBe(getLiveQueryHash(second, [])) + }) + + it(`does not collapse configs with opaque row identity behavior`, () => { + const source = createCollection<{ id: string }>({ + id: `live-query-config-identity-source`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const query = new BaseQueryBuilder().from({ source }) + const first = { query, getKey: (row: { id: string }) => row.id } + const second = { query, getKey: (row: { id: string }) => `x-${row.id}` } + + expect(getPreparedLiveQueryIdentity(first)).not.toEqual( + getPreparedLiveQueryIdentity(second), + ) + expect(() => getLiveQueryHash(first)).toThrow(/function value/) + expect(() => getLiveQueryHash(second)).toThrow(/function value/) + }) +}) diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 00062df30f..1bca17d3af 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -9,6 +9,7 @@ import { normalizeLiveQueryWindowPageSize, } from '../src/live-query-window-controller.js' import { mockSyncCollectionOptions } from './utils.js' +import type { Collection } from '../src/collection/index.js' interface Row { id: string @@ -29,10 +30,7 @@ function makeSource(initialData: Array = ROWS) { } /** Ordered live query with page 1's peek-ahead window baked in, as the React adapter builds it. */ -function makeOrderedLiveQuery( - source: ReturnType, - pageSize: number, -) { +function makeOrderedLiveQuery(source: Collection, pageSize: number) { return createLiveQueryCollection({ query: (q) => q diff --git a/packages/db/tests/query/builder/union-all.test.ts b/packages/db/tests/query/builder/union-all.test.ts index 7a2fe7b7d9..ea3d243513 100644 --- a/packages/db/tests/query/builder/union-all.test.ts +++ b/packages/db/tests/query/builder/union-all.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from 'vitest' import { CollectionImpl } from '../../../src/collection/index.js' +import { DbClient, collectionOptions } from '../../../src/client.js' import { InvalidSourceError, InvalidSourceTypeError, QueryMustHaveFromClauseError, } from '../../../src/errors.js' -import { Query, getQueryIR } from '../../../src/query/builder/index.js' +import { + BaseQueryBuilder, + Query, + getQueryIR, +} from '../../../src/query/builder/index.js' +import { eq } from '../../../src/query/builder/functions.js' interface Employee { id: number @@ -93,6 +99,70 @@ describe(`QueryBuilder.unionAll`, () => { expect(builtQuery.from.queries).toHaveLength(2) }) + it(`preserves descriptor resolution after unioning sources`, () => { + const employeeDescriptor = collectionOptions(`union-employees`, () => ({ + id: `union-employees`, + getKey: (item: Employee) => item.id, + sync: { sync: () => {} }, + })) + const departmentDescriptor = collectionOptions(`union-departments`, () => ({ + id: `union-departments`, + getKey: (item: Department) => item.id, + sync: { sync: () => {} }, + })) + const client = new DbClient() + const builder = new BaseQueryBuilder({}, (options) => + client.collection(options), + ) + + const query = builder + .unionAll({ employees: employeeDescriptor }) + .join( + { departments: departmentDescriptor }, + ({ employees, departments }) => + eq(employees.department_id, departments.id), + `inner`, + ) + + expect(getQueryIR(query).join).toHaveLength(1) + }) + + it(`preserves descriptor resolution after unioning query branches`, () => { + const employeeDescriptor = collectionOptions(`branch-employees`, () => ({ + id: `branch-employees`, + getKey: (item: Employee) => item.id, + sync: { sync: () => {} }, + })) + const departmentDescriptor = collectionOptions( + `branch-departments`, + () => ({ + id: `branch-departments`, + getKey: (item: Department) => item.id, + sync: { sync: () => {} }, + }), + ) + const client = new DbClient() + const builder = new BaseQueryBuilder({}, (options) => + client.collection(options), + ) + const employeeRows = builder + .from({ employees: employeeDescriptor }) + .select(({ employees: employee }) => ({ id: employee.id })) + const departmentRows = builder + .from({ departments: departmentDescriptor }) + .select(({ departments: department }) => ({ id: department.id })) + + const query = builder + .unionAll(employeeRows, departmentRows) + .join( + { departments: departmentDescriptor }, + ({ id, departments }) => eq(id, departments.id), + `inner`, + ) + + expect(getQueryIR(query).join).toHaveLength(1) + }) + it(`throws helpful errors for invalid source inputs`, () => { const builder = new Query() 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..145b3232af --- /dev/null +++ b/packages/db/tests/query/ir-stable-identity.test.ts @@ -0,0 +1,540 @@ +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, + getStableValueHash, +} 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: () => {} }, +}) + +describe(`stable runtime value hashing`, () => { + it(`normalizes object key order`, () => { + expect(getStableValueHash([`todos`, { status: `open`, page: 1 }])).toBe( + getStableValueHash([`todos`, { page: 1, status: `open` }]), + ) + }) + + it(`reports the path of an unhashable query key value`, () => { + expect(() => + getStableValueHash([`todos`, { predicate: () => true }], `queryKey`), + ).toThrow(/queryKey\[1\]\.predicate/) + }) +}) + +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(`preserves semantically significant union source ordering`, () => { + const usersThenPosts = getQueryIR( + new Query().unionAll({ user: usersCollection, post: postsCollection }), + ) + const postsThenUsers = getQueryIR( + new Query().unionAll({ post: postsCollection, user: usersCollection }), + ) + + expect(getStableQueryIRHash(usersThenPosts)).not.toBe( + getStableQueryIRHash(postsThenUsers), + ) + }) + + 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/transactions.test.ts b/packages/db/tests/transactions.test.ts index d9f27667d7..d77e196005 100644 --- a/packages/db/tests/transactions.test.ts +++ b/packages/db/tests/transactions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { DbClient, collectionOptions } from '../src/client.js' import { createTransaction } from '../src/transactions' import { createCollection } from '../src/collection/index.js' import { @@ -9,6 +10,62 @@ import { } from '../src/errors' describe(`Transactions`, () => { + it(`keeps a claimed default transaction ambient for later plain collection mutations`, () => { + const client = new DbClient() + const clientCollection = client.collection( + collectionOptions(`claimed-client-collection`, () => ({ + id: `claimed-client-collection`, + getKey: (row: { id: number }) => row.id, + sync: { sync: () => {} }, + })), + ) + const plainCollection = createCollection<{ id: number }>({ + id: `claimed-plain-collection`, + getKey: (row) => row.id, + sync: { sync: () => {} }, + }) + const transaction = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + + transaction.mutate(() => clientCollection.insert({ id: 1 })) + transaction.mutate(() => plainCollection.insert({ id: 2 })) + + expect(transaction.mutations).toHaveLength(2) + }) + + it(`does not cascade rollbacks across isolated client and default scopes`, () => { + const options = { + id: `isolated-rollback-scope`, + getKey: (row: { id: number }) => row.id, + sync: { sync: () => {} }, + } + const plainCollection = createCollection(options) + const client = new DbClient() + const scopedCollection = client.collection( + collectionOptions(`isolated-rollback-scope`, () => ({ + ...options, + id: `isolated-rollback-scope`, + })), + ) + const clientTransaction = client.createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + const defaultTransaction = createTransaction({ + autoCommit: false, + mutationFn: async () => {}, + }) + + clientTransaction.mutate(() => scopedCollection.insert({ id: 1 })) + defaultTransaction.mutate(() => plainCollection.insert({ id: 1 })) + clientTransaction.rollback() + + expect(defaultTransaction.state).toBe(`pending`) + defaultTransaction.rollback() + }) + it(`calling createTransaction creates a transaction`, () => { const transaction = createTransaction({ mutationFn: async () => Promise.resolve(), @@ -506,6 +563,10 @@ describe(`Transactions`, () => { mutationFn: async () => Promise.resolve(), autoCommit: false, }) + const transaction4 = createTransaction({ + mutationFn: async () => Promise.resolve(), + autoCommit: false, + }) const collection = createCollection<{ id: number value: string @@ -545,13 +606,23 @@ describe(`Transactions`, () => { }) }) + transaction4.mutate(() => { + collection.state.forEach((object) => { + collection.update(object.id, (draft) => { + draft.value = `foo-me-4` + }) + }) + }) + transaction1.rollback() transaction1.isPersisted.promise.catch(() => {}) transaction3.isPersisted.promise.catch(() => {}) + transaction4.isPersisted.promise.catch(() => {}) expect(transaction1.state).toBe(`failed`) expect(transaction2.state).toBe(`completed`) expect(transaction3.state).toBe(`failed`) + expect(transaction4.state).toBe(`failed`) }) describe(`duplicate instance detection`, () => { diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 7a4f54ae83..d025634a51 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 @@ -306,7 +319,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..112213e899 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,132 @@ 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 = getNewestElectricResumeState( + currentMeta.resume, + incomingMeta.resume, + ) + + return { + version: 1, + ...(resume ? { resume } : {}), + seenTxids: Array.from( + new Set([...currentMeta.seenTxids, ...incomingMeta.seenTxids]), + ).sort((a, b) => a - b), + } +} + +function getNewestElectricResumeState( + current: ElectricResumeState | undefined, + incoming: ElectricResumeState | undefined, +): ElectricResumeState | undefined { + if (!current) return incoming + if (!incoming) return current + return incoming.updatedAt >= current.updatedAt ? incoming : current +} + /** * Custom match function type - receives stream messages and returns boolean * indicating if the mutation has been synchronized @@ -633,6 +763,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 +823,7 @@ export function electricCollectionOptions>( const sync = createElectricSync(config.shapeOptions, { seenTxids, seenSnapshots, + hydratedResumeState, syncMode: internalSyncMode, pendingMatches, currentBatchMessages, @@ -941,10 +1075,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 +1106,14 @@ export function electricCollectionOptions>( awaitMatch, }, } + + return withCollectionConfigFactory(options, () => + ( + electricCollectionOptions as ( + nextConfig: ElectricCollectionConfig, + ) => typeof options + )(config), + ) } /** @@ -964,6 +1125,7 @@ function createElectricSync>( syncMode: ElectricSyncMode seenTxids: Store> seenSnapshots: Store> + hydratedResumeState: Store pendingMatches: Store< Map< string, @@ -987,6 +1149,7 @@ function createElectricSync>( const { seenTxids, seenSnapshots, + hydratedResumeState, syncMode, pendingMatches, currentBatchMessages, @@ -1319,40 +1482,15 @@ 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 = getNewestElectricResumeState( + readPersistedResumeState(), + hydratedResumeState.state, + ) const shapeIdentity = getStableShapeIdentity({ url: shapeOptions.url, params: shapeOptions.params as Record | undefined, @@ -1476,35 +1614,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) { @@ -1869,6 +2007,7 @@ function createElectricSync>( abortController.abort() // Reset deduplication tracking so collection can load fresh data if restarted loadSubsetDedupe?.reset() + hydratedResumeState.setState(() => undefined) }, } }, diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index c48121a9c1..6478075213 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 @@ -1994,6 +2089,9 @@ describe(`Electric Integration`, () => { // Initial stream setup expect(mockSubscribe).toHaveBeenCalledTimes(1) + mockStream.shapeHandle = `discarded-handle` + mockStream.lastOffset = `42_0` + subscriber([{ headers: { control: `up-to-date` } }]) // Cleanup await testCollection.cleanup() @@ -2005,6 +2103,10 @@ describe(`Electric Integration`, () => { // Should have started a new stream expect(mockSubscribe).toHaveBeenCalledTimes(2) expect(testCollection.status).toBe(`loading`) + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: undefined, + handle: undefined, + }) subscription.unsubscribe() }) @@ -3218,6 +3320,60 @@ describe(`Electric Integration`, () => { ) }) + it(`prefers newer persisted resume metadata over hydrated metadata`, () => { + vi.clearAllMocks() + const metadataHarness = createInMemorySyncMetadataApi( + new Map([ + [ + `electric:resume`, + { + kind: `resume`, + offset: `20_0`, + handle: `persisted-newer`, + shapeId: `{"params":{"table":"test_table"},"url":"http://test-url"}`, + updatedAt: 20, + }, + ], + ]), + ) + const options = electricCollectionOptions({ + id: `resume-recency-test`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + startSync: false, + getKey: (item) => item.id as number, + }) + options.sync.importSyncMeta?.({ + version: 1, + resume: { + kind: `resume`, + offset: `10_0`, + handle: `hydrated-older`, + shapeId: `{"params":{"table":"test_table"},"url":"http://test-url"}`, + updatedAt: 10, + }, + seenTxids: [], + }) + const originalSync = options.sync + + createCollection({ + ...options, + startSync: true, + sync: { + ...originalSync, + sync: (params: Parameters[0]) => + originalSync.sync({ ...params, metadata: metadataHarness.api }), + }, + }) + + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: `20_0`, + handle: `persisted-newer`, + }) + }) + it(`should ignore reset resume metadata and fall back to default startup`, async () => { vi.clearAllMocks() diff --git a/packages/powersync-db-collection/src/powersync.ts b/packages/powersync-db-collection/src/powersync.ts index ed73b07016..93963cbd24 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,18 @@ export function powerSyncCollectionOptions< export function powerSyncCollectionOptions< TTable extends Table, TSchema extends StandardSchemaV1 = never, +>( + config: PowerSyncCollectionConfig, +): ReturnType> { + 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..8e23f31131 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(`falls back to the configured QueryClient when DbClient has no dependency`, 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 926f9a6903..c1637d17ca 100644 --- a/packages/react-db/README.md +++ b/packages/react-db/README.md @@ -18,3 +18,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 dd907b8c03..90cfbc7bba 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 Array<{ id: string; title: string }> — no subcomponent needed ``` @@ -247,38 +248,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 rows with pending local optimistic writes ``` ## 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 @@ -296,36 +313,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..f817dd1a2a --- /dev/null +++ b/packages/react-db/src/DbProvider.tsx @@ -0,0 +1,32 @@ +'use client' + +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/HydrationBoundary.tsx b/packages/react-db/src/HydrationBoundary.tsx new file mode 100644 index 0000000000..e3fdd0b1db --- /dev/null +++ b/packages/react-db/src/HydrationBoundary.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useRef } from 'react' +import { useDbClient } from './DbProvider' +import type { DehydratedDbState } from '@tanstack/db' +import type { ReactNode } from 'react' + +export type HydrationBoundaryProps = { + state: DehydratedDbState + children?: ReactNode +} + +export function HydrationBoundary({ state, children }: HydrationBoundaryProps) { + const client = useDbClient() + const hydrated = useRef< + { client: typeof client; state: DehydratedDbState } | undefined + >(undefined) + + if (hydrated.current?.client !== client || hydrated.current.state !== state) { + client.hydrate(state) + hydrated.current = { client, state } + } + + return children +} diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts index 96db7e2796..b042d41c42 100644 --- a/packages/react-db/src/index.ts +++ b/packages/react-db/src/index.ts @@ -1,5 +1,12 @@ // Re-export all public APIs -export * from './useLiveQuery' +export { useLiveQuery } from './useLiveQuery' +export type { + LiveQueryKey, + UseLiveQueryConfig, + UseLiveQueryStatus, +} from './useLiveQuery' +export * from './DbProvider' +export * from './HydrationBoundary' export * from './useLiveSuspenseQuery' export * from './usePacedMutations' export * from './useLiveInfiniteQuery' diff --git a/packages/react-db/src/live-query-internals.ts b/packages/react-db/src/live-query-internals.ts new file mode 100644 index 0000000000..b68cdf1abc --- /dev/null +++ b/packages/react-db/src/live-query-internals.ts @@ -0,0 +1,36 @@ +import type { + DbClient, + LiveQueryObserver, + UnhashableQueryIRError, +} from '@tanstack/db' + +const liveQueryResultInfo = Symbol(`liveQueryResultInfo`) + +export type LiveQueryResultInfo = { + client: DbClient | undefined + queryHash: string | undefined + identityError: UnhashableQueryIRError | undefined + observer: LiveQueryObserver +} + +type ResultWithInfo = { + [liveQueryResultInfo]?: LiveQueryResultInfo +} + +export function setLiveQueryResultInfo( + result: object, + info: LiveQueryResultInfo, +): void { + Object.defineProperty(result, liveQueryResultInfo, { + configurable: true, + value: info, + }) +} + +export function getLiveQueryResultInfo(result: object): LiveQueryResultInfo { + const info = (result as ResultWithInfo)[liveQueryResultInfo] + if (!info) { + throw new Error(`Missing internal live query result information.`) + } + return info +} diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 9d22153ee8..cd8b53a07d 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,3 +1,5 @@ +'use client' + import { useCallback, useRef, useSyncExternalStore } from 'react' import { assertLiveQueryWindowManyResult, @@ -11,11 +13,23 @@ import { resolveLiveQueryWindowInput, shouldPreserveLiveQueryWindowPageCount, } from '@tanstack/db' -// Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. -import type { useLiveQuery } from './useLiveQuery' +import { useOptionalDbClient } from './DbProvider' +import { + prepareDerivedQuery, + prepareQueryValue, + warnDeprecatedDepsArray, + warnUnhashableDerivedIdentity, +} from './useLiveQuery' +import type { + DerivedIdentityProfiler, + LiveQueryKey, + useLiveQuery, +} from './useLiveQuery' import type { Collection, + CollectionImpl as CollectionImplType, Context, + DbClient, InferResultType, InitialQueryBuilder, LiveQueryWindowController, @@ -25,8 +39,17 @@ import type { // Live queries created here are cleaned up immediately (0 disables GC). const DEFAULT_GC_TIME_MS = 1 +const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) 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 + /** Override the nearest DbProvider for this query. */ + client?: DbClient pageSize?: number initialPageParam?: number /** @@ -62,6 +85,9 @@ type EnabledLiveQueryReturn = ReturnType< type InfiniteQueryRenderState = { inputKind: `collection` | `query` inputCollection: Collection | null + inputQuery: unknown + client: DbClient | undefined + identityMode: `collection` | `queryKey` | `legacyDeps` | `derived` dependencies: Array | null pageSize: number initialPageParam: number @@ -69,72 +95,21 @@ type InfiniteQueryRenderState = { controller: LiveQueryWindowController warning: string | null warned: boolean + deferredCollections: Set< + CollectionImplType + > } /** - * Create an infinite query using a query function with live updates + * Create an infinite query using a query function with live updates. * * Uses `utils.setWindow()` to dynamically adjust the limit/offset window * without recreating the live query collection on each page change. * * @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 - * // Basic infinite query - * const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( - * (q) => q - * .from({ posts: postsCollection }) - * .orderBy(({ posts }) => posts.createdAt, 'desc') - * .select(({ posts }) => ({ - * id: posts.id, - * title: posts.title - * })), - * { - * pageSize: 20, - * getNextPageParam: (lastPage, allPages) => - * lastPage.length === 20 ? allPages.length : undefined - * } - * ) - * - * @example - * // With dependencies - * const { pages, fetchNextPage } = useLiveInfiniteQuery( - * (q) => q - * .from({ posts: postsCollection }) - * .where(({ posts }) => eq(posts.category, category)) - * .orderBy(({ posts }) => posts.createdAt, 'desc'), - * { - * pageSize: 10, - * getNextPageParam: (lastPage) => - * lastPage.length === 10 ? lastPage.length : undefined - * }, - * [category] - * ) - * - * @example - * // Router loader pattern with pre-created collection - * // In loader: - * const postsQuery = createLiveQueryCollection({ - * query: (q) => q - * .from({ posts: postsCollection }) - * .orderBy(({ posts }) => posts.createdAt, 'desc') - * .limit(20) - * }) - * await postsQuery.preload() - * return { postsQuery } - * - * // In component: - * const { postsQuery } = useLoaderData() - * const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( - * postsQuery, - * { - * pageSize: 20, - * getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined - * } - * ) */ // Overload for pre-created collection (non-single result) @@ -158,10 +133,12 @@ export function useLiveInfiniteQuery( export function useLiveInfiniteQuery( queryFnOrCollection: any, config: UseLiveInfiniteQueryConfig, - deps: Array = [], + deps?: Array, ): UseLiveInfiniteQueryReturn { const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) const initialPageParam = config.initialPageParam ?? 0 + const contextDbClient = useOptionalDbClient() + const dbClient = config.client ?? contextDbClient const inputIsCollection = getLiveQueryWindowInputKind(queryFnOrCollection) === `collection` @@ -169,14 +146,72 @@ export function useLiveInfiniteQuery( const committedRef = useRef(null) const committed = committedRef.current const inputKind = inputIsCollection ? `collection` : `query` + const derivedIdentityProfilerRef = useRef({ + renderCount: 0, + totalMs: 0, + maxMs: 0, + warned: false, + }) + const legacyUnhashableIdentityRef = useRef>([ + `legacy-unhashable`, + ]) + const deferredCollections = new Set< + CollectionImplType + >() + let preparedQueryValue: unknown | typeof unpreparedQueryValue = + unpreparedQueryValue + let identityDeps: ReadonlyArray = [] + let identityMode: InfiniteQueryRenderState[`identityMode`] = `collection` + + if (!inputIsCollection) { + if (config.queryKey !== undefined) { + identityMode = `queryKey` + identityDeps = config.queryKey + } else if (deps !== undefined) { + identityMode = `legacyDeps` + identityDeps = deps + warnDeprecatedDepsArray(`useLiveInfiniteQuery`) + } else if ( + committed?.identityMode === `derived` && + committed.inputQuery === queryFnOrCollection && + committed.client === dbClient + ) { + identityMode = `derived` + identityDeps = committed.dependencies ?? [] + } else { + identityMode = `derived` + const preparation = prepareDerivedQuery( + queryFnOrCollection, + dbClient, + derivedIdentityProfilerRef.current, + deferredCollections, + ) + preparedQueryValue = preparation.value + if (preparation.status === `hashable`) { + identityDeps = preparation.identityDeps + } else { + warnUnhashableDerivedIdentity(preparation.error) + identityDeps = legacyUnhashableIdentityRef.current + } + } + } + + const usesLegacyDeps = + !inputIsCollection && config.queryKey === undefined && deps !== undefined const dependencyComparison = compareLiveQueryWindowDependencies( committed?.dependencies, - deps, + identityDeps, ) - const dependenciesChanged = !inputIsCollection && dependencyComparison.changed + const sameClient = committed?.client === dbClient + const dependenciesChanged = + !inputIsCollection && + (!sameClient || + (usesLegacyDeps + ? dependencyComparison.changed + : !dependencyComparison.structurallyEqual)) const dependenciesStructurallyEqual = - !inputIsCollection && dependencyComparison.structurallyEqual + usesLegacyDeps && sameClient && dependencyComparison.structurallyEqual const needsNewCollection = committed === null || committed.inputKind !== inputKind || @@ -195,7 +230,18 @@ export function useLiveInfiniteQuery( let warning: string | null = null if (needsNewCollection) { - const input = resolveLiveQueryWindowInput(queryFnOrCollection) + let inputValue = queryFnOrCollection + if (!inputIsCollection) { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + queryFnOrCollection, + dbClient, + deferredCollections, + ) + } + inputValue = () => preparedQueryValue + } + const input = resolveLiveQueryWindowInput(inputValue) if (input.kind === `collection`) { collection = input.collection } else { @@ -239,7 +285,10 @@ export function useLiveInfiniteQuery( renderState = { inputKind, inputCollection: inputIsCollection ? collection : null, - dependencies: inputIsCollection ? null : [...deps], + inputQuery: inputIsCollection ? null : queryFnOrCollection, + client: dbClient, + identityMode, + dependencies: inputIsCollection ? null : [...identityDeps], pageSize, initialPageParam, collection, @@ -250,6 +299,7 @@ export function useLiveInfiniteQuery( }), warning, warned: false, + deferredCollections, } } const currentRenderState = renderState! @@ -263,12 +313,16 @@ export function useLiveInfiniteQuery( currentRenderState.warned = true console.warn(currentRenderState.warning) } + for (const collection of currentRenderState.deferredCollections) { + collection._resumeSyncStart() + } + currentRenderState.deferredCollections.clear() return unsubscribe }, [controller, currentRenderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) - const snapshot = useSyncExternalStore(subscribe, getSnapshot) + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const fetchNextPage = useCallback( () => fetchNextLiveQueryWindowPage(controller), diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 2ada390770..7f8e303ddc 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,14 +1,25 @@ +'use client' + import { useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, + UnhashableQueryIRError, createLiveQueryCollection, createLiveQueryObserver, + deepEquals, + getPreparedLiveQueryIdentity, + getStableValueHash, isCollection, + prepareLiveQueryValue, } from '@tanstack/db' +import { useOptionalDbClient } from './DbProvider' +import { setLiveQueryResultInfo } from './live-query-internals' import type { Collection, + CollectionImpl, CollectionStatus, Context, + DbClient, GetResult, InferResultType, InitialQueryBuilder, @@ -20,57 +31,291 @@ 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`) + +export 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 + } + +export function warnDeprecatedDepsArray( + hookName: `useLiveQuery` | `useLiveInfiniteQuery` = `useLiveQuery`, +): void { + if (!shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_DEPRECATION_WARNINGS`)) { + return + } + + const callsite = getWarningCallsite(4) + if (warnedDepsCallsites.has(callsite)) { + return + } + warnedDepsCallsites.add(callsite) + const replacement = + hookName === `useLiveQuery` + ? `useLiveQuery({ query })` + : `useLiveInfiniteQuery(query, { queryKey })` + console.warn( + `[${hookName}] The dependency-array form is deprecated and will be removed in 1.0. Use ${replacement} 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 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 +} + +export function prepareQueryValue( + value: unknown, + dbClient: DbClient | undefined, + deferredCollections: Set>, +): unknown { + return prepareLiveQueryValue(value, dbClient, deferredCollections) +} + +type DerivedQueryPreparation = + | { + status: `hashable` + value: unknown + identityDeps: Array + } + | { + status: `unhashable` + value: unknown + error: UnhashableQueryIRError + } + +export 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 = getPreparedLiveQueryIdentity(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) + } + } +} + +export 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}`, + ) +} /** - * 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 +442,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 +458,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 +475,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 +536,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 +556,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 +579,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,20 +595,114 @@ export function useLiveQuery( ) const depsRef = useRef | null>(null) const configRef = useRef(null) + const clientRef = useRef(dbClient) + const legacyUnhashableIdentityRef = useRef>([ + `legacy-unhashable`, + ]) - // The shared observer owns subscription, the ready-race, and the snapshot. + const derivedIdentityProfilerRef = useRef({ + renderCount: 0, + totalMs: 0, + maxMs: 0, + warned: false, + }) + const deferredCollectionsRef = useRef( + new Set>(), + ) const observerRef = useRef | null>( null, ) + const queryHashRef = useRef(undefined) + const identityErrorRef = useRef(undefined) + + const queryKey = !inputIsCollection + ? getExplicitQueryKey(configOrQueryOrCollection) + : undefined + let preparedQueryValue: unknown | typeof unpreparedQueryValue = + unpreparedQueryValue + let identityDeps: ReadonlyArray + let streamIdentity: unknown = undefined + let identityError: UnhashableQueryIRError | undefined + + if (queryKey) { + identityDeps = queryKey + streamIdentity = [`queryKey`, queryKey] + } else if (deps !== undefined) { + identityDeps = resolvedDeps + try { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + deferredCollectionsRef.current, + ) + streamIdentity = [ + `deps`, + resolvedDeps, + getPreparedLiveQueryIdentity(preparedQueryValue), + ] + } catch (error) { + if (!(error instanceof UnhashableQueryIRError)) throw error + warnUnhashableDerivedIdentity(error) + identityError = error + } + } else if (inputIsCollection) { + identityDeps = [] + streamIdentity = [`collection`, configOrQueryOrCollection.id] + } else { + const preparation = prepareDerivedQuery( + configOrQueryOrCollection, + dbClient, + derivedIdentityProfilerRef.current, + deferredCollectionsRef.current, + ) + preparedQueryValue = preparation.value + if (preparation.status === `hashable`) { + identityDeps = preparation.identityDeps + streamIdentity = preparation.identityDeps + } else { + warnUnhashableDerivedIdentity(preparation.error) + identityDeps = legacyUnhashableIdentityRef.current + identityError = preparation.error + } + } + + let queryHash: string | undefined + if (streamIdentity !== undefined) { + try { + queryHash = getStableValueHash(streamIdentity, `queryKey`) + } catch (error) { + if (error instanceof UnhashableQueryIRError) { + if (queryKey !== undefined) throw error + identityError = error + } else { + throw error + } + } + } + + if (deps !== undefined) { + warnDeprecatedDepsArray() + } + + const identityChanged = + depsRef.current === null || + (deps !== undefined + ? depsRef.current.length !== identityDeps.length || + depsRef.current.some((dep, index) => dep !== identityDeps[index]) + : !deepEquals(depsRef.current, identityDeps)) // 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]))) + (!inputIsCollection && (clientRef.current !== dbClient || identityChanged)) + + const resumeDeferredCollections = () => { + for (const collection of deferredCollectionsRef.current) { + collection._resumeSyncStart() + } + deferredCollectionsRef.current.clear() + } if (needsNewCollection) { if (inputIsCollection) { @@ -350,12 +712,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.`, ) } @@ -364,51 +729,22 @@ 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 + queryHashRef.current = queryHash + identityErrorRef.current = identityError } // Recreate the observer when the underlying collection changes. The observer @@ -426,6 +762,9 @@ export function useLiveQuery( // useSyncExternalStore inside its own subscribe call. observerRef.current = createLiveQueryObserver(collectionRef.current, { mode: `wholesale`, + client: dbClient, + queryHash: queryHashRef.current, + onPreload: resumeDeferredCollections, }) } const observer = observerRef.current! @@ -436,13 +775,23 @@ export function useLiveQuery( ((onStoreChange: () => void) => () => void) | null >(null) if (!subscribeRef.current || needsNewCollection) { - subscribeRef.current = (onStoreChange) => - observer.subscribe(() => onStoreChange()) + subscribeRef.current = (onStoreChange: () => void) => { + const unsubscribe = observer.subscribe(() => onStoreChange()) + resumeDeferredCollections() + return unsubscribe + } } - // The observer returns a stable snapshot per revision, which is the return - // shape this hook exposes. Keep the return loose to satisfy the overloads. - return useSyncExternalStore(subscribeRef.current, () => - observer.getSnapshot(), - ) as any + const returned = useSyncExternalStore( + subscribeRef.current, + () => observer.getSnapshot(), + () => observer.getServerSnapshot(), + ) + setLiveQueryResultInfo(returned, { + client: dbClient, + queryHash: queryHashRef.current, + identityError: identityErrorRef.current, + observer, + }) + return returned as any } diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 162bf1f3fe..a4e8dec5b1 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -1,5 +1,9 @@ +'use client' + import { useRef } from 'react' import { useLiveQuery } from './useLiveQuery' +import { getLiveQueryResultInfo } from './live-query-internals' +import type { UseLiveQueryConfig } from './useLiveQuery' import type { Collection, Context, @@ -15,18 +19,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 +58,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 +91,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 +101,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 +117,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 +135,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 +148,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 +164,20 @@ 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) + const queryInfo = getLiveQueryResultInfo(result) - // 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 @@ -183,16 +197,20 @@ export function useLiveSuspenseQuery( ) } - // It’s not recommended to suspend a render based on a store value returned by useSyncExternalStore. - // result.status is the snapshot from syncExternalStore. We read the fresh status from the collection reference instead. const collectionStatus = result.collection.status // Track when we reach ready state - if (collectionStatus === `ready`) { + if (result.isReady) { hasBeenReadyRef.current = true promiseRef.current = null } + const observerError = queryInfo.observer.getError() + if (observerError !== undefined && !hasBeenReadyRef.current) { + promiseRef.current = null + throw observerError + } + // Only throw errors during initial load (before first ready) // After success, errors surface as stale data (matches TanStack Query behavior) if (collectionStatus === `error` && !hasBeenReadyRef.current) { @@ -202,10 +220,19 @@ export function useLiveSuspenseQuery( throw new Error(`Collection "${result.collection.id}" failed to load`) } - if (collectionStatus === `loading` || collectionStatus === `idle`) { + if (!hasBeenReadyRef.current && (result.isLoading || result.isIdle)) { + if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { + const reason = queryInfo.identityError + ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` + : `the query has no stable identity` + throw new Error( + `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, + ) + } + // Create or reuse promise for current collection if (!promiseRef.current) { - promiseRef.current = result.collection.preload() + promiseRef.current = queryInfo.observer.preload() } // THROW PROMISE - React Suspense catches this (React 18+ required) // Note: We don't check React version here. In React <18, this will be caught 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/HydrationBoundary.test.tsx b/packages/react-db/tests/HydrationBoundary.test.tsx new file mode 100644 index 0000000000..1daeac0dc1 --- /dev/null +++ b/packages/react-db/tests/HydrationBoundary.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { DbClient, collectionOptions } from '@tanstack/db' +import { DbProvider, useDbClient } from '../src/DbProvider' +import { HydrationBoundary } from '../src/HydrationBoundary' + +type Person = { + id: string + name: string +} + +const people = collectionOptions(`hydration-boundary-people`, () => ({ + id: `hydration-boundary-people`, + getKey: (person: Person) => person.id, + sync: { + sync: ({ markReady }) => markReady(), + }, +})) + +const state = { + collections: [ + { + collectionId: people.id, + rows: [{ key: `1`, value: { id: `1`, name: `Hydrated` } }], + }, + ], +} + +function PersonName() { + const client = useDbClient() + return {client.collection(people).get(`1`)?.name} +} + +function App({ client }: { client: DbClient }) { + return ( + + + + + + ) +} + +describe(`HydrationBoundary`, () => { + it(`hydrates before children render and follows the provider client`, () => { + const firstClient = new DbClient() + const firstHydrate = vi.spyOn(firstClient, `hydrate`) + const view = render() + + expect(screen.getByText(`Hydrated`)).toBeInTheDocument() + expect(firstHydrate).toHaveBeenCalledTimes(1) + + view.rerender() + expect(firstHydrate).toHaveBeenCalledTimes(1) + + const secondClient = new DbClient() + const secondHydrate = vi.spyOn(secondClient, `hydrate`) + view.rerender() + + expect(screen.getByText(`Hydrated`)).toBeInTheDocument() + expect(secondHydrate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index de27b0dac9..ab99efcd6b 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -215,6 +215,152 @@ describe(`useLiveInfiniteQuery`, () => { expect(result.current.hasNextPage).toBe(true) }) + 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) + }) + + await act(async () => { + await 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(`uses structural queryKey identity without rerunning a stable query`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `infinite-query-key-identity`, + getKey: (post) => post.id, + initialData: createMockPosts(20), + }), + ) + let queryExecutions = 0 + + const { result, rerender } = renderHook( + ({ filter }: { filter: { category: string } }) => + useLiveInfiniteQuery( + (q) => { + queryExecutions += 1 + return q + .from({ post: source }) + .where(({ post }) => eq(post.category, filter.category)) + .orderBy(({ post }) => post.createdAt, `desc`) + }, + { + pageSize: 2, + queryKey: [source.id, `category`, filter], + }, + ), + { initialProps: { filter: { category: `tech` } } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + const firstCollection = result.current.collection + expect(queryExecutions).toBe(1) + + rerender({ filter: { category: `tech` } }) + + expect(result.current.collection).toBe(firstCollection) + expect(queryExecutions).toBe(1) + + rerender({ filter: { category: `life` } }) + + await waitFor(() => { + expect(result.current.collection).not.toBe(firstCollection) + expect( + result.current.data.every((post) => post.category === `life`), + ).toBe(true) + }) + expect(queryExecutions).toBe(2) + }) + + it(`uses queryKey to make captured values in opaque queries reactive`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-opaque-query-key`, + getKey: (post) => post.id, + initialData: createMockPosts(20), + }), + ) + const { result, rerender } = renderHook( + ({ category }: { category: string }) => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .fn.where(({ post }) => post.category === category) + .orderBy(({ post }) => post.createdAt, `desc`), + { + pageSize: 2, + queryKey: [source.id, `category-fn`, category], + }, + ), + { initialProps: { category: `tech` } }, + ) + + await waitFor(() => { + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `tech`), + ).toBe(true) + }) + const firstCollection = result.current.collection + + rerender({ category: `life` }) + + await waitFor(() => { + expect(result.current.collection).not.toBe(firstCollection) + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `life`), + ).toBe(true) + }) + }) + it(`compares dependencies by identity instead of serialization`, async () => { const source = createCollection( mockSyncCollectionOptions({ @@ -259,6 +405,44 @@ describe(`useLiveInfiniteQuery`, () => { }) }) + it(`preserves loaded pages when dependencies are structurally unchanged`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-structurally-equal-deps`, + getKey: (post) => post.id, + initialData: createMockPosts(20), + }), + ) + const { result, rerender } = renderHook( + ({ filter }: { filter: { category: string } }) => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .where(({ post }) => eq(post.category, filter.category)) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 2 }, + [filter], + ), + { initialProps: { filter: { category: `tech` } } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + await act(async () => { + await result.current.fetchNextPage() + }) + await waitFor(() => expect(result.current.pages).toHaveLength(2)) + const firstCollection = result.current.collection + + rerender({ filter: { category: `tech` } }) + + await waitFor(() => { + expect(result.current.collection).not.toBe(firstCollection) + expect(result.current.isReady).toBe(true) + }) + expect(result.current.pages).toHaveLength(2) + }) + it(`releases a replaced controller through the external-store unsubscribe`, async () => { const source = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveQuery.test-d.tsx b/packages/react-db/tests/useLiveQuery.test-d.tsx index 43747284d8..7d31205fd3 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,12 @@ 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 { HydrationBoundary } from '../src/HydrationBoundary' +import type { DbClient, DehydratedDbState } from '../../db/src/index' +import type { JSX } from 'react' import type { OutputWithVirtual } from '../../db/tests/utils' import type { SingleResult } from '../../db/src/types' @@ -21,6 +28,22 @@ type Person = { } describe(`useLiveQuery type assertions`, () => { + it(`should type useDbClient as DbClient`, () => { + const client = useDbClient() + expectTypeOf(client).toEqualTypeOf() + }) + + it(`types HydrationBoundary state`, () => { + const state: DehydratedDbState = { collections: [] } + const boundary = ( + +
                    + + ) + + expectTypeOf(boundary).toEqualTypeOf() + }) + it(`should type findOne query builder to return a single row`, () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -68,6 +91,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..32cbee166c 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,14 @@ import { } from '@tanstack/db' import { useEffect } from 'react' import { useLiveQuery } from '../src/useLiveQuery' +import { getLiveQueryResultInfo } from '../src/live-query-internals' +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 +1982,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 +1993,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 +2059,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 +2070,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 +2604,670 @@ 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(`throws when an explicit queryKey cannot be stably hashed`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `unhashable-explicit-query-key`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + expect(() => + renderHook(() => + useLiveQuery({ + queryKey: [collection.id, () => `opaque`], + query: (q) => q.from({ people: collection }), + }), + ), + ).toThrow(/queryKey.*function value/) + }) + + it(`keeps an explicit queryKey stable across structurally equal values`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `explicit-query-key-structural-equality`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + let queryExecutions = 0 + + const { result, rerender } = renderHook( + ({ filter }: { filter: { minAge: number } }) => + useLiveQuery({ + queryKey: [collection.id, `minimum-age`, filter], + query: (q) => { + queryExecutions += 1 + return q + .from({ people: collection }) + .where(({ people }) => gt(people.age, filter.minAge)) + }, + }), + { initialProps: { filter: { minAge: 25 } } }, + ) + + await waitFor(() => expect(result.current.data).toHaveLength(2)) + const firstCollection = result.current.collection + + rerender({ filter: { minAge: 25 } }) + + expect(result.current.collection).toBe(firstCollection) + expect(queryExecutions).toBe(1) + }) + + it(`preserves reference semantics for deprecated dependency arrays`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `legacy-deps-reference-semantics`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const { result, rerender } = renderHook( + ({ filter }: { filter: { minAge: number } }) => + useLiveQuery( + (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, filter.minAge)), + [filter], + ), + { initialProps: { filter: { minAge: 25 } } }, + ) + + await waitFor(() => expect(result.current.data).toHaveLength(2)) + const firstCollection = result.current.collection + + rerender({ filter: { minAge: 25 } }) + + 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() + }) + + it(`includes the query in legacy dependency-array SSR identity`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `legacy-deps-query-identity`, + getKey: (person) => person.id, + initialData: initialPersons, + }), + ) + const first = renderHook(() => + useLiveQuery((q) => q.from({ people: collection }), [1]), + ) + const second = renderHook(() => + useLiveQuery( + (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, 30)), + [1], + ), + ) + + expect(getLiveQueryResultInfo(first.result.current).queryHash).not.toBe( + getLiveQueryResultInfo(second.result.current).queryHash, + ) + }) + }) }) diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index fc78b07968..ef2c3fe49b 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -1,13 +1,17 @@ -import { describe, expect, it } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' import { + DbClient, + collectionOptions, createCollection, createLiveQueryCollection, eq, + getStableValueHash, gt, } from '@tanstack/db' import { StrictMode, Suspense } from 'react' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' +import { DbProvider } from '../src/DbProvider' import { mockSyncCollectionOptions } from '../../db/tests/utils' import type { ReactNode } from 'react' @@ -53,6 +57,195 @@ function SuspenseWrapper({ children }: { children: ReactNode }) { } describe(`useLiveSuspenseQuery`, () => { + it(`renders a streamed query snapshot until browser sync is authoritative`, async () => { + let resolveServerLoad!: () => void + const serverLoad = new Promise((resolve) => { + resolveServerLoad = resolve + }) + let resolveBrowserLoad!: () => void + let finishBrowserLoad!: () => void + const browserLoadPromise = new Promise((resolve) => { + finishBrowserLoad = resolve + }) + const browserLoad = vi.fn() + const descriptor = collectionOptions(`streamed-people`, (client) => { + const runtime = client.requireDependency<`server` | `browser`>(`runtime`) + + return { + id: `streamed-people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: + runtime === `server` + ? async () => { + await serverLoad + begin({ immediate: true }) + write({ type: `insert`, value: initialPersons[0]! }) + commit() + } + : () => { + browserLoad() + resolveBrowserLoad = () => { + begin({ immediate: true }) + write({ + type: `insert`, + value: initialPersons[1]!, + }) + commit() + finishBrowserLoad() + } + return browserLoadPromise + }, + } + }, + }, + } + }) + const serverClient = new DbClient({ runtime: `server` }) + serverClient._setSsrStreamingEnabled(true) + const serverWrapper = ({ children }: { children: ReactNode }) => ( + + Loading...
                    }>{children} + + ) + const serverHook = renderHook( + () => + useLiveSuspenseQuery({ + query: (q) => q.from({ people: descriptor }), + }), + { wrapper: serverWrapper }, + ) + + const dehydrated = serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + const dehydratedQuery = dehydrated.liveQueries?.[0] + expect(dehydratedQuery).toBeDefined() + + const browserClient = new DbClient({ runtime: `browser` }) + browserClient._setSsrStreamingEnabled(true) + browserClient.hydrate(dehydrated) + const browserWrapper = ({ children }: { children: ReactNode }) => ( + + Loading...}>{children} + + ) + const browserHook = renderHook( + () => + useLiveSuspenseQuery({ + query: (q) => q.from({ people: descriptor }), + }), + { wrapper: browserWrapper }, + ) + + expect(browserLoad).not.toHaveBeenCalled() + + await act(async () => { + resolveServerLoad() + await browserClient._getLiveQuery(dehydratedQuery!.queryHash)?.promise + }) + + await waitFor(() => { + expect(browserHook.result.current.data).toHaveLength(1) + expect(browserHook.result.current.data[0]).toMatchObject( + initialPersons[0]!, + ) + }) + expect(browserLoad).toHaveBeenCalled() + + await act(async () => resolveBrowserLoad()) + + await waitFor(() => { + expect(browserHook.result.current.data).toHaveLength(1) + expect(browserHook.result.current.data[0]).toMatchObject( + initialPersons[1]!, + ) + }) + serverHook.unmount() + browserHook.unmount() + }) + + it(`requires queryKey for an opaque query during SSR streaming`, () => { + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const descriptor = collectionOptions(`streamed-people`, () => ({ + id: `streamed-people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => new Promise(() => {}) } + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(true) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + expect(() => + renderHook( + () => + useLiveSuspenseQuery({ + query: (q) => + q + .from({ people: descriptor }) + .fn.where(({ people }) => people.age > 20), + }), + { wrapper }, + ), + ).toThrow(/Provide an explicit serializable queryKey/) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`cannot derive a stable identity`), + ) + warn.mockRestore() + }) + + it(`throws the original streamed query error`, async () => { + const error = new Error(`Server load failed`) + const queryKey = [`streamed-people-error`] as const + const queryHash = getStableValueHash([`queryKey`, queryKey], `queryKey`) + const descriptor = collectionOptions(`streamed-people-error`, () => ({ + id: `streamed-people-error`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => new Promise(() => {}) } + }, + }, + })) + const client = new DbClient() + client._setSsrStreamingEnabled(true) + await expect( + client._registerLiveQuery(queryHash, Promise.reject(error)), + ).rejects.toBe(error) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + + expect(() => + renderHook( + () => + useLiveSuspenseQuery({ + queryKey, + query: (q) => q.from({ people: descriptor }), + }), + { wrapper }, + ), + ).toThrow(error) + + consoleError.mockRestore() + }) + it(`should suspend while loading and return data when ready`, async () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -185,7 +378,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 +389,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 +408,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/react-router-with-db/README.md b/packages/react-router-with-db/README.md new file mode 100644 index 0000000000..de90857371 --- /dev/null +++ b/packages/react-router-with-db/README.md @@ -0,0 +1,21 @@ +# @tanstack/react-router-with-db + +TanStack Router and TanStack Start SSR integration for TanStack DB. + +```tsx +const dbClient = new DbClient() +const router = createRouter({ + routeTree, + context: { dbClient }, +}) + +export default routerWithDbClient(router, dbClient) +``` + +The adapter provides the client, hydrates critical DB state, and streams +`useLiveSuspenseQuery` calls discovered during server rendering. Streamed +promises resolve to ordered live-query result snapshots. Source collections +start normally in the browser and replace the snapshot when their live result is +ready. + +See the [SSR and Hydration guide](../../docs/guides/ssr.md). diff --git a/packages/react-router-with-db/package.json b/packages/react-router-with-db/package.json new file mode 100644 index 0000000000..58f5cf1fa2 --- /dev/null +++ b/packages/react-router-with-db/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tanstack/react-router-with-db", + "version": "0.0.0", + "description": "TanStack Router SSR integration for TanStack DB", + "author": "Kyle Mathews", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/react-router-with-db" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "database", + "react", + "router", + "ssr", + "streaming", + "tanstack" + ], + "scripts": { + "build": "vite build", + "build:minified": "vite build --minify", + "dev": "vite build --watch", + "lint": "eslint . --fix", + "test": "vitest --run" + }, + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "peerDependencies": { + "@tanstack/react-db": ">=0.2.1", + "@tanstack/react-router": ">=1.43.2", + "@tanstack/router-core": ">=1.127.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@tanstack/react-db": "workspace:*", + "@tanstack/react-router": "^1.159.5", + "@tanstack/router-core": "^1.159.4", + "@vitejs/plugin-react": "^5.1.3", + "@vitest/coverage-istanbul": "^3.2.4", + "react": "^19.2.4", + "react-dom": "^19.2.4" + } +} diff --git a/packages/react-router-with-db/src/index.tsx b/packages/react-router-with-db/src/index.tsx new file mode 100644 index 0000000000..3124966fed --- /dev/null +++ b/packages/react-router-with-db/src/index.tsx @@ -0,0 +1,196 @@ +import { Fragment } from 'react' +import { DbProvider } from '@tanstack/react-db' +import '@tanstack/router-core/ssr/client' +import type { AnyRouter } from '@tanstack/react-router' +import type { DbClient, DehydratedDbState } from '@tanstack/react-db' +import type { ReactNode } from 'react' + +type AdditionalOptions = { + WrapProvider?: (props: { children: ReactNode }) => React.JSX.Element +} + +export type DehydratedRouterDbState = { + dehydratedDbClient: DehydratedDbState + dbStream: ReadableStream +} + +export type ValidateRouter = + NonNullable extends { dbClient: DbClient } + ? TRouter + : never + +export function routerWithDbClient( + router: ValidateRouter, + dbClient: DbClient, + additionalOptions?: AdditionalOptions, +): TRouter { + const originalOptions = router.options + + router.options = { + ...router.options, + context: { + ...originalOptions.context, + dbClient, + }, + Wrap: ({ children }) => { + const OuterWrapper = additionalOptions?.WrapProvider ?? Fragment + const OriginalWrap = originalOptions.Wrap ?? Fragment + + return ( + + + {children} + + + ) + }, + } + + if (router.isServer) { + dbClient._setSsrStreamingEnabled(true) + dbClient._setSsrServerCleanupEnabled(true) + const dbStream = createPushableStream() + const bufferedQueryHashes = new Set() + const streamedQueryHashes = new Set() + let criticalStateCaptured = false + let renderFinishRegistered = false + + const streamLiveQuery = (queryHash: string) => { + if (streamedQueryHashes.has(queryHash)) return + streamedQueryHashes.add(queryHash) + + const enqueued = dbStream.enqueue( + dbClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: (query) => query.queryHash === queryHash, + }), + ) + if (!enqueued) { + console.warn( + `Tried to stream live query ${queryHash} after the DB stream was closed.`, + ) + } + } + + const unsubscribe = dbClient.subscribe((event) => { + if (event.type !== `liveQueryAdded`) return + + if (!criticalStateCaptured) { + bufferedQueryHashes.add(event.query.queryHash) + return + } + + streamLiveQuery(event.query.queryHash) + }) + + router.options.dehydrate = async (): Promise => { + const originalDehydrated = await originalOptions.dehydrate?.() + const dehydratedDbClient = dbClient.dehydrate({ + shouldDehydrateLiveQuery: () => true, + }) + const criticalQueryHashes = new Set( + dehydratedDbClient.liveQueries?.map((query) => query.queryHash), + ) + criticalStateCaptured = true + + if (!renderFinishRegistered) { + renderFinishRegistered = true + router.serverSsr!.onRenderFinished(() => { + unsubscribe() + dbStream.close() + void dbClient + .cleanup() + .catch((error) => + console.error(`Error cleaning up DbClient:`, error), + ) + }) + } + + for (const queryHash of bufferedQueryHashes) { + if (!criticalQueryHashes.has(queryHash)) streamLiveQuery(queryHash) + } + bufferedQueryHashes.clear() + + return { + ...originalDehydrated, + dehydratedDbClient, + dbStream: dbStream.stream, + } + } + } else { + router.options.hydrate = async (dehydrated: DehydratedRouterDbState) => { + dbClient._setSsrStreamingEnabled(true) + try { + await originalOptions.hydrate?.(dehydrated) + dbClient.hydrate(dehydrated.dehydratedDbClient) + + const reader = dehydrated.dbStream.getReader() + void readDbStream(reader, dbClient) + .catch((error) => console.error(`Error reading DB stream:`, error)) + .finally(() => { + dbClient._setSsrStreamingEnabled(false) + }) + } catch (error) { + dbClient._setSsrStreamingEnabled(false) + throw error + } + } + } + + return router +} + +async function readDbStream( + reader: ReadableStreamDefaultReader, + dbClient: DbClient, +): Promise { + try { + let entry = await reader.read() + while (!entry.done) { + dbClient.hydrate(entry.value) + entry = await reader.read() + } + } catch (error) { + dbClient._failPendingLiveQueries(error) + throw error + } +} + +type PushableStream = { + stream: ReadableStream + enqueue: (chunk: T) => boolean + close: () => void + error: (error: unknown) => void +} + +function createPushableStream(): PushableStream { + let controllerRef!: ReadableStreamDefaultController + let state: `open` | `closed` | `errored` | `cancelled` = `open` + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller + }, + cancel() { + state = `cancelled` + }, + }) + + return { + stream, + enqueue: (chunk) => { + if (state !== `open`) return false + controllerRef.enqueue(chunk) + return true + }, + close: () => { + if (state !== `open`) return + state = `closed` + controllerRef.close() + }, + error: (error) => { + if (state !== `open`) return + state = `errored` + controllerRef.error(error) + }, + } +} diff --git a/packages/react-router-with-db/tests/index.test-d.ts b/packages/react-router-with-db/tests/index.test-d.ts new file mode 100644 index 0000000000..edb42fa95d --- /dev/null +++ b/packages/react-router-with-db/tests/index.test-d.ts @@ -0,0 +1,27 @@ +import { expectTypeOf, test } from 'vitest' +import { + createRootRouteWithContext, + createRouter, +} from '@tanstack/react-router' +import { DbClient } from '@tanstack/react-db' +import { routerWithDbClient } from '../src' + +test(`requires DbClient in router context and preserves the router type`, () => { + const dbClient = new DbClient() + const rootRoute = createRootRouteWithContext<{ dbClient: DbClient }>()() + const router = createRouter({ + routeTree: rootRoute, + context: { dbClient }, + }) + + expectTypeOf(routerWithDbClient(router, dbClient)).toEqualTypeOf(router) + + const invalidRootRoute = createRootRouteWithContext<{}>()() + const invalidRouter = createRouter({ + routeTree: invalidRootRoute, + context: {}, + }) + + // @ts-expect-error router context must contain dbClient + routerWithDbClient(invalidRouter, dbClient) +}) diff --git a/packages/react-router-with-db/tests/index.test.ts b/packages/react-router-with-db/tests/index.test.ts new file mode 100644 index 0000000000..d60b45716f --- /dev/null +++ b/packages/react-router-with-db/tests/index.test.ts @@ -0,0 +1,296 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { DbClient, collectionOptions } from '@tanstack/react-db' +import { routerWithDbClient } from '../src' +import type { AnyRouter } from '@tanstack/react-router' +import type { DehydratedDbState } from '@tanstack/react-db' +import type { DehydratedRouterDbState } from '../src' + +type Todo = { + id: string + text: string +} + +const adaptRouter = routerWithDbClient as unknown as ( + router: AnyRouter, + dbClient: DbClient, +) => AnyRouter + +function createTodoDescriptor() { + return collectionOptions(`todos`, () => ({ + id: `todos`, + getKey: (todo: Todo) => todo.id, + sync: { + sync: ({ markReady }) => markReady(), + }, + })) +} + +describe(`routerWithDbClient`, () => { + it(`declares peer floors that contain the imported SSR APIs`, () => { + const packageJson = JSON.parse(readFileSync(`package.json`, `utf8`)) as { + peerDependencies: Record + } + + expect(packageJson.peerDependencies).toMatchObject({ + '@tanstack/react-db': `>=0.2.1`, + '@tanstack/router-core': `>=1.127.0`, + }) + }) + + it(`leaves SSR streaming disabled in the browser until hydration starts`, () => { + const dbClient = new DbClient() + const router = { + options: { context: { dbClient } }, + isServer: false, + } as unknown as AnyRouter + + adaptRouter(router, dbClient) + + expect(dbClient._isSsrStreamingEnabled()).toBe(false) + }) + + it(`cleans up server collections when rendering finishes`, async () => { + const cleanup = vi.fn() + const dbClient = new DbClient() + const collection = dbClient.collection( + collectionOptions(`server-cleanup`, () => ({ + id: `server-cleanup`, + getKey: (todo: Todo) => todo.id, + sync: { + sync: ({ markReady }) => { + markReady() + return cleanup + }, + }, + })), + ) + await collection.preload() + let finishRender = () => {} + const router = { + options: { context: { dbClient } }, + isServer: true, + serverSsr: { + isDehydrated: () => false, + onRenderFinished: (callback: () => void) => { + finishRender = callback + }, + }, + } as unknown as AnyRouter + + adaptRouter(router, dbClient) + expect(dbClient._isSsrServerCleanupEnabled()).toBe(true) + await router.options.dehydrate?.() + finishRender() + + await vi.waitFor(() => expect(cleanup).toHaveBeenCalledOnce()) + expect(dbClient._isSsrServerCleanupEnabled()).toBe(false) + }) + + it(`streams live queries registered after critical dehydration`, async () => { + const dbClient = new DbClient() + let isDehydrated = false + let finishRender = () => {} + const router = { + options: { context: { dbClient } }, + isServer: true, + serverSsr: { + isDehydrated: () => isDehydrated, + onRenderFinished: (callback: () => void) => { + finishRender = callback + }, + }, + } as unknown as AnyRouter + + adaptRouter(router, dbClient) + const initialState = (await router.options.dehydrate?.()) as + | DehydratedRouterDbState + | undefined + expect(initialState).toBeDefined() + expect(initialState!.dehydratedDbClient.liveQueries).toBeUndefined() + + isDehydrated = true + let resolveQuery!: (snapshot: { + rows: Array<{ key: string; value: Todo }> + }) => void + const queryPromise = new Promise<{ + rows: Array<{ key: string; value: Todo }> + }>((resolve) => { + resolveQuery = resolve + }) + dbClient._registerLiveQuery(`open-todos`, queryPromise) + + const reader = initialState!.dbStream.getReader() + const streamedState = await reader.read() + expect(streamedState.done).toBe(false) + expect(streamedState.value?.collections).toEqual([]) + expect(streamedState.value?.liveQueries?.[0]?.queryHash).toBe(`open-todos`) + + resolveQuery({ + rows: [{ key: `1`, value: { id: `1`, text: `Streamed` } }], + }) + + await expect( + streamedState.value!.liveQueries![0]!.promise, + ).resolves.toEqual({ + rows: [{ key: `1`, value: { id: `1`, text: `Streamed` } }], + }) + + finishRender() + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it(`includes queries registered while critical dehydration is pending`, async () => { + const dbClient = new DbClient() + let releaseOriginalDehydrate!: () => void + const originalDehydrate = new Promise((resolve) => { + releaseOriginalDehydrate = resolve + }) + let finishRender = () => {} + const router = { + options: { + context: { dbClient }, + dehydrate: () => originalDehydrate, + }, + isServer: true, + serverSsr: { + isDehydrated: () => false, + onRenderFinished: (callback: () => void) => { + finishRender = callback + }, + }, + } as unknown as AnyRouter + + adaptRouter(router, dbClient) + const statePromise = router.options.dehydrate?.() + dbClient._registerLiveQuery( + `during-critical`, + Promise.resolve({ rows: [] }), + ) + releaseOriginalDehydrate() + + const state = (await statePromise) as DehydratedRouterDbState + expect( + state.dehydratedDbClient.liveQueries?.map((query) => query.queryHash), + ).toEqual([`during-critical`]) + + const reader = state.dbStream.getReader() + finishRender() + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it(`rejects pending live queries when the client stream fails`, async () => { + const dbClient = new DbClient() + const router = { + options: { context: { dbClient } }, + isServer: false, + } as unknown as AnyRouter + const error = new Error(`transport failed`) + const pendingSnapshot = new Promise<{ rows: [] }>(() => {}) + const dbStream = new ReadableStream({ + start(controller) { + controller.error(error) + }, + }) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + + adaptRouter(router, dbClient) + await router.options.hydrate?.({ + dehydratedDbClient: { + collections: [], + liveQueries: [ + { + queryHash: `pending`, + dehydratedAt: 1, + promise: pendingSnapshot, + }, + ], + }, + dbStream, + } satisfies DehydratedRouterDbState) + + await expect(dbClient._getLiveQuery(`pending`)?.promise).rejects.toBe(error) + await vi.waitFor(() => { + expect(dbClient._isSsrStreamingEnabled()).toBe(false) + }) + consoleError.mockRestore() + }) + + it(`does not enqueue after the stream is cancelled`, async () => { + const dbClient = new DbClient() + let finishRender = () => {} + const router = { + options: { context: { dbClient } }, + isServer: true, + serverSsr: { + isDehydrated: () => true, + onRenderFinished: (callback: () => void) => { + finishRender = callback + }, + }, + } as unknown as AnyRouter + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + adaptRouter(router, dbClient) + const state = + (await router.options.dehydrate?.()) as DehydratedRouterDbState + await state.dbStream.cancel() + + expect(() => + dbClient._registerLiveQuery(`late`, Promise.resolve({ rows: [] })), + ).not.toThrow() + expect(warning).toHaveBeenCalledWith( + expect.stringContaining(`after the DB stream was closed`), + ) + + finishRender() + warning.mockRestore() + }) + + it(`hydrates every client stream entry`, async () => { + const dbClient = new DbClient() + const todoDescriptor = createTodoDescriptor() + const originalHydrate = vi.fn() + const router = { + options: { + context: { dbClient }, + hydrate: originalHydrate, + }, + isServer: false, + } as unknown as AnyRouter + const dbStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + collections: [ + { + collectionId: `todos`, + rows: [{ key: `1`, value: { id: `1`, text: `From the stream` } }], + }, + ], + }) + controller.close() + }, + }) + + adaptRouter(router, dbClient) + await router.options.hydrate?.({ + dehydratedDbClient: { collections: [] }, + dbStream, + } satisfies DehydratedRouterDbState) + + expect(originalHydrate).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(dbClient.collection(todoDescriptor).get(`1`)).toMatchObject({ + id: `1`, + text: `From the stream`, + }) + expect(dbClient._isSsrStreamingEnabled()).toBe(false) + }) + }) +}) diff --git a/packages/react-router-with-db/tsconfig.json b/packages/react-router-with-db/tsconfig.json new file mode 100644 index 0000000000..5a0367056f --- /dev/null +++ b/packages/react-router-with-db/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "paths": { + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/react-db": ["../react-db/src"] + } + }, + "include": ["src/**/*", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/react-router-with-db/vite.config.ts b/packages/react-router-with-db/vite.config.ts new file mode 100644 index 0000000000..922872e859 --- /dev/null +++ b/packages/react-router-with-db/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import react from '@vitejs/plugin-react' +import packageJson from './package.json' + +export default defineConfig(async () => { + const tanstack = await tanstackViteConfig({ + entry: `./src/index.tsx`, + srcDir: `./src`, + }) + + const base = { + plugins: [react()], + test: { + name: packageJson.name, + dir: `./tests`, + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true }, + }, + } + + return mergeConfig(tanstack, base) +}) 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/svelte-db/src/DbProvider.svelte b/packages/svelte-db/src/DbProvider.svelte new file mode 100644 index 0000000000..789d3ae0e1 --- /dev/null +++ b/packages/svelte-db/src/DbProvider.svelte @@ -0,0 +1,17 @@ + + +{@render children?.()} diff --git a/packages/svelte-db/src/db-context.ts b/packages/svelte-db/src/db-context.ts new file mode 100644 index 0000000000..f7f531318e --- /dev/null +++ b/packages/svelte-db/src/db-context.ts @@ -0,0 +1,26 @@ +import { getContext, setContext } from 'svelte' +import type { DbClient } from '@tanstack/db' + +const dbClientContext = Symbol.for(`@tanstack/svelte-db.DbClient`) +type DbClientContext = () => DbClient + +export function setDbClientContext(client: DbClientContext): DbClientContext { + return setContext(dbClientContext, client) +} + +export function useDbClient(): DbClient { + const client = useOptionalDbClient() + if (!client) { + throw new Error(`useDbClient must be used within a DbProvider.`) + } + return client +} + +export function useOptionalDbClient(): DbClient | undefined { + try { + return getContext(dbClientContext)?.() + } catch { + // Legacy helpers may be called from a rune root rather than a component. + return undefined + } +} diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts index b7a01e00ad..185d1bd5ac 100644 --- a/packages/svelte-db/src/index.ts +++ b/packages/svelte-db/src/index.ts @@ -1,6 +1,8 @@ // Re-export all public APIs export * from './useLiveQuery.svelte.js' export * from './useLiveInfiniteQuery.svelte.js' +export { useDbClient, useOptionalDbClient } from './db-context.js' +export { default as DbProvider } from './DbProvider.svelte' // Re-export everything from @tanstack/db export * from '@tanstack/db' diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 0186bf0546..5af53f6b55 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -4,20 +4,28 @@ import { untrack } from 'svelte' import { SvelteMap } from 'svelte/reactivity' import { BaseQueryBuilder, + UnhashableQueryIRError, createLiveQueryCollection, createLiveQueryObserver, + getLiveQueryHash, + getStableValueHash, isCollection, isSingleResultCollection, + prepareLiveQueryValue, } from '@tanstack/db' +import { useOptionalDbClient } from './db-context.js' import type { ChangeMessage, Collection, CollectionStatus, Context, + DbClient, + DeferredLiveQueryCollections, GetResult, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryKey, LiveQueryObserver, NonSingleResult, QueryBuilder, @@ -67,6 +75,12 @@ export interface UseLiveQueryReturnWithCollection< type MaybeGetter = T | (() => T) +export type UseLiveQueryConfig = + LiveQueryCollectionConfig & { + queryKey?: MaybeGetter + client?: DbClient + } + function toValue(value: MaybeGetter): T { if (typeof value === `function`) { return (value as () => T)() @@ -218,7 +232,7 @@ export function useLiveQuery( */ // Overload 2: Accept config object export function useLiveQuery( - config: LiveQueryCollectionConfig, + config: UseLiveQueryConfig, deps?: Array<() => unknown>, ): UseLiveQueryReturn, InferResultType> @@ -292,7 +306,9 @@ export function useLiveQuery( configOrQueryOrCollection: any, deps: Array<() => unknown> = [], ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { - const collection = $derived.by(() => { + const contextDbClient = useOptionalDbClient() + + const resolved = $derived.by(() => { // First check if the original parameter might be a getter // by seeing if toValue returns something different than the original let unwrappedParam = configOrQueryOrCollection @@ -308,6 +324,10 @@ export function useLiveQuery( // Check if it's already a collection by checking for specific collection methods const inputIsCollection = isCollection(unwrappedParam) + const dbClient = inputIsCollection + ? contextDbClient + : ((unwrappedParam as { client?: DbClient } | null)?.client ?? + contextDbClient) if (inputIsCollection) { // Warn when passing a collection directly with on-demand sync mode @@ -329,132 +349,153 @@ export function useLiveQuery( if (unwrappedParam.status === `idle`) { unwrappedParam.startSyncImmediate() } - return unwrappedParam + return { + collection: unwrappedParam, + client: dbClient, + queryHash: getStableValueHash( + [`collection`, unwrappedParam.id], + `queryKey`, + ), + resumeDeferredCollections: () => {}, + } } // Reference deps to make computed reactive to them - deps.forEach((dep) => toValue(dep)) - - // Ensure we always start sync for Svelte helpers - if (typeof unwrappedParam === `function`) { - // Check if query function returns null/undefined (disabled query) - const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder - const result = unwrappedParam(queryBuilder) + const dependencyValues = deps.map((dep) => toValue(dep)) + const deferredCollections: DeferredLiveQueryCollections = new Set() + const preparedValue = prepareLiveQueryValue( + unwrappedParam, + dbClient, + deferredCollections, + ) + const configuredQueryKey = ( + unwrappedParam as { queryKey?: MaybeGetter } | null + )?.queryKey + const queryKey = configuredQueryKey + ? toValue(configuredQueryKey) + : undefined - if (result === undefined || result === null) { - // Disabled query - return null - return null - } + let queryHash: string | undefined + try { + queryHash = + deps.length > 0 && !queryKey + ? getStableValueHash( + [`deps`, dependencyValues, getLiveQueryHash(preparedValue)], + `queryKey`, + ) + : getLiveQueryHash(preparedValue, queryKey) + } catch (error) { + if (!(error instanceof UnhashableQueryIRError)) throw error + if (queryKey !== undefined) throw error + } - return createLiveQueryCollection({ - query: unwrappedParam, + let collection: Collection | null + if (preparedValue === undefined || preparedValue === null) { + collection = null + } else if (isCollection(preparedValue)) { + collection = preparedValue + } else if (preparedValue instanceof BaseQueryBuilder) { + collection = createLiveQueryCollection({ + query: preparedValue, startSync: true, }) } else { - // A reactive getter (or param-driven query fn) can resolve to null/undefined - // to mean "disabled". `toValue` above already called it, so guard here — - // otherwise `{ ...null }` reaches createLiveQueryCollection and throws. - if (unwrappedParam === undefined || unwrappedParam === null) { - return null - } - - return createLiveQueryCollection({ - ...unwrappedParam, + collection = createLiveQueryCollection({ + ...(preparedValue as LiveQueryCollectionConfig), startSync: true, }) } + + return { + collection, + client: dbClient, + queryHash, + resumeDeferredCollections: () => { + for (const deferredCollection of deferredCollections) { + deferredCollection._resumeSyncStart() + } + deferredCollections.clear() + }, + } + }) + + let currentResolved = untrack(() => resolved) + let currentObserver = createLiveQueryObserver(currentResolved.collection, { + client: currentResolved.client, + queryHash: currentResolved.queryHash, + onPreload: currentResolved.resumeDeferredCollections, }) + const initialSnapshot = currentObserver.getServerSnapshot() // Reactive state that gets updated granularly through change events - const state = new SvelteMap() + const state = new SvelteMap(initialSnapshot.state ?? []) // Reactive data array that maintains sorted order - let internalData = $state>([]) + let internalData = $state>( + Array.from(initialSnapshot.state?.values() ?? []), + ) // Track collection status reactively - let status = $state(collection ? collection.status : (`disabled` as const)) - - // Helper to sync data array from collection in correct order - const syncDataFromCollection = ( - currentCollection: Collection, - ) => { - untrack(() => { - internalData = [] - internalData.push(...Array.from(currentCollection.values())) - }) - } - - // The shared observer owns subscription, the ready-race, and status; Svelte - // materializes into its own rune-backed map (granular) + ordered array. - let currentObserver: LiveQueryObserver | null = null + let status = $state(initialSnapshot.status) const syncFromObserver = ( observer: LiveQueryObserver, - currentCollection: Collection, + changes?: Array>, ) => { - status = observer.getSnapshot().status as CollectionStatus - syncDataFromCollection(currentCollection) + const snapshot = observer.getSnapshot() + status = snapshot.status as CollectionStatus + untrack(() => { + if (changes && changes.length > 0) { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break + } + } + } else { + state.clear() + for (const [key, value] of snapshot.state ?? []) { + state.set(key, value) + } + } + internalData = Array.from(snapshot.state?.values() ?? []) + }) } // Watch for collection changes and subscribe to updates $effect(() => { - const currentCollection = collection - - // Tear down any previous observer. - currentObserver?.dispose() - currentObserver = null + const nextResolved = resolved - // Handle null collection (disabled query) - if (!currentCollection) { - status = `disabled` as const - untrack(() => { - state.clear() - internalData = [] + if (nextResolved !== currentResolved) { + currentObserver.dispose() + currentResolved = nextResolved + currentObserver = createLiveQueryObserver(nextResolved.collection, { + client: nextResolved.client, + queryHash: nextResolved.queryHash, + onPreload: nextResolved.resumeDeferredCollections, }) - return + syncFromObserver(currentObserver) } - const observer = createLiveQueryObserver(currentCollection) - currentObserver = observer - - // Initial rows arrive as the observer's first delta (includeInitialState); - // apply them and every subsequent delta granularly to the rune-backed map. - untrack(() => state.clear()) + const observer = currentObserver const unsubscribe = observer.subscribe( (changes: Array> | undefined) => { - untrack(() => { - if (changes) { - for (const change of changes) { - switch (change.type) { - case `insert`: - case `update`: - state.set(change.key, change.value) - break - case `delete`: - state.delete(change.key) - break - } - } - } else { - // Cleanup and other status-only publications carry no row deltas. - // Rebuild the keyed view so it cannot diverge from ordered data. - state.clear() - for (const [key, value] of observer.getSnapshot().state ?? []) { - state.set(key, value) - } - } - }) - syncFromObserver(observer, currentCollection) + syncFromObserver(observer, changes) }, ) - syncFromObserver(observer, currentCollection) + currentResolved.resumeDeferredCollections() + syncFromObserver(observer) // Cleanup when effect is invalidated return () => { unsubscribe() - observer.dispose() - currentObserver = null + if (observer === currentObserver) observer.dispose() } }) @@ -463,17 +504,17 @@ export function useLiveQuery( return state }, get data() { - const currentCollection = collection + const currentCollection = resolved.collection if (currentCollection && isSingleResultCollection(currentCollection)) { return internalData[0] } return internalData }, get collection() { - return collection + return resolved.collection }, get status() { - return status + return status as CollectionStatus }, get isLoading() { return status === `loading` diff --git a/packages/svelte-db/tests/SsrDbApp.svelte b/packages/svelte-db/tests/SsrDbApp.svelte new file mode 100644 index 0000000000..adc6823847 --- /dev/null +++ b/packages/svelte-db/tests/SsrDbApp.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/svelte-db/tests/SsrDbQuery.svelte b/packages/svelte-db/tests/SsrDbQuery.svelte new file mode 100644 index 0000000000..6e0c9a58da --- /dev/null +++ b/packages/svelte-db/tests/SsrDbQuery.svelte @@ -0,0 +1,30 @@ + + + + {#each people.data as person (person.id)} + {person.name} + {/each} + diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts new file mode 100644 index 0000000000..2ebcae19b6 --- /dev/null +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { flushSync } from 'svelte' +import { DbClient } from '@tanstack/db' +import { useLiveQuery } from '../src/useLiveQuery.svelte.js' +import { createPeopleDescriptor, peopleQuery } from './ssr-test-utils.js' + +describe(`Svelte hydration`, () => { + it(`keeps the hydrated result until the browser source is authoritative`, async () => { + const { descriptor, resolveBrowserLoad } = createPeopleDescriptor() + const serverClient = new DbClient({ runtime: `server` }) + await serverClient.preloadLiveQuery(peopleQuery(descriptor)) + const dehydrated = serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + + const browserClient = new DbClient({ runtime: `browser` }) + browserClient.hydrate(dehydrated) + const createQuery = () => + useLiveQuery({ + ...peopleQuery(descriptor), + client: browserClient, + }) + let query!: ReturnType + const dispose = $effect.root(() => { + query = createQuery() + }) + + flushSync() + expect(query.data).toEqual([ + expect.objectContaining({ id: `server`, name: `Server snapshot` }), + ]) + + resolveBrowserLoad() + await Promise.resolve() + await Promise.resolve() + flushSync() + + expect(query.data).toEqual([ + expect.objectContaining({ id: `browser`, name: `Browser source` }), + ]) + dispose() + }) +}) diff --git a/packages/svelte-db/tests/ssr-test-utils.ts b/packages/svelte-db/tests/ssr-test-utils.ts new file mode 100644 index 0000000000..d1dfdab2fd --- /dev/null +++ b/packages/svelte-db/tests/ssr-test-utils.ts @@ -0,0 +1,68 @@ +import { collectionOptions } from '@tanstack/db' +import type { InitialQueryBuilder } from '@tanstack/db' + +export type Person = { + id: string + name: string + sourcePayload: string +} + +export const serverPerson: Person = { + id: `server`, + name: `Server snapshot`, + sourcePayload: `SOURCE_ONLY_SERVER_PAYLOAD`, +} + +export const browserPerson: Person = { + id: `browser`, + name: `Browser source`, + sourcePayload: `SOURCE_ONLY_BROWSER_PAYLOAD`, +} + +export function createPeopleDescriptor() { + let resolveBrowserLoad: (() => void) | undefined + const descriptor = collectionOptions(`svelte-ssr-people`, (client) => { + const runtime = client.requireDependency<`server` | `browser`>(`runtime`) + + return { + id: `svelte-ssr-people`, + getKey: (person: Person) => person.id, + syncMode: `on-demand` as const, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + if (runtime === `browser`) { + await new Promise((resolve) => { + resolveBrowserLoad = resolve + }) + } + begin({ immediate: true }) + write({ + type: `insert`, + value: runtime === `server` ? serverPerson : browserPerson, + }) + commit() + }, + } + }, + }, + } + }) + + return { + descriptor, + resolveBrowserLoad: () => resolveBrowserLoad?.(), + } +} + +export const peopleQuery = ( + descriptor: ReturnType[`descriptor`], +) => ({ + query: (q: InitialQueryBuilder) => + q.from({ people: descriptor }).select(({ people }) => ({ + id: people.id, + name: people.name, + })), +}) diff --git a/packages/svelte-db/tests/ssr.svelte.test.ts b/packages/svelte-db/tests/ssr.svelte.test.ts new file mode 100644 index 0000000000..6af046c49a --- /dev/null +++ b/packages/svelte-db/tests/ssr.svelte.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' +import { render } from 'svelte/server' +import { DbClient } from '@tanstack/db' +import SsrDbApp from './SsrDbApp.svelte' +import { + createPeopleDescriptor, + peopleQuery, + serverPerson, +} from './ssr-test-utils.js' + +describe(`Svelte SSR`, () => { + it(`renders a hydrated live-query result without hydrating source rows`, async () => { + const { descriptor } = createPeopleDescriptor() + const serverClient = new DbClient({ runtime: `server` }) + await serverClient.preloadLiveQuery(peopleQuery(descriptor)) + const dehydrated = serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }) + expect(dehydrated.collections).toEqual([]) + + const browserClient = new DbClient({ runtime: `browser` }) + browserClient.hydrate(dehydrated) + const { body } = render(SsrDbApp, { + props: { client: browserClient, descriptor }, + }) + + expect(body).toContain(`Server snapshot`) + expect(body).toContain(`data-status="ready"`) + expect(body).not.toContain(serverPerson.sourcePayload) + }) +}) diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index bb0b80764e..5d490f3036 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -1,9 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { + BaseQueryBuilder, + DbClient, count, createCollection, createLiveQueryCollection, eq, + getLiveQueryHash, + getStableValueHash, gt, } from '@tanstack/db' import { flushSync } from 'svelte' @@ -75,6 +79,76 @@ const initialIssues: Array = [ ] describe(`Query Collections`, () => { + it(`includes the query in legacy dependency-array SSR identity`, () => { + const client = new DbClient() + const collection = createCollection({ + id: `svelte-legacy-query-identity`, + getKey: (person) => person.id, + startSync: false, + sync: { sync: () => {} }, + }) + const firstPrepared = new BaseQueryBuilder().from({ people: collection }) + const secondPrepared = new BaseQueryBuilder() + .from({ people: collection }) + .where(({ people }) => gt(people.age, 30)) + const firstHash = getStableValueHash( + [`deps`, [1], getLiveQueryHash({ query: firstPrepared })], + `queryKey`, + ) + const secondHash = getStableValueHash( + [`deps`, [1], getLiveQueryHash({ query: secondPrepared })], + `queryKey`, + ) + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash: firstHash, + dehydratedAt: 1, + snapshot: { + rows: [ + { key: `first`, value: { ...initialPersons[0]!, id: `first` } }, + ], + }, + }, + { + queryHash: secondHash, + dehydratedAt: 1, + snapshot: { + rows: [ + { key: `second`, value: { ...initialPersons[2]!, id: `second` } }, + ], + }, + }, + ], + }) + let firstId: string | undefined + let secondId: string | undefined + + cleanup = $effect.root(() => { + const first = useLiveQuery( + { client, query: (q) => q.from({ people: collection }) }, + [() => 1], + ) + const second = useLiveQuery( + { + client, + query: (q) => + q + .from({ people: collection }) + .where(({ people }) => gt(people.age, 30)), + }, + [() => 1], + ) + flushSync() + firstId = first.data[0]?.id + secondId = second.data[0]?.id + }) + + expect(firstId).toBe(`first`) + expect(secondId).toBe(`second`) + }) + let cleanup: (() => void) | null = null afterEach(() => { @@ -140,6 +214,26 @@ describe(`Query Collections`, () => { }) }) + it(`throws when an explicit queryKey cannot be stably hashed`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `unhashable-explicit-query-key-svelte`, + getKey: (person) => person.id, + initialData: initialPersons, + }), + ) + + expect(() => { + cleanup = $effect.root(() => { + useLiveQuery({ + queryKey: [collection.id, () => `opaque`], + query: (q) => q.from({ people: collection }), + }) + flushSync() + }) + }).toThrow(/queryKey.*function value/) + }) + it(`should maintain reactivity when destructuring return values with $derived`, () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/trailbase-db-collection/src/trailbase.ts b/packages/trailbase-db-collection/src/trailbase.ts index b47728d861..adddb6c470 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, @@ -180,6 +181,17 @@ export function trailBaseCollectionOptions< const sync = { sync: (params: SyncParams) => { const { begin, write, commit, markReady } = params + let cancelled = false + let periodicCleanupTask: ReturnType | undefined + + const cleanup = () => { + cancelled = true + cancelEventReader() + if (periodicCleanupTask !== undefined) { + clearInterval(periodicCleanupTask) + periodicCleanupTask = undefined + } + } // NOTE: We cache cursors from prior fetches. TanStack/db expects that // cursors can be derived from a key, which is not true for TB, since @@ -188,6 +200,8 @@ export function trailBaseCollectionOptions< // Load (more) data. async function load(opts: LoadSubsetOptions) { + if (cancelled) return + const lastKey = opts.cursor?.lastKey let cursor: string | undefined = lastKey !== undefined ? cursors.get(lastKey) : undefined @@ -216,6 +230,7 @@ export function trailBaseCollectionOptions< order, filters, }) + if (cancelled) return const length = response.records.length if (length === 0) { @@ -295,6 +310,10 @@ export function trailBaseCollectionOptions< async function start() { const eventStream = await config.recordApi.subscribe(`*`) + if (cancelled) { + await eventStream.cancel() + return + } const reader = (eventReader = eventStream.getReader()) // Start listening for subscriptions first. Otherwise, we'd risk a gap @@ -306,6 +325,7 @@ export function trailBaseCollectionOptions< if (internalSyncMode === `eager`) { // Load everything on initial load. await load({}) + if (cancelled) return fullSyncCompleted = true } } catch (e) { @@ -314,12 +334,14 @@ export function trailBaseCollectionOptions< } finally { // Mark ready both if everything went well or if there's an error to // avoid blocking apps waiting for `.preload()` to finish. - markReady() + if (!cancelled) markReady() } // Lastly, start a periodic cleanup task that will be removed when the // reader closes. - const periodicCleanupTask = setInterval(() => { + if (cancelled) return + + periodicCleanupTask = setInterval(() => { seenIds.setState((curr) => { const now = Date.now() let anyExpired = false @@ -337,17 +359,23 @@ export function trailBaseCollectionOptions< }) }, 120 * 1000) - reader.closed.finally(() => clearInterval(periodicCleanupTask)) + reader.closed.finally(() => { + if (periodicCleanupTask !== undefined) { + clearInterval(periodicCleanupTask) + periodicCleanupTask = undefined + } + }) } start() // Eager mode doesn't need subset loading if (internalSyncMode === `eager`) { - return + return { cleanup } } return { + cleanup, loadSubset: load, getSyncMetadata: () => ({ @@ -363,7 +391,7 @@ export function trailBaseCollectionOptions< }) as const, } - return { + const options = { ...config, sync, getKey, @@ -428,6 +456,11 @@ export function trailBaseCollectionOptions< cancel: cancelEventReader, }, } + + return withCollectionConfigFactory( + options, + () => trailBaseCollectionOptions(config) as typeof options, + ) } function buildOrder(opts: LoadSubsetOptions): undefined | Array { diff --git a/packages/trailbase-db-collection/tests/trailbase.test.ts b/packages/trailbase-db-collection/tests/trailbase.test.ts index c2c0845147..24a0586aeb 100644 --- a/packages/trailbase-db-collection/tests/trailbase.test.ts +++ b/packages/trailbase-db-collection/tests/trailbase.test.ts @@ -121,6 +121,43 @@ function setUp(recordApi: MockRecordApi) { } describe(`TrailBase Integration`, () => { + it(`cancels its event subscription when the collection is cleaned up`, async () => { + const recordApi = new MockRecordApi() + const cancel = vi.fn() + recordApi.subscribe.mockResolvedValue(new ReadableStream({ cancel })) + const collection = createCollection(setUp(recordApi)) + + await vi.waitFor(() => expect(recordApi.subscribe).toHaveBeenCalledOnce()) + await collection.cleanup() + + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) + }) + + it(`ignores an initial fetch that resolves after cleanup`, async () => { + const recordApi = new MockRecordApi() + let resolveList!: (response: ListResponse) => void + recordApi.list.mockReturnValue( + new Promise>((resolve) => { + resolveList = resolve + }), + ) + recordApi.subscribe.mockResolvedValue(new TransformStream().readable) + const options = setUp(recordApi) + const collection = createCollection(options) + + await vi.waitFor(() => expect(recordApi.list).toHaveBeenCalledOnce()) + await collection.cleanup() + resolveList({ + records: [{ id: 1, updated: 0, data: `late` }], + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(stripState(collection.state)).toEqual(new Map()) + expect(options.sync.getSyncMetadata?.()).toMatchObject({ + fullSyncComplete: false, + }) + }) + it(`initial fetch, receive update and cancel`, async () => { const records: Array = [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43d57168c1..83c5b89e95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,40 @@ importers: specifier: ^5.9.2 version: 5.9.3 + examples/react/next-ssr-e2e: + dependencies: + '@tanstack/db': + specifier: workspace:* + version: link:../../../packages/db + '@tanstack/react-db': + specifier: workspace:* + version: link:../../../packages/react-db + next: + specifier: ^16.3.1 + version: 16.3.1(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@25.2.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.90.0) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + 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) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + examples/react/offline-transactions: dependencies: '@tanstack/browser-db-sqlite-persistence': @@ -620,7 +654,7 @@ importers: version: 11.10.0(typescript@5.9.3) better-auth: specifier: ^1.4.18 - version: 1.4.18(b4f55ef685357933f61fbe0b4095cc16) + version: 1.4.18(dd19f6838762949983acd690aa0ac646) dotenv: specifier: ^17.2.4 version: 17.2.4 @@ -722,6 +756,52 @@ importers: specifier: ^5.1.0 version: 5.1.0 + examples/react/start-ssr-e2e: + dependencies: + '@tanstack/react-db': + specifier: ^0.2.1 + 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-router-with-db': + specifier: workspace:* + version: link:../../../packages/react-router-with-db + '@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': @@ -1431,6 +1511,30 @@ importers: specifier: ^12.6.2 version: 12.8.0 + packages/react-router-with-db: + devDependencies: + '@tanstack/react-db': + specifier: workspace:* + version: link:../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/router-core': + specifier: ^1.159.4 + version: 1.159.4 + '@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)) + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + packages/rxdb-db-collection: dependencies: '@standard-schema/spec': @@ -2586,6 +2690,9 @@ packages: '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -3905,70 +4012,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3976,6 +4158,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3983,6 +4172,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3990,6 +4186,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3997,6 +4200,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4004,6 +4214,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4011,6 +4228,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4018,6 +4242,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4025,29 +4256,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/checkbox@4.2.2': resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==} engines: {node: '>=18'} @@ -4524,6 +4789,61 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@next/env@16.3.1': + resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==} + + '@next/swc-darwin-arm64@16.3.1': + resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.3.1': + resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.3.1': + resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.3.1': + resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.3.1': + resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.3.1': + resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.3.1': + resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.3.1': + resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -4819,6 +5139,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==} @@ -5603,6 +5928,9 @@ packages: resolution: {integrity: sha512-08eKiDAjj4zLug1taXSIJ0kGL5cawjVCyJkBb6EWSg5fEPX6L+Wtr0CH2If4j5KYylz85iaZiFlUItvgJvll5g==} engines: {node: ^14.13.1 || ^16.0.0 || >=18} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -8586,6 +8914,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} @@ -10195,6 +10528,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.1.0: resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} engines: {node: ^20.0.0 || >=22.0.0} @@ -10234,6 +10572,27 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} + next@16.3.1: + resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -10701,6 +11060,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'} @@ -10724,6 +11093,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -11288,6 +11661,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -11361,6 +11739,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -11762,6 +12149,19 @@ packages: style-to-object@1.0.9: resolution: {integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -14078,6 +14478,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -15596,95 +16001,199 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.7.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@inquirer/checkbox@4.2.2(@types/node@25.2.2)': dependencies: '@inquirer/core': 10.2.0(@types/node@25.2.2) @@ -16224,6 +16733,32 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@next/env@16.3.1': {} + + '@next/swc-darwin-arm64@16.3.1': + optional: true + + '@next/swc-darwin-x64@16.3.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.3.1': + optional: true + + '@next/swc-linux-arm64-musl@16.3.1': + optional: true + + '@next/swc-linux-x64-gnu@16.3.1': + optional: true + + '@next/swc-linux-x64-musl@16.3.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.3.1': + optional: true + + '@next/swc-win32-x64-msvc@16.3.1': + optional: true + '@noble/ciphers@2.1.1': {} '@noble/hashes@2.0.1': {} @@ -16466,6 +17001,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': @@ -17494,6 +18033,10 @@ snapshots: transitivePeerDependencies: - encoding + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -19193,7 +19736,7 @@ snapshots: postcss: 8.5.10 postcss-media-query-parser: 0.2.3 - better-auth@1.4.18(b4f55ef685357933f61fbe0b4095cc16): + better-auth@1.4.18(dd19f6838762949983acd690aa0ac646): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -19214,6 +19757,7 @@ snapshots: drizzle-kit: 0.31.9 drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) mongodb: 6.21.0(socks@2.8.7) + next: 16.3.1(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@25.2.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.90.0) pg: 8.20.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -21315,6 +21859,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -23085,6 +23632,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanostores@1.1.0: {} napi-build-utils@2.0.0: {} @@ -23121,6 +23670,34 @@ snapshots: nested-error-stacks@2.0.1: {} + next@16.3.1(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@25.2.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.90.0): + dependencies: + '@next/env': 16.3.1 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + postcss: 8.5.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.1 + '@next/swc-darwin-x64': 16.3.1 + '@next/swc-linux-arm64-gnu': 16.3.1 + '@next/swc-linux-arm64-musl': 16.3.1 + '@next/swc-linux-x64-gnu': 16.3.1 + '@next/swc-linux-x64-musl': 16.3.1 + '@next/swc-win32-arm64-msvc': 16.3.1 + '@next/swc-win32-x64-msvc': 16.3.1 + '@playwright/test': 1.60.0 + babel-plugin-react-compiler: 1.0.0 + sass: 1.90.0 + sharp: 0.35.3(@types/node@25.2.2) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + nice-try@1.0.5: {} nkeys.js@1.1.0: @@ -23639,6 +24216,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 @@ -23663,6 +24248,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.23: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.0: {} @@ -24545,6 +25136,9 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: + optional: true + send@0.19.0: dependencies: debug: 2.6.9 @@ -24693,6 +25287,40 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.3(@types/node@25.2.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 25.2.2 + optional: true + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -25145,6 +25773,13 @@ snapshots: dependencies: inline-style-parser: 0.2.4 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.13