@tanstack/db 0.5.30 → 0.5.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.cjs +10 -10
- package/dist/esm/index.js +2 -2
- package/package.json +3 -2
- package/skills/db-core/SKILL.md +61 -0
- package/skills/db-core/collection-setup/SKILL.md +427 -0
- package/skills/db-core/collection-setup/references/electric-adapter.md +238 -0
- package/skills/db-core/collection-setup/references/local-adapters.md +220 -0
- package/skills/db-core/collection-setup/references/powersync-adapter.md +241 -0
- package/skills/db-core/collection-setup/references/query-adapter.md +183 -0
- package/skills/db-core/collection-setup/references/rxdb-adapter.md +152 -0
- package/skills/db-core/collection-setup/references/schema-patterns.md +215 -0
- package/skills/db-core/collection-setup/references/trailbase-adapter.md +147 -0
- package/skills/db-core/custom-adapter/SKILL.md +285 -0
- package/skills/db-core/live-queries/SKILL.md +332 -0
- package/skills/db-core/live-queries/references/operators.md +302 -0
- package/skills/db-core/mutations-optimistic/SKILL.md +375 -0
- package/skills/db-core/mutations-optimistic/references/transaction-api.md +207 -0
- package/skills/meta-framework/SKILL.md +361 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Query Operators Reference
|
|
2
|
+
|
|
3
|
+
All operators are imported from `@tanstack/db` (also re-exported by `@tanstack/react-db` and other framework packages).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
// Comparison
|
|
8
|
+
eq,
|
|
9
|
+
gt,
|
|
10
|
+
gte,
|
|
11
|
+
lt,
|
|
12
|
+
lte,
|
|
13
|
+
like,
|
|
14
|
+
ilike,
|
|
15
|
+
inArray,
|
|
16
|
+
isNull,
|
|
17
|
+
isUndefined,
|
|
18
|
+
// Logical
|
|
19
|
+
and,
|
|
20
|
+
or,
|
|
21
|
+
not,
|
|
22
|
+
// Aggregate
|
|
23
|
+
count,
|
|
24
|
+
sum,
|
|
25
|
+
avg,
|
|
26
|
+
min,
|
|
27
|
+
max,
|
|
28
|
+
// String
|
|
29
|
+
upper,
|
|
30
|
+
lower,
|
|
31
|
+
length,
|
|
32
|
+
concat,
|
|
33
|
+
// Math
|
|
34
|
+
add,
|
|
35
|
+
// Utility
|
|
36
|
+
coalesce,
|
|
37
|
+
} from '@tanstack/db'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Comparison Operators
|
|
43
|
+
|
|
44
|
+
### eq(left, right) -> BasicExpression\<boolean\>
|
|
45
|
+
|
|
46
|
+
Equality comparison. Works with any type.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
eq(user.id, 1)
|
|
50
|
+
eq(user.name, 'Alice')
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### not(eq(left, right)) — not-equal pattern
|
|
54
|
+
|
|
55
|
+
There is no `ne` operator. Use `not(eq(...))` for not-equal:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
not(eq(user.role, 'banned'))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### gt, gte, lt, lte (left, right) -> BasicExpression\<boolean\>
|
|
62
|
+
|
|
63
|
+
Ordering comparisons. Work with numbers, strings, dates.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
gt(user.age, 18) // greater than
|
|
67
|
+
gte(user.salary, 50000) // greater than or equal
|
|
68
|
+
lt(user.age, 65) // less than
|
|
69
|
+
lte(user.rating, 5) // less than or equal
|
|
70
|
+
gt(user.createdAt, new Date('2024-01-01'))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### like(left, right) -> BasicExpression\<boolean\>
|
|
74
|
+
|
|
75
|
+
Case-sensitive string pattern matching. Use `%` as wildcard.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
like(user.name, 'John%') // starts with John
|
|
79
|
+
like(user.email, '%@corp.com') // ends with @corp.com
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### ilike(left, right) -> BasicExpression\<boolean\>
|
|
83
|
+
|
|
84
|
+
Case-insensitive string pattern matching.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
ilike(user.email, '%@gmail.com')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### inArray(value, array) -> BasicExpression\<boolean\>
|
|
91
|
+
|
|
92
|
+
Check if value is contained in an array.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
inArray(user.id, [1, 2, 3])
|
|
96
|
+
inArray(user.role, ['admin', 'moderator'])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### isNull(value) -> BasicExpression\<boolean\>
|
|
100
|
+
|
|
101
|
+
Check if value is explicitly `null`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
isNull(user.bio)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### isUndefined(value) -> BasicExpression\<boolean\>
|
|
108
|
+
|
|
109
|
+
Check if value is `undefined` (absent). Especially useful after left joins where unmatched rows produce `undefined`.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
isUndefined(profile) // no matching profile in left join
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Logical Operators
|
|
118
|
+
|
|
119
|
+
### and(...conditions) -> BasicExpression\<boolean\>
|
|
120
|
+
|
|
121
|
+
Combine two or more conditions with AND logic.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
and(eq(user.active, true), gt(user.age, 18))
|
|
125
|
+
and(eq(user.active, true), gt(user.age, 18), eq(user.role, 'user'))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### or(...conditions) -> BasicExpression\<boolean\>
|
|
129
|
+
|
|
130
|
+
Combine two or more conditions with OR logic.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
or(eq(user.role, 'admin'), eq(user.role, 'moderator'))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### not(condition) -> BasicExpression\<boolean\>
|
|
137
|
+
|
|
138
|
+
Negate a condition.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
not(eq(user.active, false))
|
|
142
|
+
not(inArray(user.id, bannedIds))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Aggregate Functions
|
|
148
|
+
|
|
149
|
+
Used inside `.select()` with `.groupBy()`, or without `groupBy` to aggregate the entire collection as one group.
|
|
150
|
+
|
|
151
|
+
### count(value) -> Aggregate\<number\>
|
|
152
|
+
|
|
153
|
+
Count non-null values in a group.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
count(user.id)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### sum(value), avg(value) -> Aggregate\<number | null | undefined\>
|
|
160
|
+
|
|
161
|
+
Sum or average of numeric values.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
sum(order.amount)
|
|
165
|
+
avg(user.salary)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### min(value), max(value) -> Aggregate\<T\>
|
|
169
|
+
|
|
170
|
+
Minimum/maximum value (numbers, strings, dates).
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
min(order.amount)
|
|
174
|
+
max(user.createdAt)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## String Functions
|
|
180
|
+
|
|
181
|
+
### upper(value), lower(value) -> BasicExpression\<string\>
|
|
182
|
+
|
|
183
|
+
Convert string case.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
upper(user.name) // 'ALICE'
|
|
187
|
+
lower(user.email) // 'alice@example.com'
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### length(value) -> BasicExpression\<number\>
|
|
191
|
+
|
|
192
|
+
Get string or array length.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
length(user.name) // string length
|
|
196
|
+
length(user.tags) // array length
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### concat(...values) -> BasicExpression\<string\>
|
|
200
|
+
|
|
201
|
+
Concatenate any number of values into a string.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
concat(user.firstName, ' ', user.lastName)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Math Functions
|
|
210
|
+
|
|
211
|
+
### add(left, right) -> BasicExpression\<number\>
|
|
212
|
+
|
|
213
|
+
Add two numeric values.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
add(order.price, order.tax)
|
|
217
|
+
add(user.salary, coalesce(user.bonus, 0))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Utility Functions
|
|
223
|
+
|
|
224
|
+
### coalesce(...values) -> BasicExpression\<any\>
|
|
225
|
+
|
|
226
|
+
Return the first non-null, non-undefined value.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
coalesce(user.displayName, user.name, 'Unknown')
|
|
230
|
+
coalesce(user.bonus, 0)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## $selected Namespace
|
|
236
|
+
|
|
237
|
+
When a query has a `.select()` clause, the `$selected` namespace becomes available in `.orderBy()` and `.having()` callbacks. It provides access to the computed/aggregated fields defined in `select`.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
q.from({ order: ordersCollection })
|
|
241
|
+
.groupBy(({ order }) => order.customerId)
|
|
242
|
+
.select(({ order }) => ({
|
|
243
|
+
customerId: order.customerId,
|
|
244
|
+
totalSpent: sum(order.amount),
|
|
245
|
+
orderCount: count(order.id),
|
|
246
|
+
}))
|
|
247
|
+
.having(({ $selected }) => gt($selected.totalSpent, 1000))
|
|
248
|
+
.orderBy(({ $selected }) => $selected.totalSpent, 'desc')
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`$selected` is only available when `.select()` (or `.fn.select()`) has been called on the query.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Functional Variants (fn.select, fn.where, fn.having)
|
|
256
|
+
|
|
257
|
+
Escape hatches for logic that cannot be expressed with declarative operators. These execute arbitrary JS on each row but **cannot be optimized** by the query compiler (no predicate push-down, no index use).
|
|
258
|
+
|
|
259
|
+
### fn.select(callback)
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
q.from({ user: usersCollection }).fn.select((row) => ({
|
|
263
|
+
id: row.user.id,
|
|
264
|
+
domain: row.user.email.split('@')[1],
|
|
265
|
+
tier: row.user.salary > 100000 ? 'senior' : 'junior',
|
|
266
|
+
}))
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Limitation**: `fn.select()` cannot be used with `groupBy()`. The compiler must statically analyze select to discover aggregate functions.
|
|
270
|
+
|
|
271
|
+
### fn.where(callback)
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
q.from({ user: usersCollection }).fn.where(
|
|
275
|
+
(row) => row.user.active && row.user.email.endsWith('@company.com'),
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### fn.having(callback)
|
|
280
|
+
|
|
281
|
+
Receives `$selected` when a `select()` clause exists.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
q.from({ order: ordersCollection })
|
|
285
|
+
.groupBy(({ order }) => order.customerId)
|
|
286
|
+
.select(({ order }) => ({
|
|
287
|
+
customerId: order.customerId,
|
|
288
|
+
totalSpent: sum(order.amount),
|
|
289
|
+
orderCount: count(order.id),
|
|
290
|
+
}))
|
|
291
|
+
.fn.having(
|
|
292
|
+
({ $selected }) => $selected.totalSpent > 1000 && $selected.orderCount >= 3,
|
|
293
|
+
)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### When to use functional variants
|
|
297
|
+
|
|
298
|
+
- String manipulation not covered by `upper`/`lower`/`concat`/`like` (e.g., `split`, `slice`, regex)
|
|
299
|
+
- Complex conditional logic (ternaries, multi-branch)
|
|
300
|
+
- External function calls or lookups
|
|
301
|
+
|
|
302
|
+
Prefer declarative operators whenever possible for incremental maintenance.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: db-core/mutations-optimistic
|
|
3
|
+
description: >
|
|
4
|
+
collection.insert, collection.update (Immer-style draft proxy),
|
|
5
|
+
collection.delete. createOptimisticAction (onMutate + mutationFn).
|
|
6
|
+
createPacedMutations with debounceStrategy, throttleStrategy, queueStrategy.
|
|
7
|
+
createTransaction, getActiveTransaction, ambient transaction context.
|
|
8
|
+
Transaction lifecycle (pending/persisting/completed/failed). Mutation merging.
|
|
9
|
+
onInsert/onUpdate/onDelete handlers. PendingMutation type. Transaction.isPersisted.
|
|
10
|
+
type: sub-skill
|
|
11
|
+
library: db
|
|
12
|
+
library_version: '0.5.30'
|
|
13
|
+
sources:
|
|
14
|
+
- 'TanStack/db:docs/guides/mutations.md'
|
|
15
|
+
- 'TanStack/db:packages/db/src/transactions.ts'
|
|
16
|
+
- 'TanStack/db:packages/db/src/optimistic-action.ts'
|
|
17
|
+
- 'TanStack/db:packages/db/src/paced-mutations.ts'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Mutations & Optimistic State
|
|
21
|
+
|
|
22
|
+
> **Depends on:** `db-core/collection-setup` -- you need a configured collection
|
|
23
|
+
> (with `getKey`, sync adapter, and optionally `onInsert`/`onUpdate`/`onDelete`
|
|
24
|
+
> handlers) before you can mutate.
|
|
25
|
+
|
|
26
|
+
TanStack DB mutations follow a unidirectional loop:
|
|
27
|
+
**optimistic mutation -> handler persists to backend -> sync back -> confirmed state**.
|
|
28
|
+
Optimistic state is applied in the current tick and dropped when the handler resolves.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Setup -- Collection Write Operations
|
|
33
|
+
|
|
34
|
+
### insert
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// Single item
|
|
38
|
+
todoCollection.insert({
|
|
39
|
+
id: crypto.randomUUID(),
|
|
40
|
+
text: 'Buy groceries',
|
|
41
|
+
completed: false,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Multiple items
|
|
45
|
+
todoCollection.insert([
|
|
46
|
+
{ id: crypto.randomUUID(), text: 'Buy groceries', completed: false },
|
|
47
|
+
{ id: crypto.randomUUID(), text: 'Walk dog', completed: false },
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
// With metadata / non-optimistic
|
|
51
|
+
todoCollection.insert(item, { metadata: { source: 'import' } })
|
|
52
|
+
todoCollection.insert(item, { optimistic: false })
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### update (Immer-style draft proxy)
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// Single item -- mutate the draft, do NOT reassign it
|
|
59
|
+
todoCollection.update(todo.id, (draft) => {
|
|
60
|
+
draft.completed = true
|
|
61
|
+
draft.completedAt = new Date()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Multiple items
|
|
65
|
+
todoCollection.update([id1, id2], (drafts) => {
|
|
66
|
+
drafts.forEach((d) => {
|
|
67
|
+
d.completed = true
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// With metadata
|
|
72
|
+
todoCollection.update(
|
|
73
|
+
todo.id,
|
|
74
|
+
{ metadata: { reason: 'user-edit' } },
|
|
75
|
+
(draft) => {
|
|
76
|
+
draft.text = 'Updated'
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### delete
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
todoCollection.delete(todo.id)
|
|
85
|
+
todoCollection.delete([id1, id2])
|
|
86
|
+
todoCollection.delete(todo.id, { metadata: { reason: 'completed' } })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
All three return a `Transaction` object. Use `tx.isPersisted.promise` to await
|
|
90
|
+
persistence or catch rollback errors.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Core Patterns
|
|
95
|
+
|
|
96
|
+
### 1. createOptimisticAction -- intent-based mutations
|
|
97
|
+
|
|
98
|
+
Use when the optimistic change is a _guess_ at how the server will transform
|
|
99
|
+
the data, or when you need to mutate multiple collections atomically.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createOptimisticAction } from '@tanstack/db'
|
|
103
|
+
|
|
104
|
+
const likePost = createOptimisticAction<string>({
|
|
105
|
+
// MUST be synchronous -- applied in the current tick
|
|
106
|
+
onMutate: (postId) => {
|
|
107
|
+
postCollection.update(postId, (draft) => {
|
|
108
|
+
draft.likeCount += 1
|
|
109
|
+
draft.likedByMe = true
|
|
110
|
+
})
|
|
111
|
+
},
|
|
112
|
+
mutationFn: async (postId, { transaction }) => {
|
|
113
|
+
await api.posts.like(postId)
|
|
114
|
+
// IMPORTANT: wait for server state to sync back before returning
|
|
115
|
+
await postCollection.utils.refetch()
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Returns a Transaction
|
|
120
|
+
const tx = likePost(postId)
|
|
121
|
+
await tx.isPersisted.promise
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Multi-collection example:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const createProject = createOptimisticAction<{ name: string; ownerId: string }>(
|
|
128
|
+
{
|
|
129
|
+
onMutate: ({ name, ownerId }) => {
|
|
130
|
+
projectCollection.insert({ id: crypto.randomUUID(), name, ownerId })
|
|
131
|
+
userCollection.update(ownerId, (d) => {
|
|
132
|
+
d.projectCount += 1
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
mutationFn: async ({ name, ownerId }) => {
|
|
136
|
+
await api.projects.create({ name, ownerId })
|
|
137
|
+
await Promise.all([
|
|
138
|
+
projectCollection.utils.refetch(),
|
|
139
|
+
userCollection.utils.refetch(),
|
|
140
|
+
])
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 2. createPacedMutations -- auto-save with debounce / throttle / queue
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createPacedMutations, debounceStrategy } from '@tanstack/db'
|
|
150
|
+
|
|
151
|
+
const autoSaveNote = createPacedMutations<string>({
|
|
152
|
+
onMutate: (text) => {
|
|
153
|
+
noteCollection.update(noteId, (draft) => {
|
|
154
|
+
draft.body = text
|
|
155
|
+
})
|
|
156
|
+
},
|
|
157
|
+
mutationFn: async ({ transaction }) => {
|
|
158
|
+
const mutation = transaction.mutations[0]
|
|
159
|
+
await api.notes.update(mutation.key, mutation.changes)
|
|
160
|
+
await noteCollection.utils.refetch()
|
|
161
|
+
},
|
|
162
|
+
strategy: debounceStrategy({ wait: 500 }),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Each call resets the debounce timer; mutations merge into one transaction
|
|
166
|
+
autoSaveNote('Hello')
|
|
167
|
+
autoSaveNote('Hello, world') // only this version persists
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Other strategies:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { throttleStrategy, queueStrategy } from '@tanstack/db'
|
|
174
|
+
|
|
175
|
+
// Evenly spaced (sliders, scroll)
|
|
176
|
+
throttleStrategy({ wait: 200, leading: true, trailing: true })
|
|
177
|
+
|
|
178
|
+
// Sequential FIFO -- every mutation persisted in order
|
|
179
|
+
queueStrategy({ wait: 0, maxSize: 100 })
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 3. createTransaction -- manual batching
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { createTransaction } from '@tanstack/db'
|
|
186
|
+
|
|
187
|
+
const tx = createTransaction({
|
|
188
|
+
autoCommit: false, // wait for explicit commit()
|
|
189
|
+
mutationFn: async ({ transaction }) => {
|
|
190
|
+
await api.batchUpdate(transaction.mutations)
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
tx.mutate(() => {
|
|
195
|
+
todoCollection.update(id1, (d) => {
|
|
196
|
+
d.status = 'reviewed'
|
|
197
|
+
})
|
|
198
|
+
todoCollection.update(id2, (d) => {
|
|
199
|
+
d.status = 'reviewed'
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// User reviews... then commits or rolls back
|
|
204
|
+
await tx.commit()
|
|
205
|
+
// OR: tx.rollback()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Inside `tx.mutate(() => { ... })`, the transaction is pushed onto an ambient
|
|
209
|
+
stack. Any `collection.insert/update/delete` call joins the ambient transaction
|
|
210
|
+
automatically via `getActiveTransaction()`.
|
|
211
|
+
|
|
212
|
+
### 4. Mutation handler with refetch (QueryCollection pattern)
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const todoCollection = createCollection(
|
|
216
|
+
queryCollectionOptions({
|
|
217
|
+
queryKey: ['todos'],
|
|
218
|
+
queryFn: () => api.todos.getAll(),
|
|
219
|
+
getKey: (t) => t.id,
|
|
220
|
+
onInsert: async ({ transaction }) => {
|
|
221
|
+
await Promise.all(
|
|
222
|
+
transaction.mutations.map((m) => api.todos.create(m.modified)),
|
|
223
|
+
)
|
|
224
|
+
// IMPORTANT: handler must not resolve until server state is synced back
|
|
225
|
+
// QueryCollection auto-refetches after handler completes
|
|
226
|
+
},
|
|
227
|
+
onUpdate: async ({ transaction }) => {
|
|
228
|
+
await Promise.all(
|
|
229
|
+
transaction.mutations.map((m) =>
|
|
230
|
+
api.todos.update(m.original.id, m.changes),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
},
|
|
234
|
+
onDelete: async ({ transaction }) => {
|
|
235
|
+
await Promise.all(
|
|
236
|
+
transaction.mutations.map((m) => api.todos.delete(m.original.id)),
|
|
237
|
+
)
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
For ElectricCollection, return `{ txid }` instead of refetching:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
onUpdate: async ({ transaction }) => {
|
|
247
|
+
const txids = await Promise.all(
|
|
248
|
+
transaction.mutations.map(async (m) => {
|
|
249
|
+
const res = await api.todos.update(m.original.id, m.changes)
|
|
250
|
+
return res.txid
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
return { txid: txids }
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Common Mistakes
|
|
260
|
+
|
|
261
|
+
### CRITICAL: Passing an object to update() instead of a draft callback
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// WRONG -- silently fails or throws
|
|
265
|
+
collection.update(id, { ...item, title: 'new' })
|
|
266
|
+
|
|
267
|
+
// CORRECT -- mutate the draft proxy
|
|
268
|
+
collection.update(id, (draft) => {
|
|
269
|
+
draft.title = 'new'
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### CRITICAL: Hallucinating mutation API signatures
|
|
274
|
+
|
|
275
|
+
The most common AI-generated errors:
|
|
276
|
+
|
|
277
|
+
- Inventing handler signatures (e.g. `onMutate` on a collection config)
|
|
278
|
+
- Confusing `createOptimisticAction` with `createTransaction`
|
|
279
|
+
- Wrong PendingMutation property names (`mutation.data` does not exist --
|
|
280
|
+
use `mutation.modified`, `mutation.changes`, `mutation.original`)
|
|
281
|
+
- Missing the ambient transaction pattern
|
|
282
|
+
|
|
283
|
+
Always reference the exact types in `references/transaction-api.md`.
|
|
284
|
+
|
|
285
|
+
### CRITICAL: onMutate returning a Promise
|
|
286
|
+
|
|
287
|
+
`onMutate` in `createOptimisticAction` **must be synchronous**. Optimistic state
|
|
288
|
+
is applied in the current tick. Returning a Promise throws
|
|
289
|
+
`OnMutateMustBeSynchronousError`.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// WRONG
|
|
293
|
+
createOptimisticAction({
|
|
294
|
+
onMutate: async (text) => {
|
|
295
|
+
collection.insert({ id: await generateId(), text })
|
|
296
|
+
},
|
|
297
|
+
...
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// CORRECT
|
|
301
|
+
createOptimisticAction({
|
|
302
|
+
onMutate: (text) => {
|
|
303
|
+
collection.insert({ id: crypto.randomUUID(), text })
|
|
304
|
+
},
|
|
305
|
+
...
|
|
306
|
+
})
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### CRITICAL: Mutations without handler or ambient transaction
|
|
310
|
+
|
|
311
|
+
Collection mutations require either:
|
|
312
|
+
|
|
313
|
+
1. An `onInsert`/`onUpdate`/`onDelete` handler on the collection, OR
|
|
314
|
+
2. An ambient transaction from `createTransaction`/`createOptimisticAction`
|
|
315
|
+
|
|
316
|
+
Without either, throws `MissingInsertHandlerError` (or the Update/Delete variant).
|
|
317
|
+
|
|
318
|
+
### HIGH: Calling .mutate() after transaction is no longer pending
|
|
319
|
+
|
|
320
|
+
Transactions only accept new mutations while in `pending` state. Calling
|
|
321
|
+
`mutate()` after `commit()` or `rollback()` throws
|
|
322
|
+
`TransactionNotPendingMutateError`. Create a new transaction instead.
|
|
323
|
+
|
|
324
|
+
### HIGH: Changing primary key via update
|
|
325
|
+
|
|
326
|
+
The update proxy detects key changes and throws `KeyUpdateNotAllowedError`.
|
|
327
|
+
Primary keys are immutable once set. If you need a different key, delete and
|
|
328
|
+
re-insert.
|
|
329
|
+
|
|
330
|
+
### HIGH: Inserting item with duplicate key
|
|
331
|
+
|
|
332
|
+
If an item with the same key already exists (synced or optimistic), throws
|
|
333
|
+
`DuplicateKeyError`. Always generate a unique key (e.g. `crypto.randomUUID()`)
|
|
334
|
+
or check before inserting.
|
|
335
|
+
|
|
336
|
+
### HIGH: Not awaiting refetch after mutation in query collection handler
|
|
337
|
+
|
|
338
|
+
The optimistic state is held only until the handler resolves. If the handler
|
|
339
|
+
returns before server state has synced back, optimistic state is dropped and
|
|
340
|
+
users see a flash of missing data.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// WRONG -- optimistic state dropped before new server state arrives
|
|
344
|
+
onInsert: async ({ transaction }) => {
|
|
345
|
+
await api.createTodo(transaction.mutations[0].modified)
|
|
346
|
+
// missing: await collection.utils.refetch()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// CORRECT
|
|
350
|
+
onInsert: async ({ transaction }) => {
|
|
351
|
+
await api.createTodo(transaction.mutations[0].modified)
|
|
352
|
+
await collection.utils.refetch()
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Tension: Optimistic Speed vs. Data Consistency
|
|
359
|
+
|
|
360
|
+
Instant optimistic updates create a window where client state diverges from
|
|
361
|
+
server state. If the handler fails, the rollback removes the optimistic state --
|
|
362
|
+
which can discard user work the user thought was saved. Consider:
|
|
363
|
+
|
|
364
|
+
- Showing pending/saving indicators so users know state is unconfirmed
|
|
365
|
+
- Using `{ optimistic: false }` for destructive operations
|
|
366
|
+
- Designing idempotent server endpoints so retries are safe
|
|
367
|
+
- Handling `tx.isPersisted.promise` rejection to surface errors to the user
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## References
|
|
372
|
+
|
|
373
|
+
- [Transaction API Reference](references/transaction-api.md) -- createTransaction config,
|
|
374
|
+
Transaction object, PendingMutation type, mutation merging rules, strategy types
|
|
375
|
+
- [TanStack DB Mutations Guide](https://tanstack.com/db/latest/docs/guides/mutations)
|