From 4413e97a47dd47cad40cdcfcc13a824e960d5a90 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 12:55:53 -0700 Subject: [PATCH 1/9] feat(rivetkit): add inspector sqlite shell --- .../src/components/actors/actor-database.tsx | 473 +++++++++++++++++- .../actors/actor-inspector-context.tsx | 78 +++ .../packages/rivetkit/src/actor/router.ts | 65 +++ .../packages/rivetkit/src/db/drizzle/mod.ts | 14 +- .../packages/rivetkit/src/db/mod.ts | 13 +- .../packages/rivetkit/src/db/shared.ts | 46 +- .../tests/actor-inspector.ts | 104 +++- .../rivetkit/src/inspector/actor-inspector.ts | 163 ++++-- website/src/content/docs/actors/debugging.mdx | 47 ++ website/src/content/docs/actors/sqlite.mdx | 10 + website/src/metadata/skill-base-rivetkit.md | 1 + 11 files changed, 938 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/actors/actor-database.tsx b/frontend/src/components/actors/actor-database.tsx index 3930336b34..eae74cbb7c 100644 --- a/frontend/src/components/actors/actor-database.tsx +++ b/frontend/src/components/actors/actor-database.tsx @@ -1,14 +1,24 @@ -import { Button, Flex, ScrollArea, WithTooltip } from "@/components"; +import { + Badge, + Button, + Flex, + Input, + ScrollArea, + Textarea, + WithTooltip, +} from "@/components"; import { faChevronLeft, faChevronRight, + faCode, + faPlay, faRefresh, faTable, faTableCells, Icon, } from "@rivet-gg/icons"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ShimmerLine } from "../shimmer-line"; import { Select, @@ -17,17 +27,61 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { useActorInspector } from "./actor-inspector-context"; +import { + type DatabaseColumn, + type DatabaseExecuteRequest, + type DatabaseExecuteResult, + actorInspectorQueriesKeys, + useActorInspector, +} from "./actor-inspector-context"; import { DatabaseTable } from "./database/database-table"; import type { ActorId } from "./queries"; const PAGE_SIZE = 100; +const DEFAULT_SQL = [ + "SELECT id, value, created_at", + "FROM test_data", + "ORDER BY id DESC", + "LIMIT 25;", +].join("\n"); interface ActorDatabaseProps { actorId: ActorId; } export function ActorDatabase({ actorId }: ActorDatabaseProps) { + const [view, setView] = useState<"tables" | "sql">("tables"); + + return ( +
+
+ + +
+ {view === "tables" ? ( + + ) : ( + + )} +
+ ); +} + +function ActorDatabaseBrowser({ actorId }: ActorDatabaseProps) { const actorInspector = useActorInspector(); const { data, refetch } = useQuery( actorInspector.actorDatabaseQueryOptions(actorId), @@ -54,7 +108,7 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { }); const currentTable = data?.tables?.find( - (t) => t.table.name === selectedTable, + (current) => current.table.name === selectedTable, ); const totalRows = currentTable?.records ?? 0; @@ -65,11 +119,11 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { return ( <>
-
+
{ - setTable(t); + onSelect={(nextTable) => { + setTable(nextTable); setPage(0); }} value={selectedTable} @@ -103,7 +157,7 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { variant="ghost" size="icon-sm" disabled={!hasPrevPage} - onClick={() => setPage((p) => p - 1)} + onClick={() => setPage((current) => current - 1)} > @@ -119,7 +173,7 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { variant="ghost" size="icon-sm" disabled={!hasNextPage} - onClick={() => setPage((p) => p + 1)} + onClick={() => setPage((current) => current + 1)} > @@ -163,6 +217,333 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { ); } +function ActorDatabaseSqlShell({ actorId }: ActorDatabaseProps) { + const actorInspector = useActorInspector(); + const queryClient = useQueryClient(); + const [sql, setSql] = useState(DEFAULT_SQL); + const [argsText, setArgsText] = useState("[]"); + const [propertyDrafts, setPropertyDrafts] = useState< + Record + >({}); + const [bindingChangeToken, setBindingChangeToken] = useState(0); + const [result, setResult] = useState(null); + const namedBindings = useMemo(() => { + return extractNamedBindings(sql); + }, [sql]); + const hasNamedBindings = namedBindings.length > 0; + const hasPositionalBindings = useMemo(() => { + return sql.includes("?"); + }, [sql]); + const hasMixedBindings = hasNamedBindings && hasPositionalBindings; + + useEffect(() => { + setPropertyDrafts((current) => { + const next: Record = {}; + for (const name of namedBindings) { + next[name] = current[name] ?? ""; + } + return next; + }); + }, [namedBindings]); + + const parsedArgs = useMemo(() => { + if (argsText.trim() === "") { + return { value: [] as unknown[], error: null }; + } + + try { + const value = JSON.parse(argsText); + if (!Array.isArray(value)) { + return { + value: [] as unknown[], + error: "Args must be a JSON array.", + }; + } + + return { value, error: null }; + } catch { + return { + value: [] as unknown[], + error: "Args must be valid JSON.", + }; + } + }, [argsText]); + const parsedProperties = useMemo(() => { + const next: Record = {}; + for (const name of namedBindings) { + const parsed = parseBindingDraft(propertyDrafts[name] ?? ""); + if (parsed.error) { + return { + value: {} as Record, + error: `${name}: ${parsed.error}`, + }; + } + next[name] = parsed.value; + } + return { + value: next, + error: null, + }; + }, [namedBindings, propertyDrafts]); + + const resultColumns = useMemo(() => { + return createResultColumns(result?.rows ?? []); + }, [result]); + const resultRowsAreTable = useMemo(() => { + return areObjectRows(result?.rows ?? []); + }, [result]); + + const { mutateAsync, isPending, error } = useMutation( + actorInspector.actorDatabaseExecuteMutation(actorId), + ); + const argsError = hasNamedBindings ? null : parsedArgs.error; + const propertiesError = hasNamedBindings ? parsedProperties.error : null; + const runSql = useCallback(async () => { + const request: DatabaseExecuteRequest = { + sql, + }; + + if (hasNamedBindings) { + request.properties = parsedProperties.value; + } else { + request.args = parsedArgs.value; + } + + const nextResult = await mutateAsync(request); + setResult(nextResult); + await queryClient.invalidateQueries({ + queryKey: actorInspectorQueriesKeys.actorDatabase(actorId), + }); + }, [ + actorId, + hasNamedBindings, + mutateAsync, + parsedArgs.value, + parsedProperties.value, + queryClient, + sql, + ]); + const canRun = + sql.trim() !== "" && + !hasMixedBindings && + argsError === null && + propertiesError === null; + + useEffect(() => { + if ( + bindingChangeToken === 0 || + !hasNamedBindings || + result === null || + propertiesError !== null || + isPending + ) { + return; + } + + setBindingChangeToken(0); + const timer = window.setTimeout(() => { + void runSql(); + }, 250); + return () => window.clearTimeout(timer); + }, [ + bindingChangeToken, + hasNamedBindings, + isPending, + propertiesError, + result, + runSql, + ]); + + return ( +
+
+
+
+
Manual SQL
+
+ Run statements directly against this actor's + SQLite database. Use `RETURNING` when you want + mutation output. +
+
+ +
+
+