@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,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
+ ```