Enhance OpenRouter usage refinement and error handling
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

This commit is contained in:
Aadesh Kheria 2026-07-17 10:57:29 -07:00
parent 57e95d3293
commit dc2a73b8e0
7 changed files with 400 additions and 89 deletions

View File

@ -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<unknown> | (() => Promise<unknown>)) => {
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> = {}): 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<Record<string, unknown>> {
expect(callInternalTool).toHaveBeenCalledTimes(1);
const [path, opts] = vi.mocked(callInternalTool).mock.calls[0] as [string, { body: Record<string, unknown> }];
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();
});
});

View File

@ -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,

View File

@ -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<Record<string, unknown>>(),
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");
});
});

View File

@ -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<void> {
// 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)`,
));
}
}

View File

@ -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<Row extends { id: bigint }> = {
tableName: string,
iter: (ctx: SubscriptionEventContext) => Iterable<Row>,
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<Row>,
onInsert: (conn: DbConnection, cb: () => void) => void,
onDelete: (conn: DbConnection, cb: () => void) => void,
onUpdate?: (conn: DbConnection, cb: () => void) => void,
};
function useTableSubscription<Row extends { id: bigint }>(
@ -123,26 +138,43 @@ function useTableSubscription<Row extends { id: bigint }>(
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | 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<DbConnection>();
// 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<Row extends { id: bigint }>(
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<Row extends { id: bigint }>(
}
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<Row extends { id: bigint }>(
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<Row extends { id: bigint }>(
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<Row extends { id: bigint }>(
const mcpBinding: TableBinding<McpCallLogRow> = {
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<AiQueryLogRow> = {
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<PublishedQaRow> = {
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<QaEntriesRow> = {
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());
},
};

View File

@ -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 () => {

View File

@ -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(/\/+$/, "");