Back to Electric

ShapeStream State Machine Specification

packages/typescript-client/SPEC.md

latest20.1 KB
Original Source

ShapeStream State Machine Specification

Formal specification for the ShapeStreamState state machine in @electric-sql/client. This document is the single source of truth for intended behavior. Tests are derived from these invariants and constraints; the bidirectional checklist at the bottom tracks enforcement.

States

Seven states organized into three groups:

GroupStateKindDescription
FetchingInitialStateinitialNo data yet; waiting for first response
FetchingSyncingStatesyncingReceived first response; catching up to head
FetchingStaleRetryStatestale-retryResponse was stale; retrying with cache buster
ActiveLiveStateliveUp-to-date; streaming new changes
ActiveReplayingStatereplayingRe-fetching from cache after resume
DelegatePausedStatepausedSuspended; wraps previous state
DelegateErrorStateerrorFailed; wraps previous state + error

Events

Ten events that can act on any state:

EventInputNotes
responseResponseMetadataInputServer response headers arrived
messagesMessageBatchInputMessage batch (may contain up-to-date)
sseCloseSseCloseInputSSE connection closed
pause(none)Client pauses the stream
resume(none)Client resumes from pause
errorErrorUnrecoverable error occurred
retry(none)Client retries from error
markMustRefetchhandle?: stringServer says data is stale; reset
withHandlehandle: stringUpdate handle, preserve everything else
enterReplayModecursor: stringEnter replay from cache

Transition Table

All 7 states x 10 events = 70 combinations are specified in state-transition-table.ts. The type is Record<ShapeStreamStateKind, Record<EventType, ExpectedBehavior>> — no Partial, so TypeScript enforces completeness at compile time.

Summary of transitions

Initial ──response──► Syncing ──up-to-date──► Live
              │                                 │
              └──stale──► StaleRetry            │
                              │                 │
              Syncing ◄──response──┘            │
                                                │
Any ──────pause──────► Paused ───resume───► (previous)
Any ──────error──────► Error  ───retry────► (previous)
Any ──markMustRefetch─► Initial (offset = -1)

No-op rules

  • resume on a non-Paused state returns this (no-op)
  • retry on a non-Error state returns this (no-op)
  • enterReplayMode(cursor) returns this for states that don't support replay (base class default); callers should check canEnterReplayMode() first
  • pause on PausedState returns this (idempotent)
  • messages/sseClose on Paused return this (ignored)
  • response on Paused delegates to previousState, preserving the Paused wrapper for accepted and stale-retry transitions; ignored returns this
  • response/messages/sseClose on Error return this (ignored)

Invariants

Properties that must hold after every state transition. Checked automatically by assertStateInvariants() and assertReachableInvariants() in the DSL.

I0: Kind/instanceof consistency

state.kind and state instanceof XxxState must always agree. The mapping is 1:1: initial ↔ InitialState, syncing ↔ SyncingState, etc.

Enforcement: KIND_TO_CLASS map + toBeInstanceOf check in assertStateInvariants.

I1: isUpToDate iff LiveState in delegation chain

state.isUpToDate === true only when LiveState is the state itself, or is reachable via the previousState delegation chain of PausedState or ErrorState.

Enforcement: Runtime check in assertStateInvariants.

I2: Immutability

Transitions always create new state objects; they never mutate existing ones. Exception: no-op transitions return this (reference-equal).

Enforcement: The truth table tests sameReference expectations. All state fields are readonly.

I3: Pause/resume round-trip

For non-PausedState input: state.pause().resume() === state (reference equality).

For PausedState input: paused.pause() is idempotent (returns this by I8), so paused.pause().resume() returns paused.previousState, not paused. Handle and offset are still preserved through the round-trip for all states.

Enforcement: Algebraic property test across all 7 states (state.pause().resume() === state). assertReachableInvariants verifies the pause/resume round-trip holds on every transition recorded by the DSL scenario builder.

I4: Error/retry preserves identity

state.toErrorState(err).retry() === state (reference equality).

Special case: when state is already an ErrorState, the constructor unwraps same-type nesting (I12), so errorState.toErrorState(newErr).retry() returns errorState.previousState (the inner state), not errorState itself.

Enforcement: Algebraic property test across all 7 states.

I5: LiveState has lastSyncedAt

After transitioning TO LiveState from a non-Live state, lastSyncedAt is defined.

Enforcement: assertReachableInvariants checks this on every transition.

I6: StaleRetryState tracking

When state.kind === 'stale-retry':

  • staleCacheBuster is defined (non-undefined)
  • staleCacheRetryCount > 0

