@electric-sql/client
Version:
Postgres everywhere - your data, in sync, wherever you need it.
836 lines (724 loc) • 23.8 kB
text/typescript
/*
* Shape stream state machine.
*
* Class hierarchy:
*
* ShapeStreamState (abstract base)
* ├── ActiveState (abstract — shared field storage & helpers)
* │ ├── FetchingState (abstract — shared Initial/Syncing/StaleRetry behavior)
* │ │ ├── InitialState
* │ │ ├── SyncingState
* │ │ └── StaleRetryState
* │ ├── LiveState
* │ └── ReplayingState
* ├── PausedState (delegates to previousState)
* └── ErrorState (delegates to previousState)
*
* State transitions:
*
* Initial ─response─► Syncing ─up-to-date─► Live
* │ │
* └──stale──► StaleRetry
* │
* Syncing ◄──response──┘
*
* Any state ─pause─► Paused ─resume─► (previous state)
* Any state ─error─► Error ─retry──► (previous state)
* Any state ─markMustRefetch─► Initial (offset reset)
*/
import { Offset, Schema } from './types'
import {
OFFSET_QUERY_PARAM,
SHAPE_HANDLE_QUERY_PARAM,
LIVE_CACHE_BUSTER_QUERY_PARAM,
LIVE_QUERY_PARAM,
CACHE_BUSTER_QUERY_PARAM,
} from './constants'
export type ShapeStreamStateKind =
| `initial`
| `syncing`
| `live`
| `replaying`
| `stale-retry`
| `paused`
| `error`
/**
* Shared fields carried by all active (non-paused, non-error) states.
*/
export interface SharedStateFields {
readonly handle?: string
readonly offset: Offset
readonly schema?: Schema
readonly liveCacheBuster: string
readonly lastSyncedAt?: number
}
type ResponseBaseInput = {
status: number
responseHandle: string | null
responseOffset: Offset | null
responseCursor: string | null
responseSchema?: Schema
expiredHandle?: string | null
now: number
}
export type ResponseMetadataInput = ResponseBaseInput & {
maxStaleCacheRetries: number
createCacheBuster: () => string
}
export type ResponseMetadataTransition =
| { action: `accepted`; state: ShapeStreamState }
| { action: `ignored`; state: ShapeStreamState }
| {
action: `stale-retry`
state: ShapeStreamState
exceededMaxRetries: boolean
}
export interface MessageBatchInput {
hasMessages: boolean
hasUpToDateMessage: boolean
isSse: boolean
upToDateOffset?: Offset
now: number
currentCursor: string
}
export interface MessageBatchTransition {
state: ShapeStreamState
suppressBatch: boolean
becameUpToDate: boolean
}
export interface SseCloseInput {
connectionDuration: number
wasAborted: boolean
minConnectionDuration: number
maxShortConnections: number
}
export interface SseCloseTransition {
state: ShapeStreamState
fellBackToLongPolling: boolean
wasShortConnection: boolean
}
export interface UrlParamsContext {
isSnapshotRequest: boolean
canLongPoll: boolean
}
// ---------------------------------------------------------------------------
// Abstract base — shared by ALL states (including Paused/Error)
// ---------------------------------------------------------------------------
/**
* Abstract base class for all shape stream states.
*
* Each concrete state carries only its relevant fields — there is no shared
* flat context bag. Transitions create new immutable state objects.
*
* `isUpToDate` returns true for LiveState and delegating states wrapping LiveState.
*/
export abstract class ShapeStreamState {
abstract readonly kind: ShapeStreamStateKind
// --- Shared field getters (all states expose these) ---
abstract get handle(): string | undefined
abstract get offset(): Offset
abstract get schema(): Schema | undefined
abstract get liveCacheBuster(): string
abstract get lastSyncedAt(): number | undefined
// --- Derived booleans ---
get isUpToDate(): boolean {
return false
}
// --- Per-state field defaults ---
get staleCacheBuster(): string | undefined {
return undefined
}
get staleCacheRetryCount(): number {
return 0
}
get sseFallbackToLongPolling(): boolean {
return false
}
get consecutiveShortSseConnections(): number {
return 0
}
get replayCursor(): string | undefined {
return undefined
}
// --- Default no-op methods ---
canEnterReplayMode(): boolean {
return false
}
enterReplayMode(_cursor: string): ShapeStreamState {
return this
}
shouldUseSse(_opts: {
liveSseEnabled: boolean
isRefreshing: boolean
resumingFromPause: boolean
}): boolean {
return false
}
handleSseConnectionClosed(_input: SseCloseInput): SseCloseTransition {
return {
state: this,
fellBackToLongPolling: false,
wasShortConnection: false,
}
}
// --- URL param application ---
/** Adds state-specific query parameters to the fetch URL. */
applyUrlParams(_url: URL, _context: UrlParamsContext): void {}
// --- Default response/message handlers (Paused/Error never receive these) ---
handleResponseMetadata(
_input: ResponseMetadataInput
): ResponseMetadataTransition {
return { action: `ignored`, state: this }
}
handleMessageBatch(_input: MessageBatchInput): MessageBatchTransition {
return { state: this, suppressBatch: false, becameUpToDate: false }
}
// --- Universal transitions ---
/** Returns a new state identical to this one but with the handle changed. */
abstract withHandle(handle: string): ShapeStreamState
pause(): PausedState {
return new PausedState(this)
}
toErrorState(error: Error): ErrorState {
return new ErrorState(this, error)
}
markMustRefetch(handle?: string): InitialState {
return new InitialState({
handle,
offset: `-1`,
liveCacheBuster: ``,
lastSyncedAt: this.lastSyncedAt,
schema: undefined,
})
}
}
// ---------------------------------------------------------------------------
// ActiveState — intermediate base for all non-paused, non-error states
// ---------------------------------------------------------------------------
/**
* Holds shared field storage and provides helpers for response/message
* handling. All five active states extend this (via FetchingState or directly).
*/
abstract class ActiveState extends ShapeStreamState {
readonly #shared: SharedStateFields
constructor(shared: SharedStateFields) {
super()
this.#shared = shared
}
get handle() {
return this.#shared.handle
}
get offset() {
return this.#shared.offset
}
get schema() {
return this.#shared.schema
}
get liveCacheBuster() {
return this.#shared.liveCacheBuster
}
get lastSyncedAt() {
return this.#shared.lastSyncedAt
}
/** Expose shared fields to subclasses for spreading into new instances. */
protected get currentFields(): SharedStateFields {
return this.#shared
}
// --- URL param application ---
applyUrlParams(url: URL, _context: UrlParamsContext): void {
url.searchParams.set(OFFSET_QUERY_PARAM, this.#shared.offset)
if (this.#shared.handle) {
url.searchParams.set(SHAPE_HANDLE_QUERY_PARAM, this.#shared.handle)
}
}
// --- Helpers for subclass handleResponseMetadata implementations ---
/** Extracts updated SharedStateFields from response headers. */
protected parseResponseFields(
input: ResponseMetadataInput
): SharedStateFields {
const responseHandle = input.responseHandle
const handle =
responseHandle && responseHandle !== input.expiredHandle
? responseHandle
: this.#shared.handle
const offset = input.responseOffset ?? this.#shared.offset
const liveCacheBuster = input.responseCursor ?? this.#shared.liveCacheBuster
const schema = this.#shared.schema ?? input.responseSchema
const lastSyncedAt =
input.status === 204 ? input.now : this.#shared.lastSyncedAt
return { handle, offset, schema, liveCacheBuster, lastSyncedAt }
}
/**
* Stale detection. Returns a transition if the response is stale,
* or null if it is not stale and the caller should proceed normally.
*/
protected checkStaleResponse(
input: ResponseMetadataInput
): ResponseMetadataTransition | null {
const responseHandle = input.responseHandle
const expiredHandle = input.expiredHandle
if (!responseHandle || responseHandle !== expiredHandle) {
return null // not stale
}
// Stale response detected — always enter stale-retry to get a cache buster.
// Without a cache buster, the CDN will keep serving the same stale response
// and the client loops infinitely (the URL never changes).
// currentFields preserves the valid local handle when we already have one.
const retryCount = this.staleCacheRetryCount + 1
return {
action: `stale-retry`,
state: new StaleRetryState({
...this.currentFields,
staleCacheBuster: input.createCacheBuster(),
staleCacheRetryCount: retryCount,
}),
exceededMaxRetries: retryCount > input.maxStaleCacheRetries,
}
}
// --- handleMessageBatch: template method with onUpToDate override point ---
handleMessageBatch(input: MessageBatchInput): MessageBatchTransition {
if (!input.hasMessages || !input.hasUpToDateMessage) {
return { state: this, suppressBatch: false, becameUpToDate: false }
}
// Has up-to-date message — compute shared fields for the transition
let offset = this.#shared.offset
if (input.isSse && input.upToDateOffset) {
offset = input.upToDateOffset
}
const shared: SharedStateFields = {
handle: this.#shared.handle,
offset,
schema: this.#shared.schema,
liveCacheBuster: this.#shared.liveCacheBuster,
lastSyncedAt: input.now,
}
return this.onUpToDate(shared, input)
}
/** Override point for up-to-date handling. Default → LiveState. */
protected onUpToDate(
shared: SharedStateFields,
_input: MessageBatchInput
): MessageBatchTransition {
return {
state: new LiveState(shared),
suppressBatch: false,
becameUpToDate: true,
}
}
}
// ---------------------------------------------------------------------------
// FetchingState — Common behavior for Initial/Syncing/StaleRetry
// ---------------------------------------------------------------------------
/**
* Captures shared behavior of InitialState, SyncingState, StaleRetryState:
* - handleResponseMetadata: stale check → parse fields → new SyncingState (or LiveState for 204)
* - enterReplayMode(cursor) → new ReplayingState
* - canEnterReplayMode(): boolean — returns true (StaleRetryState overrides to return false,
* because entering replay would lose the stale-retry count; see C1 in SPEC.md)
*/
abstract class FetchingState extends ActiveState {
handleResponseMetadata(
input: ResponseMetadataInput
): ResponseMetadataTransition {
const staleResult = this.checkStaleResponse(input)
if (staleResult) return staleResult
const shared = this.parseResponseFields(input)
// NOTE: 204s are deprecated, the Electric server should not send these
// in latest versions but this is here for backwards compatibility.
// A 204 means "no content, you're caught up" — transition to live.
// Skip SSE detection: a 204 gives no indication SSE will work, and
// the 3-attempt fallback cycle adds unnecessary latency.
if (input.status === 204) {
return {
action: `accepted`,
state: new LiveState(shared, { sseFallbackToLongPolling: true }),
}
}
return { action: `accepted`, state: new SyncingState(shared) }
}
canEnterReplayMode(): boolean {
return true
}
enterReplayMode(cursor: string): ReplayingState {
return new ReplayingState({
...this.currentFields,
replayCursor: cursor,
})
}
}
// ---------------------------------------------------------------------------
// Concrete states
// ---------------------------------------------------------------------------
export class InitialState extends FetchingState {
readonly kind = `initial` as const
constructor(shared: SharedStateFields) {
super(shared)
}
withHandle(handle: string): InitialState {
return new InitialState({ ...this.currentFields, handle })
}
}
export class SyncingState extends FetchingState {
readonly kind = `syncing` as const
constructor(shared: SharedStateFields) {
super(shared)
}
withHandle(handle: string): SyncingState {
return new SyncingState({ ...this.currentFields, handle })
}
}
export class StaleRetryState extends FetchingState {
readonly kind = `stale-retry` as const
readonly #staleCacheBuster: string
readonly #staleCacheRetryCount: number
constructor(
fields: SharedStateFields & {
staleCacheBuster: string
staleCacheRetryCount: number
}
) {
const { staleCacheBuster, staleCacheRetryCount, ...shared } = fields
super(shared)
this.#staleCacheBuster = staleCacheBuster
this.#staleCacheRetryCount = staleCacheRetryCount
}
get staleCacheBuster() {
return this.#staleCacheBuster
}
get staleCacheRetryCount() {
return this.#staleCacheRetryCount
}
// StaleRetryState must not enter replay mode — it would lose the retry count
canEnterReplayMode(): boolean {
return false
}
withHandle(handle: string): StaleRetryState {
return new StaleRetryState({
...this.currentFields,
handle,
staleCacheBuster: this.#staleCacheBuster,
staleCacheRetryCount: this.#staleCacheRetryCount,
})
}
applyUrlParams(url: URL, context: UrlParamsContext): void {
super.applyUrlParams(url, context)
url.searchParams.set(CACHE_BUSTER_QUERY_PARAM, this.#staleCacheBuster)
}
}
export class LiveState extends ActiveState {
readonly kind = `live` as const
readonly #consecutiveShortSseConnections: number
readonly #sseFallbackToLongPolling: boolean
constructor(
shared: SharedStateFields,
sseState?: {
consecutiveShortSseConnections?: number
sseFallbackToLongPolling?: boolean
}
) {
super(shared)
this.#consecutiveShortSseConnections =
sseState?.consecutiveShortSseConnections ?? 0
this.#sseFallbackToLongPolling = sseState?.sseFallbackToLongPolling ?? false
}
get isUpToDate(): boolean {
return true
}
get consecutiveShortSseConnections(): number {
return this.#consecutiveShortSseConnections
}
get sseFallbackToLongPolling(): boolean {
return this.#sseFallbackToLongPolling
}
withHandle(handle: string): LiveState {
return new LiveState({ ...this.currentFields, handle }, this.sseState)
}
applyUrlParams(url: URL, context: UrlParamsContext): void {
super.applyUrlParams(url, context)
// Snapshot requests (with subsetParams) should never use live polling
if (!context.isSnapshotRequest) {
url.searchParams.set(LIVE_CACHE_BUSTER_QUERY_PARAM, this.liveCacheBuster)
if (context.canLongPoll) {
url.searchParams.set(LIVE_QUERY_PARAM, `true`)
}
}
}
private get sseState() {
return {
consecutiveShortSseConnections: this.#consecutiveShortSseConnections,
sseFallbackToLongPolling: this.#sseFallbackToLongPolling,
}
}
handleResponseMetadata(
input: ResponseMetadataInput
): ResponseMetadataTransition {
const staleResult = this.checkStaleResponse(input)
if (staleResult) return staleResult
const shared = this.parseResponseFields(input)
return {
action: `accepted`,
state: new LiveState(shared, this.sseState),
}
}
protected onUpToDate(
shared: SharedStateFields,
_input: MessageBatchInput
): MessageBatchTransition {
return {
state: new LiveState(shared, this.sseState),
suppressBatch: false,
becameUpToDate: true,
}
}
shouldUseSse(opts: {
liveSseEnabled: boolean
isRefreshing: boolean
resumingFromPause: boolean
}): boolean {
return (
opts.liveSseEnabled &&
!opts.isRefreshing &&
!opts.resumingFromPause &&
!this.#sseFallbackToLongPolling
)
}
handleSseConnectionClosed(input: SseCloseInput): SseCloseTransition {
let nextConsecutiveShort = this.#consecutiveShortSseConnections
let nextFallback = this.#sseFallbackToLongPolling
let fellBackToLongPolling = false
let wasShortConnection = false
if (
input.connectionDuration < input.minConnectionDuration &&
!input.wasAborted
) {
wasShortConnection = true
nextConsecutiveShort = nextConsecutiveShort + 1
if (nextConsecutiveShort >= input.maxShortConnections) {
nextFallback = true
fellBackToLongPolling = true
}
} else if (input.connectionDuration >= input.minConnectionDuration) {
nextConsecutiveShort = 0
}
return {
state: new LiveState(this.currentFields, {
consecutiveShortSseConnections: nextConsecutiveShort,
sseFallbackToLongPolling: nextFallback,
}),
fellBackToLongPolling,
wasShortConnection,
}
}
}
export class ReplayingState extends ActiveState {
readonly kind = `replaying` as const
readonly #replayCursor: string
constructor(fields: SharedStateFields & { replayCursor: string }) {
const { replayCursor, ...shared } = fields
super(shared)
this.#replayCursor = replayCursor
}
get replayCursor() {
return this.#replayCursor
}
withHandle(handle: string): ReplayingState {
return new ReplayingState({
...this.currentFields,
handle,
replayCursor: this.#replayCursor,
})
}
handleResponseMetadata(
input: ResponseMetadataInput
): ResponseMetadataTransition {
const staleResult = this.checkStaleResponse(input)
if (staleResult) return staleResult
const shared = this.parseResponseFields(input)
return {
action: `accepted`,
state: new ReplayingState({
...shared,
replayCursor: this.#replayCursor,
}),
}
}
protected onUpToDate(
shared: SharedStateFields,
input: MessageBatchInput
): MessageBatchTransition {
// Suppress replayed cache data when cursor has not moved since
// the previous session (non-SSE only).
const suppressBatch =
!input.isSse && this.#replayCursor === input.currentCursor
return {
state: new LiveState(shared),
suppressBatch,
becameUpToDate: true,
}
}
}
// ---------------------------------------------------------------------------
// Delegating states (Paused / Error)
// ---------------------------------------------------------------------------
// Union of all non-delegating states — used to type previousState fields so
// that PausedState.previousState is never another PausedState, and
// ErrorState.previousState is never another ErrorState.
export type ShapeStreamActiveState =
| InitialState
| SyncingState
| LiveState
| ReplayingState
| StaleRetryState
export class PausedState extends ShapeStreamState {
readonly kind = `paused` as const
readonly previousState: ShapeStreamActiveState | ErrorState
constructor(previousState: ShapeStreamState) {
super()
this.previousState = (
previousState instanceof PausedState
? previousState.previousState
: previousState
) as ShapeStreamActiveState | ErrorState
}
get handle(): string | undefined {
return this.previousState.handle
}
get offset(): Offset {
return this.previousState.offset
}
get schema(): Schema | undefined {
return this.previousState.schema
}
get liveCacheBuster(): string {
return this.previousState.liveCacheBuster
}
get lastSyncedAt(): number | undefined {
return this.previousState.lastSyncedAt
}
get isUpToDate(): boolean {
return this.previousState.isUpToDate
}
get staleCacheBuster(): string | undefined {
return this.previousState.staleCacheBuster
}
get staleCacheRetryCount(): number {
return this.previousState.staleCacheRetryCount
}
get sseFallbackToLongPolling(): boolean {
return this.previousState.sseFallbackToLongPolling
}
get consecutiveShortSseConnections(): number {
return this.previousState.consecutiveShortSseConnections
}
get replayCursor(): string | undefined {
return this.previousState.replayCursor
}
handleResponseMetadata(
input: ResponseMetadataInput
): ResponseMetadataTransition {
const transition = this.previousState.handleResponseMetadata(input)
if (transition.action === `accepted`) {
return { action: `accepted`, state: new PausedState(transition.state) }
}
if (transition.action === `ignored`) {
return { action: `ignored`, state: this }
}
if (transition.action === `stale-retry`) {
return {
action: `stale-retry`,
state: new PausedState(transition.state),
exceededMaxRetries: transition.exceededMaxRetries,
}
}
const _exhaustive: never = transition
throw new Error(
`PausedState.handleResponseMetadata: unhandled transition action "${(_exhaustive as ResponseMetadataTransition).action}"`
)
}
withHandle(handle: string): PausedState {
return new PausedState(this.previousState.withHandle(handle))
}
applyUrlParams(url: URL, context: UrlParamsContext): void {
this.previousState.applyUrlParams(url, context)
}
pause(): PausedState {
return this
}
resume(): ShapeStreamState {
return this.previousState
}
}
export class ErrorState extends ShapeStreamState {
readonly kind = `error` as const
readonly previousState: ShapeStreamActiveState | PausedState
readonly error: Error
constructor(previousState: ShapeStreamState, error: Error) {
super()
this.previousState = (
previousState instanceof ErrorState
? previousState.previousState
: previousState
) as ShapeStreamActiveState | PausedState
this.error = error
}
get handle(): string | undefined {
return this.previousState.handle
}
get offset(): Offset {
return this.previousState.offset
}
get schema(): Schema | undefined {
return this.previousState.schema
}
get liveCacheBuster(): string {
return this.previousState.liveCacheBuster
}
get lastSyncedAt(): number | undefined {
return this.previousState.lastSyncedAt
}
get isUpToDate(): boolean {
return this.previousState.isUpToDate
}
get staleCacheBuster(): string | undefined {
return this.previousState.staleCacheBuster
}
get staleCacheRetryCount(): number {
return this.previousState.staleCacheRetryCount
}
get sseFallbackToLongPolling(): boolean {
return this.previousState.sseFallbackToLongPolling
}
get consecutiveShortSseConnections(): number {
return this.previousState.consecutiveShortSseConnections
}
get replayCursor(): string | undefined {
return this.previousState.replayCursor
}
withHandle(handle: string): ErrorState {
return new ErrorState(this.previousState.withHandle(handle), this.error)
}
applyUrlParams(url: URL, context: UrlParamsContext): void {
this.previousState.applyUrlParams(url, context)
}
retry(): ShapeStreamState {
return this.previousState
}
reset(handle?: string): InitialState {
return this.previousState.markMustRefetch(handle)
}
}
// ---------------------------------------------------------------------------
// Type alias & factory
// ---------------------------------------------------------------------------
export function createInitialState(opts: {
offset: Offset
handle?: string
}): InitialState {
return new InitialState({
handle: opts.handle,
offset: opts.offset,
liveCacheBuster: ``,
lastSyncedAt: undefined,
schema: undefined,
})
}