packages/typescript-client/SPEC.md
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)
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() firstpause 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 thisresponse/messages/sseClose on Error return this (ignored)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':
staleCacheBuster is defined (non-undefined)staleCacheRetryCount > 0Enforcement: 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:
isUpToDate delegates to previousStateerror is always defined and instanceof ErrorapplyUrlParams delegates to previousStateEnforcement: Field-by-field equality checks in assertStateInvariants.
For any state, state.markMustRefetch(handle) produces an InitialState with:
offset === '-1'handle === handle (the argument)lastSyncedAt preserved 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 === hoffset unchangedEnforcement: 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).
handleResponseMetadata returns { action: 'ignored', state: this }
and handleMessageBatch returns { state: this, suppressBatch: false, becameUpToDate: false }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).
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).
lastSyncedAt is set to input.now immediatelylastSyncedAt is NOT updated (deferred to handleMessageBatch)Enforcement: Dedicated tests (204 response sets lastSyncedAt,
200 response does not set lastSyncedAt).
upToDateOffsetEnforcement: 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).
| 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.
Six sites in client.ts recurse or loop to issue a new fetch:
| # | Site | Line | Trigger | URL changes because | Guard |
|---|---|---|---|---|---|
| L1 | #requestShape → #requestShape | 940 | Normal completion after #fetchShape() | Offset advances from response headers | #checkFastLoop (non-live) |
| L2 | #requestShape catch → #requestShape | 874 | Abort with FORCE_DISCONNECT_AND_REFRESH or SYSTEM_WAKE | isRefreshing flag changes canLongPoll, affecting live param | Abort signals are discrete events |
| L3 | #requestShape catch → #requestShape | 886 | StaleCacheError thrown by #onInitialResponse | StaleRetryState adds cache_buster param | maxStaleCacheRetries counter in state machine |
| L4 | #requestShape catch → #requestShape | 924 | HTTP 409 (shape rotation) | #reset() sets offset=-1 + new handle; or request-scoped cache buster if no handle | New handle from 409 response or unique retry URL |
| L5 | #start catch → #start | 782 | Exception + onError returns retry opts | Params/headers merged from retryOpts | User-controlled; #checkFastLoop on next iteration |
| L6 | fetchSnapshot catch → fetchSnapshot | 1975 | HTTP 409 on snapshot fetch | New handle via withHandle(); or local retry cache buster if same/no handle | #maxSnapshotRetries (5) + cache buster on same handle |
| 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. Throws FetchError(502) after 3 consecutive stale responses. |
#maxSnapshotRetries | Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Adds cache buster when handle unchanged. Throws FetchError(502) after 5. |
| Pause lock | #requestShape entry | Returns immediately if paused. Prevents fetches during snapshots. |
| Up-to-date exit | #requestShape entry | Returns if !subscribe and isUpToDate. Breaks loop for one-shot syncs. |
| Gap | Risk | Notes |
|---|---|---|
L5 user onError infinite retry | Low | User callback controls retry; #checkFastLoop provides secondary guard |
| Live polling same URL | None | Intentionally allowed — server long-polls, cursor may not change between responses |