@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.
- package/dist/cjs/index.cjs +10 -10
- package/dist/cjs/query/builder/index.cjs +4 -2
- package/dist/cjs/query/builder/index.cjs.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/query/builder/index.js +5 -3
- package/dist/esm/query/builder/index.js.map +1 -1
- 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
- package/src/query/builder/index.ts +17 -2
|
@@ -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.
|