Skip to content

ElectricSQL

The @livestore/sync-electric package lets you sync LiveStore with ElectricSQL.

  • Package: pnpm add @livestore/sync-electric
  • Protocol: HTTP push/pull with long-polling support

The API proxy has dual responsibilities:

  • Push Events: Writes events directly to Postgres tables (bypasses Electric)
  • Pull Requests: Proxies to Electric server for reading events
  • Authentication: Implements your custom auth logic
  • Database Management: Initializes tables and manages connections

Basic usage in your worker/server code:

import {
const makeSyncBackend: ({ endpoint, ...options }: SyncBackendOptions) => SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses ElectricSQL for real-time event synchronization.

ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients. This backend handles push (inserting events) and pull (streaming events via Electric's shape-based sync protocol).

The endpoint should typically be part of your API layer to handle authentication, rate limiting, and proxying requests to the Electric server.

@example

import { makeSyncBackend } from '@livestore/sync-electric'
const adapter = makePersistedAdapter({
sync: {
backend: makeSyncBackend({
endpoint: '/api/electric',
}),
},
})

@example

// With separate endpoints for push/pull/ping
const backend = makeSyncBackend({
endpoint: {
push: '/api/push-event',
pull: '/api/pull-events',
ping: '/api/ping',
},
ping: {
enabled: true,
requestInterval: 15_000, // 15 seconds
},
})

@seehttps://livestore.dev/docs/sync/electric for setup guide

makeSyncBackend
} from '@livestore/sync-electric'
const
const _backend: SyncBackendConstructor<Struct.ReadonlySide<{
offset: typeof String;
handle: typeof String;
}, "Type">, JsonValue>
_backend
=
function makeSyncBackend({ endpoint, ...options }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses ElectricSQL for real-time event synchronization.

ElectricSQL enables real-time sync by streaming PostgreSQL changes to clients. This backend handles push (inserting events) and pull (streaming events via Electric's shape-based sync protocol).

The endpoint should typically be part of your API layer to handle authentication, rate limiting, and proxying requests to the Electric server.

@example

import { makeSyncBackend } from '@livestore/sync-electric'
const adapter = makePersistedAdapter({
sync: {
backend: makeSyncBackend({
endpoint: '/api/electric',
}),
},
})

@example

// With separate endpoints for push/pull/ping
const backend = makeSyncBackend({
endpoint: {
push: '/api/push-event',
pull: '/api/pull-events',
ping: '/api/ping',
},
ping: {
enabled: true,
requestInterval: 15_000, // 15 seconds
},
})

@seehttps://livestore.dev/docs/sync/electric for setup guide

makeSyncBackend
({
SyncBackendOptions.endpoint: string | {
push: string;
pull: string;
ping: string;
}

The endpoint to pull/push events. Pull is a GET request, push is a POST request. Usually this endpoint is part of your API layer to proxy requests to the Electric server e.g. to implement auth, rate limiting, etc.

@example "/api/electric"

@example { push: "/api/push-event", pull: "/api/pull-event" }

endpoint
: '/api/electric', // Your API proxy endpoint
SyncBackendOptions.ping?: {
enabled?: boolean;
requestTimeout?: Duration.DurationInput;
requestInterval?: Duration.DurationInput;
}
ping
: {
enabled?: boolean

@defaulttrue

enabled
: true },
})

ElectricSQL requires an API proxy on your server to handle authentication and database operations. Your proxy needs two endpoints:

// GET /api/electric - Pull events (proxied through Electric)
export const
const GET: (request: Request) => Promise<Response>
GET
= async (
request: Request<unknown, CfProperties<unknown>>
request
:
interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>

The Request interface of the Fetch API represents a resource request.

MDN Reference

Request
) => {
const
const searchParams: URLSearchParams
searchParams
= new
var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL.

MDN Reference

URL
(
request: Request<unknown, CfProperties<unknown>>
request
.
Request<unknown, CfProperties<unknown>>.url: string

The url read-only property of the Request interface contains the URL of the request.

MDN Reference

url
).
URL.searchParams: URLSearchParams

The searchParams read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL.

MDN Reference

searchParams
const {
const url: string

The URL to the ElectricSQL API endpoint with needed search params.

url
,
const storeId: string

The Livestore storeId

storeId
,
const needsInit: boolean

Whether the Postgres table needs to be created.

needsInit
} =
function makeElectricUrl({ electricHost, searchParams: providedSearchParams, sourceId, sourceSecret, apiSecret, }: {
electricHost: string;
searchParams: URLSearchParams;
sourceId?: string;
sourceSecret?: string;
apiSecret?: string;
}): {
url: string;
storeId: string;
needsInit: boolean;
payload: JsonValue | undefined;
}

This function should be called in a trusted environment (e.g. a proxy server) as it requires access to senstive information (e.g. apiSecret / sourceSecret).

makeElectricUrl
({
electricHost: string
electricHost
,
searchParams: URLSearchParams

Needed to extract information from the search params which the @livestore/sync-electric client implementation automatically adds:

  • handle: the ElectricSQL handle
  • storeId: the Livestore storeId

searchParams
,
apiSecret?: string

For self-hosted ElectricSQL

apiSecret
: 'your-electric-secret',
})
// Add your authentication logic here
// if (!isAuthenticated(request)) {
// return new Response('Unauthorized', { status: 401 })
// Initialize database tables if needed
if (
const needsInit: boolean

Whether the Postgres table needs to be created.

needsInit
=== true) {
const
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
=
const makeDb: (storeId: string) => {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}

Placeholder for your database factory function

makeDb
(
const storeId: string

The Livestore storeId

storeId
)
await
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
.
migrate: () => Promise<void>
migrate
()
await
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
.
disconnect: () => Promise<void>
disconnect
()
}
// Proxy pull request to Electric server for reading
return
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+3 overloads)
fetch
(
const url: string

The URL to the ElectricSQL API endpoint with needed search params.

url
)
}
// POST /api/electric - Push events (direct database write)
export const
const POST: (request: Request) => Promise<Response>
POST
= async (
request: Request<unknown, CfProperties<unknown>>
request
:
interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>>

The Request interface of the Fetch API represents a resource request.

MDN Reference

Request
) => {
const
const payload: unknown
payload
= await
request: Request<unknown, CfProperties<unknown>>
request
.
Body.json<unknown>(): Promise<unknown> (+1 overload)
json
()
const
const parsed: unknown
parsed
=
import Schema
Schema
.
function decodeUnknownSync<Schema.ConstraintDecoder<unknown, never>>(schema: Schema.ConstraintDecoder<unknown, never>, options?: ParseOptions): (input: unknown, options?: ParseOptions) => unknown

Decodes an unknown input against a schema synchronously, returning the decoded value or throwing a

SchemaError

for schema mismatches.

When to use

Use when you need to validate unknown data at a synchronous boundary and want schema mismatches to throw SchemaError.

Details

For input already typed as the schema's Encoded type use decodeSync. Only service-free schemas can be decoded synchronously. For alternatives that do not throw on schema mismatches, see decodeUnknownOption, decodeUnknownExit, or decodeUnknownEffect. Options may be provided either when creating the decoder or when applying it; application options override creation options.

Gotchas

Non-schema failures may throw a runtime failure instead of SchemaError.

Example (Decoding with a transformation schema)

import { Schema } from "effect"
const NumberFromString = Schema.NumberFromString
console.log(Schema.decodeUnknownSync(NumberFromString)("42"))
// Output: 42
Schema.decodeUnknownSync(NumberFromString)("not a number")
// throws SchemaError: NumberFromString
// └─ Encoded side transformation failure
// └─ NumberFromString
// └─ Expected a numeric string, actual "not a number"

@seeSchemaParser.decodeUnknownSync for the adapter that throws an Error whose cause is SchemaIssue.Issue

@since4.0.0

decodeUnknownSync
(
import ApiSchema
ApiSchema
.
const PushPayload: Schema.Struct<{
_tag: Schema.tag<"@livestore/sync-electric.Push">;
} & {
storeId: typeof Schema.String;
batch: Schema.Array$<Schema.Struct<{
name: typeof Schema.String;
args: typeof Schema.Any;
seqNum: Schema.BrandSchema<number & Brand<"GlobalEventSequenceNumber">, number, never>;
parentSeqNum: Schema.BrandSchema<...>;
clientId: typeof Schema.String;
sessionId: typeof Schema.String;
}>>;
}>
PushPayload
)(
const payload: unknown
payload
)
// Write events directly to Postgres table (bypasses Electric)
const
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
=
const makeDb: (storeId: string) => {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}

Placeholder for your database factory function

makeDb
(
const parsed: unknown
parsed
.
any
storeId
)
await
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
.
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>
createEvents
(
const parsed: unknown
parsed
.
any
batch
)
await
const db: {
migrate: () => Promise<void>;
disconnect: () => Promise<void>;
createEvents: (batch: (typeof ApiSchema.PushPayload.Type)["batch"]) => Promise<void>;
}
db
.
disconnect: () => Promise<void>
disconnect
()
return
var Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
.
function json(data: any, init?: ResponseInit): Response

The json() static method of the Response interface returns a Response that contains the provided JSON data as body, and a Content-Type header which is set to application/json. The response status, status message, and additional headers can also be set.

MDN Reference

json
({
success: boolean
success
: true })
}
  • Database Setup: Ensure your Postgres database is configured for Electric
  • Authentication: Implement proper auth checks in your proxy
  • Error Handling: Add robust error handling for database operations
  • Connection Management: Properly manage database connections

See the todomvc-sync-electric example for a complete implementation.

The initial version of the ElectricSQL sync provider will use the server-side Postgres DB as a store for the mutation event history.

Events are stored in a table following the pattern eventlog_${PERSISTENCE_FORMAT_VERSION}_${storeId} where PERSISTENCE_FORMAT_VERSION is a number that is incremented whenever the sync-electric internal storage format changes.

Can I use my existing Postgres database with the sync provider?

Section titled “Can I use my existing Postgres database with the sync provider?”

Unless the database is already modelled as a eventlog following the @livestore/sync-electric storage format, you won’t be able to easily use your existing database with this sync backend implementation.

We might support this use case in the future, you can follow the progress here. Please share any feedback you have on this use case there.

Why do I need an API proxy in front of the ElectricSQL server?

Section titled “Why do I need an API proxy in front of the ElectricSQL server?”

The API proxy is used to handle pull/push requests between LiveStore and ElectricSQL, allowing you to implement custom logic such as:

  • Authentication and authorization
  • Rate limiting and quota management
  • Database initialization and migration
  • Custom business logic and validation