ATProto-based AI Assistant Chat interface.
scn-chat specs browser-store.md
12 kB · 109 lines
Markdown
at main

Browser Store #

Summary #

The web app keeps a local copy of the signed-in user's chats in the browser, in SQLite compiled to WebAssembly (@sqlite.org/sqlite-wasm), stored in the browser's origin private file system. The chat list, open conversations, and search all read from this local database. It syncs with the user's PDS through the server's pass-through routes, fetching only changes since the revisions it last saw. The copy lives on the user's device, so the server never holds chat content, and the UI stays fast without a server cache.

Motivation #

The PDS is the source of truth, and the server keeps no copy of chat content. Reading everything from the PDS on every screen would be slow, and full-text search over hundreds of conversations is impossible without an index. Keeping the copy and the index on the user's own device gives both, while leaving the server stateless about content. SQLite with FTS5 gives real search and a familiar SQL model in the browser.

Design #

Runtime #

  • @sqlite.org/sqlite-wasm runs in a dedicated Web Worker, using the opfs-sahpool VFS. That VFS persists to the origin private file system without the cross-origin isolation headers the other OPFS mode needs.
  • The UI talks to the worker through comlink. The worker exposes every method of the store's logic as an async call, each through one guard that fails while another tab holds the store and reports an ended session. The tab's StoreClient holds those calls along with the handover state.
  • There is one database file per account, named from a hash of the DID, so accounts on a shared browser stay separate.
  • Signing out deletes that account's database file. So does any 401 from the server, which means the session has lapsed, so chats are not left on a shared device after an expired session. The data is on the PDS, so a later sign-in resyncs it.
  • opfs-sahpool allows only one open connection per database, so only one tab holds the store at a time, and it moves to whichever tab the user is working in:
    • When a tab gains focus, it requests a Web Lock named after the database with steal: true, and posts a release request on a BroadcastChannel.
    • The tab losing the lock finishes any running transaction, closes its database with SQLite's pauseVfs, and keeps showing what it last rendered. Reply streams still reach it, since they come from the server. It reopens the store when it is focused again.
    • The focused tab retries opening the database for up to three seconds, since the old tab may need a moment to release its file handles.
    • If the open still fails, most likely because the browser has frozen the other tab and it cannot release its handles, the tab blurs the chat panel, as Jump to does, and shows a card in its middle saying SCN Chat is open in another tab. Its Use here button retries. If the open fails for another reason, the card says the chats couldn't open, and its button is Try again. The card stays up while its button retries, with the button busy, and the page behind it keeps showing what it last rendered but can't be reached, by pointer or keyboard, until the store opens. The button takes the keyboard as the card opens.
    • Opening the database either succeeds or throws, so a tab always knows whether it holds the store, and two tabs never hold it at once.
  • If the origin private file system is unavailable, as in some private browsing modes, the worker uses an in-memory database and logs a warning. Everything works, but nothing persists between sessions.

Schema #

Table Columns
conversation skey (primary key), uri, title, tags_json, updated_at, rev (the last conversation revision fetched, null if never fetched)
message conversation_skey, author_did, rkey, role, parent, status, record_json, text, created_at. Primary key is the first three.
info conversation_skey (primary key), record_json
meta key (primary key), value. Holds the index revision, when the index last synced, and the schema version.

text is the concatenated text parts of a message, used by search. The schema version is checked on open. A mismatch deletes and rebuilds the database, since everything in it can be fetched again.

