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.
Seven states organized into three groups:
| Group | State | Kind | Description |
|---|---|---|---|
| Fetching | InitialState | initial |
No data yet; waiting for first response |
| Fetching | SyncingState | syncing |
Received first response; catching up to head |
| Fetching | StaleRetryState | stale-retry |
Response was stale; retrying with cache buster |
| Active | LiveState | live |
Up-to-date; streaming new changes |
| Active | ReplayingState | replaying |
Re-fetching from cache after resume |
| Delegate | PausedState | paused |
Suspended; wraps previous state |
| Delegate | ErrorState | error |
Failed; wraps previous state + error |
Ten events that can act on any state:
| Event | Input | Notes |
|---|---|---|
response |
ResponseMetadataInput | Server response headers arrived |
messages |
MessageBatchInput | Message batch (may contain up-to-date) |
sseClose |
SseCloseInput | SSE connection closed |
pause |
(none) | Client pauses the stream |
resume |
(none) | Client resumes from pause |
error |
Error | Unrecoverable error occurred |
retry |
(none) | Client retries from error |
markMustRefetch |
handle?: string | Server says data is stale; reset |
withHandle |
handle: string | Update handle, preserve everything else |
enterReplayMode |
cursor: string | Enter replay from cache |
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.
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)
resumeon a non-Paused state returnsthis(no-op)retryon a non-Error state returnsthis(no-op)enterReplayMode(cursor)returnsthisfor states that don't support replay (base class default); callers should checkcanEnterReplayMode()firstpauseon PausedState returnsthis(idempotent)messages/sseCloseon Paused returnthis(ignored)responseon Paused delegates topreviousState, preserving the Paused wrapper foracceptedandstale-retrytransitions;ignoredreturnsthisresponse/messages/sseCloseon Error returnthis(ignored)
Properties of the sync service that the client state machine depends on.
The server generates handles as {phash2_hash}-{microsecond_timestamp}. Uniqueness
is enforced by monotonic timestamps, a SQLite UNIQUE INDEX on the handle column,
and ETS insert_new checks. Even after server restarts, old handles persist in
SQLite and new ones receive fresh timestamps, so collisions cannot occur.
Implication for expired shapes cache: Once a handle is marked expired (after a 409 response), the server will never issue that handle again. If a response contains an expired handle, it must be coming from a caching layer (browser HTTP cache, CDN, or proxy) — not from the server itself.
Source: packages/sync-service/lib/electric/shapes/shape.ex (generate_id/1),
packages/sync-service/lib/electric/shape_cache/shape_status/shape_db/connection.ex
(shapes_handle_idx).
Properties that must hold after every state transition. Checked automatically by
assertStateInvariants() and assertReachableInvariants() in the DSL.
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.
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.
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.
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.
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.
After transitioning TO LiveState from a non-Live state, lastSyncedAt is defined.
Enforcement: assertReachableInvariants checks this on every transition.
When state.kind === 'stale-retry':
staleCacheBusteris defined (non-undefined)staleCacheRetryCount > 0
Enforcement: Runtime check in assertStateInvariants.
When state.kind === 'replaying': replayCursor is defined.
Enforcement: Runtime check in assertStateInvariants.
PausedState delegates ALL field getters to previousState:
handle,offset,schema,liveCacheBuster,lastSyncedAtisUpToDate,staleCacheBuster,staleCacheRetryCountsseFallbackToLongPolling,consecutiveShortSseConnections,replayCursorapplyUrlParams(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.
ErrorState delegates ALL field getters to previousState (same list as I8 minus
pause() idempotence). Additionally:
isUpToDatedelegates topreviousStateerroris always defined and instanceof ErrorapplyUrlParamsdelegates topreviousState
Enforcement: Field-by-field equality checks in assertStateInvariants.
For any state, state.markMustRefetch(handle) produces an InitialState with:
offset === '-1'handle === handle(the argument)lastSyncedAtpreserved from previous stateschema === undefinedliveCacheBuster === ''
Enforcement: Algebraic property test across all 7 states; dedicated test
(markMustRefetch resets to InitialState with correct defaults).
state.withHandle(h) produces a state of the same kind where:
handle === hoffsetunchanged- All other fields unchanged
Enforcement: Algebraic property test across all 7 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.
Things that must NOT happen.
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).
LiveState.enterReplayMode() returns this (base class default). Already
up-to-date; replay is meaningless.
Enforcement: Truth table entry (sameReference no-op).
- ErrorState:
handleResponseMetadatareturns{ action: 'ignored', state: this }andhandleMessageBatchreturns{ state: this, suppressBatch: false, becameUpToDate: false } - PausedState:
handleMessageBatchandhandleSseConnectionClosedare no-ops andhandleResponseMetadatadelegates topreviousState, preserving the paused wrapper foracceptedandstale-retrytransitions (ignoredreturnsthis)
Enforcement: Truth table entries (error + response/messages and paused + response/messages/sseClose).
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).
- 204 response:
lastSyncedAtis set toinput.nowimmediately - 200 response:
lastSyncedAtis NOT updated (deferred tohandleMessageBatch)
Enforcement: Dedicated tests (204 response sets lastSyncedAt,
200 response does not set lastSyncedAt).
- 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).
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.
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).
The Shape class (shape.ts) wraps a ShapeStream and notifies subscribers
when the materialised rows change. These invariants define when #notify
fires. They are separate from the ShapeStream state machine above; the stream
delivers every message, but the Shape decides when the resulting view is
consistent enough to surface to subscribers.
Data messages (insert/update/delete) that arrive while Shape.#status === 'syncing' apply to #data but do NOT call #notify. The first subscriber
notification fires when the shape transitions from syncing to up-to-date
via an up-to-date control message.
Rationale: the sync-service may send a response without an up-to-date
control message (e.g. the initial response for offset === -1, see
api.ex:determine_up_to_date). If the Shape notified subscribers on those
inserts, subscribers would observe a partial view AND the stream's
lastSyncedAt() would still be undefined (stream.ts handleMessageBatch
only writes lastSyncedAt when the batch contains an up-to-date). N1 ties
subscriber-visible snapshots to the stream-level "we've caught up" signal.
Enforcement: Shape#process notification PBT > regression: subscriber must not see undefined lastSyncedAt during initial sync with real ShapeStream in test/pbt-micro.test.ts and the broader PBT there.
Once #status === 'up-to-date', any data message triggers a notification,
and the status then transitions back to syncing until the next up-to-date.
This is the mechanism that delivers the [up-to-date, insert]-in-one-batch
case (the insert runs after the up-to-date message has set status to
up-to-date, so the insert sees wasUpToDate === true and calls #notify).
Enforcement: Shape#process notification PBT > deterministic: [up-to-date, insert] — subscriber's last view must match shape and the
broader PBT.
A must-refetch control message clears #data and #insertedKeys and
transitions #status back to syncing, which re-engages N1: subscribers
receive the post-rotation state on the next up-to-date without ever
observing an intermediate empty-rows notification. The
should resync from scratch on a shape rotation integration test in
test/client.test.ts pins this behavior.
| Invariant | Types | assertStateInvariants | assertReachableInvariants | Algebraic | Truth Table | Dedicated Test |
|---|---|---|---|---|---|---|
| I0 | - | yes | - | - | - | - |
| I1 | - | yes | - | - | - | - |
| I2 | readonly | - | - | - | yes (sameReference) | - |
| I3 | - | - | yes | yes | - | - |
| I4 | - | - | - | yes | - | - |
| I5 | - | - | yes | - | - | - |
| I6 | - | yes | - | - | - | - |
| I7 | - | yes | - | - | - | - |
| I8 | - | yes | - | yes (idempotence) | - | - |
| I9 | - | yes | - | - | - | - |
| I10 | - | - | - | yes | - | yes |
| I11 | - | - | - | yes | - | - |
| I12 | - | yes | - | yes | - | yes |
| Constraint | Types | Truth Table | Dedicated Test |
|---|---|---|---|
| C1 | - | - | yes |
| C2 | - | yes | yes |
| C3 | - | yes | - |
| C4 | - | - | yes |
| C5 | - | - | yes |
| C6 | - | - | yes |
| C7 | - | yes | yes |
| C8 | - | - | yes |
| Test File / Section | Spec Reference |
|---|---|
| Tier 1: scenario builder tests | I0-I11 (via auto-check) |
| Tier 2: transition truth table | All 70 cells |
| Algebraic property tests | I3, I4, I10, I11, I8 |
| Fuzz testing | I0-I12 (all invariants) |
| Mutation testing | I0-I12 (robustness) |
| shouldUseSse guard tests | LiveState SSE behavior |
| SSE connection closed tests | LiveState SSE fallback |
| applyUrlParams tests | URL construction |
| Schema adoption tests | C4 |
| 204/200 lastSyncedAt tests | C5 |
| SSE offset tests | C6 |
| Stale handle tests | C7 |
| ReplayingState suppress tests | Replay cursor semantics |
| Gap | Status | Notes |
|---|---|---|
| SSE fallback to long polling | Tested | Direct construction only (DSL doesn't expose) |
| ReplayingState suppressBatch | Tested | Direct construction only (DSL doesn't expose) |
| ErrorState.reset() | Tested | Direct construction (DSL doesn't have reset) |
| handleMessageBatch no-messages | Tested | Direct construction (edge case) |
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.
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.
Every code path that handles a 409 response must unconditionally call
createCacheBuster() before retrying. This ensures unique retry URLs regardless
of whether the server returns a new handle, the same handle, or no handle. The
cache buster is stripped by canonicalShapeKey so it doesn't affect shape
identity or caching logic — it only affects the raw URL sent to the server/CDN.
Enforcement:
- Static analysis rule
conditional-409-cache-busterinshape-stream-static-analysis.mjs— covers both L4 and L6 code paths at source level. - L4 (main stream
#requestShape409 path): model-based property test commandsRespond409SameHandleCmdandRespond409NoHandleCmdintest/model-based.test.ts. - L6 (
fetchSnapshotWithRetry409 path): property tests intest/pbt-micro.test.ts > Shape #fetchSnapshotWithRetry 409 loop PBT— asserts every retry URL carries a uniquecache-busterparam across 409-new, 409-same, and 409-no-handle sequences, and that#maxSnapshotRetries = 5is strictly upheld.
Six sites in client.ts recurse or loop to issue a new fetch:
| # | Method/path | Trigger | URL changes because | Guard |
|---|---|---|---|---|
| L1 | #requestShape normal loop |
Normal completion after #fetchShape() |
Offset advances from response headers | #checkFastLoop (non-live) |
| L2 | #requestShape restart-abort catch |
Abort marked for restart by forceDisconnectAndRefresh, system wake, or live request watchdog |
isRefreshing flag changes canLongPoll for explicit refresh/wake; watchdog retries keep the live URL valid |
Restart intent is tracked on the controller, not solely by AbortSignal.reason, because some runtimes (e.g. React Native) do not preserve abort reasons. Refresh/wake paths hold isRefreshing until the next request-loop tick completes so async URL construction cannot re-enable live=true too early. |
| L3 | #requestShape stale-cache catch |
StaleCacheError thrown by #onInitialResponse |
StaleRetryState adds cache_buster param; after max retries, self-healing clears expired entry + resets stream |
maxStaleCacheRetries counter + #expiredShapeRecoveryKey (once per shape) |
| L4 | #requestShape 409 catch |
HTTP 409 (shape rotation) | #reset() sets offset=-1 + new handle; unconditional cache buster on every 409 |
New handle + unique retry URL via cache buster |
| L5 | #start onError retry |
Exception + onError returns retry opts |
Params/headers merged from retryOpts; retry is paced by full-jitter exponential backoff using backoffOptions |
#maxConsecutiveErrorRetries (50) + abort-aware backoff |
| L6 | fetchSnapshot 409 retry |
HTTP 409 on snapshot fetch | New handle via withHandle(); unconditional cache buster on every 409 |
#maxSnapshotRetries (5) + unconditional cache buster |
| Guard | Scope | How it works |
|---|---|---|
#checkFastLoop |
Non-live #requestShape only |
Detects N requests at same offset within a time window. First: clears caches + resets. Persistent: exponential backoff → throws FetchError(502). |
maxStaleCacheRetries |
Stale response path (L3) | State machine counts stale retries. After 3 consecutive stale responses, clears expired entry and attempts one self-healing retry. Throws FetchError(502) if self-healing also fails. |
#expiredShapeRecoveryKey |
Self-healing (L3 extension) | Records shape key after first self-healing attempt. Second exhaustion on same key skips self-healing → FetchError(502). Cleared on up-to-date. |
#maxSnapshotRetries |
Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Unconditional cache buster on every retry. Throws FetchError(502) after 5. Runtime-enforced by Shape #fetchSnapshotWithRetry 409 loop PBT in test/pbt-micro.test.ts. |
#maxConsecutiveErrorRetries |
#start onError retry (L5) |
Counts consecutive error retries. Before each retry, waits with abort-aware full-jitter exponential backoff using backoffOptions (initialDelay, multiplier, maxDelay). Sends error to subscribers and tears down after 50. Reset on successful message batch or accepted 204. |
| Request watchdog | Live long-poll and refresh catch-up requests | Aborts with live-request-timeout and marks the request for restart after liveRequestTimeoutMs (default 45s; positive finite number or false to disable), then rejects independently of platform fetch abort behavior so runtimes with stuck fetch promises cannot block the request loop forever. Applies to non-live refresh catch-up requests because mobile runtimes can also wedge after the wake abort has already sent catch-up requests. |
| Pause lock | #requestShape entry |
Returns immediately if paused. Prevents fetches during snapshots and hidden/background runtime states. Browser document.visibilitychange is used when available; React Native AppState is wired through the React Native package export when Metro/Expo resolves the react-native condition; other runtimes can pass runtimeVisibility to pause while hidden/backgrounded and resume with a non-live catch-up request after foregrounding. |
| Up-to-date exit | #requestShape entry |
Returns if !subscribe and isUpToDate. Breaks loop for one-shot syncs. |
| Gap | Risk | Notes |
|---|---|---|
| Live polling same URL | None | Intentionally allowed — server long-polls, cursor may not change between responses |