Skip to content

Commit 04e4702

Browse files
KyleAMathewsclaude
andcommitted
fix(client): add EXPERIMENTAL_LIVE_SSE_QUERY_PARAM to protocol params list
The deprecated `experimental_live_sse` query param was missing from ELECTRIC_PROTOCOL_QUERY_PARAMS, causing canonicalShapeKey to produce different keys for the same shape depending on whether the SSE code path added the param to the URL. This caused: - expiredShapesCache entries written during SSE to be invisible when the stream fell back to long polling - upToDateTracker entries from SSE sessions to be lost on page refresh - fast-loop cache clearing to target the wrong key during SSE Also adds a static analysis test that verifies all internal protocol query param constants are included in the protocol params list, and updates SPEC.md with the unconditional 409 cache buster invariant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 92fad8b commit 04e4702

File tree

3 files changed

+61
-9
lines changed

3 files changed

+61
-9
lines changed

packages/typescript-client/SPEC.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,26 +342,38 @@ change the next request URL via state advancement or an explicit cache buster.
342342
This is enforced by the path-specific guards listed below. Live requests
343343
(`live=true`) legitimately reuse URLs.
344344

345+
### Invariant: unconditional 409 cache buster
346+
347+
Every code path that handles a 409 response must unconditionally call
348+
`createCacheBuster()` before retrying. This ensures unique retry URLs regardless
349+
of whether the server returns a new handle, the same handle, or no handle. The
350+
cache buster is stripped by `canonicalShapeKey` so it doesn't affect shape
351+
identity or caching logic — it only affects the raw URL sent to the server/CDN.
352+
353+
**Enforcement**: Static analysis rule `conditional-409-cache-buster` in
354+
`shape-stream-static-analysis.mjs` + model-based property test commands
355+
`Respond409SameHandleCmd` and `Respond409NoHandleCmd`.
356+
345357
### Loop-back sites
346358

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

349-
| # | Site | Line | Trigger | URL changes because | Guard |
350-
| --- | --------------------------------------- | ---- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------- |
351-
| L1 | `#requestShape``#requestShape` | 940 | Normal completion after `#fetchShape()` | Offset advances from response headers | `#checkFastLoop` (non-live) |
352-
| 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 |
353-
| L3 | `#requestShape` catch → `#requestShape` | 886 | `StaleCacheError` thrown by `#onInitialResponse` | `StaleRetryState` adds `cache_buster` param | `maxStaleCacheRetries` counter in state machine |
354-
| 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 |
355-
| L5 | `#start` catch → `#start` | 782 | Exception + `onError` returns retry opts | Params/headers merged from `retryOpts` | User-controlled; `#checkFastLoop` on next iteration |
356-
| 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 |
361+
| # | Site | Line | Trigger | URL changes because | Guard |
362+
| --- | --------------------------------------- | ---- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------ |
363+
| L1 | `#requestShape``#requestShape` | 940 | Normal completion after `#fetchShape()` | Offset advances from response headers | `#checkFastLoop` (non-live) |
364+
| 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 |
365+
| L3 | `#requestShape` catch → `#requestShape` | 886 | `StaleCacheError` thrown by `#onInitialResponse` | `StaleRetryState` adds `cache_buster` param | `maxStaleCacheRetries` counter in state machine |
366+
| L4 | `#requestShape` catch → `#requestShape` | 924 | HTTP 409 (shape rotation) | `#reset()` sets offset=-1 + new handle; unconditional cache buster on every 409 | New handle + unique retry URL via cache buster |
367+
| L5 | `#start` catch → `#start` | 782 | Exception + `onError` returns retry opts | Params/headers merged from `retryOpts` | User-controlled; `#checkFastLoop` on next iteration |
368+
| L6 | `fetchSnapshot` catch → `fetchSnapshot` | 1975 | HTTP 409 on snapshot fetch | New handle via `withHandle()`; unconditional cache buster on every 409 | `#maxSnapshotRetries` (5) + unconditional cache buster |
357369

358370
### Guard mechanisms
359371

360372
| Guard | Scope | How it works |
361373
| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
362374
| `#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). |
363375
| `maxStaleCacheRetries` | Stale response path (L3) | State machine counts stale retries. Throws FetchError(502) after 3 consecutive stale responses. |
364-
| `#maxSnapshotRetries` | Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Adds cache buster when handle unchanged. Throws FetchError(502) after 5. |
376+
| `#maxSnapshotRetries` | Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Unconditional cache buster on every retry. Throws FetchError(502) after 5. |
365377
| Pause lock | `#requestShape` entry | Returns immediately if paused. Prevents fetches during snapshots. |
366378
| Up-to-date exit | `#requestShape` entry | Returns if `!subscribe` and `isUpToDate`. Breaks loop for one-shot syncs. |
367379

packages/typescript-client/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const CACHE_BUSTER_QUERY_PARAM = `cache-buster` // Random cache buster to
3535
export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
3636
LIVE_QUERY_PARAM,
3737
LIVE_SSE_QUERY_PARAM,
38+
EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
3839
SHAPE_HANDLE_QUERY_PARAM,
3940
OFFSET_QUERY_PARAM,
4041
LIVE_CACHE_BUSTER_QUERY_PARAM,

packages/typescript-client/test/static-analysis.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,45 @@ describe(`shape-stream static analysis`, () => {
124124
}
125125
})
126126

127+
it(`includes all internal protocol QUERY_PARAM constants in ELECTRIC_PROTOCOL_QUERY_PARAMS`, async () => {
128+
// Internal protocol params (handle, offset, cursor, live, etc.) must be
129+
// listed in ELECTRIC_PROTOCOL_QUERY_PARAMS so that canonicalShapeKey
130+
// strips them. Missing entries cause cache key divergence between code
131+
// paths (e.g., SSE vs long-polling produce different shape keys).
132+
//
133+
// User-facing shape params (table, where, columns, replica) are
134+
// intentionally excluded — they define the shape identity.
135+
const constants = await import(`../src/constants`)
136+
const protocolParams = new Set(constants.ELECTRIC_PROTOCOL_QUERY_PARAMS)
137+
138+
// User-facing params that define shape identity — NOT protocol internals
139+
const userFacingParams = new Set([
140+
`COLUMNS_QUERY_PARAM`,
141+
`TABLE_QUERY_PARAM`,
142+
`WHERE_QUERY_PARAM`,
143+
`REPLICA_PARAM`, // not *_QUERY_PARAM but included for completeness
144+
`WHERE_PARAMS_PARAM`,
145+
])
146+
147+
// Collect internal *_QUERY_PARAM exports
148+
const internalParamExports = Object.entries(constants)
149+
.filter(
150+
([key]) =>
151+
key.endsWith(`_QUERY_PARAM`) &&
152+
key !== `ELECTRIC_PROTOCOL_QUERY_PARAMS` &&
153+
!userFacingParams.has(key)
154+
)
155+
.map(([key, value]) => ({ key, value: value as string }))
156+
157+
expect(internalParamExports.length).toBeGreaterThan(0)
158+
159+
const missing = internalParamExports.filter(
160+
({ value }) => !protocolParams.has(value)
161+
)
162+
163+
expect(missing.map(({ key }) => key)).toEqual([])
164+
})
165+
127166
it(`reports near-miss Electric protocol literals`, async () => {
128167
const { analyzeProtocolLiterals } = await loadAnalyzerModule()
129168
const fixturePath = path.resolve(

0 commit comments

Comments
 (0)