Skip to content
View as .md

Sync

www API routes → TanStack Query collections → Svelte components. Every house-scoped table carries a flat house_id so a list route filters by a literal equality; schemas live in @arbe/core/schemas/rows.ts, and every collection parses its rows through them at the boundary.

login → membersCollection (GET /api/members — your own member rows)
enter house → house collections (members, environments, configs, threads,
workflows WHERE house_id = $hid)
switch house → keyed collections swap to the new house id
logout → collections unmount with the session

Every structural collection is a TanStack query collection (queryCollectionOptions in apps/www/src/lib/collections/). Its queryFn calls the www API — through @arbe/core/client, or plain fetch for the two lists that predate the client methods — and returns rows parsed with the core schema. Components read them with useLiveQuery and never see the source.

The thread lists are activity-ordered, windowed, and conditional. GET /api/threads orders by coalesce(last_entry_ts, created_at) descending with (activity, id) keyset paging: an old thread bumped by a new entry rises to the top, and a client reaches every thread through cursor / nextCursor rather than hitting a silent cap. PostgREST cannot order by an expression, so the store reads that key as two keyset streams — threads with an entry by last_entry_ts, threads without by created_at — and merges them. The response carries an ETag built from one cheap per-scope revision (row count plus max(updated_at)), and the collections send it back as If-None-Match; a 304 replays the last window without re-parsing a row. The tag also names the window (limit and cursor), so a differently sized or paged read can never answer 304 with another window’s body. Each collection holds one window (THREAD_LIST_WINDOW), and loadOlderThreads pages past it with writeInsert, so a later poll can replace the window without dropping the older rows; the paging cursor advances only on a click, never on a poll, so a poll between two clicks does not rewind the reader to a page they already loaded.

Liveness is per collection, chosen from what changes without the reader acting:

CollectionRefresh
houseThreadsCollectionthe house stream while a house is open, else a 60s repair tick, plus window focus — bots and workflows open threads and bump previews/status
threadsCollection (cross-house)a 15s poll plus window focus — the house stream only tails a mounted house, so this list has no push until a per-user stream exists
membersCollectionthe house stream plus a 60s repair tick and focus — the layout reloads when one of your member rows changes
houseEnvironmentsCollectionthe house stream plus a 60s repair tick and focus — an agent can provision the house’s first environment
houseMembersCollection, houseConfigsCollection, houseWorkflowsCollectionthe house stream while a house is open, else window focus — another member can change them
workflowsCollectionwindow focus only — the reader is the only writer

Polling stops when nothing subscribes: TanStack DB unsubscribes the query observer at subscriber count zero, so leaving the page stops its timer. A collection that is not a list you must watch live (thread-entries, thread-participants, sandboxes, secrets) is invalidated explicitly or fed by the durable stream.

Push. One durable stream per house (arbe-house-<id>, houseStreamId() in @arbe/core/schemas/house) carries small invalidation events {collection, id?}. Every www write route appends one after its DB write, and the thread recency bump appends a threads event after last_entry_ts — which is what makes a bot’s reply land in another browser in about a round trip. While a house is mounted the browser tails it through the membership-checked GET /api/houses/:id/stream (same proxy shape as the thread stream); the route creates the stream if it is missing, and the stream’s seven-day TTL keeps events from accumulating forever. The tail collects the collections a batch names and flushes each once, so a burst of events is one refetch, not one per entry. The append is best-effort and not atomic with the write: it is logged on failure, and the 60s poll is the repair path.

Writes. Optimistic insert/update/delete run through withWriteHandlers (apps/www/src/lib/collections/write-handlers.ts): apply locally, call the matching per-entity route (POST /api/houses, PATCH /api/threads/:id, DELETE /api/houses/:id/members/:agentId, …), then refetch the collection before the transaction settles. The refetch is the reconciliation — server truth replaces the optimistic row. A failed request rolls back; a failed refetch leaves the query in an error state rather than undoing a write that landed. Stream writes are separate: messages POST to /api/threads/:id/entries and confirm through the durable-stream tail.

Scope loss. A 403/404 from a house-scoped fetch that checks membership evicts the keyed collection (scopeGoneEvicts in collection-factory.ts). Routes that filter instead of rejecting — GET /api/threads answers 200 [] for a house you have left — simply come back empty, so either way the collection holds no stale rows.

Reading, on the client. Prefer a collection plus a useLiveQuery projection over fetching in a $effect. A collection gives you dedupe, caching, and one source of truth per row; a $effect that calls client.x() gives you none of them, and overlapping runs fire overlapping requests — that shape cost the thread page three concurrent thread-participants GETs until arbe-eda2. When several rows want the same kind of data, scope the collection to the parent (one house-wide read of every thread’s participants, projected per thread) rather than one read per row. If the data has no collection yet, adding one is usually the cheaper fix.

agents is the one v1 table without a house_id scope (one identity, N houses). members is the house-scoped access edge: (house_id, agent_id, role), plus denormalised display_name + kind for render paths — Discord’s User vs GuildMember split. The house member list is members joined to agents. The full agents row carries settings and is mounted only when a user edits their own profile or a bot they admin; rename = server-side fan-out across that agent’s members rows.

member is the term Discord, Slack, GitHub, Linear, Notion all use. House membership grants access to every thread under the house. Ownership lives on members.role = 'owner'; there’s no author_id on houses. RLS gates every table by is_house_member / is_house_owner, and every read resolves the caller server-side, so unauthorised rows never leave the server. members RLS in v1 is peer-visible within shared houses, so a member can see the house’s other members.

Code: packages/core/schemas/rows.ts, apps/www/src/lib/collections/, apps/www/src/routes/api/.
See system/storage, system/permissions, system/www.

Open: conflict reconciliation when two people edit one row (last refetch wins); whether the thread lists want a delta endpoint (updated_after=(updated_at, id) returning upserts and delete tombstones) instead of re-reading the first window — deferred until a house crosses a few hundred live threads. The ETag names the scope revision and the window, so only an identical read can reuse it.