@tanstack/db 0.5.29 → 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,285 @@
1
+ ---
2
+ name: db-core/custom-adapter
3
+ description: >
4
+ Building custom collection adapters for new backends. SyncConfig interface:
5
+ sync function receiving begin, write, commit, markReady, truncate primitives.
6
+ ChangeMessage format (insert, update, delete). loadSubset for on-demand sync.
7
+ LoadSubsetOptions (where, orderBy, limit, cursor). Expression parsing:
8
+ parseWhereExpression, parseOrderByExpression, extractSimpleComparisons,
9
+ parseLoadSubsetOptions. Collection options creator pattern. rowUpdateMode
10
+ (partial vs full). Subscription lifecycle and cleanup functions.
11
+ type: sub-skill
12
+ library: db
13
+ library_version: '0.5.30'
14
+ sources:
15
+ - 'TanStack/db:docs/guides/collection-options-creator.md'
16
+ - 'TanStack/db:packages/db/src/collection/sync.ts'
17
+ ---
18
+
19
+ This skill builds on db-core and db-core/collection-setup. Read those first.
20
+
21
+ # Custom Adapter Authoring
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { createCollection } from '@tanstack/db'
27
+ import type { SyncConfig, CollectionConfig } from '@tanstack/db'
28
+
29
+ interface MyItem {
30
+ id: string
31
+ name: string
32
+ }
33
+
34
+ function myBackendCollectionOptions<T>(config: {
35
+ endpoint: string
36
+ getKey: (item: T) => string
37
+ }): CollectionConfig<T, string, {}> {
38
+ return {
39
+ getKey: config.getKey,
40
+ sync: {
41
+ sync: ({ begin, write, commit, markReady, collection }) => {
42
+ let isInitialSyncComplete = false
43
+ const bufferedEvents: Array<any> = []
44
+
45
+ // 1. Subscribe to real-time events FIRST
46
+ const unsubscribe = myWebSocket.subscribe(config.endpoint, (event) => {
47
+ if (!isInitialSyncComplete) {
48
+ bufferedEvents.push(event)
49
+ return
50
+ }
51
+ begin()
52
+ write({ type: event.type, key: event.id, value: event.data })
53
+ commit()
54
+ })
55
+
56
+ // 2. Fetch initial data
57
+ fetch(config.endpoint).then(async (res) => {
58
+ const items = await res.json()
59
+ begin()
60
+ for (const item of items) {
61
+ write({ type: 'insert', value: item })
62
+ }
63
+ commit()
64
+
65
+ // 3. Process buffered events
66
+ isInitialSyncComplete = true
67
+ for (const event of bufferedEvents) {
68
+ begin()
69
+ write({ type: event.type, key: event.id, value: event.data })
70
+ commit()
71
+ }
72
+
73
+ // 4. Signal readiness
74
+ markReady()
75
+ })
76
+
77
+ // 5. Return cleanup function
78
+ return () => {
79
+ unsubscribe()
80
+ }
81
+ },
82
+ rowUpdateMode: 'partial',
83
+ },
84
+ onInsert: async ({ transaction }) => {
85
+ await fetch(config.endpoint, {
86
+ method: 'POST',
87
+ body: JSON.stringify(transaction.mutations[0].modified),
88
+ })
89
+ },
90
+ onUpdate: async ({ transaction }) => {
91
+ const mut = transaction.mutations[0]
92
+ await fetch(`${config.endpoint}/${mut.key}`, {
93
+ method: 'PATCH',
94
+ body: JSON.stringify(mut.changes),
95
+ })
96
+ },
97
+ onDelete: async ({ transaction }) => {
98
+ await fetch(`${config.endpoint}/${transaction.mutations[0].key}`, {
99
+ method: 'DELETE',
100
+ })
101
+ },
102
+ }
103
+ }
104
+ ```
105
+
106
+ ## Core Patterns
107
+
108
+ ### ChangeMessage format
109
+
110
+ ```ts
111
+ // Insert
112
+ write({ type: 'insert', value: item })
113
+
114
+ // Update (partial — only changed fields)
115
+ write({ type: 'update', key: itemId, value: partialItem })
116
+
117
+ // Update (full row replacement)
118
+ write({ type: 'update', key: itemId, value: fullItem })
119
+ // Set rowUpdateMode: "full" in sync config
120
+
121
+ // Delete
122
+ write({ type: 'delete', key: itemId, value: item })
123
+ ```
124
+
125
+ ### On-demand sync with loadSubset
126
+
127
+ ```ts
128
+ import { parseLoadSubsetOptions } from "@tanstack/db"
129
+
130
+ sync: {
131
+ sync: ({ begin, write, commit, markReady }) => {
132
+ // Initial sync...
133
+ markReady()
134
+ return () => {}
135
+ },
136
+ loadSubset: async (options) => {
137
+ const { filters, sorts, limit, offset } = parseLoadSubsetOptions(options)
138
+ // filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }]
139
+ // sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }]
140
+ const params = new URLSearchParams()
141
+ for (const f of filters) {
142
+ params.set(f.field.join("."), `${f.operator}:${f.value}`)
143
+ }
144
+ const res = await fetch(`/api/items?${params}`)
145
+ return res.json()
146
+ },
147
+ }
148
+ ```
149
+
150
+ ### Managing optimistic state duration
151
+
152
+ Mutation handlers must not resolve until server changes have synced back to the collection. Five strategies:
153
+
154
+ 1. **Refetch** (simplest): `await collection.utils.refetch()`
155
+ 2. **Transaction ID**: return `{ txid }` and track via sync stream
156
+ 3. **ID-based tracking**: await specific record ID appearing in sync stream
157
+ 4. **Version/timestamp**: wait until sync stream catches up to mutation time
158
+ 5. **Provider method**: `await backend.waitForPendingWrites()`
159
+
160
+ ### Expression parsing for predicate push-down
161
+
162
+ ```ts
163
+ import {
164
+ parseWhereExpression,
165
+ parseOrderByExpression,
166
+ extractSimpleComparisons,
167
+ } from '@tanstack/db'
168
+
169
+ // In loadSubset or queryFn:
170
+ const comparisons = extractSimpleComparisons(options.where)
171
+ // Returns: [{ field: ['name'], operator: 'eq', value: 'John' }]
172
+
173
+ const orderBy = parseOrderByExpression(options.orderBy)
174
+ // Returns: [{ field: ['created_at'], direction: 'desc', nulls: 'last' }]
175
+ ```
176
+
177
+ ## Common Mistakes
178
+
179
+ ### CRITICAL Not calling markReady() in sync implementation
180
+
181
+ Wrong:
182
+
183
+ ```ts
184
+ sync: ({ begin, write, commit }) => {
185
+ fetchData().then((items) => {
186
+ begin()
187
+ items.forEach((item) => write({ type: 'insert', value: item }))
188
+ commit()
189
+ // forgot markReady()!
190
+ })
191
+ }
192
+ ```
193
+
194
+ Correct:
195
+
196
+ ```ts
197
+ sync: ({ begin, write, commit, markReady }) => {
198
+ fetchData().then((items) => {
199
+ begin()
200
+ items.forEach((item) => write({ type: 'insert', value: item }))
201
+ commit()
202
+ markReady()
203
+ })
204
+ }
205
+ ```
206
+
207
+ `markReady()` transitions the collection to "ready" status. Without it, live queries never resolve and `useLiveSuspenseQuery` hangs forever in Suspense.
208
+
209
+ Source: docs/guides/collection-options-creator.md
210
+
211
+ ### HIGH Race condition: subscribing after initial fetch
212
+
213
+ Wrong:
214
+
215
+ ```ts
216
+ sync: ({ begin, write, commit, markReady }) => {
217
+ fetchAll().then((data) => {
218
+ writeAll(data)
219
+ subscribe(onChange) // changes during fetch are LOST
220
+ markReady()
221
+ })
222
+ }
223
+ ```
224
+
225
+ Correct:
226
+
227
+ ```ts
228
+ sync: ({ begin, write, commit, markReady }) => {
229
+ const buffer = []
230
+ subscribe((event) => {
231
+ if (!ready) {
232
+ buffer.push(event)
233
+ return
234
+ }
235
+ begin()
236
+ write(event)
237
+ commit()
238
+ })
239
+ fetchAll().then((data) => {
240
+ writeAll(data)
241
+ ready = true
242
+ buffer.forEach((e) => {
243
+ begin()
244
+ write(e)
245
+ commit()
246
+ })
247
+ markReady()
248
+ })
249
+ }
250
+ ```
251
+
252
+ Subscribe to real-time events before fetching initial data. Buffer events during the fetch, then replay them after the initial sync completes.
253
+
254
+ Source: docs/guides/collection-options-creator.md
255
+
256
+ ### HIGH write() called without begin()
257
+
258
+ Wrong:
259
+
260
+ ```ts
261
+ onMessage((event) => {
262
+ write({ type: event.type, key: event.id, value: event.data })
263
+ commit()
264
+ })
265
+ ```
266
+
267
+ Correct:
268
+
269
+ ```ts
270
+ onMessage((event) => {
271
+ begin()
272
+ write({ type: event.type, key: event.id, value: event.data })
273
+ commit()
274
+ })
275
+ ```
276
+
277
+ Sync data must be written within a transaction (`begin` → `write` → `commit`). Calling `write()` without `begin()` throws `NoPendingSyncTransactionWriteError`.
278
+
279
+ Source: packages/db/src/collection/sync.ts:110
280
+
281
+ ## Tension: Simplicity vs. Correctness in Sync
282
+
283
+ Getting-started simplicity (localOnly, eager mode) conflicts with production correctness (on-demand sync, race condition prevention, proper markReady handling). Agents optimizing for quick setup tend to skip buffering, markReady, and cleanup functions.
284
+
285
+ See also: db-core/collection-setup/SKILL.md -- for built-in adapter patterns to model after.
@@ -0,0 +1,332 @@
1
+ ---
2
+ name: db-core/live-queries
3
+ description: >
4
+ Query builder fluent API: from, where, join, leftJoin, rightJoin, innerJoin,
5
+ fullJoin, select, fn.select, groupBy, having, orderBy, limit, offset, distinct,
6
+ findOne. Operators: eq, gt, gte, lt, lte, like, ilike, inArray, isNull,
7
+ isUndefined, and, or, not. Aggregates: count, sum, avg, min, max. String
8
+ functions: upper, lower, length, concat, coalesce. Math: add. $selected
9
+ namespace. createLiveQueryCollection. Derived collections. Predicate push-down.
10
+ Incremental view maintenance via differential dataflow (d2ts).
11
+ type: sub-skill
12
+ library: db
13
+ library_version: '0.5.30'
14
+ sources:
15
+ - 'TanStack/db:docs/guides/live-queries.md'
16
+ - 'TanStack/db:packages/db/src/query/builder/index.ts'
17
+ - 'TanStack/db:packages/db/src/query/compiler/index.ts'
18
+ ---
19
+
20
+ # Live Queries
21
+
22
+ > This skill builds on db-core.
23
+
24
+ TanStack DB live queries use a SQL-like fluent query builder to create **reactive derived collections** that automatically update when underlying data changes. The query engine compiles queries into incremental view maintenance (IVM) pipelines using differential dataflow (d2ts), so only deltas are recomputed.
25
+
26
+ All operators, string functions, math functions, and aggregates are incrementally maintained. Prefer them over equivalent JS code.
27
+
28
+ ## Setup
29
+
30
+ Minimal example using the core API (no framework hooks):
31
+
32
+ ```ts
33
+ import {
34
+ createCollection,
35
+ createLiveQueryCollection,
36
+ liveQueryCollectionOptions,
37
+ eq,
38
+ } from '@tanstack/db'
39
+
40
+ // Assume usersCollection is already created via createCollection(...)
41
+
42
+ // Option 1: createLiveQueryCollection shorthand
43
+ const activeUsers = createLiveQueryCollection((q) =>
44
+ q
45
+ .from({ user: usersCollection })
46
+ .where(({ user }) => eq(user.active, true))
47
+ .select(({ user }) => ({
48
+ id: user.id,
49
+ name: user.name,
50
+ email: user.email,
51
+ })),
52
+ )
53
+
54
+ // Option 2: full options via liveQueryCollectionOptions
55
+ const activeUsers2 = createCollection(
56
+ liveQueryCollectionOptions({
57
+ query: (q) =>
58
+ q
59
+ .from({ user: usersCollection })
60
+ .where(({ user }) => eq(user.active, true))
61
+ .select(({ user }) => ({
62
+ id: user.id,
63
+ name: user.name,
64
+ })),
65
+ getKey: (user) => user.id,
66
+ }),
67
+ )
68
+
69
+ // The result is a live collection -- iterate, subscribe, or use as source
70
+ for (const user of activeUsers) {
71
+ console.log(user.name)
72
+ }
73
+ ```
74
+
75
+ ## Core Patterns
76
+
77
+ ### 1. Filtering with where + operators
78
+
79
+ Chain `.where()` calls (ANDed together) using expression operators. Use `and()`, `or()`, `not()` for complex logic.
80
+
81
+ ```ts
82
+ import { eq, gt, or, and, not, inArray, like } from '@tanstack/db'
83
+
84
+ const results = createLiveQueryCollection((q) =>
85
+ q
86
+ .from({ user: usersCollection })
87
+ .where(({ user }) => eq(user.active, true))
88
+ .where(({ user }) =>
89
+ and(
90
+ gt(user.age, 18),
91
+ or(eq(user.role, 'admin'), eq(user.role, 'moderator')),
92
+ not(inArray(user.id, bannedIds)),
93
+ ),
94
+ ),
95
+ )
96
+ ```
97
+
98
+ Boolean column references work directly:
99
+
100
+ ```ts
101
+ .where(({ user }) => user.active) // bare boolean ref
102
+ .where(({ user }) => not(user.suspended)) // negated boolean ref
103
+ ```
104
+
105
+ ### 2. Joining two collections
106
+
107
+ Join conditions **must** use `eq()` (equality only -- IVM constraint). Default join type is `left`. Convenience methods: `leftJoin`, `rightJoin`, `innerJoin`, `fullJoin`.
108
+
109
+ ```ts
110
+ import { eq } from '@tanstack/db'
111
+
112
+ const userPosts = createLiveQueryCollection((q) =>
113
+ q
114
+ .from({ user: usersCollection })
115
+ .innerJoin({ post: postsCollection }, ({ user, post }) =>
116
+ eq(user.id, post.userId),
117
+ )
118
+ .select(({ user, post }) => ({
119
+ userName: user.name,
120
+ postTitle: post.title,
121
+ })),
122
+ )
123
+ ```
124
+
125
+ Multiple joins:
126
+
127
+ ```ts
128
+ q.from({ user: usersCollection })
129
+ .join({ post: postsCollection }, ({ user, post }) => eq(user.id, post.userId))
130
+ .join({ comment: commentsCollection }, ({ post, comment }) =>
131
+ eq(post.id, comment.postId),
132
+ )
133
+ ```
134
+
135
+ ### 3. Aggregation with groupBy + having
136
+
137
+ Use `groupBy` to group rows, then aggregate in `select`. Filter groups with `having`. The `$selected` namespace lets `having` and `orderBy` reference fields defined in `select`.
138
+
139
+ ```ts
140
+ import { count, sum, gt } from '@tanstack/db'
141
+
142
+ const topCustomers = createLiveQueryCollection((q) =>
143
+ q
144
+ .from({ order: ordersCollection })
145
+ .groupBy(({ order }) => order.customerId)
146
+ .select(({ order }) => ({
147
+ customerId: order.customerId,
148
+ totalSpent: sum(order.amount),
149
+ orderCount: count(order.id),
150
+ }))
151
+ .having(({ $selected }) => gt($selected.totalSpent, 1000))
152
+ .orderBy(({ $selected }) => $selected.totalSpent, 'desc')
153
+ .limit(10),
154
+ )
155
+ ```
156
+
157
+ Without `groupBy`, aggregates in `select` treat the entire collection as one group:
158
+
159
+ ```ts
160
+ const stats = createLiveQueryCollection((q) =>
161
+ q.from({ user: usersCollection }).select(({ user }) => ({
162
+ totalUsers: count(user.id),
163
+ avgAge: avg(user.age),
164
+ })),
165
+ )
166
+ ```
167
+
168
+ ### 4. Standalone derived collection with createLiveQueryCollection
169
+
170
+ Derived collections are themselves collections. Use one as a source for another query to cache intermediate results:
171
+
172
+ ```ts
173
+ // Base derived collection
174
+ const activeUsers = createLiveQueryCollection((q) =>
175
+ q.from({ user: usersCollection }).where(({ user }) => eq(user.active, true)),
176
+ )
177
+
178
+ // Second query uses the derived collection as its source
179
+ const activeUserPosts = createLiveQueryCollection((q) =>
180
+ q
181
+ .from({ user: activeUsers })
182
+ .join({ post: postsCollection }, ({ user, post }) =>
183
+ eq(user.id, post.userId),
184
+ )
185
+ .select(({ user, post }) => ({
186
+ userName: user.name,
187
+ postTitle: post.title,
188
+ })),
189
+ )
190
+ ```
191
+
192
+ Create derived collections once at module scope and reuse them. Do not recreate on every render or navigation.
193
+
194
+ ## Common Mistakes
195
+
196
+ ### CRITICAL: Using === instead of eq()
197
+
198
+ JavaScript `===` in a where callback returns a boolean primitive, not an expression object. Throws `InvalidWhereExpressionError`.
199
+
200
+ ```ts
201
+ // WRONG
202
+ q.from({ user: usersCollection }).where(({ user }) => user.active === true)
203
+
204
+ // CORRECT
205
+ q.from({ user: usersCollection }).where(({ user }) => eq(user.active, true))
206
+ ```
207
+
208
+ ### CRITICAL: Filtering in JS instead of query operators
209
+
210
+ JS `.filter()` / `.map()` on the result array throws away incremental maintenance -- the JS code re-runs from scratch on every change.
211
+
212
+ ```ts
213
+ // WRONG -- re-runs filter on every change
214
+ const { data } = useLiveQuery((q) => q.from({ todos: todosCollection }))
215
+ const active = data.filter((t) => t.completed === false)
216
+
217
+ // CORRECT -- incrementally maintained
218
+ const { data } = useLiveQuery((q) =>
219
+ q
220
+ .from({ todos: todosCollection })
221
+ .where(({ todos }) => eq(todos.completed, false)),
222
+ )
223
+ ```
224
+
225
+ ### HIGH: Not using the full operator set
226
+
227
+ The library provides string functions (`upper`, `lower`, `length`, `concat`), math (`add`), utility (`coalesce`), and aggregates (`count`, `sum`, `avg`, `min`, `max`). All are incrementally maintained. Prefer them over JS equivalents.
228
+
229
+ ```ts
230
+ // WRONG
231
+ .fn.select((row) => ({
232
+ name: row.user.name.toUpperCase(),
233
+ total: row.order.price + row.order.tax,
234
+ }))
235
+
236
+ // CORRECT
237
+ .select(({ user, order }) => ({
238
+ name: upper(user.name),
239
+ total: add(order.price, order.tax),
240
+ }))
241
+ ```
242
+
243
+ ### HIGH: .distinct() without .select()
244
+
245
+ `distinct()` deduplicates by the selected columns. Without `select()`, throws `DistinctRequiresSelectError`.
246
+
247
+ ```ts
248
+ // WRONG
249
+ q.from({ user: usersCollection }).distinct()
250
+
251
+ // CORRECT
252
+ q.from({ user: usersCollection })
253
+ .select(({ user }) => ({ country: user.country }))
254
+ .distinct()
255
+ ```
256
+
257
+ ### HIGH: .having() without .groupBy()
258
+
259
+ `having` filters aggregated groups. Without `groupBy`, there are no groups. Throws `HavingRequiresGroupByError`.
260
+
261
+ ```ts
262
+ // WRONG
263
+ q.from({ order: ordersCollection }).having(({ order }) =>
264
+ gt(count(order.id), 5),
265
+ )
266
+
267
+ // CORRECT
268
+ q.from({ order: ordersCollection })
269
+ .groupBy(({ order }) => order.customerId)
270
+ .having(({ order }) => gt(count(order.id), 5))
271
+ ```
272
+
273
+ ### HIGH: .limit() / .offset() without .orderBy()
274
+
275
+ Without deterministic ordering, limit/offset results are non-deterministic and cannot be incrementally maintained. Throws `LimitOffsetRequireOrderByError`.
276
+
277
+ ```ts
278
+ // WRONG
279
+ q.from({ user: usersCollection }).limit(10)
280
+
281
+ // CORRECT
282
+ q.from({ user: usersCollection })
283
+ .orderBy(({ user }) => user.name)
284
+ .limit(10)
285
+ ```
286
+
287
+ ### HIGH: Join condition using non-eq() operator
288
+
289
+ The differential dataflow join operator only supports equality joins. Using `gt()`, `like()`, etc. throws `JoinConditionMustBeEqualityError`.
290
+
291
+ ```ts
292
+ // WRONG
293
+ q.from({ user: usersCollection }).join(
294
+ { post: postsCollection },
295
+ ({ user, post }) => gt(user.id, post.userId),
296
+ )
297
+
298
+ // CORRECT
299
+ q.from({ user: usersCollection }).join(
300
+ { post: postsCollection },
301
+ ({ user, post }) => eq(user.id, post.userId),
302
+ )
303
+ ```
304
+
305
+ ### MEDIUM: Passing source directly instead of {alias: collection}
306
+
307
+ `from()` and `join()` require sources wrapped as `{alias: collection}`. Passing the collection directly throws `InvalidSourceTypeError`.
308
+
309
+ ```ts
310
+ // WRONG
311
+ q.from(usersCollection)
312
+
313
+ // CORRECT
314
+ q.from({ users: usersCollection })
315
+ ```
316
+
317
+ ## Tension: Query expressiveness vs. IVM constraints
318
+
319
+ The query builder looks like SQL but has constraints that SQL does not:
320
+
321
+ - **Equality joins only** -- `eq()` is the only allowed join condition operator.
322
+ - **orderBy required for limit/offset** -- non-deterministic pagination cannot be incrementally maintained.
323
+ - **distinct requires select** -- deduplication needs an explicit projection.
324
+ - **fn.select() cannot be used with groupBy()** -- the compiler must statically analyze select to discover aggregate functions.
325
+
326
+ These constraints exist because the underlying d2ts differential dataflow engine requires them for correct incremental view maintenance.
327
+
328
+ See also: react-db/SKILL.md for React hooks (`useLiveQuery`, `useLiveSuspenseQuery`, `useLiveInfiniteQuery`).
329
+
330
+ ## References
331
+
332
+ - [Query Operators Reference](./references/operators.md) -- full signatures and examples for all operators, functions, and aggregates.