# 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:

| Collection | Refresh |
| --- | --- |
| `houseThreadsCollection` | the 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 |
| `membersCollection` | the house stream plus a 60s repair tick and focus — the layout reloads when one of your member rows changes |
| `houseEnvironmentsCollection` | the house stream plus a 60s repair tick and focus — an agent can provision the house's first environment |
| `houseMembersCollection`, `houseConfigsCollection`, `houseWorkflowsCollection` | the house stream while a house is open, else window focus — another member can change them |
| `workflowsCollection` | window 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/`.<br>
See [system/storage](storage.md), [system/permissions](../access/permissions.md), [system/www](../ops/www.md).

_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._
