The Split

Concern Owner Examples
Server cache TanStack Vue Query book lists, catalogs — anything fetched
Session state Pinia (stores/auth.ts) logged-in flag, username, role
Persistent state IndexedDB store agent id, auth token

This is the deliberate point of the client architecture: queries cache by key and mutations invalidate them; the session is a handful of refs restored from IndexedDB on boot. Neither system reaches into the other.

Server Cache: Query Composables

Each view pairs with one data-access composable in client/src/io/. Reactive refs inside the query key make the query re-run automatically when pagination or filters change:

// client/src/io/useBooks.ts (excerpt)
const query = useQuery({
  // refs in the key make the query re-run when page/filters change
  queryKey: ["books", page, pageSize, filters],
  queryFn: () =>
    command<BooksPage>("books/list", {
      page: page.value,
      pageSize: pageSize.value,
      filters: Object.keys(filters.value).length ? filters.value : undefined,
    }),
  placeholderData: keepPreviousData,
})

const addMutation = useMutation({
  mutationFn: (input: { catalogId: string; price: number; meta: Record<string, any> }) =>
    command<{ success: boolean; id: string }>("books/add", input),
  onSuccess: () => qc.invalidateQueries({ queryKey: ["books"] }),
})

keepPreviousData keeps the current page on screen while the next one loads; the mutation’s onSuccess invalidates every ["books", …] key so lists refetch after a write.

Session State: the Pinia Auth Store

The auth store owns login, logout, and session restoration. Its most interesting move is how it injects the token into the protocol: the server’s token is already encrypted, so the client simply overrides the agent’s encrypt step to return it verbatim:

// client/src/stores/auth.ts (excerpt)
// inject the (already server-encrypted) token into every outgoing message
function applyToken(token: string) {
  agent.setIdentity({ accountId: "", identifier: "", role: "BUYER" })
  agent.encrypt = async () => token
}

// restore a persisted session on boot
function init() {
  const token = store.get<string>(TOKEN_KEY)
  if (token) {
    applyToken(token)
    isAuthenticated.value = true
    username.value = store.get<string>(USERNAME_KEY) ?? ""
    role.value = store.get<string>(ROLE_KEY) ?? "BUYER"
  }
}

Login and registration share one authenticate() path that differs only in the operation it targets (accounts/connect vs accounts/register), and logout sends accounts/disconnect before clearing local state — tolerating a failed network call, because local state must clear regardless.

Why Not One Store for Everything?

Putting fetched data in Pinia means hand-rolling caching, invalidation, deduplication and refetch logic that Query already solves. Putting session state in Query means modelling “logged in” as a fake server resource. Keeping the two systems separate keeps both trivial — the auth store is under a hundred lines, and no composable manages a cache by hand.