feat(service,host): poll where Neon Realtime is not configured

Draft
CH
7d ago
+468-135

Stacks on #3266. Production has NEON_REALTIME_URL and NEON_REALTIME_SECRET and keeps running on Realtime. Previews and local dev have neither, and until now they heard nothing. This PR adds a polling fallback for them, chosen in one place per side, so no live area changes.

The fallback

  • Service wake (live/live-wake.ts): the wake that died when Realtime wasn't configured is now pollingWake. Its watch fires every LIVE_WAKE_POLL (1 s), so every held read checks again about once a second, which covers board look's 8 s deadline. A Realtime that refuses its configuration also falls back to polling. LIVE_SAFETY_POLL is unchanged, and its doc comment now says it is the only recovery from a refused or dropped Realtime watch.
  • Service sealing (live/realtime.ts, runtime.ts): without the variables, liveSealingFromEnvironment returns pollSealer. Its seal runs the statement through SqlClient and answers the usual shape at the sentinel LIVE_POLL_URL, with the rows inline. The layer now needs SqlClient. runtime.ts provides it with the same webSqlClient, and layer memoization keeps it to one pool. A secret that Realtime refuses still answers unavailable.
  • Wire (live-wire.ts): sealedLiveQuerySchema gains an optional rows. The wire's JSON Schema has no form for Unknown, so the rows travel as JSON text (fromJsonString). The live endpoints now encode their answers through the answer schemas, and the host already decodes with them. The JSON Schema goldens are regenerated with LUKE_UPDATE_FIXTURES=1, and the only change in them is rows: {type: string}. No new route or token was added: the rows only travel through /api/live/*, behind the bearer.
  • Host (live-follow.ts): when the address is LIVE_POLL_URL, openRealtimeClient returns a polling LiveClient. It hands on each seal's rows and skips a renewal whose rows haven't changed. liveRows re-seals every LIVE_POLL_EVERY (3 s) at that address. I put the cadence there rather than in a made-up expiresAt because an expiry close to now would make the renewal fire immediately, over and over.

Tests

  • live-app.test.ts: with the poll sealer, a plan inbox's rows come back at the poll address. Decoded through the wire schemas, they equal what taskInboxItems (the held read) returns.
  • realtime.test.ts: without the variables the sealer polls and returns rows. A refused secret still leaves it off.
  • live-wake.test.ts: with no wake configured, a held read checks again after 1 s. This replaces the "dies when not configured" test.
  • live-follow.test.ts (new): a polled read feeds liveRows and skips unchanged rows.

Checks run locally

Typecheck for features, service, host and web. Biome, oxlint, repository-checks.sh and knip. Vitest for the host, features and service projects, plus plan-comments.

Not confirmed

  • Rows match the held read on PGlite. I haven't checked them against Neon Realtime's own row encoding. Realtime keeps no order, and the readers sort, so order doesn't matter.
  • The account inbox polls 7 statements every 3 s per open desktop. That's fine for Previews and dev, but it isn't meant for production.

Made with Luke