Skip to content

Commit 07f5a6f

Browse files
authored
Merge branch 'main' into kevin/pred-pushdown-query-coll
2 parents 8c72581 + 4a7c44a commit 07f5a6f

70 files changed

Lines changed: 6965 additions & 401 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/floppy-wings-mix.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/large-otters-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Stop pushing where clauses that target renamed subquery projections so alias remapping stays intact, preventing a bug where a where clause would not be executed correctly.

docs/collections/electric-collection.md

Lines changed: 255 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,21 @@ The `electricCollectionOptions` function accepts the following options:
5454

5555
### Persistence Handlers
5656

57+
Handlers are called before mutations to persist changes to your backend:
58+
5759
- `onInsert`: Handler called before insert operations
58-
- `onUpdate`: Handler called before update operations
60+
- `onUpdate`: Handler called before update operations
5961
- `onDelete`: Handler called before delete operations
6062

61-
## Persistence Handlers
63+
Each handler should return `{ txid }` to wait for synchronization. For cases where your API can not return txids, use the `awaitMatch` utility function.
64+
65+
## Persistence Handlers & Synchronization
66+
67+
Handlers persist mutations to the backend and wait for Electric to sync the changes back. This prevents UI glitches where optimistic updates would be removed and then re-added. TanStack DB blocks sync data until the mutation is confirmed, ensuring smooth user experience.
6268

63-
Handlers can be defined to run on mutations. They are useful to send mutations to the backend and confirming them once Electric delivers the corresponding transactions. Until confirmation, TanStack DB blocks sync data for the collection to prevent race conditions. To avoid any delays, it’s important to use a matching strategy.
69+
### 1. Using Txid (Recommended)
6470

65-
The most reliable strategy is for the backend to include the transaction ID (txid) in its response, allowing the client to match each mutation with Electric’s transaction identifiers for precise confirmation. If no strategy is provided, client mutations are automatically confirmed after three seconds.
71+
The recommended approach uses PostgreSQL transaction IDs (txids) for precise matching. The backend returns a txid, and the client waits for that specific txid to appear in the Electric stream.
6672

