Sync after Electric: two outside critiques (2026-09-10)
Asked the day the shape sync was replaced by polling query collections (docs/system/data/sync.md). Question: is polling the right shape for chat-fresh data? Two models, same brief, independent answers.
gpt-5.6-sol
Verdict: good emergency replacement, but 5s full-list polling is neither chat-fresh nor a safe steady state.
Wrong today
- The 1000 cap is correctness loss, not just scaling: the server caps newest-created rows, then the UI sorts that subset by activity. An old thread active now can vanish; 10 houses × 1000 threads leaves 9000 absent from
/threads(apps/www/src/lib/collections/threads.ts:31-46,packages/core/threads-supabase.ts:184-190,apps/www/src/routes/threads/+page.svelte:103-113). - Each subscribed thread list does
select('*')for up to 1000 rows every 5s: 12 full reads, parses, and collection diffs per minute per tab (apps/www/src/lib/collections/threads.ts:84-94,107-118,packages/core/threads-supabase.ts:184-190). - An open thread can replay its full stream twice: the collection snapshots all entries, while its tail independently starts at
-1(apps/www/src/lib/collections/thread-entries.ts:39-48,121-128,packages/streams/client.ts:203-208). - Hovering a thread preloads that full history, and the warmed/collection/sequence maps never evict it (
apps/www/src/lib/components/ThreadList.svelte:185-190,apps/www/src/lib/collections/thread-page-prefetch.ts:8-10,41-44,apps/www/src/lib/collections/thread-entries.ts:17-20,53-57). - The thread page mounts every house thread and every house thread-participant merely to resolve one thread, its participants, children, and the
#picker (apps/www/src/routes/threads/[id]/+page.svelte:70-79,apps/www/src/lib/components/Chat.svelte:84-109,apps/www/src/lib/collections/thread-participants.ts:24-35).
Ranked moves
- Fix the list contract — cost M, risk L. Keyset-page by canonical activity
(activity_ts,id), not creation; remove the silent global cap. Give children and the#picker narrow/search endpoints instead of requiring all rows. - Add one committed change feed per active house — cost S–M with Realtime, M–H with owned streams; risk M. Use events only to coalesce collection invalidations. Supabase Realtime is the quickest reliable source because it follows committed Postgres writes; prove RLS, deletes, and membership revocation first.
- Then add thread deltas — cost M, risk M.
since=(updated_at,id)should return upserts, archive/delete tombstones, and a next cursor. Use it after push events/reconnects and for a slow safety poll, rather than replacing the whole collection. - Slim the thread mount — cost M, risk L. Mount current thread, one snapshot-with-offset followed by one tail, that thread’s participants, and lazy children/mention/thread search. Remove hover history fetches and add bounded/LRU collection eviction.
- Add ETag/304 as a quick shield — cost S, risk L. Base it on a cheap house revision; hashing the already-built 1000-row body saves bandwidth but not database/Worker work. Keep focus plus a 60s fallback even after push.
A house durable stream can replace the 5s timer, but not safely via “write DB, then best-effort append”: use a transactional outbox and resumable publisher, or keep Realtime. A slow fallback still covers outages.
Keep last_entry_ts stored: computing list recency from 1000 separate streams is untenable. What is wrong is that a failed projection bump is only logged and can remain stale forever; give it retry/checkpoint repair (packages/core/thread-recency.ts:62-87).
deepseek-v4.1-flash
Verdict: as a stopgap the poll is fine. As the shape, it’s wrong — the only reason it works today is that houses are tiny. Two things in the code are outright bugs, not tradeoffs.
Right already, keep: one query collection per scope, writes applied locally then reconciled by refetch, RLS doing the fan-out, polling stopping at zero subscribers.
Wrong, not suboptimal — fix these regardless of the sync design:
- The thread page pays for its stream twice.
thread-entries.ts:46reads with no offset/limit (readThreadEntries(threadId)) and:122starts the tail at the default-1— both replay the whole stream.readStreamapplieslimitonly after pulling everything into the Worker (packages/streams/client.ts:95), solimitisn’t a bound. A 10k-entry thread downloads twice, then re-upserts every entry. - The thread page polls the entire house list to render one thread’s title and pin (
threads/[id]/+page.svelte:76): up to 1000 rows every 5s for one row’s worth of data. - There’s no cheap single-thread read to use instead:
GET /api/threads/:idcallsreconcileStuckThread(api/threads/[id]/+server.ts:76) — a write on a GET. - A dead tail never restarts while mounted (
thread-entries.ts:127-137); the chat just goes quietly stale. And:74throws from inside asetTimeout— unhandled, entry dropped. collectionsandingestSeqs(:17,:20) are never evicted: one live collection plus a map of every entry id, per thread ever visited.- Every write refetches the whole collection (
write-handlers.ts:53,63,72) — pinning one thread re-reads 1000 rows. - Each 5s poll re-runs
threadRowSchema.parseper row on the main thread (threads.ts:69). A thousand Zod parses per collection per five seconds is where the jank will come from before any server limit does.
Ranked, what I’d actually do:
- Per-house invalidation stream, slow poll as repair. New
arbe-house-{id}stream; each www write route appends{collection, houseId}after its DB write; browser tails it (new/api/houses/:id/stream, samerequireHouseMembershape asapi/threads/[id]/stream) and invalidates the matching query keys. Keep 5s → 60s as the repair path, because the append isn’t atomic with the DB write. Cost M, risk M. Wins the most: kills five timers at once, sub-second freshness, and it’s the substrate you already run and control. Wrinkle to design around: the recency bump (thread-recency.ts:85) knows only a thread id, so the bot-traffic case needs either a house lookup at that seam or a per-thread event. - ETag/304 first, delta second.
updated_atalready bumps via trigger on every change including the recency bump, so?updated_after=is cheap and correct. Cost: ETag S; delta M and riskier than it looks — query collections refetch wholesale, so a delta needs a manual merge and tombstone handling for deletes/archives. Ship the 304 now, delta once a house actually crosses a few hundred live threads. - Cut the thread page down. Read a bounded recent window, tail from its
nextOffset(never-1), add a plainGET /api/threads/:idthat skips reconciliation, and evictcollections/ingestSeqs. Cost S, risk S, biggest felt improvement. - One activity tick per house instead of a timer per collection (5s/15s/30s today). Cheap, and it makes the poll a single knob to remove later.
- Supabase Realtime: don’t. It would cover the writer paths www doesn’t own, but you just left a vendor sync service; enabling the publication, replica identity and RLS on Realtime trades one dependency for another and won’t cover
last_entry_tsbetter than the trigger already does. Revisit only if the stream hint channel proves leaky.
last_entry_ts: keep it stored. A list can’t read N streams to sort, so the denormalization is the design, not a shortcut. Its risk is the write-seam allowlist — a new producer that skips createRecencyTrackingStreamClient leaves it silently stale — so the fix is a guard test, not a recompute.
At 10 houses × 1000 threads: not a server problem, a browser and bandwidth one. One tab with /threads and a house open does two 1000-row reads per 5s (~300-500 KB each, so several MB/min), parses 2000 rows on the main thread each time, and the cross-house discovery list silently truncates at 1000 (threads.ts:36) — it will just start hiding threads.