# 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**
1. **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.
2. **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.
3. **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.
4. **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.
5. **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:**
1. The thread page pays for its stream twice. `thread-entries.ts:46` reads with no offset/limit (`readThreadEntries(threadId)`) and `:122` starts the tail at the default `-1` — both replay the whole stream. `readStream` applies `limit` only *after* pulling everything into the Worker (`packages/streams/client.ts:95`), so `limit` isn't a bound. A 10k-entry thread downloads twice, then re-upserts every entry.
2. 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.
3. There's no cheap single-thread read to use instead: `GET /api/threads/:id` calls `reconcileStuckThread` (`api/threads/[id]/+server.ts:76`) — a write on a GET.
4. A dead tail never restarts while mounted (`thread-entries.ts:127-137`); the chat just goes quietly stale. And `:74` throws from inside a `setTimeout` — unhandled, entry dropped.
5. `collections` and `ingestSeqs` (`:17`, `:20`) are never evicted: one live collection plus a map of every entry id, per thread ever visited.
6. Every write refetches the whole collection (`write-handlers.ts:53,63,72`) — pinning one thread re-reads 1000 rows.
7. Each 5s poll re-runs `threadRowSchema.parse` per 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:**

1. **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`, same `requireHouseMember` shape as `api/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.
2. **ETag/304 first, delta second.** `updated_at` already 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.
3. **Cut the thread page down.** Read a bounded recent window, tail from *its* `nextOffset` (never `-1`), add a plain `GET /api/threads/:id` that skips reconciliation, and evict `collections`/`ingestSeqs`. Cost S, risk S, biggest felt improvement.
4. **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.
5. **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_ts` better 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.
