@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
1,490 lines (1,337 loc) • 71.1 kB
text/typescript
import {
Message,
Offset,
Schema,
Row,
MaybePromise,
GetExtensions,
ChangeMessage,
SnapshotMetadata,
SubsetParams,
} from './types'
import { MessageParser, Parser, TransformFunction } from './parser'
import {
ColumnMapper,
encodeWhereClause,
quoteIdentifier,
} from './column-mapper'
import {
getOffset,
isUpToDateMessage,
isChangeMessage,
bigintSafeStringify,
} from './helpers'
import {
FetchError,
FetchBackoffAbortError,
MissingShapeUrlError,
InvalidSignalError,
MissingShapeHandleError,
ReservedParamError,
MissingHeadersError,
StaleCacheError,
} from './error'
import {
BackoffDefaults,
BackoffOptions,
createFetchWithBackoff,
createFetchWithChunkBuffer,
createFetchWithConsumedMessages,
createFetchWithResponseHeadersCheck,
} from './fetch'
import {
CHUNK_LAST_OFFSET_HEADER,
LIVE_CACHE_BUSTER_HEADER,
LIVE_CACHE_BUSTER_QUERY_PARAM,
EXPIRED_HANDLE_QUERY_PARAM,
COLUMNS_QUERY_PARAM,
LIVE_QUERY_PARAM,
OFFSET_QUERY_PARAM,
SHAPE_HANDLE_HEADER,
SHAPE_HANDLE_QUERY_PARAM,
SHAPE_SCHEMA_HEADER,
WHERE_QUERY_PARAM,
WHERE_PARAMS_PARAM,
TABLE_QUERY_PARAM,
REPLICA_PARAM,
FORCE_DISCONNECT_AND_REFRESH,
PAUSE_STREAM,
SYSTEM_WAKE,
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
LIVE_SSE_QUERY_PARAM,
ELECTRIC_PROTOCOL_QUERY_PARAMS,
LOG_MODE_QUERY_PARAM,
SUBSET_PARAM_WHERE,
SUBSET_PARAM_WHERE_PARAMS,
SUBSET_PARAM_LIMIT,
SUBSET_PARAM_OFFSET,
SUBSET_PARAM_ORDER_BY,
SUBSET_PARAM_WHERE_EXPR,
SUBSET_PARAM_ORDER_BY_EXPR,
CACHE_BUSTER_QUERY_PARAM,
} from './constants'
import { compileExpression, compileOrderBy } from './expression-compiler'
import {
EventSourceMessage,
fetchEventSource,
} from '@microsoft/fetch-event-source'
import { expiredShapesCache } from './expired-shapes-cache'
import { upToDateTracker } from './up-to-date-tracker'
import { SnapshotTracker } from './snapshot-tracker'
import {
createInitialState,
ErrorState,
PausedState,
ShapeStreamState,
} from './shape-stream-state'
import { PauseLock } from './pause-lock'
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
LIVE_CACHE_BUSTER_QUERY_PARAM,
SHAPE_HANDLE_QUERY_PARAM,
LIVE_QUERY_PARAM,
OFFSET_QUERY_PARAM,
CACHE_BUSTER_QUERY_PARAM,
])
const TROUBLESHOOTING_URL = `https://electric-sql.com/docs/guides/troubleshooting`
function createCacheBuster(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
type Replica = `full` | `default`
export type LogMode = `changes_only` | `full`
/**
* PostgreSQL-specific shape parameters that can be provided externally
*/
export interface PostgresParams<T extends Row<unknown> = Row> {
/** The root table for the shape. Not required if you set the table in your proxy. */
table?: string
/**
* The columns to include in the shape.
* Must include primary keys, and can only include valid columns.
* Defaults to all columns of the type `T`. If provided, must include primary keys, and can only include valid columns.
*/
columns?: (keyof T)[]
/** The where clauses for the shape */
where?: string
/**
* Positional where clause paramater values. These will be passed to the server
* and will substitute `$i` parameters in the where clause.
*
* It can be an array (note that positional arguments start at 1, the array will be mapped
* accordingly), or an object with keys matching the used positional parameters in the where clause.
*
* If where clause is `id = $1 or id = $2`, params must have keys `"1"` and `"2"`, or be an array with length 2.
*/
params?: Record<`${number}`, string> | string[]
/**
* If `replica` is `default` (the default) then Electric will only send the
* changed columns in an update.
*
* If it's `full` Electric will send the entire row with both changed and
* unchanged values. `old_value` will also be present on update messages,
* containing the previous value for changed columns.
*
* Setting `replica` to `full` will result in higher bandwidth
* usage and so is not generally recommended.
*/
replica?: Replica
}
type SerializableParamValue = string | string[] | Record<string, string>
type ParamValue =
| SerializableParamValue
| (() => SerializableParamValue | Promise<SerializableParamValue>)
/**
* External params type - what users provide.
* Excludes reserved parameters to prevent dynamic variations that could cause stream shape changes.
*/
export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
[K in string]: ParamValue | undefined
} & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
type ReservedParamKeys =
| typeof LIVE_CACHE_BUSTER_QUERY_PARAM
| typeof SHAPE_HANDLE_QUERY_PARAM
| typeof LIVE_QUERY_PARAM
| typeof OFFSET_QUERY_PARAM
| typeof CACHE_BUSTER_QUERY_PARAM
| `subset__${string}`
/**
* External headers type - what users provide.
* Allows string or function values for any header.
*/
export type ExternalHeadersRecord = {
[key: string]: string | (() => string | Promise<string>)
}
/**
* Internal params type - used within the library.
* All values are converted to strings.
*/
type InternalParamsRecord = {
[K in string as K extends ReservedParamKeys ? never : K]:
| string
| Record<string, string>
}
/**
* Helper function to resolve a function or value to its final value
*/
export async function resolveValue<T>(
value: T | (() => T | Promise<T>)
): Promise<T> {
if (typeof value === `function`) {
return (value as () => T | Promise<T>)()
}
return value
}
/**
* Helper function to convert external params to internal format
*/
async function toInternalParams(
params: ExternalParamsRecord<Row>
): Promise<InternalParamsRecord> {
const entries = Object.entries(params)
const resolvedEntries = await Promise.all(
entries.map(async ([key, value]) => {
if (value === undefined) return [key, undefined]
const resolvedValue = await resolveValue(value)
return [
key,
Array.isArray(resolvedValue) ? resolvedValue.join(`,`) : resolvedValue,
]
})
)
return Object.fromEntries(
resolvedEntries.filter(([_, value]) => value !== undefined)
)
}
/**
* Helper function to resolve headers
*/
async function resolveHeaders(
headers?: ExternalHeadersRecord
): Promise<Record<string, string>> {
if (!headers) return {}
const entries = Object.entries(headers)
const resolvedEntries = await Promise.all(
entries.map(async ([key, value]) => [key, await resolveValue(value)])
)
return Object.fromEntries(resolvedEntries)
}
type RetryOpts = {
params?: ExternalParamsRecord
headers?: ExternalHeadersRecord
}
type ShapeStreamErrorHandler = (
error: Error
) => void | RetryOpts | Promise<void | RetryOpts>
/**
* Options for constructing a ShapeStream.
*/
export interface ShapeStreamOptions<T = never> {
/**
* The full URL to where the Shape is served. This can either be the Electric server
* directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape`
*/
url: string
/**
* The "offset" on the shape log. This is typically not set as the ShapeStream
* will handle this automatically. A common scenario where you might pass an offset
* is if you're maintaining a local cache of the log. If you've gone offline
* and are re-starting a ShapeStream to catch-up to the latest state of the Shape,
* you'd pass in the last offset and shapeHandle you'd seen from the Electric server
* so it knows at what point in the shape to catch you up from.
*/
offset?: Offset
/**
* Similar to `offset`, this isn't typically used unless you're maintaining
* a cache of the shape log.
*/
handle?: string
/**
* HTTP headers to attach to requests made by the client.
* Values can be strings or functions (sync or async) that return strings.
* Function values are resolved in parallel when needed, making this useful
* for authentication tokens or other dynamic headers.
*/
headers?: ExternalHeadersRecord
/**
* Additional request parameters to attach to the URL.
* Values can be strings, string arrays, or functions (sync or async) that return these types.
* Function values are resolved in parallel when needed, making this useful
* for user-specific parameters or dynamic filters.
*
* These will be merged with Electric's standard parameters.
* Note: You cannot use Electric's reserved parameter names
* (offset, handle, live, cursor).
*
* PostgreSQL-specific options like table, where, columns, and replica
* should be specified here.
*/
params?: ExternalParamsRecord
/**
* Automatically fetch updates to the Shape. If you just want to sync the current
* shape and stop, pass false.
*/
subscribe?: boolean
/**
* @deprecated No longer experimental, use {@link liveSse} instead.
*/
experimentalLiveSse?: boolean
/**
* Use Server-Sent Events (SSE) for live updates.
*/
liveSse?: boolean
/**
* Initial data loading mode
*/
log?: LogMode
signal?: AbortSignal
fetchClient?: typeof fetch
backoffOptions?: BackoffOptions
parser?: Parser<T>
/**
* Function to transform rows after parsing (e.g., for encryption, type coercion).
* Applied to data received from Electric.
*
* **Note**: If you're using `transformer` solely for column name transformation
* (e.g., snake_case → camelCase), consider using `columnMapper` instead, which
* provides bidirectional transformation and automatically encodes WHERE clauses.
*
* **Execution order** when both are provided:
* 1. `columnMapper.decode` runs first (renames columns)
* 2. `transformer` runs second (transforms values)
*
* @example
* ```typescript
* // For column renaming only - use columnMapper
* import { snakeCamelMapper } from '@electric-sql/client'
* const stream = new ShapeStream({ columnMapper: snakeCamelMapper() })
* ```
*
* @example
* ```typescript
* // For value transformation (encryption, etc.) - use transformer
* const stream = new ShapeStream({
* transformer: (row) => ({
* ...row,
* encrypted_field: decrypt(row.encrypted_field)
* })
* })
* ```
*
* @example
* ```typescript
* // Use both together
* const stream = new ShapeStream({
* columnMapper: snakeCamelMapper(), // Runs first: renames columns
* transformer: (row) => ({ // Runs second: transforms values
* ...row,
* encryptedData: decrypt(row.encryptedData)
* })
* })
* ```
*/
transformer?: TransformFunction<T>
/**
* Bidirectional column name mapper for transforming between database column names
* (e.g., snake_case) and application column names (e.g., camelCase).
*
* The mapper handles both:
* - **Decoding**: Database → Application (applied to query results)
* - **Encoding**: Application → Database (applied to WHERE clauses)
*
* @example
* ```typescript
* // Most common case: snake_case ↔ camelCase
* import { snakeCamelMapper } from '@electric-sql/client'
*
* const stream = new ShapeStream({
* url: 'http://localhost:3000/v1/shape',
* params: { table: 'todos' },
* columnMapper: snakeCamelMapper()
* })
* ```
*
* @example
* ```typescript
* // Custom mapping
* import { createColumnMapper } from '@electric-sql/client'
*
* const stream = new ShapeStream({
* columnMapper: createColumnMapper({
* user_id: 'userId',
* project_id: 'projectId',
* created_at: 'createdAt'
* })
* })
* ```
*/
columnMapper?: ColumnMapper
/**
* A function for handling shapestream errors.
*
* **Automatic retries**: The client automatically retries 5xx server errors, network
* errors, and 429 rate limits with exponential backoff. The `onError` callback is
* only invoked after these automatic retries are exhausted, or for non-retryable
* errors like 4xx client errors.
*
* When not provided, non-retryable errors will be thrown and syncing will stop.
*
* **Return value behavior**:
* - Return an **object** (RetryOpts or empty `{}`) to retry syncing:
* - `{}` - Retry with the same params and headers
* - `{ params }` - Retry with modified params
* - `{ headers }` - Retry with modified headers (e.g., refreshed auth token)
* - `{ params, headers }` - Retry with both modified
* - Return **void** or **undefined** to stop the stream permanently
*
* **Important**: If you want syncing to continue after an error (e.g., to retry
* on network failures), you MUST return at least an empty object `{}`. Simply
* logging the error and returning nothing will stop syncing.
*
* Supports async functions that return `Promise<void | RetryOpts>`.
*
* @example
* ```typescript
* // Retry on network errors, stop on others
* onError: (error) => {
* console.error('Stream error:', error)
* if (error instanceof FetchError && error.status >= 500) {
* return {} // Retry with same params
* }
* // Return void to stop on other errors
* }
* ```
*
* @example
* ```typescript
* // Refresh auth token on 401
* onError: async (error) => {
* if (error instanceof FetchError && error.status === 401) {
* const newToken = await refreshAuthToken()
* return { headers: { Authorization: `Bearer ${newToken}` } }
* }
* return {} // Retry other errors
* }
* ```
*/
onError?: ShapeStreamErrorHandler
/**
* HTTP method to use for subset snapshot requests (`requestSnapshot`/`fetchSnapshot`).
*
* - `'GET'` (default): Sends subset params as URL query parameters. May fail with
* HTTP 414 errors for large queries with many parameters.
* - `'POST'`: Sends subset params in request body as JSON. Recommended for queries
* with large parameter lists (e.g., `WHERE id = ANY($1)` with hundreds of IDs).
*
* This can be overridden per-request by passing `method` in the subset params.
*
* @example
* ```typescript
* const stream = new ShapeStream({
* url: 'http://localhost:3000/v1/shape',
* params: { table: 'items' },
* subsetMethod: 'POST', // Use POST for all subset requests
* })
* ```
*/
subsetMethod?: `GET` | `POST`
}
export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
subscribe(
callback: (
messages: Message<T>[]
) => MaybePromise<void> | { columns?: (keyof T)[] },
onError?: (error: FetchError | Error) => void
): () => void
unsubscribeAll(): void
isLoading(): boolean
lastSyncedAt(): number | undefined
lastSynced(): number
isConnected(): boolean
hasStarted(): boolean
isUpToDate: boolean
lastOffset: Offset
shapeHandle?: string
error?: unknown
mode: LogMode
forceDisconnectAndRefresh(): Promise<void>
requestSnapshot(params: SubsetParams): Promise<{
metadata: SnapshotMetadata
data: Array<Message<T>>
}>
fetchSnapshot(opts: SubsetParams): Promise<{
metadata: SnapshotMetadata
data: Array<ChangeMessage<T>>
}>
}
/**
* Creates a canonical shape key from a URL excluding only Electric protocol parameters
*/
function canonicalShapeKey(url: URL): string {
const cleanUrl = new URL(url.origin + url.pathname)
// Copy all params except Electric protocol ones that vary between requests
for (const [key, value] of url.searchParams) {
if (!ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
cleanUrl.searchParams.set(key, value)
}
}
cleanUrl.searchParams.sort()
return cleanUrl.toString()
}
/**
* Reads updates to a shape from Electric using HTTP requests and long polling or
* Server-Sent Events (SSE).
* Notifies subscribers when new messages come in. Doesn't maintain any history of the
* log but does keep track of the offset position and is the best way
* to consume the HTTP `GET /v1/shape` api.
*
* @constructor
* @param {ShapeStreamOptions} options - configure the shape stream
* @example
* Register a callback function to subscribe to the messages.
* ```
* const stream = new ShapeStream(options)
* stream.subscribe(messages => {
* // messages is 1 or more row updates
* })
* ```
*
* To use Server-Sent Events (SSE) for real-time updates:
* ```
* const stream = new ShapeStream({
* url: `http://localhost:3000/v1/shape`,
* liveSse: true
* })
* ```
*
* To abort the stream, abort the `signal`
* passed in via the `ShapeStreamOptions`.
* ```
* const aborter = new AbortController()
* const issueStream = new ShapeStream({
* url: `${BASE_URL}/${table}`
* subscribe: true,
* signal: aborter.signal,
* })
* // Later...
* aborter.abort()
* ```
*/
export class ShapeStream<T extends Row<unknown> = Row>
implements ShapeStreamInterface<T>
{
static readonly Replica = {
FULL: `full` as Replica,
DEFAULT: `default` as Replica,
}
readonly options: ShapeStreamOptions<GetExtensions<T>>
#error: unknown = null
readonly #fetchClient: typeof fetch
readonly #sseFetchClient: typeof fetch
readonly #messageParser: MessageParser<T>
readonly #subscribers = new Map<
object,
[
(messages: Message<T>[]) => MaybePromise<void>,
((error: Error) => void) | undefined,
]
>()
#started = false
#syncState: ShapeStreamState
#connected: boolean = false
#mode: LogMode
#onError?: ShapeStreamErrorHandler
#requestAbortController?: AbortController
#refreshCount = 0
#snapshotCounter = 0
get #isRefreshing(): boolean {
return this.#refreshCount > 0
}
#tickPromise?: Promise<void>
#tickPromiseResolver?: () => void
#tickPromiseRejecter?: (reason?: unknown) => void
#messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
#snapshotTracker = new SnapshotTracker()
#pauseLock: PauseLock
#currentFetchUrl?: URL // Current fetch URL for computing shape key
#lastSseConnectionStartTime?: number
#minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
#maxShortSseConnections = 3 // Fall back to long polling after this many short connections
#sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
#sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
#unsubscribeFromVisibilityChanges?: () => void
#unsubscribeFromWakeDetection?: () => void
#maxStaleCacheRetries = 3
// Fast-loop detection: track recent non-live requests to detect tight retry
// loops caused by proxy/CDN misconfiguration or stale client-side caches
#recentRequestEntries: Array<{ timestamp: number; offset: string }> = []
#fastLoopWindowMs = 500
#fastLoopThreshold = 5
#fastLoopBackoffBaseMs = 100
#fastLoopBackoffMaxMs = 5_000
#fastLoopConsecutiveCount = 0
#fastLoopMaxCount = 5
#pendingRequestShapeCacheBuster?: string
#maxSnapshotRetries = 5
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
this.options = { subscribe: true, ...options }
validateOptions(this.options)
this.#syncState = createInitialState({
offset: this.options.offset ?? `-1`,
handle: this.options.handle,
})
this.#pauseLock = new PauseLock({
onAcquired: () => {
this.#syncState = this.#syncState.pause()
if (this.#started) {
this.#requestAbortController?.abort(PAUSE_STREAM)
}
},
onReleased: () => {
if (!this.#started) return
if (this.options.signal?.aborted) return
// Don't transition syncState here — let #requestShape handle
// the PausedState→previous transition so it can detect
// resumingFromPause and avoid live long-polling.
this.#start().catch(() => {
// Errors from #start are handled internally via onError.
// This catch prevents unhandled promise rejection in Node/Bun.
})
},
})
// Build transformer chain: columnMapper.decode -> transformer
// columnMapper transforms column names, transformer transforms values
let transformer: TransformFunction<GetExtensions<T>> | undefined
if (options.columnMapper) {
const applyColumnMapper = (
row: Row<GetExtensions<T>>
): Row<GetExtensions<T>> => {
const result: Record<string, unknown> = {}
for (const [dbKey, value] of Object.entries(row)) {
const appKey = options.columnMapper!.decode(dbKey)
result[appKey] = value
}
return result as Row<GetExtensions<T>>
}
transformer = options.transformer
? (row: Row<GetExtensions<T>>) =>
options.transformer!(applyColumnMapper(row))
: applyColumnMapper
} else {
transformer = options.transformer
}
this.#messageParser = new MessageParser<T>(options.parser, transformer)
this.#onError = this.options.onError
this.#mode = this.options.log ?? `full`
const baseFetchClient =
options.fetchClient ??
((...args: Parameters<typeof fetch>) => fetch(...args))
const backOffOpts = {
...(options.backoffOptions ?? BackoffDefaults),
onFailedAttempt: () => {
this.#connected = false
options.backoffOptions?.onFailedAttempt?.()
},
}
const fetchWithBackoffClient = createFetchWithBackoff(
baseFetchClient,
backOffOpts
)
this.#sseFetchClient = createFetchWithResponseHeadersCheck(
createFetchWithChunkBuffer(fetchWithBackoffClient)
)
this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient)
this.#subscribeToVisibilityChanges()
}
get shapeHandle() {
return this.#syncState.handle
}
get error() {
return this.#error
}
get isUpToDate() {
return this.#syncState.isUpToDate
}
get lastOffset() {
return this.#syncState.offset
}
get mode() {
return this.#mode
}
async #start(): Promise<void> {
this.#started = true
this.#subscribeToWakeDetection()
try {
await this.#requestShape()
} catch (err) {
this.#error = err
if (err instanceof Error) {
this.#syncState = this.#syncState.toErrorState(err)
}
// Check if onError handler wants to retry
if (this.#onError) {
const retryOpts = await this.#onError(err as Error)
// Guard against null (typeof null === "object" in JavaScript)
const isRetryable = !(err instanceof MissingHeadersError)
if (retryOpts && typeof retryOpts === `object` && isRetryable) {
// Update params/headers but don't reset offset
// We want to continue from where we left off, not refetch everything
if (retryOpts.params) {
// Merge new params with existing params to preserve other parameters
this.options.params = {
...(this.options.params ?? {}),
...retryOpts.params,
}
}
if (retryOpts.headers) {
// Merge new headers with existing headers to preserve other headers
this.options.headers = {
...(this.options.headers ?? {}),
...retryOpts.headers,
}
}
// Clear the error since we're retrying
this.#error = null
if (this.#syncState instanceof ErrorState) {
this.#syncState = this.#syncState.retry()
}
this.#fastLoopConsecutiveCount = 0
this.#recentRequestEntries = []
// Restart from current offset
this.#started = false
await this.#start()
return
}
// onError returned void, meaning it doesn't want to retry
// This is an unrecoverable error, notify subscribers
if (err instanceof Error) {
this.#sendErrorToSubscribers(err)
}
this.#teardown()
return
}
// No onError handler provided, this is an unrecoverable error
// Notify subscribers and throw
if (err instanceof Error) {
this.#sendErrorToSubscribers(err)
}
this.#teardown()
throw err
}
this.#teardown()
}
#teardown() {
this.#connected = false
this.#tickPromiseRejecter?.()
this.#unsubscribeFromWakeDetection?.()
}
async #requestShape(requestShapeCacheBuster?: string): Promise<void> {
const activeCacheBuster =
requestShapeCacheBuster ?? this.#pendingRequestShapeCacheBuster
if (this.#pauseLock.isPaused) {
if (activeCacheBuster) {
this.#pendingRequestShapeCacheBuster = activeCacheBuster
}
return
}
if (
!this.options.subscribe &&
(this.options.signal?.aborted || this.#syncState.isUpToDate)
) {
return
}
// Only check for fast loops on non-live requests; live polling is expected to be rapid
if (!this.#syncState.isUpToDate) {
await this.#checkFastLoop()
} else {
this.#fastLoopConsecutiveCount = 0
this.#recentRequestEntries = []
}
let resumingFromPause = false
if (this.#syncState instanceof PausedState) {
resumingFromPause = true
this.#syncState = this.#syncState.resume()
}
const { url, signal } = this.options
const { fetchUrl, requestHeaders } = await this.#constructUrl(
url,
resumingFromPause
)
if (activeCacheBuster) {
fetchUrl.searchParams.set(CACHE_BUSTER_QUERY_PARAM, activeCacheBuster)
fetchUrl.searchParams.sort()
}
const abortListener = await this.#createAbortListener(signal)
const requestAbortController = this.#requestAbortController! // we know that it is not undefined because it is set by `this.#createAbortListener`
// Re-check after async setup — the lock may have been acquired
// during URL construction or abort controller creation (e.g., by
// requestSnapshot), when the abort controller didn't exist yet.
if (this.#pauseLock.isPaused) {
if (abortListener && signal) {
signal.removeEventListener(`abort`, abortListener)
}
if (activeCacheBuster) {
this.#pendingRequestShapeCacheBuster = activeCacheBuster
}
this.#requestAbortController = undefined
return
}
this.#pendingRequestShapeCacheBuster = undefined
try {
await this.#fetchShape({
fetchUrl,
requestAbortController,
headers: requestHeaders,
resumingFromPause,
})
} catch (e) {
const abortReason = requestAbortController.signal.reason
const isRestartAbort =
requestAbortController.signal.aborted &&
(abortReason === FORCE_DISCONNECT_AND_REFRESH ||
abortReason === SYSTEM_WAKE)
if (
(e instanceof FetchError || e instanceof FetchBackoffAbortError) &&
isRestartAbort
) {
return this.#requestShape()
}
if (e instanceof FetchBackoffAbortError) {
return // interrupted
}
if (e instanceof StaleCacheError) {
// Received a stale cached response from CDN with an expired handle.
// The #staleCacheBuster has been set in #onInitialResponse, so retry
// the request which will include a random cache buster to bypass the
// misconfigured CDN cache.
return this.#requestShape()
}
if (!(e instanceof FetchError)) throw e // should never happen
if (e.status == 409) {
// Upon receiving a 409, start from scratch with the newly
// provided shape handle. If the header is missing (e.g. proxy
// stripped it), reset without a handle and use a random
// cache-buster query param to ensure the retry URL is unique.
// Store the current shape URL as expired to avoid future 409s
if (this.#syncState.handle) {
const shapeKey = canonicalShapeKey(fetchUrl)
expiredShapesCache.markExpired(shapeKey, this.#syncState.handle)
}
const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
let nextRequestShapeCacheBuster: string | undefined
if (!newShapeHandle) {
console.warn(
`[Electric] Received 409 response without a shape handle header. ` +
`This likely indicates a proxy or CDN stripping required headers.`,
new Error(`stack trace`)
)
nextRequestShapeCacheBuster = createCacheBuster()
}
this.#reset(newShapeHandle)
// must refetch control message might be in a list or not depending
// on whether it came from an SSE request or long poll. The body may
// also be null/undefined if a proxy returned an unexpected response.
// Handle all cases defensively here.
const messages409 = Array.isArray(e.json)
? e.json
: e.json != null
? [e.json]
: []
await this.#publish(messages409 as Message<T>[])
return this.#requestShape(nextRequestShapeCacheBuster)
} else {
// errors that have reached this point are not actionable without
// additional user input, such as 400s or failures to read the
// body of a response, so we exit the loop and let #start handle it
// Note: We don't notify subscribers here because onError might recover
throw e
}
} finally {
if (abortListener && signal) {
signal.removeEventListener(`abort`, abortListener)
}
this.#requestAbortController = undefined
}
this.#tickPromiseResolver?.()
return this.#requestShape()
}
/**
* Detects tight retry loops (e.g., from stale client-side caches or
* proxy/CDN misconfiguration) and attempts recovery. On first detection,
* clears client-side caches (in-memory and localStorage) and resets the
* stream to fetch from scratch.
* If the loop persists, applies exponential backoff and eventually throws.
*/
async #checkFastLoop(): Promise<void> {
const now = Date.now()
const currentOffset = this.#syncState.offset
this.#recentRequestEntries = this.#recentRequestEntries.filter(
(e) => now - e.timestamp < this.#fastLoopWindowMs
)
this.#recentRequestEntries.push({ timestamp: now, offset: currentOffset })
// Only flag as a fast loop if requests are stuck at the same offset.
// Normal rapid syncing advances the offset with each response.
const sameOffsetCount = this.#recentRequestEntries.filter(
(e) => e.offset === currentOffset
).length
if (sameOffsetCount < this.#fastLoopThreshold) return
this.#fastLoopConsecutiveCount++
if (this.#fastLoopConsecutiveCount >= this.#fastLoopMaxCount) {
throw new FetchError(
502,
undefined,
undefined,
{},
this.options.url,
`Client is stuck in a fast retry loop ` +
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset, ` +
`repeated ${this.#fastLoopMaxCount} times). ` +
`Client-side caches were cleared automatically on first detection, but the loop persists. ` +
`This usually indicates a proxy or CDN misconfiguration. ` +
`Common causes:\n` +
` - Proxy is not including query parameters (handle, offset) in its cache key\n` +
` - CDN is serving stale 409 responses\n` +
` - Proxy is stripping required Electric headers from responses\n` +
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
)
}
if (this.#fastLoopConsecutiveCount === 1) {
console.warn(
`[Electric] Detected fast retry loop ` +
`(${this.#fastLoopThreshold} requests in ${this.#fastLoopWindowMs}ms at the same offset). ` +
`Clearing client-side caches and resetting stream to recover. ` +
`If this persists, check that your proxy includes all query parameters ` +
`(especially 'handle' and 'offset') in its cache key, ` +
`and that required Electric headers are forwarded to the client. ` +
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`,
new Error(`stack trace`)
)
if (this.#currentFetchUrl) {
const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
expiredShapesCache.delete(shapeKey)
upToDateTracker.delete(shapeKey)
} else {
expiredShapesCache.clear()
upToDateTracker.clear()
}
this.#reset()
this.#recentRequestEntries = []
return
}
// Exponential backoff with full jitter
const maxDelay = Math.min(
this.#fastLoopBackoffMaxMs,
this.#fastLoopBackoffBaseMs * Math.pow(2, this.#fastLoopConsecutiveCount)
)
const delayMs = Math.floor(Math.random() * maxDelay)
await new Promise((resolve) => setTimeout(resolve, delayMs))
this.#recentRequestEntries = []
}
async #constructUrl(
url: string,
resumingFromPause: boolean,
subsetParams?: SubsetParams
) {
// Resolve headers and params in parallel
const [requestHeaders, params] = await Promise.all([
resolveHeaders(this.options.headers),
this.options.params
? toInternalParams(convertWhereParamsToObj(this.options.params))
: undefined,
])
// Validate params after resolution
if (params) validateParams(params)
const fetchUrl = new URL(url)
// Add PostgreSQL-specific parameters
if (params) {
if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
if (params.where && typeof params.where === `string`) {
const encodedWhere = encodeWhereClause(
params.where,
this.options.columnMapper?.encode
)
setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere)
}
if (params.columns) {
// Get original columns array from options (before toInternalParams converted to string)
const originalColumns = await resolveValue(this.options.params?.columns)
if (Array.isArray(originalColumns)) {
// Apply columnMapper encoding if present
let encodedColumns = originalColumns.map(String)
if (this.options.columnMapper) {
encodedColumns = encodedColumns.map(
this.options.columnMapper.encode
)
}
// Quote each column name to handle special characters (commas, etc.)
const serializedColumns = encodedColumns
.map(quoteIdentifier)
.join(`,`)
setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, serializedColumns)
} else {
// Fallback: columns was already a string
setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
}
}
if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
if (params.params)
setQueryParam(fetchUrl, WHERE_PARAMS_PARAM, params.params)
// Add any remaining custom parameters
const customParams = { ...params }
delete customParams.table
delete customParams.where
delete customParams.columns
delete customParams.replica
delete customParams.params
for (const [key, value] of Object.entries(customParams)) {
setQueryParam(fetchUrl, key, value)
}
}
if (subsetParams) {
// Prefer structured expressions when available (allows proper columnMapper application)
// Fall back to legacy string format for backwards compatibility
if (subsetParams.whereExpr) {
// Compile structured expression with columnMapper applied
const compiledWhere = compileExpression(
subsetParams.whereExpr,
this.options.columnMapper?.encode
)
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, compiledWhere)
// Also send the structured expression for servers that support it
fetchUrl.searchParams.set(
SUBSET_PARAM_WHERE_EXPR,
JSON.stringify(subsetParams.whereExpr)
)
} else if (subsetParams.where && typeof subsetParams.where === `string`) {
// Legacy string format (no columnMapper applied to already-compiled SQL)
const encodedWhere = encodeWhereClause(
subsetParams.where,
this.options.columnMapper?.encode
)
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
}
if (subsetParams.params)
// Serialize params as JSON to keep the parameter name constant for proxy configs
fetchUrl.searchParams.set(
SUBSET_PARAM_WHERE_PARAMS,
bigintSafeStringify(subsetParams.params)
)
if (subsetParams.limit)
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
if (subsetParams.offset)
setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
// Prefer structured ORDER BY expressions when available
if (subsetParams.orderByExpr) {
// Compile structured ORDER BY with columnMapper applied
const compiledOrderBy = compileOrderBy(
subsetParams.orderByExpr,
this.options.columnMapper?.encode
)
setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, compiledOrderBy)
// Also send the structured expression for servers that support it
fetchUrl.searchParams.set(
SUBSET_PARAM_ORDER_BY_EXPR,
JSON.stringify(subsetParams.orderByExpr)
)
} else if (
subsetParams.orderBy &&
typeof subsetParams.orderBy === `string`
) {
// Legacy string format
const encodedOrderBy = encodeWhereClause(
subsetParams.orderBy,
this.options.columnMapper?.encode
)
setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy)
}
}
// Add state-specific parameters (offset, handle, live cache busters, etc.)
this.#syncState.applyUrlParams(fetchUrl, {
isSnapshotRequest: subsetParams !== undefined,
// Don't long-poll when resuming from pause or refreshing — avoids
// a 20s hold during which `isConnected` would be false
canLongPoll: !this.#isRefreshing && !resumingFromPause,
})
fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
// Add cache buster for shapes known to be expired to prevent 409s
const shapeKey = canonicalShapeKey(fetchUrl)
const expiredHandle = expiredShapesCache.getExpiredHandle(shapeKey)
if (expiredHandle) {
fetchUrl.searchParams.set(EXPIRED_HANDLE_QUERY_PARAM, expiredHandle)
}
// sort query params in-place for stable URLs and improved cache hits
fetchUrl.searchParams.sort()
return {
fetchUrl,
requestHeaders,
}
}
async #createAbortListener(signal?: AbortSignal) {
// Create a new AbortController for this request
this.#requestAbortController = new AbortController()
// If user provided a signal, listen to it and pass on the reason for the abort
if (signal) {
const abortListener = () => {
this.#requestAbortController?.abort(signal.reason)
}
signal.addEventListener(`abort`, abortListener, { once: true })
if (signal.aborted) {
// If the signal is already aborted, abort the request immediately
this.#requestAbortController?.abort(signal.reason)
}
return abortListener
}
}
/**
* Processes response metadata (headers, status) and updates sync state.
* Returns `true` if the response body should be processed by the caller,
* or `false` if the response was ignored (stale) and the body should be skipped.
* Throws on stale-retry (to trigger a retry with cache buster).
*/
async #onInitialResponse(response: Response): Promise<boolean> {
const { headers, status } = response
const shapeHandle = headers.get(SHAPE_HANDLE_HEADER)
const shapeKey = this.#currentFetchUrl
? canonicalShapeKey(this.#currentFetchUrl)
: null
const expiredHandle = shapeKey
? expiredShapesCache.getExpiredHandle(shapeKey)
: null
const transition = this.#syncState.handleResponseMetadata({
status,
responseHandle: shapeHandle,
responseOffset: headers.get(CHUNK_LAST_OFFSET_HEADER) as Offset | null,
responseCursor: headers.get(LIVE_CACHE_BUSTER_HEADER),
responseSchema: getSchemaFromHeaders(headers),
expiredHandle,
now: Date.now(),
maxStaleCacheRetries: this.#maxStaleCacheRetries,
createCacheBuster,
})
this.#syncState = transition.state
if (transition.action === `stale-retry`) {
// Cancel the response body to release the connection before retrying.
await response.body?.cancel()
if (transition.exceededMaxRetries) {
throw new FetchError(
502,
undefined,
undefined,
{},
this.#currentFetchUrl?.toString() ?? ``,
`CDN continues serving stale cached responses after ${this.#maxStaleCacheRetries} retry attempts. ` +
`This indicates a severe proxy/CDN misconfiguration. ` +
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL}`
)
}
console.warn(
`[Electric] Received stale cached response with expired shape handle. ` +
`This should not happen and indicates a proxy/CDN caching misconfiguration. ` +
`The response contained handle "${shapeHandle}" which was previously marked as expired. ` +
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key. ` +
`For more information visit the troubleshooting guide: ${TROUBLESHOOTING_URL} ` +
`Retrying with a random cache buster to bypass the stale cache (attempt ${this.#syncState.staleCacheRetryCount}/${this.#maxStaleCacheRetries}).`,
new Error(`stack trace`)
)
throw new StaleCacheError(
`Received stale cached response with expired handle "${shapeHandle}". ` +
`This indicates a proxy/CDN caching misconfiguration. ` +
`Check that your proxy includes all query parameters (especially 'handle' and 'offset') in its cache key.`
)
}
if (transition.action === `ignored`) {
console.warn(
`[Electric] Response was ignored by state "${this.#syncState.kind}". ` +
`The response body will be skipped. ` +
`This may indicate a proxy/CDN caching issue or a client state machine bug.`,
new Error(`stack trace`)
)
return false
}
return true
}
async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
if (!Array.isArray(batch)) {
console.warn(
`[Electric] #onMessages called with non-array argument (${typeof batch}). ` +
`This is a client bug — please report it.`,
new Error(`stack trace`)
)
return
}
if (batch.length === 0) return
const lastMessage = batch[batch.length - 1]
const hasUpToDateMessage = isUpToDateMessage(lastMessage)
const upToDateOffset = hasUpToDateMessage
? getOffset(lastMessage)
: undefined
const transition = this.#syncState.handleMessageBatch({
hasMessages: true,
hasUpToDateMessage,
isSse: isSseMessage,
upToDateOffset,
now: Date.now(),
currentCursor: this.#syncState.liveCacheBuster,
})
this.#syncState = transition.state
if (hasUpToDateMessage) {
if (transition.suppressBatch) {
return
}
if (this.#currentFetchUrl) {
const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
upToDateTracker.recordUpToDate(
shapeKey,
this.#syncState.liveCacheBuster
)
}
}
// Filter messages using snapshot tracker
const messagesToProcess = batch.filter((message) => {
if (isChangeMessage(message)) {
return !this.#snapshotTracker.shouldRejectMessage(message)
}
return true // Always process control messages
})
await this.#publish(messagesToProcess)
}
/**
* Fetches the shape from the server using either long polling or SSE.
* Upon receiving a successful response, the #onInitialResponse method is called.
* Afterwards, the #onMessages method is called for all the incoming updates.
* @param opts - The options for the request.
* @returns A promise that resolves when the request is complete (i.e. the long poll receives a response or the SSE connection is closed).
*/
async #fetchShape(opts: {
fetchUrl: URL
requestAbortController: AbortController
headers: Record<string, string>
resumingFromPause?: boolean
}): Promise<void> {
// Store current fetch URL for shape key computation
this.#currentFetchUrl = opts.fetchUrl
// Check if we should enter replay mode (replaying cached responses)
// This happens when we're starting fresh (offset=-1 or before first up-to-date)
// and there's a recent up-to-date in localStorage (< 60s)
if (!this.#syncState.isUpToDate && this.#syncState.canEnterReplayMode()) {
const shapeKey = canonicalShapeKey(opts.fetchUrl)
const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey)
if (lastSeenCursor) {
// Enter replay mode and store the last seen cursor
this.#syncState = this.#syncState.enterReplayMode(lastSeenCursor)
}
}
const useSse = this.options.liveSse ?? this.options.experimentalLiveSse
if (
this.#syncState.shouldUseSse({
liveSseEnabled: !!useSse,
isRefreshing: this.#isRefreshing,
resumingFromPause: !!opts.resumingFromPause,
})
) {
opts.fetchUrl.searchParams.set(EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, `true`)
opts.fetchUrl.searchParams.set(LIVE_SSE_QUERY_PARAM, `true`)
return this.#requestShapeSSE(opts)
}
return this.#requestShapeLongPoll(opts)
}
async #requestShapeLongPoll(opts: {
fetchUrl: URL
requestAbortController: AbortController
headers: Record<string, string>
}): Promise<void> {
const { fetchUrl, requestAbortController, headers } = opts
const response = await this.#fetchClient(fetchUrl.toString(), {
signal: requestAbortController.signal,
headers,
})
this.#connected = true
const shouldProcessBody = await this.#onInitialResponse(response)
if (!shouldProcessBody) return
const schema = this.#syncState.schema! // we know that it is not undefined because it is set by `this.#onInitialResponse`
const res = await response.text()
const messages = res || `[]`
const batch = this.#messageParser.parse<Array<Message<T>>>(messages, schema)
if (!Array.isArray(batch)) {
const preview = bigintSafeStringify(batch)?.slice(0, 200)
throw new FetchError(
response.status,
`Received non-array response body from shape endpoint. ` +
`This may indicate a proxy or CDN is returning an unexpected response. ` +
`Expected a JSON array, got ${typeof batch}: ${preview}`,
undefined,
Object.fromEntries(response.headers.entries()),
fetchUrl.toString()
)
}
await this.#onMessages(batch)
}
async #requestShapeSSE(opts: {
fetchUrl: URL
requestAbortController: AbortController
headers: Record<string, string>
}): Promise<void> {
const { fetchUrl, requestAbortController, headers } = opts
const fetch = this.#sseFetchClient
// Track when the SSE connection starts
this.#lastSseConnectionStartTime = Date.now()
// Add Accept header for SSE requests
const sseHeaders = {
...headers,
Accept: `text/event-stream`,
}
let ignoredStaleResponse = false
try {
let buffer: Array<Message<T>> = []
await fetchEventSource(fetchUrl.toString(), {
headers: sseHeaders,
fetch,
onopen: async (response: Response) => {
this.#connected = true
const shouldProcessBody = await this.#onInitialResponse(response)
if (!shouldProcessBody) {
ignoredStaleResponse = true
throw new Error(`stale response ignored`)
}
},
onmessage: (event: EventSourceMessage) => {
if (event.data) {
// event.data is a single JSON object
const schema = this.#syncState.schema! // we know that it is not undefined because it is set in onopen when we call this.#onInitialResponse
const message = this.#messageParser.parse<Message<T>>(
event.data,
schema
)
buffer.push(message)
if (isUpToDateMessage(message)) {
// Flush the buffer on up-to-date message.
// Ensures that we only process complete batches of operations.
this.#onMessages(buffer, true)
buffer = []
}
}
},
onerror: (error: Error) => {
// rethrow to close the SSE connection
throw error
},
signal: requestAbortController.signal,
})
} catch (error) {
if (ignoredStaleResponse) {
// Stale response was ignored in onopen — let the fetch loop retry
return
}
if (requestAbortController.signal.aborted) {
// An abort during SSE stream parsing produces a raw AbortError
// instead of going through createFetchWithBackoff -- wrap it so
// #start handles it correctly.
throw new FetchBackoffAbortError()
}
// Re-throw known Electric errors so the caller can handle them
// (e.g., 409 shape rotation, stale cache retry, missing headers).
// Other errors (body parsing, SSE protocol failures, null body)
// are SSE connection failures handled by the fallback mechanism
// in the finally block below.
if (
error instanceof FetchError ||
error instanceof St