Skip to content

Commit 106b34c

Browse files
userFRMclaudewesm
authored
feat: persist starred sessions in SQLite database (wesm#94)
## Summary - Adds `starred_sessions` table to SQLite schema for server-side star persistence - New REST API endpoints: `GET /starred`, `PUT /sessions/{id}/star`, `DELETE /sessions/{id}/star`, `POST /starred/bulk` - Rewrites frontend `StarredStore` to load from API with optimistic updates - Automatic migration: existing localStorage stars are bulk-inserted into the DB on first load, then localStorage is cleared - All existing tests pass; no changes to existing behavior Closes wesm#93 ## Changes | Layer | File | What | |-------|------|------| | Schema | `internal/db/schema.sql` | New `starred_sessions` table | | Schema probe | `internal/db/db.go` | Detect table for auto-rebuild | | DB layer | `internal/db/starred.go` | `StarSession`, `UnstarSession`, `ListStarredSessionIDs`, `BulkStarSessions` | | Handlers | `internal/server/starred.go` | Star/unstar/list/bulk handlers | | Routes | `internal/server/server.go` | 4 new route registrations | | Frontend API | `frontend/src/lib/api/client.ts` | `listStarred`, `starSession`, `unstarSession`, `bulkStarSessions` | | Frontend store | `frontend/src/lib/stores/starred.svelte.ts` | API-backed store with optimistic updates + localStorage migration | | App init | `frontend/src/App.svelte` | Call `starred.load()` on mount | ## Test plan - [x] `go vet ./...` — clean - [x] `go test ./...` — all pass - [x] `npm run build` — clean - [x] API verified via curl: star, unstar, list, bulk endpoints all return correct responses - [ ] Manual: star a session → refresh page → star persists (no localStorage) - [ ] Manual: pre-existing localStorage stars migrate to DB on first load 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent aea2eb1 commit 106b34c

File tree

9 files changed

+788
-37
lines changed

9 files changed

+788
-37
lines changed

frontend/src/App.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { sync } from "./lib/stores/sync.svelte.js";
1818
import { ui } from "./lib/stores/ui.svelte.js";
1919
import { router } from "./lib/stores/router.svelte.js";
20+
import { starred } from "./lib/stores/starred.svelte.js";
2021
import { registerShortcuts } from "./lib/utils/keyboard.js";
2122
import type { DisplayItem } from "./lib/utils/display-items.js";
2223
import {
@@ -154,6 +155,7 @@
154155
});
155156
156157
onMount(() => {
158+
starred.load();
157159
sync.loadStatus();
158160
sync.loadStats();
159161
sync.loadVersion();

frontend/src/lib/api/client.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,46 @@ export function setGithubConfig(
341341
});
342342
}
343343

344+
/* Starred */
345+
346+
export async function listStarred(): Promise<{ session_ids: string[] }> {
347+
return fetchJSON("/starred");
348+
}
349+
350+
export async function starSession(id: string): Promise<void> {
351+
const res = await fetch(`${BASE}/sessions/${id}/star`, {
352+
method: "PUT",
353+
});
354+
if (!res.ok) {
355+
const body = await res.text();
356+
throw new ApiError(res.status, apiErrorMessage(res.status, body));
357+
}
358+
}
359+
360+
export async function unstarSession(id: string): Promise<void> {
361+
const res = await fetch(`${BASE}/sessions/${id}/star`, {
362+
method: "DELETE",
363+
});
364+
if (!res.ok) {
365+
const body = await res.text();
366+
throw new ApiError(res.status, apiErrorMessage(res.status, body));
367+
}
368+
}
369+
370+
export async function bulkStarSessions(
371+
sessionIds: string[],
372+
): Promise<void> {
373+
const res = await fetch(`${BASE}/starred/bulk`, {
374+
method: "POST",
375+
headers: { "Content-Type": "application/json" },
376+
body: JSON.stringify({ session_ids: sessionIds }),
377+
});
378+
if (!res.ok) {
379+
const body = await res.text();
380+
throw new ApiError(res.status, apiErrorMessage(res.status, body));
381+
}
382+
}
383+
344384
/* Analytics */
345385