Enforcement: Runtime check in assertStateInvariants.

I7: ReplayingState has cursor

When state.kind === 'replaying': replayCursor is defined.

Enforcement: Runtime check in assertStateInvariants.

I8: PausedState delegation

PausedState delegates ALL field getters to previousState:

  • handle, offset, schema, liveCacheBuster, lastSyncedAt
  • isUpToDate, staleCacheBuster, staleCacheRetryCount
  • sseFallbackToLongPolling, consecutiveShortSseConnections, replayCursor
  • applyUrlParams (URL params match inner state)

Additionally: PausedState.pause() is idempotent (returns this).

Enforcement: Field-by-field equality checks in assertStateInvariants. Idempotence checked in algebraic property tests.

I9: ErrorState delegation

ErrorState delegates ALL field getters to previousState (same list as I8 minus pause() idempotence). Additionally:

  • isUpToDate delegates to previousState
  • error is always defined and instanceof Error
  • applyUrlParams delegates to previousState

Enforcement: Field-by-field equality checks in assertStateInvariants.

I10: markMustRefetch always resets

For any state, state.markMustRefetch(handle) produces an InitialState with:

  • offset === '-1'
  • handle === handle (the argument)
  • lastSyncedAt preserved from previous state
  • schema === undefined
  • liveCacheBuster === ''

Enforcement: Algebraic property test across all 7 states; dedicated test (markMustRefetch resets to InitialState with correct defaults).

I11: withHandle preserves everything except handle

state.withHandle(h) produces a state of the same kind where:

  • handle === h
  • offset unchanged
  • All other fields unchanged

Enforcement: Algebraic property test across all 7 states.

I12: No same-type nesting of delegating states

PausedState.previousState is never a PausedState. ErrorState.previousState is never an ErrorState. The constructors unwrap same-type nesting automatically:

  • Paused(Paused(X))Paused(X)
  • Error(Error(X))Error(X) (newer error replaces older)

Cross-type nesting (Paused(Error(X)), Error(Paused(X))) is preserved — it's semantically meaningful. Alternating types can still produce chains longer than 2 (e.g. Paused(Error(Paused(X)))); the guard prevents only same-type stacking.

Enforcement: Runtime check in assertStateInvariants + dedicated algebraic test.

Constraints

Things that must NOT happen.

C1: StaleRetryState must not enter replay mode

StaleRetryState.canEnterReplayMode() returns false. Entering replay would lose the stale cache retry count. The caller (client.ts) checks this before calling enterReplayMode().

Enforcement: Explicit test (canEnterReplayMode returns false).

C2: LiveState enterReplayMode returns this

LiveState.enterReplayMode() returns this (base class default). Already up-to-date; replay is meaningless.

Enforcement: Truth table entry (sameReference no-op).

C3: Error ignores response/messages; Paused ignores messages/SSE close

  • ErrorState: handleResponseMetadata returns { action: 'ignored', state: this } and handleMessageBatch returns { state: this, suppressBatch: false, becameUpToDate: false }
  • PausedState: handleMessageBatch and handleSseConnectionClosed are no-ops and handleResponseMetadata delegates to previousState, preserving the paused wrapper for accepted and stale-retry transitions (ignored returns this)

Enforcement: Truth table entries (error + response/messages and paused + response/messages/sseClose).

C4: Schema adoption is first-write-wins

schema = this.schema ?? input.responseSchema — once a schema is set, subsequent responses cannot overwrite it.

Enforcement: Dedicated tests (response adopts schema when state has none, response does not overwrite existing schema).

C5: 204 vs 200 lastSyncedAt semantics

  • 204 response: lastSyncedAt is set to input.now immediately
  • 200 response: lastSyncedAt is NOT updated (deferred to handleMessageBatch)

Enforcement: Dedicated tests (204 response sets lastSyncedAt, 200 response does not set lastSyncedAt).

C6: SSE offset update rules

  • SSE up-to-date messages update offset via upToDateOffset
  • Non-SSE up-to-date messages preserve existing offset

Enforcement: Dedicated tests (SSE up-to-date message updates offset, non-SSE up-to-date message preserves existing offset).

C7: Stale response always enters stale-retry

When a stale response arrives (responseHandle === expiredHandle), the state always enters stale-retry regardless of whether the state has a valid local handle. The currentFields (including any valid local handle) are preserved in the new StaleRetryState, and a cache buster is added to ensure the retry URL is unique.

Enforcement: Dedicated stale-handle tests.

C8: SSE state is private to LiveState

