@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,427 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: db-core/collection-setup
|
|
3
|
+
description: >
|
|
4
|
+
Creating typed collections with createCollection. Adapter selection:
|
|
5
|
+
queryCollectionOptions (REST/TanStack Query), electricCollectionOptions
|
|
6
|
+
(ElectricSQL real-time sync), powerSyncCollectionOptions (PowerSync SQLite),
|
|
7
|
+
rxdbCollectionOptions (RxDB), trailbaseCollectionOptions (TrailBase),
|
|
8
|
+
localOnlyCollectionOptions, localStorageCollectionOptions. CollectionConfig
|
|
9
|
+
options: getKey, schema, sync, gcTime, autoIndex, syncMode (eager/on-demand/
|
|
10
|
+
progressive). StandardSchema validation with Zod/Valibot/ArkType. Collection
|
|
11
|
+
lifecycle (idle/loading/ready/error). Adapter-specific sync patterns including
|
|
12
|
+
Electric txid tracking and Query direct writes.
|
|
13
|
+
type: sub-skill
|
|
14
|
+
library: db
|
|
15
|
+
library_version: '0.5.30'
|
|
16
|
+
sources:
|
|
17
|
+
- 'TanStack/db:docs/overview.md'
|
|
18
|
+
- 'TanStack/db:docs/guides/schemas.md'
|
|
19
|
+
- 'TanStack/db:docs/collections/query-collection.md'
|
|
20
|
+
- 'TanStack/db:docs/collections/electric-collection.md'
|
|
21
|
+
- 'TanStack/db:docs/collections/powersync-collection.md'
|
|
22
|
+
- 'TanStack/db:docs/collections/rxdb-collection.md'
|
|
23
|
+
- 'TanStack/db:docs/collections/trailbase-collection.md'
|
|
24
|
+
- 'TanStack/db:packages/db/src/collection/index.ts'
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
This skill builds on db-core. Read it first for the overall mental model.
|
|
28
|
+
|
|
29
|
+
# Collection Setup & Schema
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createCollection } from '@tanstack/react-db'
|
|
35
|
+
import { queryCollectionOptions } from '@tanstack/query-db-collection'
|
|
36
|
+
import { QueryClient } from '@tanstack/query-core'
|
|
37
|
+
import { z } from 'zod'
|
|
38
|
+
|
|
39
|
+
const queryClient = new QueryClient()
|
|
40
|
+
|
|
41
|
+
const todoSchema = z.object({
|
|
42
|
+
id: z.number(),
|
|
43
|
+
text: z.string(),
|
|
44
|
+
completed: z.boolean().default(false),
|
|
45
|
+
created_at: z
|
|
46
|
+
.union([z.string(), z.date()])
|
|
47
|
+
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const todoCollection = createCollection(
|
|
51
|
+
queryCollectionOptions({
|
|
52
|
+
queryKey: ['todos'],
|
|
53
|
+
queryFn: async () => {
|
|
54
|
+
const res = await fetch('/api/todos')
|
|
55
|
+
return res.json()
|
|
56
|
+
},
|
|
57
|
+
queryClient,
|
|
58
|
+
getKey: (item) => item.id,
|
|
59
|
+
schema: todoSchema,
|
|
60
|
+
onInsert: async ({ transaction }) => {
|
|
61
|
+
await api.todos.create(transaction.mutations[0].modified)
|
|
62
|
+
await todoCollection.utils.refetch()
|
|
63
|
+
},
|
|
64
|
+
onUpdate: async ({ transaction }) => {
|
|
65
|
+
const mut = transaction.mutations[0]
|
|
66
|
+
await api.todos.update(mut.key, mut.changes)
|
|
67
|
+
await todoCollection.utils.refetch()
|
|
68
|
+
},
|
|
69
|
+
onDelete: async ({ transaction }) => {
|
|
70
|
+
await api.todos.delete(transaction.mutations[0].key)
|
|
71
|
+
await todoCollection.utils.refetch()
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Choosing an Adapter
|
|
78
|
+
|
|
79
|
+
| Backend | Adapter | Package |
|
|
80
|
+
| -------------------------------- | ------------------------------- | ----------------------------------- |
|
|
81
|
+
| REST API / TanStack Query | `queryCollectionOptions` | `@tanstack/query-db-collection` |
|
|
82
|
+
| ElectricSQL (real-time Postgres) | `electricCollectionOptions` | `@tanstack/electric-db-collection` |
|
|
83
|
+
| PowerSync (SQLite offline) | `powerSyncCollectionOptions` | `@tanstack/powersync-db-collection` |
|
|
84
|
+
| RxDB (reactive database) | `rxdbCollectionOptions` | `@tanstack/rxdb-db-collection` |
|
|
85
|
+
| TrailBase (event streaming) | `trailbaseCollectionOptions` | `@tanstack/trailbase-db-collection` |
|
|
86
|
+
| No backend (UI state) | `localOnlyCollectionOptions` | `@tanstack/db` |
|
|
87
|
+
| Browser localStorage | `localStorageCollectionOptions` | `@tanstack/db` |
|
|
88
|
+
|
|
89
|
+
If the user specifies a backend (e.g. Electric, PowerSync), use that adapter directly. Only use `localOnlyCollectionOptions` when there is no backend yet — the collection API is uniform, so swapping to a real adapter later only changes the options creator.
|
|
90
|
+
|
|
91
|
+
## Sync Modes
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
queryCollectionOptions({
|
|
95
|
+
syncMode: 'eager', // default — loads all data upfront
|
|
96
|
+
// syncMode: "on-demand", // loads only what live queries request
|
|
97
|
+
// syncMode: "progressive", // (Electric only) query subset first, full sync in background
|
|
98
|
+
})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Mode | Best for | Data size |
|
|
102
|
+
| ------------- | ---------------------------------------------- | --------- |
|
|
103
|
+
| `eager` | Mostly-static datasets | <10k rows |
|
|
104
|
+
| `on-demand` | Search, catalogs, large tables | >50k rows |
|
|
105
|
+
| `progressive` | Collaborative apps needing instant first paint | Any |
|
|
106
|
+
|
|
107
|
+
## Core Patterns
|
|
108
|
+
|
|
109
|
+
### Local-only collection for prototyping
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import {
|
|
113
|
+
createCollection,
|
|
114
|
+
localOnlyCollectionOptions,
|
|
115
|
+
} from '@tanstack/react-db'
|
|
116
|
+
|
|
117
|
+
const todoCollection = createCollection(
|
|
118
|
+
localOnlyCollectionOptions({
|
|
119
|
+
getKey: (item) => item.id,
|
|
120
|
+
initialData: [{ id: 1, text: 'Learn TanStack DB', completed: false }],
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Schema with type transformations
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const schema = z.object({
|
|
129
|
+
id: z.number(),
|
|
130
|
+
title: z.string(),
|
|
131
|
+
due_date: z
|
|
132
|
+
.union([z.string(), z.date()])
|
|
133
|
+
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
|
|
134
|
+
priority: z.number().default(0),
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Use `z.union([z.string(), z.date()])` for transformed fields — this ensures `TInput` is a superset of `TOutput` so that `update()` works correctly with the draft proxy.
|
|
139
|
+
|
|
140
|
+
### ElectricSQL with txid tracking
|
|
141
|
+
|
|
142
|
+
Always use a schema with Electric — without one, the collection types as `Record<string, unknown>`.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
|
|
146
|
+
import { z } from 'zod'
|
|
147
|
+
|
|
148
|
+
const todoSchema = z.object({
|
|
149
|
+
id: z.string(),
|
|
150
|
+
text: z.string(),
|
|
151
|
+
completed: z.boolean(),
|
|
152
|
+
created_at: z.coerce.date(),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const todoCollection = createCollection(
|
|
156
|
+
electricCollectionOptions({
|
|
157
|
+
schema: todoSchema,
|
|
158
|
+
shapeOptions: { url: '/api/electric/todos' },
|
|
159
|
+
getKey: (item) => item.id,
|
|
160
|
+
onInsert: async ({ transaction }) => {
|
|
161
|
+
const res = await api.todos.create(transaction.mutations[0].modified)
|
|
162
|
+
return { txid: res.txid }
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The returned `txid` tells the collection to hold optimistic state until Electric streams back that transaction. See the [Electric adapter reference](references/electric-adapter.md) for the full dual-path pattern (schema + parser).
|
|
169
|
+
|
|
170
|
+
## Common Mistakes
|
|
171
|
+
|
|
172
|
+
### CRITICAL queryFn returning empty array deletes all data
|
|
173
|
+
|
|
174
|
+
Wrong:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
queryCollectionOptions({
|
|
178
|
+
queryFn: async () => {
|
|
179
|
+
const res = await fetch('/api/todos?status=active')
|
|
180
|
+
return res.json() // returns [] when no active todos — deletes everything
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Correct:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
queryCollectionOptions({
|
|
189
|
+
queryFn: async () => {
|
|
190
|
+
const res = await fetch('/api/todos') // fetch complete state
|
|
191
|
+
return res.json()
|
|
192
|
+
},
|
|
193
|
+
// Use on-demand mode + live query where() for filtering
|
|
194
|
+
syncMode: 'on-demand',
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`queryFn` result is treated as complete server state. Returning `[]` means "server has no items", deleting all existing collection data.
|
|
199
|
+
|
|
200
|
+
Source: docs/collections/query-collection.md
|
|
201
|
+
|
|
202
|
+
### CRITICAL Not using the correct adapter for your backend
|
|
203
|
+
|
|
204
|
+
Wrong:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const todoCollection = createCollection(
|
|
208
|
+
localOnlyCollectionOptions({
|
|
209
|
+
getKey: (item) => item.id,
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
// Manually fetching and inserting...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Correct:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
const todoCollection = createCollection(
|
|
219
|
+
queryCollectionOptions({
|
|
220
|
+
queryKey: ['todos'],
|
|
221
|
+
queryFn: async () => fetch('/api/todos').then((r) => r.json()),
|
|
222
|
+
queryClient,
|
|
223
|
+
getKey: (item) => item.id,
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Each backend has a dedicated adapter that handles sync, mutation handlers, and utilities. Using `localOnlyCollectionOptions` or bare `createCollection` for a real backend bypasses all of this.
|
|
229
|
+
|
|
230
|
+
Source: docs/overview.md
|
|
231
|
+
|
|
232
|
+
### CRITICAL Electric txid queried outside mutation transaction
|
|
233
|
+
|
|
234
|
+
Wrong:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// Backend handler
|
|
238
|
+
app.post('/api/todos', async (req, res) => {
|
|
239
|
+
const txid = await generateTxId(sql) // WRONG: separate transaction
|
|
240
|
+
await sql`INSERT INTO todos ${sql(req.body)}`
|
|
241
|
+
res.json({ txid })
|
|
242
|
+
})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Correct:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
app.post('/api/todos', async (req, res) => {
|
|
249
|
+
let txid
|
|
250
|
+
await sql.begin(async (tx) => {
|
|
251
|
+
txid = await generateTxId(tx) // CORRECT: same transaction
|
|
252
|
+
await tx`INSERT INTO todos ${tx(req.body)}`
|
|
253
|
+
})
|
|
254
|
+
res.json({ txid })
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`pg_current_xact_id()` must be queried inside the same SQL transaction as the mutation. Otherwise the txid doesn't match and `awaitTxId` stalls forever.
|
|
259
|
+
|
|
260
|
+
Source: docs/collections/electric-collection.md
|
|
261
|
+
|
|
262
|
+
### CRITICAL queryFn returning partial data without merging
|
|
263
|
+
|
|
264
|
+
Wrong:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
queryCollectionOptions({
|
|
268
|
+
queryFn: async () => {
|
|
269
|
+
const newItems = await fetch('/api/todos?since=' + lastSync)
|
|
270
|
+
return newItems.json() // only new items — everything else deleted
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Correct:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
queryCollectionOptions({
|
|
279
|
+
queryFn: async (ctx) => {
|
|
280
|
+
const existing = ctx.queryClient.getQueryData(['todos']) || []
|
|
281
|
+
const newItems = await fetch('/api/todos?since=' + lastSync).then((r) =>
|
|
282
|
+
r.json(),
|
|
283
|
+
)
|
|
284
|
+
return [...existing, ...newItems]
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`queryFn` result replaces all collection data. For incremental fetches, merge with existing data.
|
|
290
|
+
|
|
291
|
+
Source: docs/collections/query-collection.md
|
|
292
|
+
|
|
293
|
+
### HIGH Using async schema validation
|
|
294
|
+
|
|
295
|
+
Wrong:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const schema = z.object({
|
|
299
|
+
email: z.string().refine(async (val) => {
|
|
300
|
+
const exists = await checkEmail(val)
|
|
301
|
+
return !exists
|
|
302
|
+
}),
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Correct:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
const schema = z.object({
|
|
310
|
+
email: z.string().email(),
|
|
311
|
+
})
|
|
312
|
+
// Do async validation in the mutation handler instead
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Schema validation must be synchronous. Async validation throws `SchemaMustBeSynchronousError` at mutation time.
|
|
316
|
+
|
|
317
|
+
Source: packages/db/src/collection/mutations.ts:101
|
|
318
|
+
|
|
319
|
+
### HIGH getKey returning undefined for some items
|
|
320
|
+
|
|
321
|
+
Wrong:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
createCollection(
|
|
325
|
+
queryCollectionOptions({
|
|
326
|
+
getKey: (item) => item.metadata.id, // undefined if metadata missing
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Correct:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
createCollection(
|
|
335
|
+
queryCollectionOptions({
|
|
336
|
+
getKey: (item) => item.id, // always present
|
|
337
|
+
}),
|
|
338
|
+
)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`getKey` must return a defined value for every item. Throws `UndefinedKeyError` otherwise.
|
|
342
|
+
|
|
343
|
+
Source: packages/db/src/collection/mutations.ts:148
|
|
344
|
+
|
|
345
|
+
### HIGH TInput not a superset of TOutput with schema transforms
|
|
346
|
+
|
|
347
|
+
Wrong:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
const schema = z.object({
|
|
351
|
+
created_at: z.string().transform((val) => new Date(val)),
|
|
352
|
+
})
|
|
353
|
+
// update() fails — draft.created_at is Date but schema only accepts string
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Correct:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
const schema = z.object({
|
|
360
|
+
created_at: z
|
|
361
|
+
.union([z.string(), z.date()])
|
|
362
|
+
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
|
|
363
|
+
})
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
When a schema transforms types, `TInput` must accept both the pre-transform and post-transform types for `update()` to work with the draft proxy.
|
|
367
|
+
|
|
368
|
+
Source: docs/guides/schemas.md
|
|
369
|
+
|
|
370
|
+
### HIGH React Native missing crypto.randomUUID polyfill
|
|
371
|
+
|
|
372
|
+
TanStack DB uses `crypto.randomUUID()` internally. React Native doesn't provide this. Install `react-native-random-uuid` and import it at your app entry point.
|
|
373
|
+
|
|
374
|
+
Source: docs/overview.md
|
|
375
|
+
|
|
376
|
+
### MEDIUM Providing both explicit type parameter and schema
|
|
377
|
+
|
|
378
|
+
Wrong:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
createCollection<Todo>(queryCollectionOptions({ schema: todoSchema, ... }))
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Correct:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
createCollection(queryCollectionOptions({ schema: todoSchema, ... }))
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
When a schema is provided, the collection infers types from it. An explicit generic creates conflicting type constraints.
|
|
391
|
+
|
|
392
|
+
Source: docs/overview.md
|
|
393
|
+
|
|
394
|
+
### MEDIUM Direct writes overridden by next query sync
|
|
395
|
+
|
|
396
|
+
Wrong:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
todoCollection.utils.writeInsert(newItem)
|
|
400
|
+
// Next queryFn execution replaces all data, losing the direct write
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Correct:
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
todoCollection.utils.writeInsert(newItem)
|
|
407
|
+
// Use staleTime to prevent immediate refetch
|
|
408
|
+
// Or return { refetch: false } from mutation handlers
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Direct writes update the collection immediately, but the next `queryFn` returns complete server state which overwrites them.
|
|
412
|
+
|
|
413
|
+
Source: docs/collections/query-collection.md
|
|
414
|
+
|
|
415
|
+
## References
|
|
416
|
+
|
|
417
|
+
- [TanStack Query adapter](references/query-adapter.md)
|
|
418
|
+
- [ElectricSQL adapter](references/electric-adapter.md)
|
|
419
|
+
- [PowerSync adapter](references/powersync-adapter.md)
|
|
420
|
+
- [RxDB adapter](references/rxdb-adapter.md)
|
|
421
|
+
- [TrailBase adapter](references/trailbase-adapter.md)
|
|
422
|
+
- [Local adapters (local-only, localStorage)](references/local-adapters.md)
|
|
423
|
+
- [Schema validation patterns](references/schema-patterns.md)
|
|
424
|
+
|
|
425
|
+
See also: db-core/mutations-optimistic/SKILL.md — mutation handlers configured here execute during mutations.
|
|
426
|
+
|
|
427
|
+
See also: db-core/custom-adapter/SKILL.md — for building your own adapter.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Electric Adapter Reference
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @tanstack/electric-db-collection @tanstack/react-db
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Required Config
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { createCollection } from '@tanstack/react-db'
|
|
13
|
+
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
|
|
14
|
+
|
|
15
|
+
const collection = createCollection(
|
|
16
|
+
electricCollectionOptions({
|
|
17
|
+
shapeOptions: { url: '/api/todos' },
|
|
18
|
+
getKey: (item) => item.id,
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `shapeOptions` -- ElectricSQL ShapeStream config; `url` is the proxy URL to Electric
|
|
24
|
+
- `getKey` -- extracts unique key from each item
|
|
25
|
+
|
|
26
|
+
## Optional Config
|
|
27
|
+
|
|
28
|
+
| Option | Default | Description |
|
|
29
|
+
| --------------------- | ------- | --------------------------------------------------- |
|
|
30
|
+
| `id` | (none) | Unique collection identifier |
|
|
31
|
+
| `schema` | (none) | StandardSchema validator |
|
|
32
|
+
| `shapeOptions.params` | (none) | Additional shape params (e.g. `{ table: 'todos' }`) |
|
|
33
|
+
| `onInsert` | (none) | Persistence handler; should return `{ txid }` |
|
|
34
|
+
| `onUpdate` | (none) | Persistence handler; should return `{ txid }` |
|
|
35
|
+
| `onDelete` | (none) | Persistence handler; should return `{ txid }` |
|
|
36
|
+
|
|
37
|
+
## Three Sync Strategies
|
|
38
|
+
|
|
39
|
+
### 1. Txid Return (Recommended)
|
|
40
|
+
|
|
41
|
+
Handler returns `{ txid }`. Client waits for that txid in the Electric stream.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
onInsert: async ({ transaction }) => {
|
|
45
|
+
const response = await api.todos.create(transaction.mutations[0].modified)
|
|
46
|
+
return { txid: response.txid }
|
|
47
|
+
},
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. awaitMatch (Custom Match)
|
|
51
|
+
|
|
52
|
+
Use when txids are unavailable. Import `isChangeMessage` to match on message content.
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { isChangeMessage } from "@tanstack/electric-db-collection"
|
|
56
|
+
|
|
57
|
+
onInsert: async ({ transaction, collection }) => {
|
|
58
|
+
const newItem = transaction.mutations[0].modified
|
|
59
|
+
await api.todos.create(newItem)
|
|
60
|
+
await collection.utils.awaitMatch(
|
|
61
|
+
(message) =>
|
|
62
|
+
isChangeMessage(message) &&
|
|
63
|
+
message.headers.operation === "insert" &&
|
|
64
|
+
message.value.text === newItem.text,
|
|
65
|
+
5000 // timeout ms, defaults to 3000
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 3. Simple Timeout (Prototyping)
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
onInsert: async ({ transaction }) => {
|
|
74
|
+
await api.todos.create(transaction.mutations[0].modified)
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
76
|
+
},
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Utility Methods (`collection.utils`)
|
|
80
|
+
|
|
81
|
+
- `awaitTxId(txid, timeout?)` -- wait for txid in Electric stream; default timeout 30s
|
|
82
|
+
- `awaitMatch(matchFn, timeout?)` -- wait for message matching predicate; default timeout 3000ms
|
|
83
|
+
|
|
84
|
+
### Helper Exports
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import {
|
|
88
|
+
isChangeMessage,
|
|
89
|
+
isControlMessage,
|
|
90
|
+
} from '@tanstack/electric-db-collection'
|
|
91
|
+
// isChangeMessage(msg) -- true for insert/update/delete
|
|
92
|
+
// isControlMessage(msg) -- true for up-to-date/must-refetch
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## generateTxId Backend Pattern
|
|
96
|
+
|
|
97
|
+
The txid **must** be queried inside the same Postgres transaction as the mutation.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
async function generateTxId(tx: any): Promise<number> {
|
|
101
|
+
const result = await tx`SELECT pg_current_xact_id()::xid::text as txid`
|
|
102
|
+
const txid = result[0]?.txid
|
|
103
|
+
if (txid === undefined) throw new Error('Failed to get transaction ID')
|
|
104
|
+
return parseInt(txid, 10)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function createTodo(data) {
|
|
108
|
+
let txid!: number
|
|
109
|
+
const result = await sql.begin(async (tx) => {
|
|
110
|
+
txid = await generateTxId(tx) // INSIDE the transaction
|
|
111
|
+
const [todo] = await tx`INSERT INTO todos ${tx(data)} RETURNING *`
|
|
112
|
+
return todo
|
|
113
|
+
})
|
|
114
|
+
return { todo: result, txid }
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Querying txid outside the transaction produces a mismatched txid -- `awaitTxId` stalls indefinitely.
|
|
119
|
+
|
|
120
|
+
## Schema vs Parser: Two Separate Paths
|
|
121
|
+
|
|
122
|
+
When using Electric with a schema, data enters the collection via **two independent paths**:
|
|
123
|
+
|
|
124
|
+
1. **Sync path** — Electric's `ShapeStream` applies the `parser` from `shapeOptions`. The schema is NOT applied to synced data.
|
|
125
|
+
2. **Mutation path** — `insert()` and `update()` run through the collection schema. The parser is not involved.
|
|
126
|
+
|
|
127
|
+
For types that need transformation (e.g., `timestamptz`), you need BOTH configured:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const todosCollection = createCollection(
|
|
131
|
+
electricCollectionOptions({
|
|
132
|
+
schema: z.object({
|
|
133
|
+
id: z.string(),
|
|
134
|
+
text: z.string(),
|
|
135
|
+
completed: z.boolean(), // Electric auto-parses bools
|
|
136
|
+
created_at: z.coerce.date(), // mutation path: coerce string → Date
|
|
137
|
+
}),
|
|
138
|
+
shapeOptions: {
|
|
139
|
+
url: '/api/todos',
|
|
140
|
+
parser: {
|
|
141
|
+
timestamptz: (value: string) => new Date(value), // sync path: parse incoming strings
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
getKey: (item) => item.id,
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Postgres → Electric type handling
|
|
150
|
+
|
|
151
|
+
| PG type | Electric auto-parses? | Schema needed? | Parser needed? |
|
|
152
|
+
| -------------- | --------------------- | ----------------- | --------------------------------------------------- |
|
|
153
|
+
| `text`, `uuid` | Yes (string) | `z.string()` | No |
|
|
154
|
+
| `int4`, `int8` | Yes (number) | `z.number()` | No |
|
|
155
|
+
| `bool` | Yes (boolean) | `z.boolean()` | No |
|
|
156
|
+
| `timestamptz` | No (stays string) | `z.coerce.date()` | Yes — `parser: { timestamptz: (v) => new Date(v) }` |
|
|
157
|
+
| `jsonb` | Yes (parsed object) | As needed | No |
|
|
158
|
+
|
|
159
|
+
Note: `z.coerce.date()` is Zod-specific. Other StandardSchema libraries have their own coercion patterns.
|
|
160
|
+
|
|
161
|
+
## Proxy Route
|
|
162
|
+
|
|
163
|
+
Electric collections connect to a proxy URL (`shapeOptions.url`), not directly to Electric. Your app server must forward shape requests to Electric, passing through the Electric protocol query params.
|
|
164
|
+
|
|
165
|
+
The proxy route must:
|
|
166
|
+
|
|
167
|
+
1. Accept GET requests at the URL you specify in `shapeOptions.url`
|
|
168
|
+
2. Forward all query parameters (these are Electric protocol params like `offset`, `handle`, `live`, etc.)
|
|
169
|
+
3. Proxy the response (SSE stream) back to the client
|
|
170
|
+
4. Optionally add authentication headers or filter params
|
|
171
|
+
|
|
172
|
+
Implementation depends on your framework — use `createServerFn` in TanStack Start, API routes in Next.js, `loader` in Remix, etc. See the `@electric-sql/client` skills for proxy route examples:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npx @electric-sql/client intent list
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Electric Client Skills
|
|
179
|
+
|
|
180
|
+
For deeper Electric-specific guidance (ShapeStream config, shape filtering, etc.), load the Electric client's built-in skills:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npx @electric-sql/client intent list
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Debug Logging
|
|
187
|
+
|
|
188
|
+
```javascript
|
|
189
|
+
localStorage.debug = 'ts/db:electric'
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Complete Example
|
|
193
|
+
|
|
194
|
+
Always use a schema — types are inferred automatically, avoiding generic placement confusion.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { createCollection } from '@tanstack/react-db'
|
|
198
|
+
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
|
|
199
|
+
import { z } from 'zod'
|
|
200
|
+
|
|
201
|
+
const todoSchema = z.object({
|
|
202
|
+
id: z.string(),
|
|
203
|
+
text: z.string().min(1),
|
|
204
|
+
completed: z.boolean(),
|
|
205
|
+
created_at: z.coerce.date(),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const todosCollection = createCollection(
|
|
209
|
+
electricCollectionOptions({
|
|
210
|
+
id: 'todos',
|
|
211
|
+
schema: todoSchema,
|
|
212
|
+
getKey: (item) => item.id,
|
|
213
|
+
shapeOptions: {
|
|
214
|
+
url: '/api/todos',
|
|
215
|
+
params: { table: 'todos' },
|
|
216
|
+
parser: {
|
|
217
|
+
timestamptz: (value: string) => new Date(value), // sync path
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
onInsert: async ({ transaction }) => {
|
|
221
|
+
const response = await api.todos.create(transaction.mutations[0].modified)
|
|
222
|
+
return { txid: response.txid }
|
|
223
|
+
},
|
|
224
|
+
onUpdate: async ({ transaction }) => {
|
|
225
|
+
const { original, changes } = transaction.mutations[0]
|
|
226
|
+
const response = await api.todos.update({
|
|
227
|
+
where: { id: original.id },
|
|
228
|
+
data: changes,
|
|
229
|
+
})
|
|
230
|
+
return { txid: response.txid }
|
|
231
|
+
},
|
|
232
|
+
onDelete: async ({ transaction }) => {
|
|
233
|
+
const response = await api.todos.delete(transaction.mutations[0].key)
|
|
234
|
+
return { txid: response.txid }
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
```
|