346386
export interface AnalyticsParams {
Lines changed: 242 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,278 @@
1+
import * as api from "../api/client.js";
2+
13
const STORAGE_KEY = "agentsview-starred-sessions";
24

3-
function readStarred(): Set<string> {
4-
try {
5-
const raw = localStorage?.getItem(STORAGE_KEY);
6-
if (raw) {
7-
const arr = JSON.parse(raw);
8-
if (Array.isArray(arr)) return new Set(arr);
5+
class StarredStore {
6+
// Seed from localStorage so legacy stars are visible immediately,
7+
// before the async server load and migration complete.
8+
ids: Set<string> = $state(readLocalStorage());
9+
filterOnly: boolean = $state(false);
10+
private loaded = false;
11+
private loading: Promise<void> | null = null;
12+
/** Global mutation counter for load/migration staleness detection. */
13+
private mutationVersion = 0;
14+
/** Monotonic counter for listStarred refresh calls so only the
15+
* latest response applies when multiple are in-flight. */
16+
private refreshId = 0;
17+
/** Per-session promise chains to serialize server mutations. */
18+
private queues: Map<string, Promise<void>> = new Map();
19+
private retryCount = 0;
20+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
21+
private reconcileTimer: ReturnType<typeof setTimeout> | null = null;
22+
private reconcileRetries = 0;
23+
24+
async load() {
25+
if (this.loaded) return;
26+
if (this.loading) return this.loading;
27+
this.loading = this.doLoad();
28+
return this.loading;
29+
}
30+
31+
private async doLoad() {
32+
const mutVer = this.mutationVersion;
33+
const rid = ++this.refreshId;
34+
try {
35+
const res = await api.listStarred();
36+
if (this.mutationVersion === mutVer && this.refreshId === rid) {
37+
this.ids = new Set(res.session_ids);
38+
}
39+
try {
40+
await this.migrateLocalStorage();
41+
} finally {
42+
// Mark loaded after migration completes (or fails) so
43+
// concurrent load() callers don't see partially-initialized
44+
// state. Only set when listStarred succeeded.
45+
this.loaded = true;
46+
this.cancelRetry();
47+
}
48+
} catch {
49+
const local = readLocalStorage();
50+
if (local.size > 0) {
51+
if (this.mutationVersion === mutVer && this.refreshId === rid) {
52+
// No mutations during load — safe to replace.
53+
this.ids = local;
54+
} else {
55+
// Mutations occurred — merge local stars into current
56+
// optimistic state so legacy IDs aren't lost, but skip
57+
// IDs with in-flight mutations to avoid resurrecting
58+
// explicitly unstarred sessions.
59+
const merged = new Set(this.ids);
60+
for (const id of local) {
61+
if (!this.queues.has(id)) merged.add(id);
62+
}
63+
this.ids = merged;
64+
}
65+
}
66+
this.scheduleRetry();
67+
} finally {
68+
this.loading = null;
969
}
10-
} catch {
11-
// ignore
1270
}
13-
return new Set();
14-
}
1571

16-
function persist(ids: Set<string>) {
17-
try {
18-
localStorage?.setItem(
19-
STORAGE_KEY,
20-
JSON.stringify([...ids]),
21-
);
22-
} catch {
23-
// ignore
72+
private cancelRetry() {
73+
if (this.retryTimer !== null) {
74+
clearTimeout(this.retryTimer);
75+
this.retryTimer = null;
76+
}
77+
this.retryCount = 0;
2478
}
25-
}
2679

27-
class StarredStore {
28-
ids: Set<string> = $state(readStarred());
29-
filterOnly: boolean = $state(false);
80+
private scheduleRetry() {
81+
if (this.retryTimer !== null) return;
82+
if (this.retryCount >= 3) return;
83+
const delay = 2000 * 2 ** this.retryCount;
84+
this.retryCount++;
85+
this.retryTimer = setTimeout(() => {
86+
this.retryTimer = null;
87+
this.load();
88+
}, delay);
89+
}
90+
91+
private async migrateLocalStorage() {
92+
const local = readLocalStorage();
93+
if (local.size === 0) return;
94+
95+
const toMigrate = [...local].filter((id) => !this.ids.has(id));
96+
if (toMigrate.length > 0) {
97+
const mutVer = this.mutationVersion;
98+
const rid = ++this.refreshId;
99+
try {
100+
await api.bulkStarSessions(toMigrate);
101+
} catch {
102+
// Bulk star failed — merge into memory and preserve
103+
// localStorage for retry on next page reload.
104+
const merged = new Set(this.ids);
105+
for (const id of toMigrate) merged.add(id);
106+
this.ids = merged;
107+
return;
108+
}
109+
// Server has the data — clear localStorage immediately so
110+
// stale IDs are never re-migrated on a later reload.
111+
clearLocalStorage();
112+
try {
113+
const refreshed = await api.listStarred();
114+
if (this.mutationVersion === mutVer && this.refreshId === rid) {
115+
this.ids = new Set(refreshed.session_ids);
116+
}
117+
} catch {
118+
// Refresh failed — don't merge toMigrate IDs because
119+
// the server silently skips stale session IDs during
120+
// bulk star. Merging unverified IDs would introduce
121+
// phantom stars. Schedule retried reconciliation so
122+
// the correct server state is fetched once connectivity
123+
// recovers.
124+
this.scheduleReconcile();
125+
}
126+
} else {
127+
clearLocalStorage();
128+
}
129+
}
30130

31131
isStarred(sessionId: string): boolean {
32132
return this.ids.has(sessionId);
33133
}
34134

35135
toggle(sessionId: string) {
36-
const next = new Set(this.ids);
37-
if (next.has(sessionId)) {
38-
next.delete(sessionId);
136+
if (this.ids.has(sessionId)) {
137+
this.unstar(sessionId);
39138
} else {
40-
next.add(sessionId);
139+
this.star(sessionId);
41140
}
42-
this.ids = next;
43-
persist(next);
44141
}
45142

46143
star(sessionId: string) {
47144
if (this.ids.has(sessionId)) return;
48145
const next = new Set(this.ids);
49146
next.add(sessionId);
50147
this.ids = next;
51-
persist(next);
148+
this.mutationVersion++;
149+
this.enqueue(sessionId, () => api.starSession(sessionId));
52150
}
53151

54152
unstar(sessionId: string) {
55153
if (!this.ids.has(sessionId)) return;
56154
const next = new Set(this.ids);
57155
next.delete(sessionId);
58156
this.ids = next;
59-
persist(next);
157+
this.mutationVersion++;
158+
// Mirror into localStorage while the legacy key exists so
159+
// a migration retry doesn't re-star this session.
160+
removeFromLocalStorage(sessionId);
161+
this.enqueue(sessionId, () => api.unstarSession(sessionId));
162+
}
163+
164+
private enqueue(
165+
sessionId: string,
166+
op: () => Promise<unknown>,
167+
) {
168+
const prev = this.queues.get(sessionId) ?? Promise.resolve();
169+
const chain: Promise<void> = prev
170+
.then(() => op(), () => op())
171+
.then(() => {}, () => {})
172+
.then(() => {
173+
if (this.queues.get(sessionId) === chain) {
174+
this.queues.delete(sessionId);
175+
}
176+
this.reconcileIfIdle();
177+
});
178+
this.queues.set(sessionId, chain);
179+
}
180+
181+
/**
182+
* After all in-flight mutations settle, re-fetch server state
183+
* to correct any drift from failed requests. Uses refreshId to
184+
* ensure only the latest listStarred response is applied when
185+
* multiple refreshes are in-flight.
186+
*/
187+
private reconcileIfIdle() {
188+
if (this.queues.size > 0) return;
189+
const mutVer = this.mutationVersion;
190+
const rid = ++this.refreshId;
191+
api.listStarred().then((res) => {
192+
if (this.mutationVersion === mutVer && this.refreshId === rid) {
193+
this.ids = new Set(res.session_ids);
194+
}
195+
}).catch(() => {
196+
// Server unavailable; keep optimistic state.
197+
});
198+
}
199+
200+
/**
201+
* Retried reconciliation for post-migration refresh failures.
202+
* Unlike reconcileIfIdle (single fire-and-forget), this retries
203+
* with backoff so migrated IDs eventually appear even if the
204+
* server is still temporarily unavailable.
205+
*/
206+
private scheduleReconcile() {
207+
if (this.reconcileTimer !== null) return;
208+
if (this.reconcileRetries >= 3) return;
209+
const delay = 2000 * 2 ** this.reconcileRetries;
210+
this.reconcileRetries++;
211+
this.reconcileTimer = setTimeout(() => {
212+
this.reconcileTimer = null;
213+
const mutVer = this.mutationVersion;
214+
const rid = ++this.refreshId;
215+
api.listStarred().then((res) => {
216+
if (
217+
this.mutationVersion === mutVer &&
218+
this.refreshId === rid
219+
) {
220+
this.ids = new Set(res.session_ids);
221+
}
222+
this.reconcileRetries = 0;
223+
}).catch(() => {
224+
this.scheduleReconcile();
225+
});
226+
}, delay);
60227
}
61228

62229
get count(): number {
63230
return this.ids.size;
64231
}
65232
}
66233

67-
export const starred = new StarredStore();
234+
function readLocalStorage(): Set<string> {
235+
try {
236+
const raw = localStorage?.getItem(STORAGE_KEY);
237+
if (raw) {
238+
const arr = JSON.parse(raw);
239+
if (Array.isArray(arr)) return new Set(arr);
240+
}
241+
} catch {
242+
// ignore
243+
}
244+
return new Set();
245+
}
246+
247+
function clearLocalStorage() {
248+
try {
249+
localStorage?.removeItem(STORAGE_KEY);
250+
} catch {
251+
// ignore
252+
}
253+
}
254+
255+
/** Remove a single ID from localStorage (if the key exists). */
256+
function removeFromLocalStorage(id: string) {
257+
try {
258+
const raw = localStorage?.getItem(STORAGE_KEY);
259+
if (!raw) return;
260+
const arr = JSON.parse(raw);
261+
if (!Array.isArray(arr)) return;
262+
const filtered = arr.filter((v: unknown) => v !== id);
263+
if (filtered.length === arr.length) return;
264+
if (filtered.length === 0) {
265+
localStorage?.removeItem(STORAGE_KEY);
266+
} else {
267+
localStorage?.setItem(STORAGE_KEY, JSON.stringify(filtered));
268+
}
269+
} catch {
270+
// ignore
271+
}
272+
}
273+
274+
export function createStarredStore(): StarredStore {
275+
return new StarredStore();
276+
}
277+
278+
export const starred = createStarredStore();

0 commit comments

Comments
 (0)