sseFallbackToLongPolling and consecutiveShortSseConnections are private fields on LiveState, not carried in SharedStateFields. LiveState preserves SSE state through its own self-transitions (handleResponseMetadata, onUpToDate, handleSseConnectionClosed, withHandle) via a private sseState accessor. Other states don't carry SSE state — when transitioning from a non-Live state back to Live, SSE state resets to defaults.

Enforcement: Dedicated test (SSE state is preserved through LiveState self-transitions).

Bidirectional Enforcement Checklist

Doc -> Code: Is each invariant enforced?

InvariantTypesassertStateInvariantsassertReachableInvariantsAlgebraicTruth TableDedicated Test
I0-yes----
I1-yes----
I2readonly---yes (sameReference)-
I3--yesyes--
I4---yes--
I5--yes---
I6-yes----
I7-yes----
I8-yes-yes (idempotence)--
I9-yes----
I10---yes-yes
I11---yes--
I12-yes-yes-yes
ConstraintTypesTruth TableDedicated Test
C1--yes
C2-yesyes
C3-yes-
C4--yes
C5--yes
C6--yes
C7-yesyes
C8--yes

Code -> Doc: Is each test derived from the spec?

Test File / SectionSpec Reference
Tier 1: scenario builder testsI0-I11 (via auto-check)
Tier 2: transition truth tableAll 70 cells
Algebraic property testsI3, I4, I10, I11, I8
Fuzz testingI0-I12 (all invariants)
Mutation testingI0-I12 (robustness)
shouldUseSse guard testsLiveState SSE behavior
SSE connection closed testsLiveState SSE fallback
applyUrlParams testsURL construction
Schema adoption testsC4
204/200 lastSyncedAt testsC5
SSE offset testsC6
Stale handle testsC7
ReplayingState suppress testsReplay cursor semantics

Gaps

GapStatusNotes
SSE fallback to long pollingTestedDirect construction only (DSL doesn't expose)
ReplayingState suppressBatchTestedDirect construction only (DSL doesn't expose)
ErrorState.reset()TestedDirect construction (DSL doesn't have reset)
handleMessageBatch no-messagesTestedDirect construction (edge case)

Client Fetch Loop Paths

Exhaustive enumeration of every code path in client.ts that loops back to make another HTTP request. Each path must change the URL to avoid infinite loops.

Invariant: loop-back URL progression

Any loop-back path that would otherwise resend a stuck non-live request must change the next request URL via state advancement or an explicit cache buster. This is enforced by the path-specific guards listed below. Live requests (live=true) legitimately reuse URLs.

Loop-back sites

Six sites in client.ts recurse or loop to issue a new fetch:

#SiteLineTriggerURL changes becauseGuard
L1#requestShape#requestShape940Normal completion after #fetchShape()Offset advances from response headers#checkFastLoop (non-live)
L2#requestShape catch → #requestShape874Abort with FORCE_DISCONNECT_AND_REFRESH or SYSTEM_WAKEisRefreshing flag changes canLongPoll, affecting live paramAbort signals are discrete events
L3#requestShape catch → #requestShape886StaleCacheError thrown by #onInitialResponseStaleRetryState adds cache_buster parammaxStaleCacheRetries counter in state machine
L4#requestShape catch → #requestShape924HTTP 409 (shape rotation)#reset() sets offset=-1 + new handle; or request-scoped cache buster if no handleNew handle from 409 response or unique retry URL
L5#start catch → #start782Exception + onError returns retry optsParams/headers merged from retryOptsUser-controlled; #checkFastLoop on next iteration
L6fetchSnapshot catch → fetchSnapshot1975HTTP 409 on snapshot fetchNew handle via withHandle(); or local retry cache buster if same/no handle#maxSnapshotRetries (5) + cache buster on same handle

Guard mechanisms

GuardScopeHow it works
#checkFastLoopNon-live #requestShape onlyDetects N requests at same offset within a time window. First: clears caches + resets. Persistent: exponential backoff → throws FetchError(502).
maxStaleCacheRetriesStale response path (L3)State machine counts stale retries. Throws FetchError(502) after 3 consecutive stale responses.
#maxSnapshotRetriesSnapshot 409 path (L6)Counts consecutive snapshot 409s. Adds cache buster when handle unchanged. Throws FetchError(502) after 5.
Pause lock#requestShape entryReturns immediately if paused. Prevents fetches during snapshots.
Up-to-date exit#requestShape entryReturns if !subscribe and isUpToDate. Breaks loop for one-shot syncs.

Coverage gaps

GapRiskNotes
L5 user onError infinite retryLowUser callback controls retry; #checkFastLoop provides secondary guard
Live polling same URLNoneIntentionally allowed — server long-polls, cursor may not change between responses