From dc2a73b8e051ba2b4684bce524fd1dc5156d409d Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Fri, 17 Jul 2026 10:57:29 -0700 Subject: [PATCH] Enhance OpenRouter usage refinement and error handling --- .../lib/ai/loggers/ai-query-logger.test.ts | 130 +++++++++ .../src/lib/ai/loggers/ai-query-logger.ts | 6 +- .../src/lib/ai/openrouter-usage.test.ts | 67 ++++- apps/backend/src/lib/ai/openrouter-usage.ts | 18 +- .../internal-tool/src/hooks/useSpacetimeDB.ts | 251 ++++++++++++------ .../src/lib/server/spacetimedb-token.test.ts | 5 +- .../src/lib/server/spacetimedb-token.ts | 12 +- 7 files changed, 400 insertions(+), 89 deletions(-) create mode 100644 apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts diff --git a/apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts b/apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts new file mode 100644 index 000000000..dd42a0c52 --- /dev/null +++ b/apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts @@ -0,0 +1,130 @@ +/** + * Success-path usage baseline in `logAiQuery`: the AI SDK's token counts must + * be written into the log entry at insert time. Rows used to be inserted with + * all token fields undefined, relying entirely on the best-effort OpenRouter + * /generation refinement — when that missed (it polls only a few times), the + * row stayed permanently token-less in the analytics UI. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LanguageModelUsage } from "ai"; +import type { CommonLogFields } from "@/lib/ai/types"; + +vi.mock("../internal-tool-client", () => ({ + callInternalTool: vi.fn(), +})); + +vi.mock("@/lib/ai/openrouter-usage", () => ({ + refineGenerationUsage: vi.fn(() => Promise.resolve()), +})); + +// Resolve the logged promise inline so assertions can run right after the call. +vi.mock("@/utils/background-tasks", () => ({ + runAsynchronouslyAndWaitUntil: vi.fn((promiseOrFunction: Promise | (() => Promise)) => { + if (typeof promiseOrFunction === "function") { + return promiseOrFunction(); + } + return promiseOrFunction; + }), +})); + +import { callInternalTool } from "../internal-tool-client"; +import { refineGenerationUsage } from "@/lib/ai/openrouter-usage"; +import { logAiQuery } from "./ai-query-logger"; + +const common: CommonLogFields = { + correlationId: "corr-1", + mode: "stream", + systemPromptId: "create-dashboard", + quality: "smart", + speed: "fast", + modelId: "openai/gpt-5.5", + isAuthenticated: true, + projectId: undefined, + userId: undefined, + requestedToolsJson: "[]", + messagesJson: "[]", + conversationId: undefined, +}; + +function makeUsage(overrides: Partial = {}): LanguageModelUsage { + return { + inputTokens: 65_664, + inputTokenDetails: { + noCacheTokens: 56_448, + cacheReadTokens: 9_216, + cacheWriteTokens: undefined, + }, + outputTokens: 5_083, + outputTokenDetails: { + textTokens: 5_083, + reasoningTokens: undefined, + }, + totalTokens: 70_747, + ...overrides, + }; +} + +async function loggedEntry(): Promise> { + expect(callInternalTool).toHaveBeenCalledTimes(1); + const [path, opts] = vi.mocked(callInternalTool).mock.calls[0] as [string, { body: Record }]; + expect(path).toBe("/api/backend/log-ai-query"); + return opts.body; +} + +beforeEach(() => { + vi.mocked(callInternalTool).mockReset(); + vi.mocked(refineGenerationUsage).mockClear(); +}); + +describe("logAiQuery success path", () => { + it("writes the AI SDK usage as a baseline at insert time", async () => { + logAiQuery({ + type: "success", + common, + startedAt: 0, + steps: [], + text: "done", + usage: makeUsage(), + openrouterGenerationId: "gen-1", + }); + const entry = await loggedEntry(); + + expect(entry.inputTokens).toBe(65_664); + expect(entry.outputTokens).toBe(5_083); + expect(entry.cachedInputTokens).toBe(9_216); + expect(entry.cacheCreationTokens).toBeUndefined(); + // Cost is only known to OpenRouter; the refinement fills it in. + expect(entry.costUsd).toBeUndefined(); + expect(entry.cacheDiscountUsd).toBeUndefined(); + expect(refineGenerationUsage).toHaveBeenCalledWith({ + generationId: "gen-1", + correlationId: "corr-1", + }); + }); + + it("keeps token fields undefined when the SDK reports no usage", async () => { + logAiQuery({ + type: "success", + common, + startedAt: 0, + steps: [], + text: "done", + usage: makeUsage({ + inputTokens: undefined, + outputTokens: undefined, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + }), + openrouterGenerationId: undefined, + }); + const entry = await loggedEntry(); + + expect(entry.inputTokens).toBeUndefined(); + expect(entry.outputTokens).toBeUndefined(); + expect(entry.cachedInputTokens).toBeUndefined(); + expect(refineGenerationUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/lib/ai/loggers/ai-query-logger.ts b/apps/backend/src/lib/ai/loggers/ai-query-logger.ts index b28cbbcd7..b7efca02b 100644 --- a/apps/backend/src/lib/ai/loggers/ai-query-logger.ts +++ b/apps/backend/src/lib/ai/loggers/ai-query-logger.ts @@ -66,9 +66,9 @@ export function logAiQuery(args: LogAiQueryArgs): void { ...args.common, stepsJson: serializeSteps(args.steps), finalText: args.text, - inputTokens: undefined, - outputTokens: undefined, - cachedInputTokens: undefined, + inputTokens: args.usage.inputTokens, + outputTokens: args.usage.outputTokens, + cachedInputTokens: args.usage.inputTokenDetails.cacheReadTokens, cacheCreationTokens: args.usage.inputTokenDetails.cacheWriteTokens ?? undefined, costUsd: undefined, cacheDiscountUsd: undefined, diff --git a/apps/backend/src/lib/ai/openrouter-usage.test.ts b/apps/backend/src/lib/ai/openrouter-usage.test.ts index 1d5ac58a3..61cfb26fc 100644 --- a/apps/backend/src/lib/ai/openrouter-usage.test.ts +++ b/apps/backend/src/lib/ai/openrouter-usage.test.ts @@ -21,7 +21,13 @@ vi.mock("@/lib/ai/internal-tool-client", () => ({ callInternalTool: vi.fn(), })); +vi.mock("@hexclave/shared/dist/utils/errors", async (importOriginal) => ({ + ...await importOriginal>(), + captureError: vi.fn(), +})); + import { callInternalTool } from "@/lib/ai/internal-tool-client"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; import { refineGenerationUsage } from "./openrouter-usage"; const fetchMock = vi.fn(); @@ -38,6 +44,7 @@ beforeEach(() => { vi.useFakeTimers(); fetchMock.mockReset(); vi.mocked(callInternalTool).mockReset(); + vi.mocked(captureError).mockReset(); globalThis.fetch = fetchMock as unknown as typeof fetch; }); @@ -173,15 +180,71 @@ describe("OpenRouter generation usage refinement", () => { }); }); - it("keeps 429 failures best-effort and does not throw into callers", async () => { + it("keeps 429 failures best-effort, captures the last error, and does not throw into callers", async () => { fetchMock.mockImplementation(() => Promise.resolve(new Response("too many requests", { status: 429 }))); const promise = refineGenerationUsage({ generationId: "gen-3", correlationId: "corr-3" }); await vi.advanceTimersByTimeAsync(1500); await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(10000); await expect(promise).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(callInternalTool).not.toHaveBeenCalled(); + expect(captureError).toHaveBeenCalledTimes(1); + }); + + it("succeeds on the final attempt after repeated not-ready responses", async () => { + fetchMock + .mockResolvedValueOnce(new Response("not ready", { status: 404 })) + .mockResolvedValueOnce(new Response("not ready", { status: 404 })) + .mockResolvedValueOnce(jsonResponse({ + data: { + tokens_prompt: 5, + tokens_completion: 3, + native_tokens_prompt: null, + native_tokens_completion: null, + native_tokens_cached: null, + total_cost: 0.0007, + cache_discount: null, + }, + })); + + const promise = refineGenerationUsage({ generationId: "gen-slow", correlationId: "corr-slow" }); + await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(10000); + await promise; + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(callInternalTool).toHaveBeenCalledWith("/api/backend/update-ai-query-usage", { + body: { + correlationId: "corr-slow", + inputTokens: 5, + outputTokens: 3, + cachedInputTokens: undefined, + costUsd: 0.0007, + cacheDiscountUsd: undefined, + }, + }); + expect(captureError).not.toHaveBeenCalled(); + }); + + it("reports loudly (instead of silently giving up) when the generation never becomes ready", async () => { + fetchMock.mockImplementation(() => Promise.resolve(new Response("not ready", { status: 404 }))); + + const promise = refineGenerationUsage({ generationId: "gen-never", correlationId: "corr-never" }); + await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(10000); + await expect(promise).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(callInternalTool).not.toHaveBeenCalled(); + expect(captureError).toHaveBeenCalledTimes(1); + const [location, err] = vi.mocked(captureError).mock.calls[0]; + expect(location).toBe("openrouter-generation-usage-refine"); + expect(String(err)).toContain("gen-never"); + expect(String(err)).toContain("corr-never"); }); }); diff --git a/apps/backend/src/lib/ai/openrouter-usage.ts b/apps/backend/src/lib/ai/openrouter-usage.ts index 74788a0cd..bbcd4422b 100644 --- a/apps/backend/src/lib/ai/openrouter-usage.ts +++ b/apps/backend/src/lib/ai/openrouter-usage.ts @@ -7,8 +7,7 @@ import { captureError } from "@hexclave/shared/dist/utils/errors"; type OpenRouterGenerationResponse = { data: OpenRouterGenerationData, }; - -const GENERATION_RETRY_DELAYS_MS = [1500, 4000]; +const GENERATION_RETRY_DELAYS_MS = [1500, 4000, 10000]; const GENERATION_PER_REQUEST_TIMEOUT_MS = 5000; function isOpenRouterGenerationResponse(value: unknown): value is OpenRouterGenerationResponse { @@ -58,11 +57,18 @@ export async function refineGenerationUsage(opts: { generationId: string, correlationId: string, }): Promise { + // Tracks how the final attempt failed so exhaustion is never silent: the + // not_ready path used to fall out of the loop without any error report, + // leaving rows permanently missing cost data with no trace of why. + let sawNotReadyOnLastAttempt = false; for (let attempt = 0; attempt < GENERATION_RETRY_DELAYS_MS.length; attempt++) { await new Promise(r => setTimeout(r, GENERATION_RETRY_DELAYS_MS[attempt])); try { const result = await fetchGenerationOnce(opts.generationId); - if (result === "not_ready") continue; + if (result === "not_ready") { + sawNotReadyOnLastAttempt = true; + continue; + } if (result == null) return; await callInternalTool("/api/backend/update-ai-query-usage", { body: { @@ -76,9 +82,15 @@ export async function refineGenerationUsage(opts: { }); return; } catch (err) { + sawNotReadyOnLastAttempt = false; if (attempt === GENERATION_RETRY_DELAYS_MS.length - 1) { captureError("openrouter-generation-usage-refine", err); } } } + if (sawNotReadyOnLastAttempt) { + captureError("openrouter-generation-usage-refine", new Error( + `OpenRouter generation ${opts.generationId} was still not ready after ${GENERATION_RETRY_DELAYS_MS.length} attempts; usage for AI query ${opts.correlationId} was not refined (no cost data recorded)`, + )); + } } diff --git a/apps/internal-tool/src/hooks/useSpacetimeDB.ts b/apps/internal-tool/src/hooks/useSpacetimeDB.ts index b9fda1e9f..84269c469 100644 --- a/apps/internal-tool/src/hooks/useSpacetimeDB.ts +++ b/apps/internal-tool/src/hooks/useSpacetimeDB.ts @@ -2,7 +2,7 @@ import { captureError } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { useEffect, useState, useRef } from "react"; import { createPendingCallRegistry } from "../lib/pending-call-registry"; -import { DbConnection, type ErrorContext, type EventContext, type SubscriptionEventContext } from "../module_bindings"; +import { DbConnection, type ErrorContext, type EventContext } from "../module_bindings"; import type { AiQueryLogRow, McpCallLogRow, PublishedQaRow, QaEntriesRow } from "../types"; /** @@ -34,10 +34,22 @@ function getConfig() { return cachedConfig; } -const MAX_RETRIES = 5; +// After this many consecutive failures the error banner is shown (and the +// error captured once), but reconnection attempts continue forever with capped +// exponential backoff: this is a long-lived dashboard, and a transient outage +// (dev-server rebuild, server restart, laptop sleep, network blip) must not +// permanently kill the page until someone manually reloads it. +const RETRIES_BEFORE_ERROR_STATE = 5; const RETRY_DELAY_MS = 2000; -// Access tokens live ~10min and `withToken` is fixed at build() time, so tear -// down and reconnect with a fresh token before the current one expires. +const MAX_RETRY_DELAY_MS = 30_000; +// Reconnect with a fresh token well before the current one expires +// (`withToken` is fixed at build() time, so reconnecting is the only way to +// rotate it). The token TTL (30min, see lib/server/spacetimedb-token.ts) is +// deliberately much longer than this interval: browsers throttle timers in +// backgrounded tabs, so this can fire late — the wide margin keeps the +// server-side session row valid (and the my_visible_* views non-empty, which +// would otherwise delete every row out from under the subscription) even when +// several refresh cycles are delayed. const TOKEN_RECONNECT_INTERVAL_MS = 8 * 60 * 1000; type ConnectionState = "connecting" | "connected" | "error"; @@ -87,12 +99,15 @@ function formatUnknownError(value: unknown, fallback: string, depth = 0): string return stringified !== "[object Object]" ? stringified : fallback; } +// The row callbacks intentionally take no arguments: React state is always +// rebuilt from the SDK's table cache (see resyncFromCache below), never +// patched row-by-row from event payloads. type TableBinding = { tableName: string, - iter: (ctx: SubscriptionEventContext) => Iterable, - onInsert: (conn: DbConnection, cb: (row: Row) => void) => void, - onDelete: (conn: DbConnection, cb: (row: Row) => void) => void, - onUpdate?: (conn: DbConnection, cb: (row: Row) => void) => void, + iter: (conn: DbConnection) => Iterable, + onInsert: (conn: DbConnection, cb: () => void) => void, + onDelete: (conn: DbConnection, cb: () => void) => void, + onUpdate?: (conn: DbConnection, cb: () => void) => void, }; function useTableSubscription( @@ -123,26 +138,43 @@ function useTableSubscription( let retryTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let lastConnectionErrorMessage: string | null = null; + // Every connection built in this effect generation that hasn't been + // disposed yet. Old connections are kept alive until their replacement's + // subscription has applied (make-before-break), so the UI never blanks + // during a token refresh; this set lets the winner (and the unmount + // cleanup) dispose of the rest. + const liveConns = new Set(); + // Mirrors the length of the last rows array pushed to React state; used to + // detect a non-empty view collapsing to empty (see resyncFromCache). + let lastRenderedRowCount = 0; + // True while a session-lapse-triggered reconnect is pending, so repeated + // empty resyncs don't stack up reconnect attempts. Reset when any + // subscription applies. + let sessionRefreshInFlight = false; const query = `SELECT * FROM ${binding.tableName}`; + const closedConnError = () => new Error("SpacetimeDB connection was closed while the call was in flight. The change may or may not have been applied — check the list and retry if needed."); + function retry() { - if (cancelled) return; + if (cancelled || retryTimer != null) return; retryCount++; - if (retryCount > MAX_RETRIES) { + if (retryCount >= RETRIES_BEFORE_ERROR_STATE) { const message = lastConnectionErrorMessage == null - ? `Gave up connecting to ${binding.tableName} after ${MAX_RETRIES} retries` - : `Gave up connecting to ${binding.tableName} after ${MAX_RETRIES} retries. Last error: ${lastConnectionErrorMessage}`; - captureError("spacetimedb-connect-max-retries", new Error(message)); + ? `Failed to connect to ${binding.tableName} after ${retryCount} attempts (still retrying)` + : `Failed to connect to ${binding.tableName} after ${retryCount} attempts (still retrying). Last error: ${lastConnectionErrorMessage}`; + if (retryCount === RETRIES_BEFORE_ERROR_STATE) { + captureError("spacetimedb-connect-retries", new Error(message)); + } setConnectionErrorMessage(message); setConnectionState("error"); - return; } + const delay = Math.min(RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 5), MAX_RETRY_DELAY_MS); retryTimer = setTimeout(() => { retryTimer = null; if (!cancelled) { runAsynchronously(() => connect()); } - }, RETRY_DELAY_MS); + }, delay); } function scheduleTokenReconnect() { @@ -150,10 +182,8 @@ function useTableSubscription( reconnectTimer = setTimeout(() => { reconnectTimer = null; if (cancelled) return; - connRef.current?.disconnect(); - connRef.current = null; - setConn(null); - pendingCalls.rejectAll(new Error("SpacetimeDB connection was closed while the call was in flight. The change may or may not have been applied — check the list and retry if needed.")); + // Make-before-break: the current connection keeps serving rows until + // the replacement's subscription has applied (see onApplied below). runAsynchronously(() => connect()); }, TOKEN_RECONNECT_INTERVAL_MS); } @@ -175,32 +205,103 @@ function useTableSubscription( } if (cancelled) return; + let thisConn: DbConnection | null = null; const builder = DbConnection.builder() .withUri(config.host) .withDatabaseName(config.dbName) .onConnect((connInstance: DbConnection, _identity, _token) => { if (cancelled) return; + if (connRef.current !== connInstance) { + // A newer connect() superseded this attempt while it was still in + // flight; let the winner drive the UI and dispose of this one. + connInstance.disconnect(); + liveConns.delete(connInstance); + return; + } retryCount = 0; - connRef.current = connInstance; setConn(connInstance); setConnectionErrorMessage(null); scheduleTokenReconnect(); + // Our tables are backed by function views, and view row types carry + // no primary key on the client. For PK-less tables the SpacetimeDB + // SDK never emits `update` events — a server-side row update arrives + // as insert(newValue) + delete(oldValue), with inserts processed + // FIRST. Mirroring those events into React state keyed by `id` is + // therefore wrong: the insert replaces the row in place, then the + // delete (same `id`, old value) removes it entirely, so every row + // silently disappeared from the UI the moment it was updated (e.g. + // when the OpenRouter usage refinement landed a few seconds after + // each AI call). The SDK's own table cache refcounts by full row + // value and stays correct through all of this, so we treat it as the + // source of truth and rebuild React state from it on every event. + const snapshotFromCache = () => { + const all = Array.from(binding.iter(connInstance)); + all.sort((a, b) => Number(b.id - a.id)); + return all; + }; + + const applyRows = (all: Row[]) => { + lastRenderedRowCount = all.length; + setRows(all); + }; + + const resyncFromCache = () => { + // The connRef guard freezes rows sourced from a superseded + // connection: once a replacement has been built, only ITS cache + // may drive the UI, so a dying connection's teardown events can't + // blank the table. + if (cancelled || connRef.current !== connInstance) return; + const all = snapshotFromCache(); + // A non-empty view collapsing to empty mid-connection is (almost + // always) NOT real data loss: it's the server-side session row + // expiring under a still-open WebSocket — the module's + // my_visible_* views return [] for sessionless subscribers and + // SpacetimeDB then deletes every row under the subscription, and + // no further events ever arrive on that connection. (Verified + // against a local instance by subscribing with a short-exp token: + // the session_gc sweep produced a delete-all, then silence.) + // Keep showing the last-known rows and reconnect with a fresh + // token instead; the new subscription's onApplied below is + // authoritative and will set the true state — including a + // genuinely emptied table, which costs one spurious reconnect. + if (all.length === 0 && lastRenderedRowCount > 0) { + if (!sessionRefreshInFlight) { + sessionRefreshInFlight = true; + runAsynchronously(() => connect()); + } + return; + } + applyRows(all); + }; + const startSubscription = () => { if (cancelled) return; connInstance.subscriptionBuilder() - .onApplied((ctx: SubscriptionEventContext) => { - if (cancelled) return; - const initial: Row[] = []; - for (const row of binding.iter(ctx)) { - initial.push(row); + .onApplied(() => { + if (cancelled || connRef.current !== connInstance) return; + sessionRefreshInFlight = false; + // The new subscription is live — only now is it safe to drop + // older connections without the UI ever missing rows. + let droppedAny = false; + for (const old of liveConns) { + if (old !== connInstance) { + old.disconnect(); + liveConns.delete(old); + droppedAny = true; + } } - initial.sort((a, b) => Number(b.id - a.id)); - setRows(initial); + if (droppedAny) { + pendingCalls.rejectAll(closedConnError()); + } + // Unlike event-driven resyncs, a fresh subscription's snapshot + // is authoritative even when empty (the session was just + // re-established, so [] here means the table really is empty). + applyRows(snapshotFromCache()); setConnectionState("connected"); }) .onError((ctx: ErrorContext) => { - if (cancelled) return; + if (cancelled || connRef.current !== connInstance) return; const message = formatUnknownError(ctx, "SpacetimeDB subscription error"); lastConnectionErrorMessage = message; captureError("spacetimedb-subscription", ctx); @@ -212,48 +313,45 @@ function useTableSubscription( startSubscription(); - binding.onInsert(connInstance, (row) => { - if (cancelled) return; - setRows(prev => { - const existing = prev.findIndex(r => r.id === row.id); - if (existing >= 0) { - const updated = [...prev]; - updated[existing] = row; - return updated; - } - return [row, ...prev]; - }); - }); - - binding.onDelete(connInstance, (row) => { - if (cancelled) return; - setRows(prev => prev.filter(r => r.id !== row.id)); - }); - - binding.onUpdate?.(connInstance, (row) => { - if (cancelled) return; - setRows(prev => { - const idx = prev.findIndex(r => r.id === row.id); - if (idx < 0) return [row, ...prev]; - const updated = [...prev]; - updated[idx] = row; - return updated; - }); - }); + // Callbacks within one transaction batch fire after the cache has + // fully absorbed the batch, so each resync sees a consistent + // snapshot; redundant resyncs collapse in React's render batching. + binding.onInsert(connInstance, resyncFromCache); + binding.onDelete(connInstance, resyncFromCache); + // Never fires for PK-less view tables today (see above), but kept so + // rows stay correct if a view ever regains primary-key metadata. + binding.onUpdate?.(connInstance, resyncFromCache); }) .onConnectError((_ctx: unknown, err: unknown) => { if (cancelled) return; + if (thisConn != null) liveConns.delete(thisConn); const message = formatUnknownError(err, "SpacetimeDB connection error"); lastConnectionErrorMessage = message; setConnectionErrorMessage(message); captureError("spacetimedb-connect", err); retry(); + }) + .onDisconnect(() => { + // Fires when an ESTABLISHED connection ends. Intentional teardowns + // (superseded generation, unmount) are filtered by the guards below; + // what remains is the active connection dying unexpectedly (server + // restart, network drop, laptop wake) — reconnect right away so the + // dashboard self-heals instead of sitting dead until a reload. + if (thisConn != null) liveConns.delete(thisConn); + if (cancelled || connRef.current !== thisConn) return; + pendingCalls.rejectAll(closedConnError()); + setConnectionState("connecting"); + retry(); }); if (token != null) { builder.withToken(token); } const newConn = builder.build(); - + thisConn = newConn; + liveConns.add(newConn); + // Assigned at build time (not connect time) so that from this moment on, + // the previous connection's events are frozen out of the UI and the + // handlers above can tell whether they belong to the newest generation. connRef.current = newConn; } @@ -269,12 +367,13 @@ function useTableSubscription( clearTimeout(reconnectTimer); reconnectTimer = null; } - if (connRef.current) { - connRef.current.disconnect(); - connRef.current = null; + for (const c of liveConns) { + c.disconnect(); } + liveConns.clear(); + connRef.current = null; setConn(null); - pendingCalls.rejectAll(new Error("SpacetimeDB connection was closed while the call was in flight. The change may or may not have been applied — check the list and retry if needed.")); + pendingCalls.rejectAll(closedConnError()); }; }, [binding, getToken, requireAuth, pendingCalls]); @@ -290,54 +389,54 @@ function useTableSubscription( const mcpBinding: TableBinding = { tableName: "my_visible_mcp_call_log", - iter: (ctx) => ctx.db.myVisibleMcpCallLog.iter(), + iter: (conn) => conn.db.myVisibleMcpCallLog.iter(), onInsert: (conn, cb) => { - conn.db.myVisibleMcpCallLog.onInsert((_ctx: EventContext, row: McpCallLogRow) => cb(row)); + conn.db.myVisibleMcpCallLog.onInsert((_ctx: EventContext, _row: McpCallLogRow) => cb()); }, onDelete: (conn, cb) => { - conn.db.myVisibleMcpCallLog.onDelete((_ctx: EventContext, row: McpCallLogRow) => cb(row)); + conn.db.myVisibleMcpCallLog.onDelete((_ctx: EventContext, _row: McpCallLogRow) => cb()); }, onUpdate: (conn, cb) => { - conn.db.myVisibleMcpCallLog.onUpdate((_ctx: EventContext, _old: McpCallLogRow, row: McpCallLogRow) => cb(row)); + conn.db.myVisibleMcpCallLog.onUpdate((_ctx: EventContext, _old: McpCallLogRow, _row: McpCallLogRow) => cb()); }, }; const aiQueryBinding: TableBinding = { tableName: "my_visible_ai_query_log", - iter: (ctx) => ctx.db.myVisibleAiQueryLog.iter(), + iter: (conn) => conn.db.myVisibleAiQueryLog.iter(), onInsert: (conn, cb) => { - conn.db.myVisibleAiQueryLog.onInsert((_ctx: EventContext, row: AiQueryLogRow) => cb(row)); + conn.db.myVisibleAiQueryLog.onInsert((_ctx: EventContext, _row: AiQueryLogRow) => cb()); }, onDelete: (conn, cb) => { - conn.db.myVisibleAiQueryLog.onDelete((_ctx: EventContext, row: AiQueryLogRow) => cb(row)); + conn.db.myVisibleAiQueryLog.onDelete((_ctx: EventContext, _row: AiQueryLogRow) => cb()); }, onUpdate: (conn, cb) => { - conn.db.myVisibleAiQueryLog.onUpdate((_ctx: EventContext, _old: AiQueryLogRow, row: AiQueryLogRow) => cb(row)); + conn.db.myVisibleAiQueryLog.onUpdate((_ctx: EventContext, _old: AiQueryLogRow, _row: AiQueryLogRow) => cb()); }, }; const publishedQaBinding: TableBinding = { tableName: "published_qa", - iter: (ctx) => ctx.db.publishedQa.iter(), + iter: (conn) => conn.db.publishedQa.iter(), onInsert: (conn, cb) => { - conn.db.publishedQa.onInsert((_ctx: EventContext, row: PublishedQaRow) => cb(row)); + conn.db.publishedQa.onInsert((_ctx: EventContext, _row: PublishedQaRow) => cb()); }, onDelete: (conn, cb) => { - conn.db.publishedQa.onDelete((_ctx: EventContext, row: PublishedQaRow) => cb(row)); + conn.db.publishedQa.onDelete((_ctx: EventContext, _row: PublishedQaRow) => cb()); }, }; const qaEntriesBinding: TableBinding = { tableName: "my_visible_qa_entries", - iter: (ctx) => ctx.db.myVisibleQaEntries.iter(), + iter: (conn) => conn.db.myVisibleQaEntries.iter(), onInsert: (conn, cb) => { - conn.db.myVisibleQaEntries.onInsert((_ctx: EventContext, row: QaEntriesRow) => cb(row)); + conn.db.myVisibleQaEntries.onInsert((_ctx: EventContext, _row: QaEntriesRow) => cb()); }, onDelete: (conn, cb) => { - conn.db.myVisibleQaEntries.onDelete((_ctx: EventContext, row: QaEntriesRow) => cb(row)); + conn.db.myVisibleQaEntries.onDelete((_ctx: EventContext, _row: QaEntriesRow) => cb()); }, onUpdate: (conn, cb) => { - conn.db.myVisibleQaEntries.onUpdate((_ctx: EventContext, _old: QaEntriesRow, row: QaEntriesRow) => cb(row)); + conn.db.myVisibleQaEntries.onUpdate((_ctx: EventContext, _old: QaEntriesRow, _row: QaEntriesRow) => cb()); }, }; diff --git a/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts b/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts index b6837ad58..fd3a0e9aa 100644 --- a/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts +++ b/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts @@ -132,8 +132,9 @@ describe("signSpacetimeToken", () => { expect(payload.name).toBe("Ada Lovelace"); expect(payload.iat).toBeDefined(); expect(payload.exp).toBeDefined(); - // Default TTL is 10 minutes. - expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(10 * 60); + // Default TTL is 30 minutes (see USER_TOKEN_TTL for why it's much longer + // than the frontend's 8-minute refresh interval). + expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(30 * 60); }); it("omits the name claim when name is not provided or empty", async () => { diff --git a/apps/internal-tool/src/lib/server/spacetimedb-token.ts b/apps/internal-tool/src/lib/server/spacetimedb-token.ts index 37bfc86cd..e7a1e4585 100644 --- a/apps/internal-tool/src/lib/server/spacetimedb-token.ts +++ b/apps/internal-tool/src/lib/server/spacetimedb-token.ts @@ -11,9 +11,15 @@ import * as jose from "jose"; // tokens can't be validated by SpacetimeDB directly; this shim can be deleted // if that ever ships — see the module's ALLOWED_ISSUERS.) -// Matches the ~10min lifetime of Stack Auth access tokens; the frontend -// reconnects with a fresh token every 8 minutes. -const USER_TOKEN_TTL = "10m"; +// The frontend reconnects with a fresh token every 8 minutes, so this TTL is +// deliberately much longer than one refresh cycle: browsers throttle timers in +// backgrounded tabs, and if the session row expired server-side before the +// (delayed) refresh landed, the module's my_visible_* views would return +// empty and delete every row out from under still-connected subscribers. The +// wide margin tolerates several missed cycles; the token still expires well +// within the day, and authorization is project membership (not per-token +// grants), so the longer lifetime doesn't widen what a holder can do. +const USER_TOKEN_TTL = "30m"; export function spacetimeTokenIssuer(): string { const issuer = getEnvVariable("HEXCLAVE_SPACETIMEDB_TOKEN_ISSUER", "").trim().replace(/\/+$/, "");