feat(service,host): poll where Neon Realtime is not configured
Draft
CH
7d ago+468+468+012345678901234567890123456789-135-135-012345678901234567890123456789
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 nowpollingWake. Itswatchfires everyLIVE_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_POLLis 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,liveSealingFromEnvironmentreturnspollSealer. Itssealruns the statement throughSqlClientand answers the usual shape at the sentinelLIVE_POLL_URL, with the rows inline. The layer now needsSqlClient.runtime.tsprovides it with the samewebSqlClient, and layer memoization keeps it to one pool. A secret that Realtime refuses still answersunavailable. - Wire (
live-wire.ts):sealedLiveQuerySchemagains an optionalrows. The wire's JSON Schema has no form forUnknown, 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 withLUKE_UPDATE_FIXTURES=1, and the only change in them isrows: {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 isLIVE_POLL_URL,openRealtimeClientreturns a pollingLiveClient. It hands on each seal's rows and skips a renewal whose rows haven't changed.liveRowsre-seals everyLIVE_POLL_EVERY(3 s) at that address. I put the cadence there rather than in a made-upexpiresAtbecause 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 whattaskInboxItems(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 feedsliveRowsand 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