@electric-sql/client 1.5.11 → 1.5.12
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/bin/intent.mjs +6 -0
- package/package.json +9 -2
- package/skills/electric-debugging/SKILL.md +217 -0
- package/skills/electric-deployment/SKILL.md +196 -0
- package/skills/electric-new-feature/SKILL.md +366 -0
- package/skills/electric-orm/SKILL.md +189 -0
- package/skills/electric-postgres-security/SKILL.md +196 -0
- package/skills/electric-proxy-auth/SKILL.md +269 -0
- package/skills/electric-schema-shapes/SKILL.md +200 -0
- package/skills/electric-shapes/SKILL.md +339 -0
- package/skills/electric-shapes/references/type-parsers.md +64 -0
- package/skills/electric-shapes/references/where-clause.md +64 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: electric-new-feature
|
|
3
|
+
description: >
|
|
4
|
+
End-to-end guide for adding a new synced feature with Electric and TanStack
|
|
5
|
+
DB. Covers the full journey: design Postgres schema, set REPLICA IDENTITY
|
|
6
|
+
FULL, define shape, create proxy route, set up TanStack DB collection with
|
|
7
|
+
electricCollectionOptions, implement optimistic mutations with txid
|
|
8
|
+
handshake (pg_current_xact_id, awaitTxId), and build live queries with
|
|
9
|
+
useLiveQuery. Also covers migration from old ElectricSQL (electrify/db
|
|
10
|
+
pattern does not exist), current API patterns (table as query param not
|
|
11
|
+
path, handle not shape_id). Load when building a new feature from scratch.
|
|
12
|
+
type: lifecycle
|
|
13
|
+
library: electric
|
|
14
|
+
library_version: '1.5.10'
|
|
15
|
+
requires:
|
|
16
|
+
- electric-shapes
|
|
17
|
+
- electric-proxy-auth
|
|
18
|
+
- electric-schema-shapes
|
|
19
|
+
sources:
|
|
20
|
+
- 'electric-sql/electric:AGENTS.md'
|
|
21
|
+
- 'electric-sql/electric:examples/tanstack-db-web-starter/'
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
This skill builds on electric-shapes, electric-proxy-auth, and electric-schema-shapes. Read those first.
|
|
25
|
+
|
|
26
|
+
# Electric — New Feature End-to-End
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
### 0. Start Electric locally
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
# docker-compose.yml
|
|
34
|
+
services:
|
|
35
|
+
postgres:
|
|
36
|
+
image: postgres:17-alpine
|
|
37
|
+
environment:
|
|
38
|
+
POSTGRES_DB: electric
|
|
39
|
+
POSTGRES_USER: postgres
|
|
40
|
+
POSTGRES_PASSWORD: password
|
|
41
|
+
ports:
|
|
42
|
+
- '54321:5432'
|
|
43
|
+
tmpfs:
|
|
44
|
+
- /tmp
|
|
45
|
+
command:
|
|
46
|
+
- -c
|
|
47
|
+
- listen_addresses=*
|
|
48
|
+
- -c
|
|
49
|
+
- wal_level=logical
|
|
50
|
+
|
|
51
|
+
electric:
|
|
52
|
+
image: electricsql/electric:latest
|
|
53
|
+
environment:
|
|
54
|
+
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric?sslmode=disable
|
|
55
|
+
ELECTRIC_INSECURE: true # Dev only — use ELECTRIC_SECRET in production
|
|
56
|
+
ports:
|
|
57
|
+
- '3000:3000'
|
|
58
|
+
depends_on:
|
|
59
|
+
- postgres
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
docker compose up -d
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 1. Create Postgres table
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
CREATE TABLE todos (
|
|
70
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
71
|
+
user_id UUID NOT NULL,
|
|
72
|
+
text TEXT NOT NULL,
|
|
73
|
+
completed BOOLEAN DEFAULT false,
|
|
74
|
+
created_at TIMESTAMPTZ DEFAULT now()
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
ALTER TABLE todos REPLICA IDENTITY FULL;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Create proxy route
|
|
81
|
+
|
|
82
|
+
The proxy forwards Electric protocol params and injects server-side secrets. Use your framework's server route pattern (TanStack Start, Next.js API route, Express, etc.).
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// Example: TanStack Start — src/routes/api/todos.ts
|
|
86
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
87
|
+
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
|
|
88
|
+
|
|
89
|
+
const serve = async ({ request }: { request: Request }) => {
|
|
90
|
+
const url = new URL(request.url)
|
|
91
|
+
const electricUrl = process.env.ELECTRIC_URL || 'http://localhost:3000'
|
|
92
|
+
const origin = new URL(`${electricUrl}/v1/shape`)
|
|
93
|
+
|
|
94
|
+
url.searchParams.forEach((v, k) => {
|
|
95
|
+
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(k))
|
|
96
|
+
origin.searchParams.set(k, v)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
origin.searchParams.set('table', 'todos')
|
|
100
|
+
|
|
101
|
+
// Add auth if using Electric Cloud
|
|
102
|
+
if (process.env.ELECTRIC_SOURCE_ID && process.env.ELECTRIC_SECRET) {
|
|
103
|
+
origin.searchParams.set('source_id', process.env.ELECTRIC_SOURCE_ID)
|
|
104
|
+
origin.searchParams.set('secret', process.env.ELECTRIC_SECRET)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const res = await fetch(origin)
|
|
108
|
+
const headers = new Headers(res.headers)
|
|
109
|
+
headers.delete('content-encoding')
|
|
110
|
+
headers.delete('content-length')
|
|
111
|
+
return new Response(res.body, {
|
|
112
|
+
status: res.status,
|
|
113
|
+
statusText: res.statusText,
|
|
114
|
+
headers,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const Route = createFileRoute('/api/todos')({
|
|
119
|
+
server: {
|
|
120
|
+
handlers: {
|
|
121
|
+
GET: serve,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3. Define schema
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// db/schema.ts — Zod schema matching your Postgres table
|
|
131
|
+
import { z } from 'zod'
|
|
132
|
+
|
|
133
|
+
export const todoSchema = z.object({
|
|
134
|
+
id: z.string().uuid(),
|
|
135
|
+
user_id: z.string().uuid(),
|
|
136
|
+
text: z.string(),
|
|
137
|
+
completed: z.boolean(),
|
|
138
|
+
created_at: z.date(),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
export type Todo = z.infer<typeof todoSchema>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If using Drizzle, generate schemas from your table definitions with `createSelectSchema(todosTable)` from `drizzle-zod`.
|
|
145
|
+
|
|
146
|
+
### 4. Create mutation endpoint
|
|
147
|
+
|
|
148
|
+
Implement your write endpoint using your framework's server function or API route. The endpoint must return `{ txid }` from the same transaction as the mutation.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// Example: server function that inserts and returns txid
|
|
152
|
+
async function createTodo(todo: { text: string; user_id: string }) {
|
|
153
|
+
const client = await pool.connect()
|
|
154
|
+
try {
|
|
155
|
+
await client.query('BEGIN')
|
|
156
|
+
const result = await client.query(
|
|
157
|
+
'INSERT INTO todos (text, user_id) VALUES ($1, $2) RETURNING id',
|
|
158
|
+
[todo.text, todo.user_id]
|
|
159
|
+
)
|
|
160
|
+
const txResult = await client.query(
|
|
161
|
+
'SELECT pg_current_xact_id()::xid::text AS txid'
|
|
162
|
+
)
|
|
163
|
+
await client.query('COMMIT')
|
|
164
|
+
return { id: result.rows[0].id, txid: Number(txResult.rows[0].txid) }
|
|
165
|
+
} finally {
|
|
166
|
+
client.release()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 5. Create TanStack DB collection
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { createCollection } from '@tanstack/react-db'
|
|
175
|
+
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
|
|
176
|
+
import { todoSchema } from './db/schema'
|
|
177
|
+
|
|
178
|
+
export const todoCollection = createCollection(
|
|
179
|
+
electricCollectionOptions({
|
|
180
|
+
id: 'todos',
|
|
181
|
+
schema: todoSchema,
|
|
182
|
+
getKey: (row) => row.id,
|
|
183
|
+
shapeOptions: {
|
|
184
|
+
url: new URL(
|
|
185
|
+
'/api/todos',
|
|
186
|
+
typeof window !== 'undefined'
|
|
187
|
+
? window.location.origin
|
|
188
|
+
: 'http://localhost:5173'
|
|
189
|
+
).toString(),
|
|
190
|
+
// Electric auto-parses: bool, int2, int4, float4, float8, json, jsonb
|
|
191
|
+
// You only need custom parsers for types like timestamptz, date, numeric
|
|
192
|
+
// See electric-shapes/references/type-parsers.md for the full list
|
|
193
|
+
parser: {
|
|
194
|
+
timestamptz: (date: string) => new Date(date),
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
onInsert: async ({ transaction }) => {
|
|
198
|
+
const { modified: newTodo } = transaction.mutations[0]
|
|
199
|
+
const result = await createTodo({
|
|
200
|
+
text: newTodo.text,
|
|
201
|
+
user_id: newTodo.user_id,
|
|
202
|
+
})
|
|
203
|
+
return { txid: result.txid }
|
|
204
|
+
},
|
|
205
|
+
onUpdate: async ({ transaction }) => {
|
|
206
|
+
const { modified: updated } = transaction.mutations[0]
|
|
207
|
+
const result = await updateTodo(updated.id, {
|
|
208
|
+
text: updated.text,
|
|
209
|
+
completed: updated.completed,
|
|
210
|
+
})
|
|
211
|
+
return { txid: result.txid }
|
|
212
|
+
},
|
|
213
|
+
onDelete: async ({ transaction }) => {
|
|
214
|
+
const { original: deleted } = transaction.mutations[0]
|
|
215
|
+
const result = await deleteTodo(deleted.id)
|
|
216
|
+
return { txid: result.txid }
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### 6. Build live queries
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { useLiveQuery, eq } from '@tanstack/react-db'
|
|
226
|
+
|
|
227
|
+
export function TodoList() {
|
|
228
|
+
const { data: todos } = useLiveQuery((q) =>
|
|
229
|
+
q
|
|
230
|
+
.from({ todo: todoCollection })
|
|
231
|
+
.where(({ todo }) => eq(todo.completed, false))
|
|
232
|
+
.orderBy(({ todo }) => todo.created_at, 'desc')
|
|
233
|
+
.limit(50)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<ul>
|
|
238
|
+
{todos.map((todo) => (
|
|
239
|
+
<li key={todo.id}>{todo.text}</li>
|
|
240
|
+
))}
|
|
241
|
+
</ul>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### 7. Optimistic mutations
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
const handleAdd = () => {
|
|
250
|
+
todoCollection.insert({
|
|
251
|
+
id: crypto.randomUUID(),
|
|
252
|
+
text: 'New todo',
|
|
253
|
+
completed: false,
|
|
254
|
+
created_at: new Date(),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const handleToggle = (todo) => {
|
|
259
|
+
todoCollection.update(todo.id, (draft) => {
|
|
260
|
+
draft.completed = !draft.completed
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const handleDelete = (todoId) => todoCollection.delete(todoId)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Common Mistakes
|
|
268
|
+
|
|
269
|
+
### HIGH Removing parsers because the TanStack DB schema handles types
|
|
270
|
+
|
|
271
|
+
Wrong:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// "My Zod schema has z.coerce.date() so I don't need a parser"
|
|
275
|
+
electricCollectionOptions({
|
|
276
|
+
schema: z.object({ created_at: z.coerce.date() }),
|
|
277
|
+
shapeOptions: { url: '/api/todos' }, // No parser!
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Correct:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
electricCollectionOptions({
|
|
285
|
+
schema: z.object({ created_at: z.coerce.date() }),
|
|
286
|
+
shapeOptions: {
|
|
287
|
+
url: '/api/todos',
|
|
288
|
+
parser: { timestamptz: (date: string) => new Date(date) },
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Electric's sync path delivers data directly into the collection store, bypassing the TanStack DB schema. The `parser` in `shapeOptions` handles type coercion on the sync path; the schema handles the mutation path. You need both. Without the parser, `timestamptz` arrives as a string and `getTime()` or other Date methods will fail at runtime.
|
|
294
|
+
|
|
295
|
+
### CRITICAL Using old electrify() bidirectional sync API
|
|
296
|
+
|
|
297
|
+
Wrong:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
const { db } = await electrify(conn, schema)
|
|
301
|
+
await db.todos.create({ text: 'New todo' })
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Correct:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
todoCollection.insert({ id: crypto.randomUUID(), text: 'New todo' })
|
|
308
|
+
// Write path: collection.insert() → onInsert → API → Postgres → txid → awaitTxId
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Old ElectricSQL (v0.x) had bidirectional SQLite sync. Current Electric is read-only. Writes go through your API endpoint and are reconciled via txid handshake.
|
|
312
|
+
|
|
313
|
+
Source: `AGENTS.md:386-392`
|
|
314
|
+
|
|
315
|
+
### HIGH Using path-based table URL pattern
|
|
316
|
+
|
|
317
|
+
Wrong:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const stream = new ShapeStream({
|
|
321
|
+
url: 'http://localhost:3000/v1/shape/todos?offset=-1',
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Correct:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const stream = new ShapeStream({
|
|
329
|
+
url: 'http://localhost:3000/v1/shape?table=todos&offset=-1',
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The table-as-path-segment pattern (`/v1/shape/todos`) was removed in v0.8.0. Table is now a query parameter.
|
|
334
|
+
|
|
335
|
+
Source: `packages/sync-service/CHANGELOG.md:1124`
|
|
336
|
+
|
|
337
|
+
### MEDIUM Using shape_id instead of handle
|
|
338
|
+
|
|
339
|
+
Wrong:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
const stream = new ShapeStream({
|
|
343
|
+
url: '/api/todos',
|
|
344
|
+
params: { shape_id: '12345' },
|
|
345
|
+
})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Correct:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
const stream = new ShapeStream({
|
|
352
|
+
url: '/api/todos',
|
|
353
|
+
handle: '12345',
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Renamed from `shape_id` to `handle` in v0.8.0.
|
|
358
|
+
|
|
359
|
+
Source: `packages/sync-service/CHANGELOG.md:1123`
|
|
360
|
+
|
|
361
|
+
See also: electric-orm/SKILL.md — Getting txid from ORM transactions.
|
|
362
|
+
See also: electric-proxy-auth/SKILL.md — E2E feature journey includes setting up proxy routes.
|
|
363
|
+
|
|
364
|
+
## Version
|
|
365
|
+
|
|
366
|
+
Targets @electric-sql/client v1.5.10, @tanstack/react-db latest.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: electric-orm
|
|
3
|
+
description: >
|
|
4
|
+
Use Electric with Drizzle ORM or Prisma for the write path. Covers getting
|
|
5
|
+
pg_current_xact_id() from ORM transactions using Drizzle tx.execute(sql)
|
|
6
|
+
and Prisma $queryRaw, running migrations that preserve REPLICA IDENTITY
|
|
7
|
+
FULL, and schema management patterns compatible with Electric shapes.
|
|
8
|
+
Load when using Drizzle or Prisma alongside Electric for writes.
|
|
9
|
+
type: composition
|
|
10
|
+
library: electric
|
|
11
|
+
library_version: '1.5.10'
|
|
12
|
+
requires:
|
|
13
|
+
- electric-shapes
|
|
14
|
+
- electric-schema-shapes
|
|
15
|
+
sources:
|
|
16
|
+
- 'electric-sql/electric:AGENTS.md'
|
|
17
|
+
- 'electric-sql/electric:website/docs/guides/troubleshooting.md'
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
This skill builds on electric-shapes and electric-schema-shapes. Read those first.
|
|
21
|
+
|
|
22
|
+
# Electric — ORM Integration
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
### Drizzle ORM
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
30
|
+
import { sql } from 'drizzle-orm'
|
|
31
|
+
import { todos } from './schema'
|
|
32
|
+
|
|
33
|
+
const db = drizzle(pool)
|
|
34
|
+
|
|
35
|
+
// Write with txid for Electric reconciliation
|
|
36
|
+
async function createTodo(text: string, userId: string) {
|
|
37
|
+
return await db.transaction(async (tx) => {
|
|
38
|
+
const [row] = await tx
|
|
39
|
+
.insert(todos)
|
|
40
|
+
.values({
|
|
41
|
+
id: crypto.randomUUID(),
|
|
42
|
+
text,
|
|
43
|
+
userId,
|
|
44
|
+
})
|
|
45
|
+
.returning()
|
|
46
|
+
|
|
47
|
+
const [{ txid }] = await tx.execute<{ txid: string }>(
|
|
48
|
+
sql`SELECT pg_current_xact_id()::xid::text AS txid`
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return { id: row.id, txid: parseInt(txid) }
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Prisma
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { PrismaClient } from '@prisma/client'
|
|
60
|
+
|
|
61
|
+
const prisma = new PrismaClient()
|
|
62
|
+
|
|
63
|
+
async function createTodo(text: string, userId: string) {
|
|
64
|
+
return await prisma.$transaction(async (tx) => {
|
|
65
|
+
const todo = await tx.todo.create({
|
|
66
|
+
data: { id: crypto.randomUUID(), text, userId },
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const [{ txid }] = await tx.$queryRaw<[{ txid: string }]>`
|
|
70
|
+
SELECT pg_current_xact_id()::xid::text AS txid
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
return { id: todo.id, txid: parseInt(txid) }
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Core Patterns
|
|
79
|
+
|
|
80
|
+
### Drizzle migration with REPLICA IDENTITY
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// In migration file
|
|
84
|
+
import { sql } from 'drizzle-orm'
|
|
85
|
+
|
|
86
|
+
export async function up(db) {
|
|
87
|
+
await db.execute(sql`
|
|
88
|
+
CREATE TABLE todos (
|
|
89
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
90
|
+
text TEXT NOT NULL,
|
|
91
|
+
completed BOOLEAN DEFAULT false
|
|
92
|
+
)
|
|
93
|
+
`)
|
|
94
|
+
await db.execute(sql`ALTER TABLE todos REPLICA IDENTITY FULL`)
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Prisma migration with REPLICA IDENTITY
|
|
99
|
+
|
|
100
|
+
```sql
|
|
101
|
+
-- prisma/migrations/001_init/migration.sql
|
|
102
|
+
CREATE TABLE "todos" (
|
|
103
|
+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
104
|
+
"text" TEXT NOT NULL,
|
|
105
|
+
"completed" BOOLEAN DEFAULT false
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
ALTER TABLE "todos" REPLICA IDENTITY FULL;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Collection onInsert with ORM
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { createCollection } from '@tanstack/react-db'
|
|
115
|
+
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
|
|
116
|
+
|
|
117
|
+
export const todoCollection = createCollection(
|
|
118
|
+
electricCollectionOptions({
|
|
119
|
+
id: 'todos',
|
|
120
|
+
schema: todoSchema,
|
|
121
|
+
getKey: (row) => row.id,
|
|
122
|
+
shapeOptions: { url: '/api/todos' },
|
|
123
|
+
onInsert: async ({ transaction }) => {
|
|
124
|
+
const newTodo = transaction.mutations[0].modified
|
|
125
|
+
const { txid } = await createTodo(newTodo.text, newTodo.userId)
|
|
126
|
+
return { txid }
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Common Mistakes
|
|
133
|
+
|
|
134
|
+
### HIGH Not returning txid from ORM write operations
|
|
135
|
+
|
|
136
|
+
Wrong:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// Drizzle — no txid returned
|
|
140
|
+
const [todo] = await db.insert(todos).values({ text: 'New' }).returning()
|
|
141
|
+
return { id: todo.id }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Correct:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// Drizzle — txid in same transaction
|
|
148
|
+
const result = await db.transaction(async (tx) => {
|
|
149
|
+
const [row] = await tx.insert(todos).values({ text: 'New' }).returning()
|
|
150
|
+
const [{ txid }] = await tx.execute<{ txid: string }>(
|
|
151
|
+
sql`SELECT pg_current_xact_id()::xid::text AS txid`
|
|
152
|
+
)
|
|
153
|
+
return { id: row.id, txid: parseInt(txid) }
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
ORMs do not return `pg_current_xact_id()` by default. Add a raw SQL query for txid within the same transaction. Without it, optimistic state may drop before the synced version arrives, causing UI flicker.
|
|
158
|
+
|
|
159
|
+
Source: `AGENTS.md:116-119`
|
|
160
|
+
|
|
161
|
+
### MEDIUM Running migrations that drop replica identity
|
|
162
|
+
|
|
163
|
+
Wrong:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// ORM migration recreates table without REPLICA IDENTITY
|
|
167
|
+
await db.execute(sql`DROP TABLE todos`)
|
|
168
|
+
await db.execute(sql`CREATE TABLE todos (...)`)
|
|
169
|
+
// Missing: ALTER TABLE todos REPLICA IDENTITY FULL
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Correct:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await db.execute(sql`DROP TABLE todos`)
|
|
176
|
+
await db.execute(sql`CREATE TABLE todos (...)`)
|
|
177
|
+
await db.execute(sql`ALTER TABLE todos REPLICA IDENTITY FULL`)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Some migration tools reset table properties. Always ensure `REPLICA IDENTITY FULL` is set after table recreation. Without it, Electric cannot stream updates and deletes correctly.
|
|
181
|
+
|
|
182
|
+
Source: `website/docs/guides/troubleshooting.md:373`
|
|
183
|
+
|
|
184
|
+
See also: electric-new-feature/SKILL.md — Full write-path journey including txid handshake.
|
|
185
|
+
See also: electric-schema-shapes/SKILL.md — Schema design affects both shapes and ORM queries.
|
|
186
|
+
|
|
187
|
+
## Version
|
|
188
|
+
|
|
189
|
+
Targets @electric-sql/client v1.5.10.
|