mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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
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:
parent
57e95d3293
commit
dc2a73b8e0
130
apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts
Normal file
130
apps/backend/src/lib/ai/loggers/ai-query-logger.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(/\/+$/, "");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user