6773
```typescript
6874
const todosCollection = createCollection(
@@ -74,15 +80,83 @@ const todosCollection = createCollection(
7480
url: '/api/todos',
7581
params: { table: 'todos' },
7682
},
77-
83+
7884
onInsert: async ({ transaction }) => {
7985
const newItem = transaction.mutations[0].modified
8086
const response = await api.todos.create(newItem)
81-
87+
88+
// Return txid to wait for sync
8289
return { txid: response.txid }
8390
},
84-
85-
// you can also implement onUpdate and onDelete handlers
91+
92+
onUpdate: async ({ transaction }) => {
93+
const { original, changes } = transaction.mutations[0]
94+
const response = await api.todos.update({
95+
where: { id: original.id },
96+
data: changes
97+
})
98+
99+
return { txid: response.txid }
100+
}
101+
})
102+
)
103+
```
104+
105+
### 2. Using Custom Match Functions
106+
107+
For cases where txids aren't available, use the `awaitMatch` utility function to wait for synchronization with custom matching logic:
108+
109+
```typescript
110+
import { isChangeMessage } from '@tanstack/electric-db-collection'
111+
112+
const todosCollection = createCollection(
113+
electricCollectionOptions({
114+
id: 'todos',
115+
getKey: (item) => item.id,
116+
shapeOptions: {
117+
url: '/api/todos',
118+
params: { table: 'todos' },
119+
},
120+
121+
onInsert: async ({ transaction, collection }) => {
122+
const newItem = transaction.mutations[0].modified
123+
await api.todos.create(newItem)
124+
125+
// Use awaitMatch utility for custom matching
126+
await collection.utils.awaitMatch(
127+
(message) => {
128+
return isChangeMessage(message) &&
129+
message.headers.operation === 'insert' &&
130+
message.value.text === newItem.text
131+
},
132+
5000 // timeout in ms (optional, defaults to 3000)
133+
)
134+
}
135+
})
136+
)
137+
```
138+
139+
### 3. Using Simple Timeout
140+
141+
For quick prototyping or when you're confident about timing, you can use a simple timeout. This is crude but works as almost always the data will be synced back in under 2 seconds:
142+
143+
```typescript
144+
const todosCollection = createCollection(
145+
electricCollectionOptions({
146+
id: 'todos',
147+
getKey: (item) => item.id,
148+
shapeOptions: {
149+
url: '/api/todos',
150+
params: { table: 'todos' },
151+
},
152+
153+
onInsert: async ({ transaction }) => {
154+
const newItem = transaction.mutations[0].modified
155+
await api.todos.create(newItem)
156+
157+
// Simple timeout approach
158+
await new Promise(resolve => setTimeout(resolve, 2000))
159+
}
86160
})
87161
)
88162
```
@@ -162,7 +236,9 @@ export const ServerRoute = createServerFileRoute("/api/todos").methods({
162236

163237
## Optimistic Updates with Explicit Transactions
164238

165-
For more advanced use cases, you can create custom actions that can do multiple mutations across collections transactionally. In this case, you need to explicitly await for the transaction ID using `utils.awaitTxId()`.
239+
For more advanced use cases, you can create custom actions that can do multiple mutations across collections transactionally. You can use the utility methods to wait for synchronization with different strategies:
240+
241+
### Using Txid Strategy
166242

167243
```typescript
168244
const addTodoAction = createOptimisticAction({
@@ -184,19 +260,187 @@ const addTodoAction = createOptimisticAction({
184260
data: { text, completed: false }
185261
})
186262

263+
// Wait for the specific txid
187264
await todosCollection.utils.awaitTxId(response.txid)
188265
}
189266
})
190267
```
191268

269+
### Using Custom Match Function
270+
271+
```typescript
272+
import { isChangeMessage } from '@tanstack/electric-db-collection'
273+
274+
const addTodoAction = createOptimisticAction({
275+
onMutate: ({ text }) => {
276+
const tempId = crypto.randomUUID()
277+
todosCollection.insert({
278+
id: tempId,
279+
text,
280+
completed: false,
281+
created_at: new Date(),
282+
})
283+
},
284+
285+
mutationFn: async ({ text }) => {
286+
await api.todos.create({
287+
data: { text, completed: false }
288+
})
289+
290+
// Wait for matching message
291+
await todosCollection.utils.awaitMatch(
292+
(message) => {
293+
return isChangeMessage(message) &&
294+
message.headers.operation === 'insert' &&
295+
message.value.text === text
296+
}
297+
)
298+
}
299+
})
300+
```
301+
192302
## Utility Methods
193303

194304
The collection provides these utility methods via `collection.utils`:
195305

196-
- `awaitTxId(txid, timeout?)`: Manually wait for a specific transaction ID to be synchronized
306+
### `awaitTxId(txid, timeout?)`
307+
308+
Manually wait for a specific transaction ID to be synchronized:
197309

198310
```typescript
199-
todosCollection.utils.awaitTxId(12345)
311+
// Wait for specific txid
312+
await todosCollection.utils.awaitTxId(12345)
313+
314+
// With custom timeout (default is 30 seconds)
315+
await todosCollection.utils.awaitTxId(12345, 10000)
200316
```
201317

202318
This is useful when you need to ensure a mutation has been synchronized before proceeding with other operations.
319+
320+
### `awaitMatch(matchFn, timeout?)`
321+
322+
Manually wait for a custom match function to find a matching message:
323+
324+
```typescript
325+
import { isChangeMessage } from '@tanstack/electric-db-collection'
326+
327+
// Wait for a specific message pattern
328+
await todosCollection.utils.awaitMatch(
329+
(message) => {
330+
return isChangeMessage(message) &&
331+
message.headers.operation === 'insert' &&
332+
message.value.text === 'New Todo'
333+
},
334+
5000 // timeout in ms
335+
)
336+
```
337+
338+
### Helper Functions
339+
340+
The package exports helper functions for use in custom match functions:
341+
342+
- `isChangeMessage(message)`: Check if a message is a data change (insert/update/delete)
343+
- `isControlMessage(message)`: Check if a message is a control message (up-to-date, must-refetch)
344+
345+
```typescript
346+
import { isChangeMessage, isControlMessage } from '@tanstack/electric-db-collection'
347+
348+
// Use in custom match functions
349+
const matchFn = (message) => {
350+
if (isChangeMessage(message)) {
351+
return message.headers.operation === 'insert'
352+
}
353+
return false
354+
}
355+
```
356+
357+
## Debugging
358+
359+
### Common Issue: awaitTxId Stalls or Times Out
360+
361+
A frequent issue developers encounter is that `awaitTxId` (or the transaction's `isPersisted.promise`) stalls indefinitely, eventually timing out with no error messages. The data persists correctly to the database, but the optimistic mutation never resolves.
362+
363+
**Root Cause:** This happens when the transaction ID (txid) returned from your API doesn't match the actual transaction ID of the mutation in Postgres. This mismatch occurs when you query `pg_current_xact_id()` **outside** the same transaction that performs the mutation.
364+
365+
### Enable Debug Logging
366+
367+
To diagnose txid issues, enable debug logging in your browser console:
368+
369+
```javascript
370+
localStorage.debug = 'ts/db:electric'
371+
```
372+
373+
This will show you when mutations start waiting for txids and when txids arrive from Electric's sync stream.
374+
375+
This is powered by the [debug](https://www.npmjs.com/package/debug) package.
376+
377+
**When txids DON'T match (common bug):**
378+
```
379+
ts/db:electric awaitTxId called with txid 124
380+
ts/db:electric new txids synced from pg [123]
381+
// Stalls forever - 124 never arrives!
382+
```
383+
384+
In this example, the mutation happened in transaction 123, but you queried `pg_current_xact_id()` in a separate transaction (124) that ran after the mutation. The client waits for 124 which will never arrive.
385+
386+
**When txids DO match (correct):**
387+
```
388+
ts/db:electric awaitTxId called with txid 123
389+
ts/db:electric new txids synced from pg [123]
390+
ts/db:electric awaitTxId found match for txid 123
391+
// Resolves immediately!
392+
```
393+
394+
### The Solution: Query txid Inside the Transaction
395+
396+
You **must** call `pg_current_xact_id()` inside the same transaction as your mutation:
397+
398+
**❌ Wrong - txid queried outside transaction:**
399+
```typescript
400+
// DON'T DO THIS
401+
async function createTodo(data) {
402+
const txid = await generateTxId(sql) // Wrong: separate transaction
403+
404+
await sql.begin(async (tx) => {
405+
await tx`INSERT INTO todos ${tx(data)}`
406+
})
407+
408+
return { txid } // This txid won't match!
409+
}
410+
```
411+
412+
**✅ Correct - txid queried inside transaction:**
413+
```typescript
414+
// DO THIS
415+
async function createTodo(data) {
416+
let txid!: Txid
417+
418+
const result = await sql.begin(async (tx) => {
419+
// Call generateTxId INSIDE the transaction
420+
txid = await generateTxId(tx)
421+
422+
const [todo] = await tx`
423+
INSERT INTO todos ${tx(data)}
424+
RETURNING *
425+
`
426+
return todo
427+
})
428+
429+
return { todo: result, txid } // txid matches the mutation
430+
}
431+
432+
async function generateTxId(tx: any): Promise<Txid> {
433+
const result = await tx`SELECT pg_current_xact_id()::xid::text as txid`
434+
const txid = result[0]?.txid
435+
436+
if (txid === undefined) {
437+
throw new Error(`Failed to get transaction ID`)
438+
}
439+
440+
return parseInt(txid, 10)
441+
}
442+
```
443+
444+
See working examples in:
445+
- `examples/react/todo/src/routes/api/todos.ts`
446+
- `examples/react/todo/src/api/server.ts`

0 commit comments

Comments
 (0)