HAD-239, plus a related stuck-game case found while working on it.
The cursor (HAD-239). waitForOpponent subscribed with
initialCursor: (Date.now() - 60_000) * 1000. Jetstream does not seek to a
cursor and then jump to the live tail; it drains the backlog at close to real
time, so the socket stayed roughly a minute behind and a join could take that
long to notice. Measured against jetstream2.us-east:
live tail (no cursor) lag median -31ms p90 -22ms max 59ms
cursor 60s in the past lag median 55614ms p90 59556ms max 60296ms
That socket has no record to read, so the catch-up trick used for the move path
did not apply directly. JetstreamConnection now takes an optional onCatchUp
that runs on every connect, after the record read when there is one, and
waitForOpponent uses it to re-run findJoiner against Constellation's backlink
index. Gaps are closed by reading, the socket stays at the tail.
No recursion: loadGame only calls waitForOpponent again when the game is
still unpaired, in which case findJoiner returns null and the chain stops.
Directed invites could stick in 'waiting' forever. The discovery added in
HAD-149 is gated on (!record.white || !record.black), i.e. an open game with a
free slot. CreateGameForm fills both slots up front for a directed invite, so
discovery never ran for one. The owner branch then found the opponent's child
record via findGameRecordByParent and applied its PGN, but never moved its own
status off waiting -- so an inviter who was away when their opponent joined sat
on "Waiting for opponent" permanently, with the moves arriving underneath.
The child record is the proof of a join, so the owner branch now activates on it
and persists the change. No extra query: findGameRecordByParent was already
being called there.
Tests cover onCatchUp firing on open, again on reconnect, with no record to
read, ordered after the record read, and not at all after destroy().
Also folded in: five isInCheck regression tests as their own commit. That
getter drives the "Check!" banner and had no coverage; a reported stuck banner
turned out to be a stale cached bundle rather than a bug, but the gap was real.