@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,196 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: electric-postgres-security
|
|
3
|
+
description: >
|
|
4
|
+
Pre-deploy security checklist for Postgres with Electric. Checks REPLICATION
|
|
5
|
+
role, SELECT grants, CREATE on database, table ownership, REPLICA IDENTITY
|
|
6
|
+
FULL on all synced tables, publication management (auto vs manual with
|
|
7
|
+
ELECTRIC_MANUAL_TABLE_PUBLISHING), connection pooler exclusion for
|
|
8
|
+
DATABASE_URL (use direct connection), and ELECTRIC_POOLED_DATABASE_URL
|
|
9
|
+
for pooled queries. Load before deploying Electric to production or when
|
|
10
|
+
diagnosing Postgres permission errors.
|
|
11
|
+
type: security
|
|
12
|
+
library: electric
|
|
13
|
+
library_version: '1.5.10'
|
|
14
|
+
requires:
|
|
15
|
+
- electric-proxy-auth
|
|
16
|
+
sources:
|
|
17
|
+
- 'electric-sql/electric:website/docs/guides/postgres-permissions.md'
|
|
18
|
+
- 'electric-sql/electric:website/docs/guides/troubleshooting.md'
|
|
19
|
+
- 'electric-sql/electric:website/docs/guides/deployment.md'
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
This skill builds on electric-proxy-auth. Read it first for proxy security patterns.
|
|
23
|
+
|
|
24
|
+
# Electric — Postgres Security Checklist
|
|
25
|
+
|
|
26
|
+
Run through each section before deploying Electric to production.
|
|
27
|
+
|
|
28
|
+
## User Permission Checks
|
|
29
|
+
|
|
30
|
+
### Check: Electric user has REPLICATION role
|
|
31
|
+
|
|
32
|
+
Expected:
|
|
33
|
+
|
|
34
|
+
```sql
|
|
35
|
+
SELECT rolreplication FROM pg_roles WHERE rolname = 'electric_user';
|
|
36
|
+
-- Should return: true
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Fail condition: `rolreplication = false` or user does not exist.
|
|
40
|
+
Fix: `ALTER ROLE electric_user WITH REPLICATION;`
|
|
41
|
+
|
|
42
|
+
### Check: Electric user has SELECT on synced tables
|
|
43
|
+
|
|
44
|
+
Expected:
|
|
45
|
+
|
|
46
|
+
```sql
|
|
47
|
+
SELECT has_table_privilege('electric_user', 'todos', 'SELECT');
|
|
48
|
+
-- Should return: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Fail condition: Returns `false`.
|
|
52
|
+
Fix: `GRANT SELECT ON todos TO electric_user;` or `GRANT SELECT ON ALL TABLES IN SCHEMA public TO electric_user;`
|
|
53
|
+
|
|
54
|
+
### Check: Electric user has CREATE on database
|
|
55
|
+
|
|
56
|
+
Expected:
|
|
57
|
+
|
|
58
|
+
```sql
|
|
59
|
+
SELECT has_database_privilege('electric_user', current_database(), 'CREATE');
|
|
60
|
+
-- Should return: true (unless using manual publishing mode)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Fail condition: Returns `false` and not using `ELECTRIC_MANUAL_TABLE_PUBLISHING=true`.
|
|
64
|
+
Fix: `GRANT CREATE ON DATABASE mydb TO electric_user;`
|
|
65
|
+
|
|
66
|
+
## Table Configuration Checks
|
|
67
|
+
|
|
68
|
+
### Check: REPLICA IDENTITY FULL on all synced tables
|
|
69
|
+
|
|
70
|
+
Expected:
|
|
71
|
+
|
|
72
|
+
```sql
|
|
73
|
+
SELECT relname, relreplident
|
|
74
|
+
FROM pg_class
|
|
75
|
+
WHERE relname IN ('todos', 'users')
|
|
76
|
+
AND relreplident = 'f'; -- 'f' = FULL
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Fail condition: `relreplident` is `'d'` (default) or `'n'` (nothing).
|
|
80
|
+
Fix: `ALTER TABLE todos REPLICA IDENTITY FULL;`
|
|
81
|
+
|
|
82
|
+
### Check: Tables are in the Electric publication
|
|
83
|
+
|
|
84
|
+
Expected:
|
|
85
|
+
|
|
86
|
+
```sql
|
|
87
|
+
SELECT tablename FROM pg_publication_tables
|
|
88
|
+
WHERE pubname = 'electric_publication_default';
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Fail condition: Synced tables missing from the list.
|
|
92
|
+
Fix (manual mode): `ALTER PUBLICATION electric_publication_default ADD TABLE todos;`
|
|
93
|
+
|
|
94
|
+
## Connection Checks
|
|
95
|
+
|
|
96
|
+
### Check: DATABASE_URL uses direct connection (not pooler)
|
|
97
|
+
|
|
98
|
+
Expected:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
DATABASE_URL=postgres://user:pass@db-host:5432/mydb
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Fail condition: URL points to a connection pooler (e.g., PgBouncer on port 6432, Supabase pooler).
|
|
105
|
+
Fix: Use direct Postgres connection for `DATABASE_URL`. Set `ELECTRIC_POOLED_DATABASE_URL` separately for pooled queries.
|
|
106
|
+
|
|
107
|
+
### Check: wal_level is set to logical
|
|
108
|
+
|
|
109
|
+
Expected:
|
|
110
|
+
|
|
111
|
+
```sql
|
|
112
|
+
SHOW wal_level;
|
|
113
|
+
-- Should return: logical
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Fail condition: Returns `replica` or `minimal`.
|
|
117
|
+
Fix: Set `wal_level = logical` in `postgresql.conf` and restart Postgres.
|
|
118
|
+
|
|
119
|
+
## Common Security Mistakes
|
|
120
|
+
|
|
121
|
+
### CRITICAL Using connection pooler for DATABASE_URL
|
|
122
|
+
|
|
123
|
+
Wrong:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
DATABASE_URL=postgres://user:pass@pooler.example.com:6432/mydb
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Correct:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
DATABASE_URL=postgres://user:pass@db.example.com:5432/mydb
|
|
133
|
+
ELECTRIC_POOLED_DATABASE_URL=postgres://user:pass@pooler.example.com:6432/mydb
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Connection poolers (except PgBouncer 1.23+) do not support logical replication. Electric must connect directly to Postgres for its replication slot.
|
|
137
|
+
|
|
138
|
+
Source: `website/docs/guides/deployment.md:91`
|
|
139
|
+
|
|
140
|
+
### HIGH Missing REPLICA IDENTITY FULL on tables
|
|
141
|
+
|
|
142
|
+
Wrong:
|
|
143
|
+
|
|
144
|
+
```sql
|
|
145
|
+
CREATE TABLE todos (id UUID PRIMARY KEY, text TEXT);
|
|
146
|
+
-- Replica identity defaults to 'default' (PK only)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Correct:
|
|
150
|
+
|
|
151
|
+
```sql
|
|
152
|
+
CREATE TABLE todos (id UUID PRIMARY KEY, text TEXT);
|
|
153
|
+
ALTER TABLE todos REPLICA IDENTITY FULL;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Without `REPLICA IDENTITY FULL`, Electric cannot stream the full row on updates and deletes. Updates may be missing non-PK columns.
|
|
157
|
+
|
|
158
|
+
Source: `website/docs/guides/troubleshooting.md:373`
|
|
159
|
+
|
|
160
|
+
### HIGH Electric user without REPLICATION role
|
|
161
|
+
|
|
162
|
+
Wrong:
|
|
163
|
+
|
|
164
|
+
```sql
|
|
165
|
+
CREATE USER electric_user WITH PASSWORD 'secret';
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Correct:
|
|
169
|
+
|
|
170
|
+
```sql
|
|
171
|
+
CREATE USER electric_user WITH PASSWORD 'secret' REPLICATION;
|
|
172
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO electric_user;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Electric uses logical replication and requires the `REPLICATION` role on the database user.
|
|
176
|
+
|
|
177
|
+
Source: `website/docs/guides/postgres-permissions.md`
|
|
178
|
+
|
|
179
|
+
## Pre-Deploy Summary
|
|
180
|
+
|
|
181
|
+
- [ ] Electric user has `REPLICATION` role
|
|
182
|
+
- [ ] Electric user has `SELECT` on all synced tables
|
|
183
|
+
- [ ] Electric user has `CREATE` on database (or manual publishing configured)
|
|
184
|
+
- [ ] All synced tables have `REPLICA IDENTITY FULL`
|
|
185
|
+
- [ ] All synced tables are in the Electric publication
|
|
186
|
+
- [ ] `DATABASE_URL` uses direct Postgres connection (not pooler)
|
|
187
|
+
- [ ] `wal_level = logical` in Postgres config
|
|
188
|
+
- [ ] `ELECTRIC_SECRET` is set (not using `ELECTRIC_INSECURE=true`)
|
|
189
|
+
- [ ] Secrets are injected server-side only (never in client bundle)
|
|
190
|
+
|
|
191
|
+
See also: electric-proxy-auth/SKILL.md — Proxy injects secrets that Postgres security enforces.
|
|
192
|
+
See also: electric-deployment/SKILL.md — Deployment requires correct Postgres configuration.
|
|
193
|
+
|
|
194
|
+
## Version
|
|
195
|
+
|
|
196
|
+
Targets Electric sync service v1.x.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: electric-proxy-auth
|
|
3
|
+
description: >
|
|
4
|
+
Set up a server-side proxy to forward Electric shape requests securely.
|
|
5
|
+
Covers ELECTRIC_PROTOCOL_QUERY_PARAMS forwarding, server-side shape
|
|
6
|
+
definition (table, where, params), content-encoding/content-length header
|
|
7
|
+
cleanup, CORS configuration for electric-offset/electric-handle/
|
|
8
|
+
electric-schema/electric-cursor headers, auth token injection,
|
|
9
|
+
ELECTRIC_SECRET/SOURCE_SECRET server-side only, tenant isolation via
|
|
10
|
+
WHERE positional params, onError 401 token refresh, and subset security
|
|
11
|
+
(AND semantics). Load when creating proxy routes, adding auth, or
|
|
12
|
+
configuring CORS for Electric.
|
|
13
|
+
type: core
|
|
14
|
+
library: electric
|
|
15
|
+
library_version: '1.5.10'
|
|
16
|
+
requires:
|
|
17
|
+
- electric-shapes
|
|
18
|
+
sources:
|
|
19
|
+
- 'electric-sql/electric:packages/typescript-client/src/constants.ts'
|
|
20
|
+
- 'electric-sql/electric:examples/proxy-auth/app/shape-proxy/route.ts'
|
|
21
|
+
- 'electric-sql/electric:website/docs/guides/auth.md'
|
|
22
|
+
- 'electric-sql/electric:website/docs/guides/security.md'
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This skill builds on electric-shapes. Read it first for ShapeStream configuration.
|
|
26
|
+
|
|
27
|
+
# Electric — Proxy and Auth
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
|
|
33
|
+
|
|
34
|
+
// Server route (Next.js App Router example)
|
|
35
|
+
export async function GET(request: Request) {
|
|
36
|
+
const url = new URL(request.url)
|
|
37
|
+
const originUrl = new URL('/v1/shape', process.env.ELECTRIC_URL)
|
|
38
|
+
|
|
39
|
+
// Only forward Electric protocol params — never table/where from client
|
|
40
|
+
url.searchParams.forEach((value, key) => {
|
|
41
|
+
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
|
42
|
+
originUrl.searchParams.set(key, value)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Server decides shape definition
|
|
47
|
+
originUrl.searchParams.set('table', 'todos')
|
|
48
|
+
originUrl.searchParams.set('secret', process.env.ELECTRIC_SOURCE_SECRET!)
|
|
49
|
+
|
|
50
|
+
const response = await fetch(originUrl)
|
|
51
|
+
const headers = new Headers(response.headers)
|
|
52
|
+
headers.delete('content-encoding')
|
|
53
|
+
headers.delete('content-length')
|
|
54
|
+
|
|
55
|
+
return new Response(response.body, {
|
|
56
|
+
status: response.status,
|
|
57
|
+
statusText: response.statusText,
|
|
58
|
+
headers,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Client usage:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { ShapeStream } from '@electric-sql/client'
|
|
67
|
+
|
|
68
|
+
const stream = new ShapeStream({
|
|
69
|
+
url: '/api/todos', // Points to your proxy, not Electric directly
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Core Patterns
|
|
74
|
+
|
|
75
|
+
### Tenant isolation with WHERE params
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// In proxy route — inject user context server-side
|
|
79
|
+
const user = await getAuthUser(request)
|
|
80
|
+
originUrl.searchParams.set('table', 'todos')
|
|
81
|
+
originUrl.searchParams.set('where', 'org_id = $1')
|
|
82
|
+
originUrl.searchParams.set('params[1]', user.orgId)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Auth token refresh on 401
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const stream = new ShapeStream({
|
|
89
|
+
url: '/api/todos',
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: async () => `Bearer ${await getToken()}`,
|
|
92
|
+
},
|
|
93
|
+
onError: async (error) => {
|
|
94
|
+
if (error instanceof FetchError && error.status === 401) {
|
|
95
|
+
const newToken = await refreshToken()
|
|
96
|
+
return { headers: { Authorization: `Bearer ${newToken}` } }
|
|
97
|
+
}
|
|
98
|
+
return {}
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### CORS configuration for cross-origin proxies
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// In proxy response headers
|
|
107
|
+
headers.set(
|
|
108
|
+
'Access-Control-Expose-Headers',
|
|
109
|
+
'electric-offset, electric-handle, electric-schema, electric-cursor'
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Subset security (AND semantics)
|
|
114
|
+
|
|
115
|
+
Electric combines the main shape WHERE (set in proxy) with subset WHERE (from POST body) using AND. Subsets can only narrow results, never widen them:
|
|
116
|
+
|
|
117
|
+
```sql
|
|
118
|
+
-- Main shape: WHERE org_id = $1 (set by proxy)
|
|
119
|
+
-- Subset: WHERE status = 'active' (from client POST)
|
|
120
|
+
-- Effective: WHERE org_id = $1 AND status = 'active'
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Even `WHERE 1=1` in the subset cannot bypass the main shape's WHERE.
|
|
124
|
+
|
|
125
|
+
## Common Mistakes
|
|
126
|
+
|
|
127
|
+
### CRITICAL Forwarding all client params to Electric
|
|
128
|
+
|
|
129
|
+
Wrong:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
url.searchParams.forEach((value, key) => {
|
|
133
|
+
originUrl.searchParams.set(key, value)
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Correct:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
|
|
141
|
+
|
|
142
|
+
url.searchParams.forEach((value, key) => {
|
|
143
|
+
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
|
144
|
+
originUrl.searchParams.set(key, value)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
originUrl.searchParams.set('table', 'todos')
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Forwarding all params lets the client control `table`, `where`, and `columns`, accessing any Postgres table. Only forward `ELECTRIC_PROTOCOL_QUERY_PARAMS`.
|
|
151
|
+
|
|
152
|
+
Source: `examples/proxy-auth/app/shape-proxy/route.ts`
|
|
153
|
+
|
|
154
|
+
### CRITICAL Not deleting content-encoding and content-length headers
|
|
155
|
+
|
|
156
|
+
Wrong:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
return new Response(response.body, {
|
|
160
|
+
status: response.status,
|
|
161
|
+
headers: response.headers,
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Correct:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const headers = new Headers(response.headers)
|
|
169
|
+
headers.delete('content-encoding')
|
|
170
|
+
headers.delete('content-length')
|
|
171
|
+
return new Response(response.body, { status: response.status, headers })
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`fetch()` decompresses the response body but keeps the original `content-encoding` and `content-length` headers, causing browser decoding failures.
|
|
175
|
+
|
|
176
|
+
Source: `examples/proxy-auth/app/shape-proxy/route.ts:49-56`
|
|
177
|
+
|
|
178
|
+
### CRITICAL Exposing ELECTRIC_SECRET or SOURCE_SECRET to browser
|
|
179
|
+
|
|
180
|
+
Wrong:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// Client-side code
|
|
184
|
+
const url = `/v1/shape?table=todos&secret=${import.meta.env.VITE_ELECTRIC_SOURCE_SECRET}`
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Correct:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// Server proxy only
|
|
191
|
+
originUrl.searchParams.set('secret', process.env.ELECTRIC_SOURCE_SECRET!)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Bundlers like Vite expose `VITE_*` env vars to client code. The secret must only be injected server-side in the proxy.
|
|
195
|
+
|
|
196
|
+
Source: `AGENTS.md:17-20`
|
|
197
|
+
|
|
198
|
+
### CRITICAL SQL injection in WHERE clause via string interpolation
|
|
199
|
+
|
|
200
|
+
Wrong:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
originUrl.searchParams.set('where', `org_id = '${user.orgId}'`)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Correct:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
originUrl.searchParams.set('where', 'org_id = $1')
|
|
210
|
+
originUrl.searchParams.set('params[1]', user.orgId)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
String interpolation in WHERE clauses enables SQL injection. Use positional params (`$1`, `$2`).
|
|
214
|
+
|
|
215
|
+
Source: `website/docs/guides/auth.md`
|
|
216
|
+
|
|
217
|
+
### HIGH Not exposing Electric response headers via CORS
|
|
218
|
+
|
|
219
|
+
Wrong:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// No CORS header configuration — browser strips custom headers
|
|
223
|
+
return new Response(response.body, { headers })
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Correct:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
headers.set(
|
|
230
|
+
'Access-Control-Expose-Headers',
|
|
231
|
+
'electric-offset, electric-handle, electric-schema, electric-cursor'
|
|
232
|
+
)
|
|
233
|
+
return new Response(response.body, { headers })
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The client throws `MissingHeadersError` if Electric response headers are stripped by CORS. Expose `electric-offset`, `electric-handle`, `electric-schema`, and `electric-cursor`.
|
|
237
|
+
|
|
238
|
+
Source: `packages/typescript-client/src/error.ts:109-118`
|
|
239
|
+
|
|
240
|
+
### CRITICAL Calling Electric directly from production client
|
|
241
|
+
|
|
242
|
+
Wrong:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
new ShapeStream({
|
|
246
|
+
url: 'https://my-electric.example.com/v1/shape',
|
|
247
|
+
params: { table: 'todos' },
|
|
248
|
+
})
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Correct:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
new ShapeStream({
|
|
255
|
+
url: '/api/todos', // Your proxy route
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Electric's HTTP API is public by default with no auth. Always proxy through your server so the server controls shape definitions and injects secrets.
|
|
260
|
+
|
|
261
|
+
Source: `AGENTS.md:19-20`
|
|
262
|
+
|
|
263
|
+
See also: electric-shapes/SKILL.md — Shape URLs must point to proxy routes, not directly to Electric.
|
|
264
|
+
See also: electric-deployment/SKILL.md — Production requires ELECTRIC_SECRET and proxy; dev uses ELECTRIC_INSECURE=true.
|
|
265
|
+
See also: electric-postgres-security/SKILL.md — Proxy injects secrets that Postgres security enforces.
|
|
266
|
+
|
|
267
|
+
## Version
|
|
268
|
+
|
|
269
|
+
Targets @electric-sql/client v1.5.10.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: electric-schema-shapes
|
|
3
|
+
description: >
|
|
4
|
+
Design Postgres schema and Electric shape definitions together for a new
|
|
5
|
+
feature. Covers single-table shape constraint, cross-table joins using
|
|
6
|
+
multiple shapes, WHERE clause design for tenant isolation, column selection
|
|
7
|
+
for bandwidth optimization, replica mode choice (default vs full for
|
|
8
|
+
old_value), enum casting in WHERE clauses, and txid handshake setup with
|
|
9
|
+
pg_current_xact_id() for optimistic writes. Load when designing database
|
|
10
|
+
tables for use with Electric shapes.
|
|
11
|
+
type: core
|
|
12
|
+
library: electric
|
|
13
|
+
library_version: '1.5.10'
|
|
14
|
+
requires:
|
|
15
|
+
- electric-shapes
|
|
16
|
+
sources:
|
|
17
|
+
- 'electric-sql/electric:AGENTS.md'
|
|
18
|
+
- 'electric-sql/electric:website/docs/guides/shapes.md'
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
This skill builds on electric-shapes. Read it first for ShapeStream configuration.
|
|
22
|
+
|
|
23
|
+
# Electric — Schema and Shapes
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
Design tables knowing each shape syncs one table. For cross-table data, use multiple shapes with client-side joins.
|
|
28
|
+
|
|
29
|
+
```sql
|
|
30
|
+
-- Schema designed for Electric shapes
|
|
31
|
+
CREATE TABLE todos (
|
|
32
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
33
|
+
org_id UUID NOT NULL,
|
|
34
|
+
text TEXT NOT NULL,
|
|
35
|
+
completed BOOLEAN DEFAULT false,
|
|
36
|
+
created_at TIMESTAMPTZ DEFAULT now()
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
ALTER TABLE todos REPLICA IDENTITY FULL;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { ShapeStream } from '@electric-sql/client'
|
|
44
|
+
|
|
45
|
+
const todoStream = new ShapeStream({
|
|
46
|
+
url: '/api/todos', // Proxy sets: table=todos, where=org_id=$1
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Core Patterns
|
|
51
|
+
|
|
52
|
+
### Cross-table data with multiple shapes
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// Each shape syncs one table — join client-side
|
|
56
|
+
const todoStream = new ShapeStream({ url: '/api/todos' })
|
|
57
|
+
const userStream = new ShapeStream({ url: '/api/users' })
|
|
58
|
+
|
|
59
|
+
// With TanStack DB, use .join() in live queries:
|
|
60
|
+
// q.from({ todo: todoCollection })
|
|
61
|
+
// .join({ user: userCollection }, ({ todo, user }) => eq(todo.userId, user.id))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Choose replica mode
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Default: only changed columns sent on update
|
|
68
|
+
const stream = new ShapeStream({ url: '/api/todos' })
|
|
69
|
+
|
|
70
|
+
// Full: all columns + old_value on updates (more bandwidth, needed for diffs)
|
|
71
|
+
const stream = new ShapeStream({
|
|
72
|
+
url: '/api/todos',
|
|
73
|
+
params: { replica: 'full' },
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Backend txid handshake for optimistic writes
|
|
78
|
+
|
|
79
|
+
Call `pg_current_xact_id()::xid::text` inside the same transaction as your mutation. If you query it outside the transaction, you get a different txid and the client will never reconcile.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// API endpoint — txid MUST be in the same transaction as the INSERT
|
|
83
|
+
app.post('/api/todos', async (req, res) => {
|
|
84
|
+
const client = await pool.connect()
|
|
85
|
+
try {
|
|
86
|
+
await client.query('BEGIN')
|
|
87
|
+
const result = await client.query(
|
|
88
|
+
'INSERT INTO todos (id, text, org_id) VALUES ($1, $2, $3) RETURNING id',
|
|
89
|
+
[crypto.randomUUID(), req.body.text, req.body.orgId]
|
|
90
|
+
)
|
|
91
|
+
const txResult = await client.query(
|
|
92
|
+
'SELECT pg_current_xact_id()::xid::text AS txid'
|
|
93
|
+
)
|
|
94
|
+
await client.query('COMMIT')
|
|
95
|
+
// txid accepts number | bigint | `${bigint}`
|
|
96
|
+
res.json({ id: result.rows[0].id, txid: parseInt(txResult.rows[0].txid) })
|
|
97
|
+
} finally {
|
|
98
|
+
client.release()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// Client awaits txid before dropping optimistic state
|
|
105
|
+
await todoCollection.utils.awaitTxId(txid)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Common Mistakes
|
|
109
|
+
|
|
110
|
+
### HIGH Designing shapes that span multiple tables
|
|
111
|
+
|
|
112
|
+
Wrong:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const stream = new ShapeStream({
|
|
116
|
+
url: '/api/data',
|
|
117
|
+
params: {
|
|
118
|
+
table: 'todos JOIN users ON todos.user_id = users.id',
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Correct:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const todoStream = new ShapeStream({ url: '/api/todos' })
|
|
127
|
+
const userStream = new ShapeStream({ url: '/api/users' })
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Shapes are single-table only. Cross-table data requires multiple shapes joined client-side via TanStack DB live queries.
|
|
131
|
+
|
|
132
|
+
Source: `AGENTS.md:104-105`
|
|
133
|
+
|
|
134
|
+
### MEDIUM Using enum columns without casting to text in WHERE
|
|
135
|
+
|
|
136
|
+
Wrong:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// Proxy route
|
|
140
|
+
originUrl.searchParams.set('where', "status IN ('active', 'done')")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Correct:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
originUrl.searchParams.set('where', "status::text IN ('active', 'done')")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Enum types in WHERE clauses require explicit `::text` cast. Without it, the query may fail or return unexpected results.
|
|
150
|
+
|
|
151
|
+
Source: `packages/sync-service/lib/electric/replication/eval/env/known_functions.ex`
|
|
152
|
+
|
|
153
|
+
### HIGH Not setting up txid handshake for optimistic writes
|
|
154
|
+
|
|
155
|
+
Wrong:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// Backend: just INSERT, return id
|
|
159
|
+
app.post('/api/todos', async (req, res) => {
|
|
160
|
+
const result = await db.query(
|
|
161
|
+
'INSERT INTO todos (text) VALUES ($1) RETURNING id',
|
|
162
|
+
[req.body.text]
|
|
163
|
+
)
|
|
164
|
+
res.json({ id: result.rows[0].id })
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Correct:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// Backend: INSERT and return txid in same transaction
|
|
172
|
+
app.post('/api/todos', async (req, res) => {
|
|
173
|
+
const client = await pool.connect()
|
|
174
|
+
try {
|
|
175
|
+
await client.query('BEGIN')
|
|
176
|
+
const result = await client.query(
|
|
177
|
+
'INSERT INTO todos (text) VALUES ($1) RETURNING id',
|
|
178
|
+
[req.body.text]
|
|
179
|
+
)
|
|
180
|
+
const txResult = await client.query(
|
|
181
|
+
'SELECT pg_current_xact_id()::xid::text AS txid'
|
|
182
|
+
)
|
|
183
|
+
await client.query('COMMIT')
|
|
184
|
+
res.json({ id: result.rows[0].id, txid: parseInt(txResult.rows[0].txid) })
|
|
185
|
+
} finally {
|
|
186
|
+
client.release()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Without txid, the UI flickers when optimistic state is dropped before the synced version arrives from Electric. The client uses `awaitTxId(txid)` to hold optimistic state until the real data syncs.
|
|
192
|
+
|
|
193
|
+
Source: `AGENTS.md:116-119`
|
|
194
|
+
|
|
195
|
+
See also: electric-shapes/SKILL.md — Shapes are immutable; dynamic filters require new ShapeStream instances.
|
|
196
|
+
See also: electric-orm/SKILL.md — Schema design affects both shapes (read) and ORM queries (write).
|
|
197
|
+
|
|
198
|
+
## Version
|
|
199
|
+
|
|
200
|
+
Targets @electric-sql/client v1.5.10.
|