From 21cbb9a520d6100f2d6fb8802b0ca41cbd692c1e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 30 Oct 2026 09:33:27 -0600 Subject: [PATCH 1/3] docs: add Neon Realtime collection guide --- docs/collections/neon-realtime-collection.md | 275 +++++++++++++++++++ docs/config.json | 12 +- docs/overview.md | 4 + 3 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 docs/collections/neon-realtime-collection.md diff --git a/docs/collections/neon-realtime-collection.md b/docs/collections/neon-realtime-collection.md new file mode 100644 index 0000000000..04169a3982 --- /dev/null +++ b/docs/collections/neon-realtime-collection.md @@ -0,0 +1,275 @@ +--- +title: Neon Realtime Collection +--- + +Neon Realtime collections integrate TanStack DB with [Neon Realtime](https://neon.com/docs/realtime/overview), syncing the results of live SQL queries from Postgres into client-side collections. + +## Overview + +The `@neon/realtime-tanstack` package, maintained by Neon, allows you to create collections that: + +- Sync live SQL query results, including joins, aggregates, ordering, and limits +- Apply complete query results and committed change batches atomically +- Support local live queries with filters, sorts, and joins across collections +- Handle optimistic updates with transaction ID matching and automatic rollback on errors + +Neon Realtime is currently in beta. Follow the [Neon Realtime quickstart](https://neon.com/docs/realtime/quickstart) to enable it on your branch and configure your application's allowed origins before creating a collection. + +## Installation + +Install the Realtime SDK, its TanStack DB adapter, and your framework integration. The examples below use React: + +```bash +npm install @neon/realtime @neon/realtime-tanstack @tanstack/react-db +``` + +## Basic Usage + +A Realtime collection needs a client, a sealed query from your backend, and a stable key for each row. A sealed query lets the browser subscribe to an authorized SQL query without being able to change its SQL or parameters. + +The examples share this row type in `types.ts`: + +```ts +export type Todo = { + id: string + title: string + completed: boolean +} +``` + +### Seal a Query on the Server + +Define and seal queries in trusted backend code. For example, this handler serves `POST /api/todos/live`. It assumes a `todos` table with `id`, `title`, `completed`, and `user_id` columns, and an application `requireUser` function that authenticates the request: + +```ts +import { createRealtime, rawSql } from '@neon/realtime/server' +import { requireUser } from './auth' + +const realtime = createRealtime({ + secret: process.env.NEON_REALTIME_SECRET!, + db: 'neondb', +}) + +export async function POST(request: Request) { + const user = await requireUser(request) + const query = rawSql( + 'SELECT id, title, completed FROM todos WHERE user_id = $1', + [user.id], + ) + + return Response.json(await realtime.seal({ query })) +} +``` + +Use the database name from your connection string for `db`. Keep `NEON_REALTIME_SECRET` on the server. Authorize the request before sealing, and derive user-specific parameters from the authenticated session. See Neon's [query and authorization guide](https://neon.com/docs/realtime/guides/queries) for ORM adapters and other ways to expose sealed queries. + +### Create the Collection + +In browser code, create one Realtime client for your application and fetch the sealed query from your endpoint. For example, in `collections.ts`: + +```ts +import { createCollection } from '@tanstack/react-db' +import { createRealtimeClient } from '@neon/realtime/client' +import { realtimeCollectionOptions } from '@neon/realtime-tanstack' +import type { SealedLiveQuery } from '@neon/realtime/client' +import type { Todo } from './types' + +const client = createRealtimeClient({ + url: 'YOUR_NEON_REALTIME_URL', +}) + +async function fetchTodosQuery(): Promise> { + const response = await fetch('/api/todos/live', { method: 'POST' }) + + if (!response.ok) { + throw new Error(`Could not fetch the todos query (${response.status})`) + } + + return response.json() +} + +export const todosCollection = createCollection( + realtimeCollectionOptions({ + id: 'todos', + client, + query: await fetchTodosQuery(), + refreshQuery: fetchTodosQuery, + getKey: (todo: Todo) => todo.id, + }), +) +``` + +Replace `YOUR_NEON_REALTIME_URL` with the Realtime URL for your branch. The client opens a WebSocket lazily and shares it across subscriptions. The adapter takes the client directly, so it does not need Neon's React `RealtimeProvider`. + +Create the collection once for the signed-in user and reuse it across components. This example is browser-only. For server rendering, use request-scoped collections and follow the [SSR and hydration guide](../guides/ssr.md) and [Neon's TanStack Start example](https://neon.com/docs/realtime/examples/tanstack). + +## Configuration Options + +The `realtimeCollectionOptions` function accepts the following options: + +### Required Options + +| Option | Description | +| -------- | --------------------------------------------------------------------------- | +| `client` | A reusable `RealtimeClient` created with `createRealtimeClient()` | +| `query` | The initial `SealedLiveQuery` for the SQL query backing the collection | +| `getKey` | A function that returns a stable, unique string or number key for each row | + +Use a primary key or another stable, unique value from the query result for `getKey`. A Realtime subscription's opaque `rowId` is not a stable application key. + +### Optional Options + +| Option | Description | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | +| `id` | A unique identifier for the collection | +| `refreshQuery` | An async function that obtains a fresh sealed query for the same SQL and parameters | +| `schema` | A [Standard Schema](https://standardschema.dev) compatible schema for collection validation | +| `onInsert`, `onUpdate`, `onDelete` | Mutation handlers that persist optimistic writes to your backend | +| `startSync` | Start syncing on collection creation instead of waiting for the first subscriber | +| `gcTime` | Time in milliseconds before an unused collection is garbage collected | + +The adapter uses eager synchronization: it syncs the full result of the sealed SQL query. TanStack DB filters, ordering, joins, and limits run locally over those rows. They do not change the server query or fetch rows outside its result. + +See the [Neon SDK reference](https://neon.com/docs/realtime/sdks/typescript#tanstack-db-realtimecollectionconfig) for all supported options. + +### Renewing Sealed Queries + +Sealed queries are short-lived. Provide `refreshQuery` to renew the subscription before the current query expires. It must return a sealed query for the same SQL and parameter values. To change those parameters, create a collection with a newly sealed query. + +The adapter retries failed refresh requests. Once a collection is ready, it retains its last rows and stays ready while the subscription reconnects or waits for renewal. Readiness alone does not mean those rows are current. A terminal subscription error ends automatic recovery; handle sync failures in the UI as shown below. + +Use your application's auth state to stop consumers and clean up user-specific collections on sign-out. A failed renewal request alone does not clear the collection's rows. Sealed queries are bearer tokens: send them over HTTPS and keep them out of URLs, logs, and persistent browser storage. + +## Querying the Collection + +Pass the collection to your components and use TanStack DB's `useLiveQuery` to derive local views from the synced rows: + +```tsx +import { eq, useLiveQuery } from '@tanstack/react-db' +import type { Collection } from '@tanstack/react-db' +import type { Todo } from './types' + +export function TodoList({ + todosCollection, +}: { + todosCollection: Collection +}) { + const { + data: todos, + isReady, + isLoadingError, + isCrashed, + isSyncError, + } = useLiveQuery({ + query: (q) => + q + .from({ todo: todosCollection }) + .where(({ todo }) => eq(todo.completed, false)) + .orderBy(({ todo }) => todo.title, 'asc'), + }) + + if (isLoadingError || isCrashed) return

Could not load todos

+ if (!isReady) return

Loading todos...

+ + return ( + <> + {isSyncError && ( +

Live updates stopped. Showing the last synced todos.

+ )} +
    + {todos.map((todo) => ( +
  • {todo.title}
  • + ))} +
+ + ) +} +``` + +Sync begins when the first live query subscribes, unless `startSync` is enabled. The collection becomes ready after the first complete query result arrives. Other views can query the same rows without another sealed query or server round trip. + +## Persistence Handlers & Synchronization + +Realtime syncs reads. Persist writes through your own backend using `onInsert`, `onUpdate`, and `onDelete`. TanStack DB applies changes optimistically and invokes the matching handler once per transaction, with all of its mutations. + +After the backend commits a write, wait for its Postgres transaction ID with `collection.utils.awaitTxId(txid, timeout)`. This keeps the optimistic state until the corresponding synced changes have entered TanStack DB's sync queue. + +For example, add these handlers to the collection options above. The application API functions shown here send each batch in one request and return `{ txid: string | null }`, with `null` when no rows changed: + +```ts +import { createCollection } from '@tanstack/react-db' +import { realtimeCollectionOptions } from '@neon/realtime-tanstack' +import { api } from './api' +import type { Todo } from './types' + +export const todosCollection = createCollection( + realtimeCollectionOptions({ + id: 'todos', + client, + query: await fetchTodosQuery(), + refreshQuery: fetchTodosQuery, + getKey: (todo: Todo) => todo.id, + onInsert: async ({ transaction, collection }) => { + const { txid } = await api.todos.create( + transaction.mutations.map(({ modified }) => modified), + ) + + if (txid) await collection.utils.awaitTxId(txid, 10_000) + }, + onUpdate: async ({ transaction, collection }) => { + const { txid } = await api.todos.update( + transaction.mutations.map(({ key, changes }) => ({ id: key, changes })), + ) + + if (txid) await collection.utils.awaitTxId(txid, 10_000) + }, + onDelete: async ({ transaction, collection }) => { + const { txid } = await api.todos.delete( + transaction.mutations.map(({ key }) => key), + ) + + if (txid) await collection.utils.awaitTxId(txid, 10_000) + }, + }), +) +``` + +Each backend handler must authenticate the request, validate the batch, and scope writes to the authenticated user. Return the ID from the same Postgres transaction that performed the write, after it commits. Use `pg_current_xact_id()::text` to return the decimal string expected by `awaitTxId`; for example, include it in a write statement's `RETURNING` clause. Write each batch atomically so there is one transaction ID to wait for. See Neon's [backend mutation examples](https://neon.com/docs/realtime/guides/mutations#returning-the-txid-from-your-backend). + +Pass an explicit timeout to `awaitTxId`. Without one, it can wait until the transaction arrives or the collection is cleaned up. Keep waits bounded because TanStack DB holds synced changes while a mutation handler runs. A rejected write or failed wait rolls back the optimistic change. If the write committed but its sync confirmation timed out, it remains saved on the server and appears when sync catches up. + +With the handlers configured, use the normal collection mutation methods: + +```ts +import { todosCollection } from './collections' + +const id = crypto.randomUUID() +const inserted = todosCollection.insert({ + id, + title: 'Try Neon Realtime', + completed: false, +}) + +await inserted.when('settled') + +const updated = todosCollection.update(id, (draft) => { + draft.completed = true +}) +await updated.when('settled') + +const deleted = todosCollection.delete(id) +await deleted.when('settled') +``` + +An optimistic insert needs a complete row with its final key. Wait for a write to settle before sending another write that depends on it, or disable controls while a row's `$hasPendingWrites` is true. Handle rejections from `transaction.when('settled')` to show write errors in your UI. + +## Additional Resources + +- [Neon Realtime documentation](https://neon.com/docs/realtime/overview) +- [TanStack DB integration guide](https://neon.com/docs/realtime/guides/reactivity#tanstack-db) +- [TanStack DB SDK reference](https://neon.com/docs/realtime/sdks/typescript#tanstack-db) +- [Optimistic mutations with TanStack DB](https://neon.com/docs/realtime/guides/mutations#with-tanstack-db-collections) +- [TanStack Start example](https://neon.com/docs/realtime/examples/tanstack) +- [Live Queries guide](../guides/live-queries.md) +- [Mutations guide](../guides/mutations.md) +- [Error Handling guide](../guides/error-handling.md) diff --git a/docs/config.json b/docs/config.json index 1bc477dd48..815ef19821 100644 --- a/docs/config.json +++ b/docs/config.json @@ -71,18 +71,22 @@ { "label": "Collections", "children": [ - { - "label": "Convex Collection", - "to": "collections/convex-collection" - }, { "label": "Query Collection", "to": "collections/query-collection" }, + { + "label": "Neon Realtime Collection", + "to": "collections/neon-realtime-collection" + }, { "label": "Electric Collection", "to": "collections/electric-collection" }, + { + "label": "Convex Collection", + "to": "collections/convex-collection" + }, { "label": "TrailBase Collection", "to": "collections/trailbase-collection" diff --git a/docs/overview.md b/docs/overview.md index 66c02463b8..d0a6388026 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -221,8 +221,12 @@ TanStack DB provides several built-in collection types for different data source **Sync Collections** +- **[Neon Realtime Collection](./collections/neon-realtime-collection.md)** — Sync live SQL query results from Postgres into collections using Neon Realtime. + - **[ElectricCollection](./collections/electric-collection.md)** — Sync data into collections from Postgres using ElectricSQL's real-time sync engine. +- **[Convex Collection](./collections/convex-collection.md)** — Sync reactive Convex query results into collections. + - **[TrailBaseCollection](./collections/trailbase-collection.md)** — Sync data into collections using TrailBase's self-hosted backend with real-time subscriptions. - **[RxDBCollection](./collections/rxdb-collection.md)** — Integrate with RxDB for offline-first local persistence with powerful replication and sync capabilities. From fe7d5be995d55039b6d70fe3ac90c5d480f13ca9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 30 Oct 2026 09:34:04 -0600 Subject: [PATCH 2/3] docs: add Neon Realtime collection changeset --- .changeset/neon-realtime-collection-docs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/neon-realtime-collection-docs.md diff --git a/.changeset/neon-realtime-collection-docs.md b/.changeset/neon-realtime-collection-docs.md new file mode 100644 index 0000000000..297ed39e69 --- /dev/null +++ b/.changeset/neon-realtime-collection-docs.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Document Neon Realtime collection setup, live queries, and optimistic mutations. From e1cb715cf4b415832de9fb502d66f3306bc5c7f1 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 30 Oct 2026 09:54:38 -0600 Subject: [PATCH 3/3] docs: correct Neon collection session and write examples --- .changeset/neon-realtime-collection-docs.md | 5 - docs/collections/neon-realtime-collection.md | 213 ++++++++++++------- 2 files changed, 140 insertions(+), 78 deletions(-) delete mode 100644 .changeset/neon-realtime-collection-docs.md diff --git a/.changeset/neon-realtime-collection-docs.md b/.changeset/neon-realtime-collection-docs.md deleted file mode 100644 index 297ed39e69..0000000000 --- a/.changeset/neon-realtime-collection-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/db': patch ---- - -Document Neon Realtime collection setup, live queries, and optimistic mutations. diff --git a/docs/collections/neon-realtime-collection.md b/docs/collections/neon-realtime-collection.md index 04169a3982..8f9f01087d 100644 --- a/docs/collections/neon-realtime-collection.md +++ b/docs/collections/neon-realtime-collection.md @@ -15,14 +15,18 @@ The `@neon/realtime-tanstack` package, maintained by Neon, allows you to create Neon Realtime is currently in beta. Follow the [Neon Realtime quickstart](https://neon.com/docs/realtime/quickstart) to enable it on your branch and configure your application's allowed origins before creating a collection. +> **Version compatibility:** The published `@neon/realtime-tanstack@0.1.1` adapter supports TanStack DB **0.9 only**. It has not been updated for **1.0 RC**. The TanStack DB examples below use 1.0 RC APIs and require a compatible adapter release. For a working 0.9 integration today, follow [Neon's TanStack example](https://neon.com/docs/realtime/examples/tanstack). + ## Installation -Install the Realtime SDK, its TanStack DB adapter, and your framework integration. The examples below use React: +In an existing TanStack DB 0.9 application, install the Realtime SDK and its adapter: ```bash -npm install @neon/realtime @neon/realtime-tanstack @tanstack/react-db +npm install @neon/realtime@0.1.1 @neon/realtime-tanstack@0.1.1 ``` +Keep your framework integration on a version that depends on `@tanstack/db` 0.9. The examples below use React's `@tanstack/react-db` integration. Do not install the current Neon adapter into a 1.0 RC application. + ## Basic Usage A Realtime collection needs a client, a sealed query from your backend, and a stable key for each row. A sealed query lets the browser subscribe to an authorized SQL query without being able to change its SQL or parameters. @@ -52,6 +56,17 @@ const realtime = createRealtime({ export async function POST(request: Request) { const user = await requireUser(request) + const input: unknown = await request.json() + + if ( + typeof input !== 'object' || + input === null || + !('userId' in input) || + input.userId !== user.id + ) { + return new Response('Session changed', { status: 401 }) + } + const query = rawSql( 'SELECT id, title, completed FROM todos WHERE user_id = $1', [user.id], @@ -61,11 +76,11 @@ export async function POST(request: Request) { } ``` -Use the database name from your connection string for `db`. Keep `NEON_REALTIME_SECRET` on the server. Authorize the request before sealing, and derive user-specific parameters from the authenticated session. See Neon's [query and authorization guide](https://neon.com/docs/realtime/guides/queries) for ORM adapters and other ways to expose sealed queries. +Use the database name from your connection string for `db`. Keep `NEON_REALTIME_SECRET` on the server. Authorize the request before sealing, and derive user-specific parameters from the authenticated session. The browser's `userId` only checks that the session still belongs to the user who opened the collection; it does not grant access. See Neon's [query and authorization guide](https://neon.com/docs/realtime/guides/queries) for ORM adapters and other ways to expose sealed queries. ### Create the Collection -In browser code, create one Realtime client for your application and fetch the sealed query from your endpoint. For example, in `collections.ts`: +In browser code, create one Realtime client for your application. Export a factory that fetches the sealed query after sign-in, rather than fetching during module import. For example, in `collections.ts`: ```ts import { createCollection } from '@tanstack/react-db' @@ -78,8 +93,16 @@ const client = createRealtimeClient({ url: 'YOUR_NEON_REALTIME_URL', }) -async function fetchTodosQuery(): Promise> { - const response = await fetch('/api/todos/live', { method: 'POST' }) +async function fetchTodosQuery( + userId: string, + signal: AbortSignal, +): Promise> { + const response = await fetch('/api/todos/live', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + signal, + }) if (!response.ok) { throw new Error(`Could not fetch the todos query (${response.status})`) @@ -88,20 +111,33 @@ async function fetchTodosQuery(): Promise> { return response.json() } -export const todosCollection = createCollection( - realtimeCollectionOptions({ - id: 'todos', - client, - query: await fetchTodosQuery(), - refreshQuery: fetchTodosQuery, - getKey: (todo: Todo) => todo.id, - }), -) +export async function createTodosCollection(userId: string, signal: AbortSignal) { + const refreshQuery = () => fetchTodosQuery(userId, signal) + const query = await refreshQuery() + signal.throwIfAborted() + + return createCollection( + realtimeCollectionOptions({ + id: `todos:${userId}`, + client, + query, + refreshQuery, + getKey: (todo: Todo) => todo.id, + gcTime: 0, + }), + ) +} + +export type TodosCollection = Awaited> ``` Replace `YOUR_NEON_REALTIME_URL` with the Realtime URL for your branch. The client opens a WebSocket lazily and shares it across subscriptions. The adapter takes the client directly, so it does not need Neon's React `RealtimeProvider`. -Create the collection once for the signed-in user and reuse it across components. This example is browser-only. For server rendering, use request-scoped collections and follow the [SSR and hydration guide](../guides/ssr.md) and [Neon's TanStack Start example](https://neon.com/docs/realtime/examples/tanstack). +After sign-in, create an `AbortController` for that signed-in session and call `await createTodosCollection(user.id, controller.signal)` in your authenticated route loader or application setup. Handle setup errors there, then pass the result to components. Reuse that collection for the session. `gcTime: 0` disables automatic garbage collection so the session owner controls cleanup. + +On sign-out or a user switch, remove the collection's consumers, abort the controller, and await `todosCollection.cleanup()` before discarding it. Aborting also cancels a pending setup request; the factory checks the signal before returning a collection. Create a new controller and collection for the next session. Never reuse the previous user's collection or signal. + +This example is browser-only. For server rendering, use request-scoped collections and follow the [SSR and hydration guide](../guides/ssr.md) and [Neon's TanStack Start example](https://neon.com/docs/realtime/examples/tanstack). ## Configuration Options @@ -138,7 +174,7 @@ Sealed queries are short-lived. Provide `refreshQuery` to renew the subscription The adapter retries failed refresh requests. Once a collection is ready, it retains its last rows and stays ready while the subscription reconnects or waits for renewal. Readiness alone does not mean those rows are current. A terminal subscription error ends automatic recovery; handle sync failures in the UI as shown below. -Use your application's auth state to stop consumers and clean up user-specific collections on sign-out. A failed renewal request alone does not clear the collection's rows. Sealed queries are bearer tokens: send them over HTTPS and keep them out of URLs, logs, and persistent browser storage. +Use your application's auth state to end the session lifecycle described above on sign-out, a user switch, or a 401 renewal response. A failed renewal request alone does not clear the collection's rows. The server's user check prevents a renewal from silently switching the collection to another user's query. Sealed queries are bearer tokens: send them over HTTPS and keep them out of URLs, logs, and persistent browser storage. ## Querying the Collection @@ -147,16 +183,18 @@ Pass the collection to your components and use TanStack DB's `useLiveQuery` to d ```tsx import { eq, useLiveQuery } from '@tanstack/react-db' import type { Collection } from '@tanstack/react-db' +import type { RealtimeCollectionUtils } from '@neon/realtime-tanstack' import type { Todo } from './types' export function TodoList({ todosCollection, }: { - todosCollection: Collection + todosCollection: Collection }) { const { data: todos, isReady, + isLoading, isLoadingError, isCrashed, isSyncError, @@ -169,7 +207,8 @@ export function TodoList({ }) if (isLoadingError || isCrashed) return

Could not load todos

- if (!isReady) return

Loading todos...

+ if (isLoading) return

Loading todos...

+ if (!isReady) return null return ( <> @@ -194,74 +233,102 @@ Realtime syncs reads. Persist writes through your own backend using `onInsert`, After the backend commits a write, wait for its Postgres transaction ID with `collection.utils.awaitTxId(txid, timeout)`. This keeps the optimistic state until the corresponding synced changes have entered TanStack DB's sync queue. -For example, add these handlers to the collection options above. The application API functions shown here send each batch in one request and return `{ txid: string | null }`, with `null` when no rows changed: +In `collections.ts`, keep the existing imports, `client`, `fetchTodosQuery`, and `TodosCollection` type. Add the `api` import and error class below, and **replace** the existing `createTodosCollection` function with this writable version. The application API functions send `{ userId, batch }` in one request, pass `signal` to `fetch`, and return `{ txid: string | null }`, with `null` when no rows changed: ```ts -import { createCollection } from '@tanstack/react-db' -import { realtimeCollectionOptions } from '@neon/realtime-tanstack' import { api } from './api' -import type { Todo } from './types' -export const todosCollection = createCollection( - realtimeCollectionOptions({ - id: 'todos', - client, - query: await fetchTodosQuery(), - refreshQuery: fetchTodosQuery, - getKey: (todo: Todo) => todo.id, - onInsert: async ({ transaction, collection }) => { - const { txid } = await api.todos.create( - transaction.mutations.map(({ modified }) => modified), - ) - - if (txid) await collection.utils.awaitTxId(txid, 10_000) - }, - onUpdate: async ({ transaction, collection }) => { - const { txid } = await api.todos.update( - transaction.mutations.map(({ key, changes }) => ({ id: key, changes })), - ) - - if (txid) await collection.utils.awaitTxId(txid, 10_000) - }, - onDelete: async ({ transaction, collection }) => { - const { txid } = await api.todos.delete( - transaction.mutations.map(({ key }) => key), - ) - - if (txid) await collection.utils.awaitTxId(txid, 10_000) - }, - }), -) -``` +export class NotSyncedYetError extends Error {} -Each backend handler must authenticate the request, validate the batch, and scope writes to the authenticated user. Return the ID from the same Postgres transaction that performed the write, after it commits. Use `pg_current_xact_id()::text` to return the decimal string expected by `awaitTxId`; for example, include it in a write statement's `RETURNING` clause. Write each batch atomically so there is one transaction ID to wait for. See Neon's [backend mutation examples](https://neon.com/docs/realtime/guides/mutations#returning-the-txid-from-your-backend). +function notSyncedYet(cause: unknown): never { + throw new NotSyncedYetError('Saved, but not synced yet', { cause }) +} -Pass an explicit timeout to `awaitTxId`. Without one, it can wait until the transaction arrives or the collection is cleaned up. Keep waits bounded because TanStack DB holds synced changes while a mutation handler runs. A rejected write or failed wait rolls back the optimistic change. If the write committed but its sync confirmation timed out, it remains saved on the server and appears when sync catches up. +export async function createTodosCollection(userId: string, signal: AbortSignal) { + const refreshQuery = () => fetchTodosQuery(userId, signal) + const query = await refreshQuery() + signal.throwIfAborted() + + return createCollection( + realtimeCollectionOptions({ + id: `todos:${userId}`, + client, + query, + refreshQuery, + getKey: (todo: Todo) => todo.id, + gcTime: 0, + onInsert: async ({ transaction, collection }) => { + const { txid } = await api.todos.create({ + userId, + batch: transaction.mutations.map(({ modified }) => modified), + signal, + }) + + if (txid) { + await collection.utils.awaitTxId(txid, 10_000).catch(notSyncedYet) + } + }, + onUpdate: async ({ transaction, collection }) => { + const { txid } = await api.todos.update({ + userId, + batch: transaction.mutations.map(({ key, changes }) => ({ id: key, changes })), + signal, + }) + + if (txid) { + await collection.utils.awaitTxId(txid, 10_000).catch(notSyncedYet) + } + }, + onDelete: async ({ transaction, collection }) => { + const { txid } = await api.todos.delete({ + userId, + batch: transaction.mutations.map(({ key }) => key), + signal, + }) + + if (txid) { + await collection.utils.awaitTxId(txid, 10_000).catch(notSyncedYet) + } + }, + }), + ) +} +``` -With the handlers configured, use the normal collection mutation methods: +Each backend handler must authenticate the request, reject a `userId` that differs from the session (as the sealing endpoint does), validate the batch, and scope writes to the authenticated user. Return the ID from the same Postgres transaction that performed the write, after it commits. Use `pg_current_xact_id()::text` to return the full 64-bit decimal string expected by Neon; for example, include it in a write statement's `RETURNING` clause. Write each batch atomically so there is one transaction ID to wait for. See Neon's [backend mutation examples](https://neon.com/docs/realtime/guides/mutations#returning-the-txid-from-your-backend). -```ts -import { todosCollection } from './collections' +Pass an explicit timeout to `awaitTxId`. Without one, it can wait until the transaction arrives or the collection is cleaned up. Keep waits bounded because TanStack DB holds synced changes while a mutation handler runs. A rejected write or failed wait rolls back the optimistic change. -const id = crypto.randomUUID() -const inserted = todosCollection.insert({ - id, - title: 'Try Neon Realtime', - completed: false, -}) +Once the backend returns a committed txid, a failed wait means the saved write is unconfirmed by sync. `NotSyncedYetError` preserves that distinction: show a saved-but-waiting notice and let sync catch up. Do not automatically retry the write. If your application supports retries after an ambiguous network failure, make backend writes idempotent with a stable request ID or row key. -await inserted.when('settled') +With the handlers configured, use the collection passed by your session owner. For example, in `addTodo.ts`: -const updated = todosCollection.update(id, (draft) => { - draft.completed = true -}) -await updated.when('settled') +```ts +import { NotSyncedYetError } from './collections' +import type { TodosCollection } from './collections' + +export async function addTodo(todosCollection: TodosCollection) { + const transaction = todosCollection.insert({ + id: crypto.randomUUID(), + title: 'Try Neon Realtime', + completed: false, + }) -const deleted = todosCollection.delete(id) -await deleted.when('settled') + try { + await transaction.when('settled') + return 'Saved' + } catch (error) { + if (error instanceof NotSyncedYetError) { + return 'Saved. It will appear when live updates reconnect.' + } + throw error + } +} ``` -An optimistic insert needs a complete row with its final key. Wait for a write to settle before sending another write that depends on it, or disable controls while a row's `$hasPendingWrites` is true. Handle rejections from `transaction.when('settled')` to show write errors in your UI. +Render the returned notice in your UI and handle other rejections as write errors. Use `todosCollection.update(id, (draft) => { draft.completed = true })` and `todosCollection.delete(id)` for updates and deletes, with the same settlement handling. + +An optimistic insert needs a complete row with its final key. Wait for a write to settle successfully before sending another write that depends on it, or disable controls while a row's `$hasPendingWrites` is true. After a `NotSyncedYetError`, wait for sync to show the saved row before sending a dependent write. ## Additional Resources