Skip to content
View as .md

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.