@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.
@@ -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)