Skip to content
On this page

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/server creates the server application and websocket services.
  • @jcbuisson/express-x/client creates the browser service client.
  • @jcbuisson/express-x-plugins/electric-server registers mutation services and an authenticated Shape proxy.
  • @jcbuisson/express-x-plugins/electric-client exposes 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:

bash
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins pg

Each synchronized table needs a primary key. The default is uid; configure primaryKey when a table uses another column.

js
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:

OptionDefaultDescription
authorizerequiredAuthorizes every Shape request and mutation
electricUrlELECTRIC_URL or http://localhost:3000/v1/shapeUpstream Electric Shape endpoint
shapePath/electric/v1/shape/:modelExpress route exposed to clients
sourceIdnoneElectric source ID, kept server-side
sourceSecretnoneElectric source secret, kept server-side
fetchglobalThis.fetchOptional 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:

MethodDescription
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:

bash
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins socket.io-client @electric-sql/client rxjs
js
import { 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

js
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:

js
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.