diff --git a/.github/pr-assets/inspector-sqlite-shell/edit-inline.png b/.github/pr-assets/inspector-sqlite-shell/edit-inline.png new file mode 100644 index 0000000000..a6ecb9cf32 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/edit-inline.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/edit-staged.png b/.github/pr-assets/inspector-sqlite-shell/edit-staged.png new file mode 100644 index 0000000000..544bd20df3 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/edit-staged.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/edit-updated.png b/.github/pr-assets/inspector-sqlite-shell/edit-updated.png new file mode 100644 index 0000000000..283a7293af Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/edit-updated.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/query-results.png b/.github/pr-assets/inspector-sqlite-shell/query-results.png new file mode 100644 index 0000000000..da2aad7f81 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/query-results.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/tables.png b/.github/pr-assets/inspector-sqlite-shell/tables.png new file mode 100644 index 0000000000..8fd32c11c6 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/tables.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/verify-applied.png b/.github/pr-assets/inspector-sqlite-shell/verify-applied.png new file mode 100644 index 0000000000..97dd6f0ef8 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/verify-applied.png differ diff --git a/.github/pr-assets/inspector-sqlite-shell/verify-discard.png b/.github/pr-assets/inspector-sqlite-shell/verify-discard.png new file mode 100644 index 0000000000..88c7692d05 Binary files /dev/null and b/.github/pr-assets/inspector-sqlite-shell/verify-discard.png differ diff --git a/frontend/src/components/actors/actor-database.tsx b/frontend/src/components/actors/actor-database.tsx index 3930336b34..359d99fc16 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,18 +27,85 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { useActorInspector } from "./actor-inspector-context"; -import { DatabaseTable } from "./database/database-table"; +import { + type DatabaseColumn, + type DatabaseExecuteRequest, + type DatabaseExecuteResult, + actorInspectorQueriesKeys, + useActorInspector, +} from "./actor-inspector-context"; +import { + type DatabaseTableCellContext, + DatabaseTable, + isBlobColumn, + renderDatabaseCellValue, +} 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; } +type DatabaseBrowserRow = Record; + +type EditingCell = { + rowKey: string; + columnName: string; +}; + +type StagedCellEdit = { + id: string; + rowKey: string; + columnName: string; + primaryKeys: Array<{ name: string; value: unknown }>; + originalValue: unknown; + nextValue: unknown; + draft: string; +}; + export function ActorDatabase({ actorId }: ActorDatabaseProps) { + const [view, setView] = useState<"tables" | "sql">("tables"); + + return ( +
+
+ + +
+ {view === "tables" ? ( + + ) : ( + + )} +
+ ); +} + +function ActorDatabaseBrowser({ actorId }: ActorDatabaseProps) { const actorInspector = useActorInspector(); + const queryClient = useQueryClient(); const { data, refetch } = useQuery( actorInspector.actorDatabaseQueryOptions(actorId), ); @@ -54,22 +131,272 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { }); const currentTable = data?.tables?.find( - (t) => t.table.name === selectedTable, + (current) => current.table.name === selectedTable, + ); + const primaryKeyColumns = useMemo(() => { + return [...(currentTable?.columns ?? [])] + .filter((column) => Boolean(column.pk)) + .sort( + (a, b) => Number(a.pk ?? Number.MAX_SAFE_INTEGER) - Number(b.pk ?? Number.MAX_SAFE_INTEGER), + ); + }, [currentTable]); + const canEditRows = + currentTable?.table.type === "table" && primaryKeyColumns.length > 0; + const visibleRows = useMemo(() => { + return (rows ?? []).filter(isDatabaseBrowserRow); + }, [rows]); + const rowLookup = useMemo(() => { + const next = new Map(); + for (const row of visibleRows) { + const rowKey = createRowKey(row, primaryKeyColumns); + if (rowKey) { + next.set(rowKey, row); + } + } + return next; + }, [primaryKeyColumns, visibleRows]); + const columnLookup = useMemo(() => { + return new Map( + (currentTable?.columns ?? []).map((column) => { + return [column.name, column] as const; + }), + ); + }, [currentTable?.columns]); + const [editingCell, setEditingCell] = useState(null); + const [editingValue, setEditingValue] = useState(""); + const [stagedEdits, setStagedEdits] = useState>( + {}, ); + const [isApplyingEdits, setIsApplyingEdits] = useState(false); + const [tableEditError, setTableEditError] = useState(null); + const { mutateAsync: executeDatabaseSql } = useMutation( + actorInspector.actorDatabaseExecuteMutation(actorId), + ); + const stagedEditList = useMemo(() => { + return Object.values(stagedEdits); + }, [stagedEdits]); + const stagedEditCount = stagedEditList.length; + + useEffect(() => { + setEditingCell(null); + setEditingValue(""); + setStagedEdits({}); + setTableEditError(null); + }, [selectedTable]); const totalRows = currentTable?.records ?? 0; const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); const hasNextPage = page < totalPages - 1; const hasPrevPage = page > 0; + const beginCellEdit = useCallback( + ({ column, row, value }: DatabaseTableCellContext) => { + if (!canEditRows || isBlobColumn(column, value)) { + return; + } + + const rowKey = createRowKey(row, primaryKeyColumns); + if (!rowKey) { + return; + } + + const editId = createStagedEditId(rowKey, column.name); + setEditingCell({ rowKey, columnName: column.name }); + setEditingValue( + stagedEdits[editId]?.draft ?? formatCellDraft(value), + ); + setTableEditError(null); + }, + [canEditRows, primaryKeyColumns, stagedEdits], + ); + + const commitCellEdit = useCallback(() => { + if (!editingCell) { + return; + } + + const row = rowLookup.get(editingCell.rowKey); + const column = columnLookup.get(editingCell.columnName); + if (!row || !column) { + setEditingCell(null); + setEditingValue(""); + return; + } + + const nextValue = parseEditedCellValue( + editingValue, + row[column.name], + column, + ); + const editId = createStagedEditId(editingCell.rowKey, column.name); + const primaryKeys = extractPrimaryKeyValues(row, primaryKeyColumns); + if (primaryKeys.length !== primaryKeyColumns.length) { + setEditingCell(null); + setEditingValue(""); + return; + } + + setStagedEdits((current) => { + if (areDatabaseValuesEqual(nextValue, row[column.name])) { + if (!(editId in current)) { + return current; + } + const next = { ...current }; + delete next[editId]; + return next; + } + + return { + ...current, + [editId]: { + id: editId, + rowKey: editingCell.rowKey, + columnName: column.name, + primaryKeys, + originalValue: row[column.name], + nextValue, + draft: editingValue, + }, + }; + }); + setEditingCell(null); + setEditingValue(""); + }, [columnLookup, editingCell, editingValue, primaryKeyColumns, rowLookup]); + + const discardEdits = useCallback(() => { + setEditingCell(null); + setEditingValue(""); + setStagedEdits({}); + setTableEditError(null); + }, []); + + const applyEdits = useCallback(async () => { + if (!selectedTable || stagedEditList.length === 0) { + return; + } + + setIsApplyingEdits(true); + setTableEditError(null); + try { + for (const edit of stagedEditList) { + const args: unknown[] = [edit.nextValue]; + const whereClauses = edit.primaryKeys.map((primaryKey) => { + const columnName = quoteSqlIdentifier(primaryKey.name); + if (primaryKey.value === null) { + return `"${columnName}" IS NULL`; + } + args.push(primaryKey.value); + return `"${columnName}" = ?`; + }); + await executeDatabaseSql({ + sql: `UPDATE "${quoteSqlIdentifier(selectedTable)}" SET "${quoteSqlIdentifier(edit.columnName)}" = ? WHERE ${whereClauses.join(" AND ")}`, + args, + }); + } + + setEditingCell(null); + setEditingValue(""); + setStagedEdits({}); + await Promise.all([ + refetch(), + refetchData(), + queryClient.invalidateQueries({ + queryKey: actorInspectorQueriesKeys.actorDatabase(actorId), + }), + ]); + } catch (error) { + setTableEditError( + error instanceof Error + ? error.message + : "Failed to update edited cells.", + ); + } finally { + setIsApplyingEdits(false); + } + }, [ + actorId, + executeDatabaseSql, + queryClient, + refetch, + refetchData, + selectedTable, + stagedEditList, + ]); + + const renderBrowserCell = useCallback( + (context: DatabaseTableCellContext) => { + const rowKey = createRowKey(context.row, primaryKeyColumns); + if (!rowKey) { + return renderDatabaseCellValue(context.column, context.value); + } + + const editId = createStagedEditId(rowKey, context.column.name); + const stagedEdit = stagedEdits[editId]; + if ( + editingCell?.rowKey === rowKey && + editingCell.columnName === context.column.name + ) { + return ( + setEditingValue(event.target.value)} + onBlur={commitCellEdit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitCellEdit(); + } else if (event.key === "Escape") { + event.preventDefault(); + setEditingCell(null); + setEditingValue(""); + } + }} + className="h-8 font-mono-console text-xs" + /> + ); + } + + return renderDatabaseCellValue( + context.column, + stagedEdit ? stagedEdit.nextValue : context.value, + ); + }, + [commitCellEdit, editingCell, editingValue, primaryKeyColumns, stagedEdits], + ); + + const getBrowserCellClassName = useCallback( + (context: DatabaseTableCellContext) => { + const rowKey = createRowKey(context.row, primaryKeyColumns); + if (!rowKey) { + return undefined; + } + const editId = createStagedEditId(rowKey, context.column.name); + if (stagedEdits[editId]) { + return "bg-primary/10 ring-1 ring-inset ring-primary/35"; + } + if ( + editingCell?.rowKey === rowKey && + editingCell.columnName === context.column.name + ) { + return "bg-primary/15 ring-1 ring-inset ring-primary/45"; + } + if (canEditRows && !isBlobColumn(context.column, context.value)) { + return "cursor-text"; + } + return undefined; + }, + [canEditRows, editingCell, primaryKeyColumns, stagedEdits], + ); + return ( <>
-
+
{ - setTable(t); + onSelect={(nextTable) => { + setTable(nextTable); setPage(0); }} value={selectedTable} @@ -95,6 +422,28 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) {
+ {stagedEditCount > 0 ? ( + <> + + + + ) : null}
setPage((p) => p - 1)} + onClick={() => setPage((current) => current - 1)} > @@ -119,7 +468,7 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { variant="ghost" size="icon-sm" disabled={!hasNextPage} - onClick={() => setPage((p) => p + 1)} + onClick={() => setPage((current) => current + 1)} > @@ -147,14 +496,22 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) {
{isLoading ? : null} + {tableEditError ? ( +
+ {tableEditError} +
+ ) : null} {currentTable ? ( ) : null}
@@ -163,6 +520,270 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) { ); } +function ActorDatabaseSqlShell({ actorId }: ActorDatabaseProps) { + const actorInspector = useActorInspector(); + const queryClient = useQueryClient(); + const [sql, setSql] = useState(DEFAULT_SQL); + 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 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 bindingError = hasMixedBindings + ? "Mixing positional `?` bindings and named properties is not supported in Inspector. Use one binding style." + : hasPositionalBindings + ? "Positional `?` bindings are only supported in the Inspector HTTP API. Use named properties in the UI." + : null; + const propertiesError = hasNamedBindings ? parsedProperties.error : null; + const runSql = useCallback(async () => { + const request: DatabaseExecuteRequest = { + sql, + }; + + if (hasNamedBindings) { + request.properties = parsedProperties.value; + } + + const nextResult = await mutateAsync(request); + setResult(nextResult); + await queryClient.invalidateQueries({ + queryKey: actorInspectorQueriesKeys.actorDatabase(actorId), + }); + }, [ + actorId, + hasNamedBindings, + mutateAsync, + parsedProperties.value, + queryClient, + sql, + ]); + const canRun = + sql.trim() !== "" && + bindingError === null && + propertiesError === null; + + useEffect(() => { + if ( + bindingChangeToken === 0 || + !hasNamedBindings || + result === null || + propertiesError !== null || + isPending + ) { + return; + } + + const timer = window.setTimeout(() => { + setBindingChangeToken(0); + 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. +
+
+ +
+
+