Skip to content

Commit 9f767cf

Browse files
KyleAMathewsclaude
andauthored
test(client): add fast-check model-based property tests and retry bound analysis (#4089)
## Summary Fixes **twelve** bugs in ShapeStream's retry, error-handling, caching, and subscription paths — all uncovered by fast-check property-based testing (model-based + micro-target) and AST-based static analysis. Every bug is a concrete end-user hazard: infinite retry loops, stale data delivery, stack-frame leaks, cache-key collisions, dropped notifications, lost snapshots, wrong-row corruption, and deadlocks. ## Root Cause Every bug in this PR is an edge case in an error-handling or reconciliation path that only manifests under a specific *sequence* of events. Hand-written tests don't naturally explore adversarial sequences like "409 with same handle, then 409 with no handle, then 200 with empty body" or "addSnapshot(mark=1, xmax=5); removeSnapshot(1); addSnapshot(mark=1, xmax=10); reject(xid=5)". These bugs survived because they required rare permutations — a proxy stripping a header, two schema columns differing only in underscore count, a subscriber pushed over the must-refetch/up-to-date boundary at just the wrong instant. PBT shakes them out mechanically. ## Approach Two complementary verification strategies that catch bugs without a human having to imagine the failure mode: ### Model-based property testing (`test/model-based.test.ts`) `fc.asyncModelRun` with 21 command types drives adversarial server response sequences. A simple model tracks `{ consecutiveErrors, terminated }`, and each command predicts the model's state change before asserting the real ShapeStream matches. **FetchGate pattern**: a controllable mock fetch that blocks each request until the test provides a response — turn-based coordination where the test controls *what* the server returns while ShapeStream drives *when* it fetches. **Global invariants after every command**: - URL length stays bounded (catches suffix growth) - `isUpToDate` / `lastSyncedAt` consistency - After 409, retry URL differs from pre-409 URL (catches identity loops) ### Micro-target PBT (`test/pbt-micro.test.ts`, new) Twelve narrow TARGETs, one per suspected bug site. Each TARGET's opening comment documents the invariant under test, then runs `fc.assert` with 500 runs in CI (2000-run soak available via `PBT_MICRO_RUNS=2000`). Runs against the dedicated `vitest.pbt.config.ts` (skips the real-Electric `globalSetup`). `bin/pbt-soak.sh` wraps it in a fresh-seed loop for overnight soaks. ### Static analysis via AST walking Seven rule types that mechanically detect structural bug patterns: | Rule | What it catches | |------|----------------| | `unbounded-retry-loop` | Recursive calls in catch blocks without detectable bounds | | `conditional-409-cache-buster` | 409 handlers where `createCacheBuster()` is conditional or missing | | `parked-tail-await` | `await this.#method(); return` patterns that park stack frames | | `error-path-publish` | `#publish`/`#onMessages` calls inside catch blocks or error status handlers | | `shared-instance-field` | Mutable fields written before async boundaries and read by other methods | | `ignored-response-transition` | Non-delegate states returning `{ action: 'ignored' }` | | `protocol-literal-drift` | Near-miss string literals for Electric protocol params | Each rule was RED/GREEN verified. ## Bugs Fixed ### From model-based tests + static analysis **1. Conditional cache buster on 409.** Same-handle (and no-handle) 409s produced identical retry URLs, causing infinite CDN-cached retry loops. `createCacheBuster()` is now unconditional on every 409 in both `#requestShape` and `#fetchSnapshotWithRetry`. **2. Parked stack frame in `#start` retry.** `await this.#start(); return` kept the caller's frame alive for the whole recursive chain. Switched to `return this.#start()` so the frame is released via promise chaining. **3. Missing `EXPERIMENTAL_LIVE_SSE_QUERY_PARAM` in protocol params.** `canonicalShapeKey` wasn't stripping the deprecated param, so clients using it landed on a different cache key than clients on current `live_sse`. Added to the strip list; static analysis now enforces that all internal `*_QUERY_PARAM` exports live in the list. **4. Publishing 409 body to subscribers.** The 409 handler was parsing `e.json` and calling `#publish(messages409)` before retrying, delivering stale rotation frames. Replaced with a synthetic `must-refetch` control message so the Shape still resets its cache on 409 rotation, without leaking the raw payload. ### From `test/pbt-micro.test.ts` (new micro-target PBTs) **5. `canonicalShapeKey` collapsed duplicate custom params.** Used `URLSearchParams.set()` in the copy loop, so `?tag=a&tag=b` became `?tag=b`. Two genuinely distinct shapes shared a cache key, producing cross-shape cache poisoning via `expiredShapesCache`/`upToDateTracker`. Switched to `append()`. **6. `Shape#process` clobbered `shouldNotify`.** Three sites assigned `shouldNotify = this.#updateShapeStatus(...)` instead of OR-accumulating. Any sequence where a change message followed a status-change message silently dropped the change's notification. Changed to OR-accumulate, and `must-refetch` now tracks `hadData` before clearing so subscribers still see the reset. **7. `SubsetParams` GET serialization dropped `limit=0` / `offset=0`.** Falsy checks. Switched to explicit `!== undefined` guards. Limit-0 is a valid "probe" shape request. **8. `Shape#requestedSubSnapshots` non-canonical JSON dedup.** Keyed on `bigintSafeStringify`, which preserves insertion order — so `{a:1, b:2}` and `{b:2, a:1}` re-executed the same snapshot twice on rotation. Added `canonicalBigintSafeStringify` to `helpers.ts` that recursively sorts object keys. **9. `snakeToCamel` collided multi-underscore columns.** A run of `n` underscores collapsed to a single camelCase boundary, so `user_id` and `user__id` (distinct columns) decoded to the same app key — **wrong-row corruption at the ColumnMapper layer**. `snakeToCamel` now preserves `(n-1)` literal underscores for a run of `n`, and `camelToSnake`'s boundary regex was widened to `([a-z_])([A-Z])` so the round-trip is injective. **10. `Shape#reexecuteSnapshots` silently swallowed errors.** Caught and discarded errors from `stream.requestSnapshot` on shape rotation, so failed sub-snapshot re-execution was invisible. Errors are now collected and the first is surfaced via `#error` + `#notify`, plus the pre-step `#awaitUpToDate` throw is caught and surfaced the same way. **11. `SnapshotTracker` stale reverse indexes on re-add.** `xmaxSnapshots` and `snapshotsByDatabaseLsn` were populated on `addSnapshot` but never cleaned up on `removeSnapshot`, and re-adding with the same mark left stale entries pointing at the old `(xmax, database_lsn)`. A later `shouldRejectMessage` eviction loop would walk the stale index and wrongly delete the current snapshot — **duplicate change messages would slip through the filter**. Tracked `databaseLsn` on each entry and added `#detachFromReverseIndexes` that runs on both add (before overwriting) and remove. **12. `Shape#awaitUpToDate` deadlocked on terminally-errored stream.** The helper polled `stream.isUpToDate` via `setInterval(check, 10)` but never observed `stream.error`, so calling `requestSnapshot` after the stream had terminally failed hung forever. The helper now checks `#error` / `stream.error` up front, subscribes to the stream's `onError`, and settles the internal promise via `reject` on any terminal error path. ## Key Invariants 1. Every 409 handler unconditionally calls `createCacheBuster()` before retrying (static analysis + PBT). 2. Every recursive call in a catch block has a detectable retry bound. 3. No `await this.#method(); return` in recursive methods. 4. No `#publish` or `#onMessages` calls in error handling paths. 5. All internal `*_QUERY_PARAM` constants appear in `ELECTRIC_PROTOCOL_QUERY_PARAMS`. 6. Consecutive-error counter resets only on proven success (200+data, 200 up-to-date, 204). 7. `canonicalShapeKey` is stable under protocol-param noise and distinct under origin/pathname/custom-param changes (PBT). 8. `Shape#process` notifies subscribers on every data change, even when interleaved with status-change messages (PBT). 9. `snakeToCamel` ∘ `camelToSnake` is identity; distinct db columns never collide on the same app key (PBT). 10. `SnapshotTracker` reverse indexes are consistent with `activeSnapshots` after every operation (PBT with 500–2000 runs). 11. `Shape#awaitUpToDate` settles on any terminal outcome — up-to-date OR error — in bounded time. ## Non-goals - Refactoring `#start`/`#requestShape` into iterative loops (structural change, separate PR). - Fixing the `pgArrayParser` double-push bug (pre-existing, unreachable for valid PostgreSQL arrays). - Testing network-level failures or backoff timing (model tests focus on response sequences). - The wake-detection `queueMicrotask` timing issue (pre-existing, requires deeper investigation). - Log-mode and `subset__*` collision in `canonicalShapeKey` — TARGET 10 pins current behavior with deterministic assertions; fixing them is a separate scope decision. ## Verification ```bash cd packages/typescript-client # Unit suite (no Electric server required) pnpm vitest run --config vitest.unit.config.ts # 319 tests # Micro-target PBT suite (12 TARGETs) PBT_MICRO_RUNS=500 pnpm vitest run --config vitest.pbt.config.ts test/pbt-micro.test.ts # 44 tests # Soak runner (fresh seed each iteration, default 1 hour budget) bin/pbt-soak.sh # Static analysis tests pnpm vitest run --config vitest.unit.config.ts test/static-analysis.test.ts # 10 tests # Typecheck + lint pnpm exec tsc --noEmit && pnpm exec eslint src test ``` Verified: 319 unit tests, 44 PBT tests at 500 runs each (plus 2000-run soak), 62 column-mapper/snapshot-tracker tests, typecheck clean, eslint clean, 10 static-analysis rules clean. ## Files changed | File | Change | |------|--------| | `src/client.ts` | Unconditional cache buster on 409; `return this.#start()`; synthetic `must-refetch` on 409 instead of raw publish; `EXPERIMENTAL_LIVE_SSE_QUERY_PARAM` in strip list; `canonicalShapeKey` uses `append()`; `SubsetParams` GET uses `!== undefined` | | `src/shape.ts` | `Shape#process` OR-accumulates `shouldNotify` + tracks `hadData` on must-refetch; `#reexecuteSnapshots` surfaces errors via `#error`+`#notify`; `#awaitUpToDate` settles on terminal stream/error states; `requestSnapshot` dedup uses canonical stringify | | `src/column-mapper.ts` | `snakeToCamel` preserves `(n-1)` underscores; `camelToSnake` boundary regex widened to `([a-z_])([A-Z])` for injective round-trip | | `src/snapshot-tracker.ts` | Reverse-index cleanup via `#detachFromReverseIndexes` on both add-before-overwrite and remove; tracks `databaseLsn` per entry | | `src/helpers.ts` | Added `canonicalBigintSafeStringify` (recursive key-sorted stringify) | | `src/constants.ts` | `EXPERIMENTAL_LIVE_SSE_QUERY_PARAM` in protocol strip list | | `test/model-based.test.ts` | 11 response factories + FetchGate + 21 commands + global invariants; new update/delete/mixed-batch 200 responses with `lsn`/`op_position`/`txids` headers | | `test/pbt-micro.test.ts` | **New.** 12 micro-target PBTs (44 individual tests) | | `test/static-analysis.test.ts` | Tests for all 7 AST-based rules including protocol param completeness | | `bin/lib/shape-stream-static-analysis.mjs` | AST analysis: unbounded retry, 409 cache buster, tail-position await, error-path publish, protocol literal drift, shared-instance-field, ignored-response-transition | | `bin/pbt-soak.sh` | **New.** Fresh-seed soak runner with configurable budget | | `vitest.pbt.config.ts` | **New.** Dedicated config for PBT suites — skips Electric `globalSetup` | | `vitest.unit.config.ts` | Added model-based test to include list | | `SPEC.md` | Cross-references L6 `fetchSnapshotWithRetry` PBT from unconditional-409-cache-buster invariant; updated loop-back site line numbers | | `.changeset/add-model-based-property-tests.md` | Updated to cover all 12 bugs in one entry | | `package.json` | Added `fast-check` devDependency | --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 690e25a commit 9f767cf

20 files changed

+4081
-345
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@electric-sql/client': patch
3+
---
4+
5+
Add fast-check model-based and micro-target property tests (plus static analysis for unbounded retry loops, unconditional 409 cache busters, tail-position awaits, and error-path `#publish` calls) and fix client bugs uncovered by the new PBT suite:
6+
7+
**Stream / retry-loop fixes (uncovered by model-based PBT):**
8+
- Unconditionally create a new cache buster on every 409 response so that the follow-up request URL always differs from the pre-409 URL (prevents CDN infinite loops on cached 409s).
9+
- Fix a parked stack-frame leak in `ShapeStream#start` where awaiting a never-resolving live fetch retained the full error handler chain.
10+
- Add `EXPERIMENTAL_LIVE_SSE_QUERY_PARAM` to `ELECTRIC_PROTOCOL_QUERY_PARAMS` so `canonicalShapeKey` strips it; previously the SSE and long-polling code paths produced divergent cache keys for the same shape.
11+
- Replace the raw 409 response body publish in `#requestShape` with a synthetic `must-refetch` control message so subscribers clear accumulated state rather than receiving stale data rows.
12+
- Bound the `onError` retry loop at 50 consecutive retries so a broken `onError` handler can no longer spin forever.
13+
14+
**Micro-target PBT fixes:**
15+
- `canonicalShapeKey` collapsing duplicate query params
16+
- `Shape#process` clobbering notifications on `[up-to-date, insert]` batches
17+
- `subset__limit=0` / `subset__offset=0` dropped on GET path due to truthiness check
18+
- Non-canonical JSON keys in `Shape#reexecuteSnapshots` dedup
19+
- `snakeToCamel` colliding multi-underscore columns
20+
- `Shape#reexecuteSnapshots` swallowing errors silently
21+
- `SnapshotTracker` leaving stale reverse-index entries on re-add/remove
22+
- `Shape#awaitUpToDate` hanging forever on a terminally-errored stream
23+
24+
**Shape notification contract fix:**
25+
- `Shape#process` no longer notifies subscribers on data messages while the shape is still `syncing` (i.e. before the first `up-to-date` control message). Previously, the sync-service's initial response (offset=-1) could cause subscribers to fire with a partial view while `stream.lastSyncedAt()` was still `undefined`. Shape now follows the N1/N2 invariants documented in `SPEC.md` (Shape notification semantics).
26+
- `Shape#process` no longer fires an intermediate empty-rows notification on `must-refetch`. The status transitions back to `syncing` and subscribers receive the post-rotation state on the next `up-to-date`, matching the long-standing `should resync from scratch on a shape rotation` integration test.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ sst-*.log
2525
**/deps/*
2626
**/junit
2727
**/coverage
28+
**/pbt-soak-logs
2829
response.tmp
2930
.claude
3031
!website/.claude/commands

packages/typescript-client/SPEC.md

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,52 @@ back to Live, SSE state resets to defaults.
292292

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

295+
## Shape notification semantics
296+
297+
The `Shape` class (`shape.ts`) wraps a `ShapeStream` and notifies subscribers
298+
when the materialised `rows` change. These invariants define _when_ `#notify`
299+
fires. They are separate from the ShapeStream state machine above; the stream
300+
delivers every message, but the Shape decides when the resulting view is
301+
consistent enough to surface to subscribers.
302+
303+
### N1: No notify before first up-to-date
304+
305+
Data messages (insert/update/delete) that arrive while `Shape.#status ===
306+
'syncing'` apply to `#data` but do NOT call `#notify`. The first subscriber
307+
notification fires when the shape transitions from `syncing` to `up-to-date`
308+
via an `up-to-date` control message.
309+
310+
**Rationale**: the sync-service may send a response without an up-to-date
311+
control message (e.g. the initial response for `offset === -1`, see
312+
`api.ex:determine_up_to_date`). If the Shape notified subscribers on those
313+
inserts, subscribers would observe a partial view AND the stream's
314+
`lastSyncedAt()` would still be `undefined` (stream.ts `handleMessageBatch`
315+
only writes `lastSyncedAt` when the batch contains an up-to-date). N1 ties
316+
subscriber-visible snapshots to the stream-level "we've caught up" signal.
317+
318+
**Enforcement**: `Shape#process notification PBT > regression: subscriber
319+
must not see undefined lastSyncedAt during initial sync with real
320+
ShapeStream` in `test/pbt-micro.test.ts` and the broader PBT there.
321+
322+
### N2: Notify on change while up-to-date
323+
324+
Once `#status === 'up-to-date'`, any data message triggers a notification,
325+
and the status then transitions back to `syncing` until the next up-to-date.
326+
This is the mechanism that delivers the `[up-to-date, insert]`-in-one-batch
327+
case (the insert runs after the up-to-date message has set status to
328+
up-to-date, so the insert sees `wasUpToDate === true` and calls `#notify`).
329+
330+
**Enforcement**: `Shape#process notification PBT > deterministic:
331+
[up-to-date, insert] — subscriber's last view must match shape` and the
332+
broader PBT.
333+
334+
A `must-refetch` control message clears `#data` and `#insertedKeys` and
335+
transitions `#status` back to `syncing`, which re-engages N1: subscribers
336+
receive the post-rotation state on the next `up-to-date` without ever
337+
observing an intermediate empty-rows notification. The
338+
`should resync from scratch on a shape rotation` integration test in
339+
`test/client.test.ts` pins this behavior.
340+
295341
## Bidirectional Enforcement Checklist
296342

297343
### Doc -> Code: Is each invariant enforced?
@@ -362,30 +408,44 @@ change the next request URL via state advancement or an explicit cache buster.
362408
This is enforced by the path-specific guards listed below. Live requests
363409
(`live=true`) legitimately reuse URLs.
364410

411+
### Invariant: unconditional 409 cache buster
412+
413+
Every code path that handles a 409 response must unconditionally call
414+
`createCacheBuster()` before retrying. This ensures unique retry URLs regardless
415+
of whether the server returns a new handle, the same handle, or no handle. The
416+
cache buster is stripped by `canonicalShapeKey` so it doesn't affect shape
417+
identity or caching logic — it only affects the raw URL sent to the server/CDN.
418+
419+
**Enforcement**:
420+
421+
- Static analysis rule `conditional-409-cache-buster` in `shape-stream-static-analysis.mjs` — covers both L4 and L6 code paths at source level.
422+
- L4 (main stream `#requestShape` 409 path): model-based property test commands `Respond409SameHandleCmd` and `Respond409NoHandleCmd` in `test/model-based.test.ts`.
423+
- L6 (`fetchSnapshotWithRetry` 409 path): property tests in `test/pbt-micro.test.ts > Shape #fetchSnapshotWithRetry 409 loop PBT` — asserts every retry URL carries a unique `cache-buster` param across 409-new, 409-same, and 409-no-handle sequences, and that `#maxSnapshotRetries = 5` is strictly upheld.
424+
365425
### Loop-back sites
366426

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

369429
| # | Site | Line | Trigger | URL changes because | Guard |
370430
| --- | --------------------------------------- | ---- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
371-
| L1 | `#requestShape``#requestShape` | 940 | Normal completion after `#fetchShape()` | Offset advances from response headers | `#checkFastLoop` (non-live) |
372-
| 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 |
373-
| L3 | `#requestShape` catch → `#requestShape` | 886 | `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) |
374-
| 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 |
375-
| L5 | `#start` catch → `#start` | 782 | Exception + `onError` returns retry opts | Params/headers merged from `retryOpts` | `#maxConsecutiveErrorRetries` (50) |
376-
| 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 |
431+
| L1 | `#requestShape``#requestShape` | 939 | Normal completion after `#fetchShape()` | Offset advances from response headers | `#checkFastLoop` (non-live) |
432+
| L2 | `#requestShape` catch → `#requestShape` | 883 | Abort with `FORCE_DISCONNECT_AND_REFRESH` or `SYSTEM_WAKE` | `isRefreshing` flag changes `canLongPoll`, affecting `live` param | Abort signals are discrete events |
433+
| L3 | `#requestShape` catch → `#requestShape` | 895 | `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) |
434+
| L4 | `#requestShape` catch → `#requestShape` | 923 | HTTP 409 (shape rotation) | `#reset()` sets offset=-1 + new handle; unconditional cache buster on every 409 | New handle + unique retry URL via cache buster |
435+
| L5 | `#start` catch → `#start` | 775 | Exception + `onError` returns retry opts | Params/headers merged from `retryOpts` | `#maxConsecutiveErrorRetries` (50) |
436+
| L6 | `fetchSnapshot` catch → `fetchSnapshot` | 1937 | HTTP 409 on snapshot fetch | New handle via `withHandle()`; unconditional cache buster on every 409 | `#maxSnapshotRetries` (5) + unconditional cache buster |
377437

378438
### Guard mechanisms
379439

380-
| Guard | Scope | How it works |
381-
| ----------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382-
| `#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). |
383-
| `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. |
384-
| `#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. |
385-
| `#maxSnapshotRetries` | Snapshot 409 path (L6) | Counts consecutive snapshot 409s. Adds cache buster when handle unchanged. Throws FetchError(502) after 5. |
386-
| `#maxConsecutiveErrorRetries` | `#start` onError retry (L5) | Counts consecutive error retries. Sends error to subscribers and tears down after 50. Reset on successful message batch. |
387-
| Pause lock | `#requestShape` entry | Returns immediately if paused. Prevents fetches during snapshots. |
388-
| Up-to-date exit | `#requestShape` entry | Returns if `!subscribe` and `isUpToDate`. Breaks loop for one-shot syncs. |
440+
| Guard | Scope | How it works |
441+
| ----------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
442+
| `#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). |
443+
| `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. |
444+
| `#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. |
445+
| `#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`. |
446+
| `#maxConsecutiveErrorRetries` | `#start` onError retry (L5) | Counts consecutive error retries. Sends error to subscribers and tears down after 50. Reset on successful message batch. |
447+
| Pause lock | `#requestShape` entry | Returns immediately if paused. Prevents fetches during snapshots. |
448+
| Up-to-date exit | `#requestShape` entry | Returns if `!subscribe` and `isUpToDate`. Breaks loop for one-shot syncs. |
389449

390450
### Coverage gaps
391451

0 commit comments

Comments
 (0)