Electric offline guide
ExpressX integrates with ElectricSQL through @jcbuisson/express-x-plugins. ExpressX handles authorized PostgreSQL mutations, while Electric Shapes stream database changes to browser clients.
Architecture
@jcbuisson/express-x/servercreates the server application and websocket services.@jcbuisson/express-x/clientcreates the browser service client.@jcbuisson/express-x-plugins/electric-serverregisters mutation services and an authenticated Shape proxy.@jcbuisson/express-x-plugins/electric-clientexposes reactive Electric models.
The browser never receives Electric source credentials. It requests Shapes through the server proxy, and all writes go through ExpressX authorization before reaching PostgreSQL.
Server setup
Install the server dependencies:
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins pgEach synchronized table needs a primary key. The default is uid; configure primaryKey when a table uses another column.
import { Pool } from 'pg'
import { expressX } from '@jcbuisson/express-x/server'
import { electricOfflinePlugin } from '@jcbuisson/express-x-plugins/electric-server'
const app = expressX()
const db = new Pool({ connectionString: process.env.DATABASE_URL })
app.configure(electricOfflinePlugin, db, [
'post',
{ name: 'project', table: 'projects', primaryKey: 'id' },
], {
electricUrl: process.env.ELECTRIC_URL,
sourceId: process.env.ELECTRIC_SOURCE_ID,
sourceSecret: process.env.ELECTRIC_SOURCE_SECRET,
authorize: async (context, operation) => {
// Shape requests use HTTP; mutations use websocket contexts.
const user = context.request?.user ?? context.socket?.data?.user
return Boolean(user && canAccess(user, operation))
},
})
app.httpServer.listen(8000)authorize(context, operation) is required. operation contains modelName, action, and args; its action is shape, createWithMeta, updateWithMeta, or deleteWithMeta. Treat model names, filters, identifiers, timestamps, and mutation data as untrusted input.
Server options:
| Option | Default | Description |
|---|---|---|
authorize | required | Authorizes every Shape request and mutation |
electricUrl | ELECTRIC_URL or http://localhost:3000/v1/shape | Upstream Electric Shape endpoint |
shapePath | /electric/v1/shape/:model | Express route exposed to clients |
sourceId | none | Electric source ID, kept server-side |
sourceSecret | none | Electric source secret, kept server-side |
fetch | globalThis.fetch | Optional custom Fetch implementation |
Models may be table-name strings or { name, table, primaryKey } objects. Names, tables, primary keys, and mutation column names must be simple PostgreSQL identifiers.
Mutation services
The plugin registers one ExpressX service per model with three methods:
| Method | Description |
|---|---|
createWithMeta(uid, data, createdAt) | Insert or update a row with the supplied key |
updateWithMeta(uid, data, updatedAt) | Update a row by primary key |
deleteWithMeta(uid, deletedAt) | Delete a row by primary key |
Each method returns [value, meta]. meta.txid contains the PostgreSQL transaction ID for applications that need to correlate a mutation with the matching Electric Shape update. The convenience client methods return only value.
Client setup
Install the optional browser dependencies:
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins socket.io-client @electric-sql/client rxjsimport { io } from 'socket.io-client'
import { createClient } from '@jcbuisson/express-x/client'
import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
const socket = io('http://localhost:8000', { transports: ['websocket'] })
const app = createClient(socket)
app.configure(electricClientPlugin, {
shapePath: '/electric/v1/shape',
})
const post = app.createElectricModel('post')createElectricModel(modelName, options) accepts an optional Shape url and streamOptions. The model name must match the ExpressX service registered on the server.
CRUD operations
const created = await post.create({ title: 'Hello', published: false })
await post.update(created.uid, { published: true })
await post.remove(created.uid)The browser generates new IDs with crypto.randomUUID(). Mutations are network operations and are not queued while offline. Electric's Shape stream retries transient read failures and catches up after reconnection.
Real-time observable
getObservable(where) returns an RxJS Observable that emits the current Shape rows whenever they change:
const subscription = post.getObservable({
published: true,
created_at: { gte: new Date('2026-01-01') },
}).subscribe(rows => {
console.log(rows)
})
// Dispose it when the owning view or process is finished.
subscription.unsubscribe()Filters support exact values, null, and the range operators gt, gte, lt, and lte. Values are parameterized; unsupported objects, invalid dates, non-finite numbers, and unsafe identifiers are rejected.
All Electric cursor parameters in streamOptions.params are forwarded. The client cannot override the table selected by the configured model, and source credentials remain on the server.