Sync #

  • The index. On start, and whenever the server's event stream says the index changed, the worker calls GET /api/conversations?since=<index revision>. It applies the returned entries and deletions to conversation, and stores the new revision. The first call has no revision and returns the whole index.
  • Conversations. A conversation is fetched with GET /api/conversations/:skey?since=<rev>, applying changed records and deletions and storing the new revision. The first fetch of a conversation has no revision and returns everything.
  • Opening a conversation fetches its changes before showing it, and shows the local copy immediately while that runs.
  • Background download. After the index syncs, the worker fetches every conversation it has never fetched, newest first, two at a time, so search eventually covers the whole history. A conversation whose updated_at is newer than when it was last fetched is refetched the same way.
  • Live changes. The worker subscribes to GET /api/events. A conversation-changed event fetches that conversation's changes, and an index-changed event syncs the index.
  • Own writes. After the UI writes through the server, the worker fetches the affected conversation's changes, so the local copy matches the PDS.
  • Reconciling. At startup for the index, and when a conversation is opened, the worker fetches the matching keys route from the chat-storage spec. It compares every collection, key, and CID with its own rows, fetches records it lacks or holds at a different CID, and deletes rows the PDS no longer has. That repairs any gap in the revision-based fetches without downloading content it already has.

Streaming replies are shown from the turn's stream, as the chat-turns spec describes. The finished record arrives through a normal changes fetch.

Worker API #

listConversations({ limit, after, tag }): Promise<{ chats: ChatListItem[]; next }> // a page, below
listTags(): Promise<{ tag; count }[]>           // every tag across the chats, newest use first
latestReplies(): Promise<{ skey; latestReply }[]> // every chat's newest finished reply
getConversation(skey): Promise<{ info; messages }>
refreshConversation(skey): Promise<void>
syncIndex(): Promise<void>
search(query, opts): Promise<SearchResult[]>   // see the search spec
remainingDownloads(): Promise<number>          // conversations not yet fetched
syncStatus(): Promise<{ syncedAt; remaining }> // when the index last synced, and the download count
reset(): Promise<void>                          // delete and rebuild the database

Each chat-list item adds two things its messages say, read from the message table: pending, whether an assistant reply is still pending, and latestReply, the key of the newest complete assistant reply. The sidebar shows the first as a replying dot and compares the second with what this device has seen. A change to a conversation therefore also refreshes the chat list. Each index sync stores its time under index_synced_at, even when nothing changed, for the sidebar's sync status.

The chat list reads a page at a time, 50 by default, newest first. It pages by (updated_at, skey), so next is the last chat's pair, or null after the last page, and a chat that moves to the top between pages is neither shown twice nor skipped. Given a tag, it pages through only the chats carrying it. An index on (updated_at desc, skey desc) serves it, created on every open when missing, so adding it needed no new schema version. The tags and their counts, and every chat's newest reply for the seen marks, cover all chats, not only the pages read.

Changes are announced to the UI through a subscription, so screens rerender when the data under them changes.

Scope Boundaries #

  • No writes to the local database from the UI. All writes go through the server to the PDS, and the local copy follows.
  • No offline writing or queueing.
  • No sync between devices other than through the PDS.
  • No local copy of shared conversations owned by other people.
  • No encryption of the local database beyond what the browser provides.

Edge Cases and Decisions #

  • The local database is disposable. A schema change or a reset rebuilds it from the PDS, so it never needs migrations.
  • Signing out deletes the local copy, trading a resync on next sign-in for not leaving chats on a shared device.
  • Search only covers conversations the background download has fetched, so a new device's search fills in over the first minutes.
  • opfs-sahpool avoids the cross-origin isolation headers, which would break embedding and some third-party resources. The cost is one connection at a time, handled by moving the store to the focused tab.
  • The handover cannot be guaranteed, because a frozen background tab cannot release its file handles. The card saying SCN Chat is open in another tab covers that case.
  • The local tables also keep each record's CID, each index entry's CID, and when each conversation was last fetched. Reconciling compares CIDs, and the background download refetches conversations whose entry is newer than their last fetch.
  • Reconciling refetches the whole conversation, or the whole index, when anything differs, since the server has no per-record route.
  • The store logic runs over a small SQL interface, so tests run it on better-sqlite3 while the worker runs it on SQLite WebAssembly. StoreClient has two adapters: the worker in the browser, and testStore in tests/helpers/store.ts, which runs the same logic and guard in the test's process. Component tests use it with a fake server API, so they go through the real reads, syncs, and change events rather than a hand-built store.
  • The worker, OPFS persistence, and reload survival need a real browser, so they are not covered by the jsdom tests.

Acceptance Criteria #